From a05ad463941be3840aeb7ae1dcbf27c07170df31 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 12 Nov 2025 17:13:14 -0800 Subject: [PATCH 1/4] feat: add litellm cloud self-serve to docs --- docs/my-website/docs/proxy/demo.md | 9 --------- docs/my-website/sidebars.js | 6 +++++- 2 files changed, 5 insertions(+), 10 deletions(-) delete mode 100644 docs/my-website/docs/proxy/demo.md diff --git a/docs/my-website/docs/proxy/demo.md b/docs/my-website/docs/proxy/demo.md deleted file mode 100644 index c4b8671aab..0000000000 --- a/docs/my-website/docs/proxy/demo.md +++ /dev/null @@ -1,9 +0,0 @@ -# Demo App - -Here is a demo of the proxy. To log in pass in: - -- Username: admin -- Password: sk-1234 - - -[Demo UI](https://demo.litellm.ai/ui) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 2f69f0e46c..ddd23ec481 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -130,7 +130,11 @@ const sidebars = { "proxy/release_cycle", ], }, - "proxy/demo", + { + "type": "link", + "label": "Demo LiteLLM Cloud", + "href": "https://www.litellm.ai/cloud" + }, { type: "category", label: "Admin UI", From f0c83e183e71f8c92f2057aa78727a20e1d7b563 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 12 Nov 2025 17:55:05 -0800 Subject: [PATCH 2/4] SSO Modal Cosmetic Changes (#16554) --- .../src/components/SSOModals.test.tsx | 8 ++-- .../src/components/SSOModals.tsx | 43 +++++++++++-------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/ui/litellm-dashboard/src/components/SSOModals.test.tsx b/ui/litellm-dashboard/src/components/SSOModals.test.tsx index a2e979d9eb..9be4a08535 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.test.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.test.tsx @@ -83,7 +83,7 @@ describe("SSOModals", () => { fireEvent.change(emailInput, { target: { value: "test@example.com" } }); // Fill in an invalid URL - const urlInput = getByLabelText("PROXY BASE URL"); + const urlInput = getByLabelText("Proxy Base URL"); fireEvent.change(urlInput, { target: { value: "invalid-url" } }); // Submit the form @@ -137,7 +137,7 @@ describe("SSOModals", () => { fireEvent.change(emailInput, { target: { value: "test@example.com" } }); // Fill in a URL with trailing slash - const urlInput = getByLabelText("PROXY BASE URL") as HTMLInputElement; + const urlInput = getByLabelText("Proxy Base URL") as HTMLInputElement; fireEvent.change(urlInput, { target: { value: "https://example.com/" } }); // Submit the form @@ -171,7 +171,7 @@ describe("SSOModals", () => { const { getByLabelText } = render(); - const urlInput = getByLabelText("PROXY BASE URL") as HTMLInputElement; + const urlInput = getByLabelText("Proxy Base URL") as HTMLInputElement; // Simulate user typing "https://" fireEvent.change(urlInput, { target: { value: "h" } }); @@ -237,7 +237,7 @@ describe("SSOModals", () => { fireEvent.change(emailInput, { target: { value: "test@example.com" } }); // Fill in an incomplete URL like "http:" - const urlInput = getByLabelText("PROXY BASE URL"); + const urlInput = getByLabelText("Proxy Base URL"); fireEvent.change(urlInput, { target: { value: "http:" } }); // Submit the form diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index 437e4b1776..26e33ace2d 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -43,8 +43,8 @@ const ssoProviderConfigs: Record = { google_client_secret: "GOOGLE_CLIENT_SECRET", }, fields: [ - { label: "GOOGLE CLIENT ID", name: "google_client_id" }, - { label: "GOOGLE CLIENT SECRET", name: "google_client_secret" }, + { label: "Google Client ID", name: "google_client_id" }, + { label: "Google Client Secret", name: "google_client_secret" }, ], }, microsoft: { @@ -54,9 +54,9 @@ const ssoProviderConfigs: Record = { microsoft_tenant: "MICROSOFT_TENANT", }, fields: [ - { label: "MICROSOFT CLIENT ID", name: "microsoft_client_id" }, - { label: "MICROSOFT CLIENT SECRET", name: "microsoft_client_secret" }, - { label: "MICROSOFT TENANT", name: "microsoft_tenant" }, + { label: "Microsoft Client ID", name: "microsoft_client_id" }, + { label: "Microsoft Client Secret", name: "microsoft_client_secret" }, + { label: "Microsoft Tenant", name: "microsoft_tenant" }, ], }, okta: { @@ -68,18 +68,18 @@ const ssoProviderConfigs: Record = { generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT", }, fields: [ - { label: "GENERIC CLIENT ID", name: "generic_client_id" }, - { label: "GENERIC CLIENT SECRET", name: "generic_client_secret" }, + { label: "Generic Client ID", name: "generic_client_id" }, + { label: "Generic Client Secret", name: "generic_client_secret" }, { - label: "AUTHORIZATION ENDPOINT", + label: "Authorization Endpoint", name: "generic_authorization_endpoint", - placeholder: "https://your-okta-domain/authorize", + placeholder: "https://your-domain/authorize", }, - { label: "TOKEN ENDPOINT", name: "generic_token_endpoint", placeholder: "https://your-okta-domain/token" }, + { label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" }, { - label: "USERINFO ENDPOINT", + label: "Userinfo Endpoint", name: "generic_userinfo_endpoint", - placeholder: "https://your-okta-domain/userinfo", + placeholder: "https://your-domain/userinfo", }, ], }, @@ -92,11 +92,11 @@ const ssoProviderConfigs: Record = { generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT", }, fields: [ - { label: "GENERIC CLIENT ID", name: "generic_client_id" }, - { label: "GENERIC CLIENT SECRET", name: "generic_client_secret" }, - { label: "AUTHORIZATION ENDPOINT", name: "generic_authorization_endpoint" }, - { label: "TOKEN ENDPOINT", name: "generic_token_endpoint" }, - { label: "USERINFO ENDPOINT", name: "generic_userinfo_endpoint" }, + { label: "Generic Client ID", name: "generic_client_id" }, + { label: "Generic Client Secret", name: "generic_client_secret" }, + { label: "Authorization Endpoint", name: "generic_authorization_endpoint" }, + { label: "Token Endpoint", name: "generic_token_endpoint" }, + { label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" }, ], }, }; @@ -282,7 +282,12 @@ const SSOModals: React.FC = ({ style={{ height: 24, width: 24, marginRight: 12, objectFit: "contain" }} /> )} - {value.charAt(0).toUpperCase() + value.slice(1)} SSO + + {value.toLowerCase() === "okta" + ? "Okta / Auth0" + : value.charAt(0).toUpperCase() + value.slice(1)}{" "} + SSO + ))} @@ -307,7 +312,7 @@ const SSOModals: React.FC = ({ value?.trim()} rules={[ From 1393900c227a9591d4818f487b95c0b05fdf427d Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Wed, 12 Nov 2025 21:08:52 -0500 Subject: [PATCH 3/4] [Docs] Fix code block indentation for fallbacks page (#16542) --- docs/my-website/docs/proxy/reliability.md | 172 +++++++++++----------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/docs/my-website/docs/proxy/reliability.md b/docs/my-website/docs/proxy/reliability.md index 682421ede1..86de7cc114 100644 --- a/docs/my-website/docs/proxy/reliability.md +++ b/docs/my-website/docs/proxy/reliability.md @@ -28,7 +28,7 @@ fallbacks=[{"gpt-3.5-turbo": ["gpt-4"]}] ```python from litellm import Router router = Router( - model_list=[ + model_list=[ { "model_name": "gpt-3.5-turbo", "litellm_params": { @@ -47,8 +47,8 @@ router = Router( "rpm": 6 } } - ], - fallbacks=[{"gpt-3.5-turbo": ["gpt-4"]}] # 👈 KEY CHANGE + ], + fallbacks=[{"gpt-3.5-turbo": ["gpt-4"]}] # 👈 KEY CHANGE ) ``` @@ -104,9 +104,9 @@ model_list = [{..}, {..}] # defined in Step 1. router = Router(model_list=model_list, fallbacks=[{"bad-model": ["my-good-model"]}]) response = router.completion( - model="bad-model", - messages=[{"role": "user", "content": "Hey, how's it going?"}], - mock_testing_fallbacks=True, + model="bad-model", + messages=[{"role": "user", "content": "Hey, how's it going?"}], + mock_testing_fallbacks=True, ) ``` @@ -431,32 +431,32 @@ content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}] from litellm import Router router = Router( - model_list=[ - { - "model_name": "claude-2", - "litellm_params": { - "model": "claude-2", - "api_key": "", - "mock_response": Exception("content filtering policy"), - }, - }, - { - "model_name": "my-fallback-model", - "litellm_params": { - "model": "claude-2", - "api_key": "", - "mock_response": "This works!", - }, - }, - ], - content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}], # 👈 KEY CHANGE - # fallbacks=[..], # [OPTIONAL] - # context_window_fallbacks=[..], # [OPTIONAL] + model_list=[ + { + "model_name": "claude-2", + "litellm_params": { + "model": "claude-2", + "api_key": "", + "mock_response": Exception("content filtering policy"), + }, + }, + { + "model_name": "my-fallback-model", + "litellm_params": { + "model": "claude-2", + "api_key": "", + "mock_response": "This works!", + }, + }, + ], + content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}], # 👈 KEY CHANGE + # fallbacks=[..], # [OPTIONAL] + # context_window_fallbacks=[..], # [OPTIONAL] ) response = router.completion( - model="claude-2", - messages=[{"role": "user", "content": "Hey, how's it going?"}], + model="claude-2", + messages=[{"role": "user", "content": "Hey, how's it going?"}], ) ``` @@ -466,7 +466,7 @@ In your proxy config.yaml just add this line 👇 ```yaml router_settings: - content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}] + content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}] ``` Start proxy @@ -495,32 +495,32 @@ context_window_fallbacks=[{"claude-2": ["my-fallback-model"]}] from litellm import Router router = Router( - model_list=[ - { - "model_name": "claude-2", - "litellm_params": { - "model": "claude-2", - "api_key": "", - "mock_response": Exception("prompt is too long"), - }, - }, - { - "model_name": "my-fallback-model", - "litellm_params": { - "model": "claude-2", - "api_key": "", - "mock_response": "This works!", - }, - }, - ], - context_window_fallbacks=[{"claude-2": ["my-fallback-model"]}], # 👈 KEY CHANGE - # fallbacks=[..], # [OPTIONAL] - # content_policy_fallbacks=[..], # [OPTIONAL] + model_list=[ + { + "model_name": "claude-2", + "litellm_params": { + "model": "claude-2", + "api_key": "", + "mock_response": Exception("prompt is too long"), + }, + }, + { + "model_name": "my-fallback-model", + "litellm_params": { + "model": "claude-2", + "api_key": "", + "mock_response": "This works!", + }, + }, + ], + context_window_fallbacks=[{"claude-2": ["my-fallback-model"]}], # 👈 KEY CHANGE + # fallbacks=[..], # [OPTIONAL] + # content_policy_fallbacks=[..], # [OPTIONAL] ) response = router.completion( - model="claude-2", - messages=[{"role": "user", "content": "Hey, how's it going?"}], + model="claude-2", + messages=[{"role": "user", "content": "Hey, how's it going?"}], ) ``` @@ -530,7 +530,7 @@ In your proxy config.yaml just add this line 👇 ```yaml router_settings: - context_window_fallbacks=[{"claude-2": ["my-fallback-model"]}] + context_window_fallbacks=[{"claude-2": ["my-fallback-model"]}] ``` Start proxy @@ -725,22 +725,22 @@ Filter older instances of a model (e.g. gpt-3.5-turbo) with smaller context wind ```yaml router_settings: - enable_pre_call_checks: true # 1. Enable pre-call checks + enable_pre_call_checks: true # 1. Enable pre-call checks model_list: - - model_name: gpt-3.5-turbo - litellm_params: - model: azure/chatgpt-v-2 - api_base: os.environ/AZURE_API_BASE - api_key: os.environ/AZURE_API_KEY - api_version: "2023-07-01-preview" - model_info: - base_model: azure/gpt-4-1106-preview # 2. 👈 (azure-only) SET BASE MODEL - - - model_name: gpt-3.5-turbo - litellm_params: - model: gpt-3.5-turbo-1106 - api_key: os.environ/OPENAI_API_KEY + - model_name: gpt-3.5-turbo + litellm_params: + model: azure/chatgpt-v-2 + api_base: os.environ/AZURE_API_BASE + api_key: os.environ/AZURE_API_KEY + api_version: "2023-07-01-preview" + model_info: + base_model: azure/gpt-4-1106-preview # 2. 👈 (azure-only) SET BASE MODEL + + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo-1106 + api_key: os.environ/OPENAI_API_KEY ``` **2. Start proxy** @@ -766,8 +766,8 @@ text = "What is the meaning of 42?" * 5000 response = client.chat.completions.create( model="gpt-3.5-turbo", messages = [ - {"role": "system", "content": text}, - {"role": "user", "content": "Who was Alexander?"}, + {"role": "system", "content": text}, + {"role": "user", "content": "Who was Alexander?"}, ], ) @@ -782,20 +782,20 @@ Fallback to larger models if current model is too small. ```yaml router_settings: - enable_pre_call_checks: true # 1. Enable pre-call checks + enable_pre_call_checks: true # 1. Enable pre-call checks model_list: - - model_name: gpt-3.5-turbo-small - litellm_params: - model: azure/chatgpt-v-2 + - model_name: gpt-3.5-turbo-small + litellm_params: + model: azure/chatgpt-v-2 api_base: os.environ/AZURE_API_BASE api_key: os.environ/AZURE_API_KEY api_version: "2023-07-01-preview" model_info: base_model: azure/gpt-4-1106-preview # 2. 👈 (azure-only) SET BASE MODEL - - - model_name: gpt-3.5-turbo-large - litellm_params: + + - model_name: gpt-3.5-turbo-large + litellm_params: model: gpt-3.5-turbo-1106 api_key: os.environ/OPENAI_API_KEY @@ -831,8 +831,8 @@ text = "What is the meaning of 42?" * 5000 response = client.chat.completions.create( model="gpt-3.5-turbo", messages = [ - {"role": "system", "content": text}, - {"role": "user", "content": "Who was Alexander?"}, + {"role": "system", "content": text}, + {"role": "user", "content": "Who was Alexander?"}, ], ) @@ -849,9 +849,9 @@ Fallback across providers (e.g. from Azure OpenAI to Anthropic) if you hit conte ```yaml model_list: - - model_name: gpt-3.5-turbo-small - litellm_params: - model: azure/chatgpt-v-2 + - model_name: gpt-3.5-turbo-small + litellm_params: + model: azure/chatgpt-v-2 api_base: os.environ/AZURE_API_BASE api_key: os.environ/AZURE_API_KEY api_version: "2023-07-01-preview" @@ -874,9 +874,9 @@ You can also set default_fallbacks, in case a specific model group is misconfigu ```yaml model_list: - - model_name: gpt-3.5-turbo-small - litellm_params: - model: azure/chatgpt-v-2 + - model_name: gpt-3.5-turbo-small + litellm_params: + model: azure/chatgpt-v-2 api_base: os.environ/AZURE_API_BASE api_key: os.environ/AZURE_API_KEY api_version: "2023-07-01-preview" @@ -906,7 +906,7 @@ Set 'region_name' of deployment. ```yaml router_settings: - enable_pre_call_checks: true # 1. Enable pre-call checks + enable_pre_call_checks: true # 1. Enable pre-call checks model_list: - model_name: gpt-3.5-turbo From b30439257bc94f3ea883a9484735a19ec3127221 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 12 Nov 2025 18:20:14 -0800 Subject: [PATCH 4/4] [Feat] Add RunwayML Img Gen API support (#16557) * TestRunwaymlImageGeneration * fix RUNWAYML * rename * fix rename * get_runwayml_image_generation_config * get_runwayml_image_generation_config * TestRunwaymlImageGeneration * add RUNWAYML_POLLING_TIMEOUT * fix rnwayml transform img gen * runwayml_image_cost_calculator * runwayml_image_cost_calculator * docs runwayml * fix runwayML polling * test_get_first_default_fallback --- .../docs/providers/runwayml/images.md | 198 +++++++ docs/my-website/sidebars.js | 1 + litellm/constants.py | 1 + litellm/images/main.py | 1 + .../litellm_core_utils/llm_cost_calc/utils.py | 9 + litellm/llms/runway/__init__.py | 2 - litellm/llms/runwayml/__init__.py | 6 + litellm/llms/runwayml/cost_calculator.py | 31 ++ .../runwayml/image_generation/__init__.py | 13 + .../image_generation/transformation.py | 513 ++++++++++++++++++ .../{runway => runwayml}/videos/__init__.py | 0 .../videos/transformation.py | 0 litellm/utils.py | 8 +- provider_endpoints_support.json | 2 +- .../image_gen_tests/test_image_generation.py | 4 + .../test_router_helper_utils.py | 40 ++ .../test_runway_video_transformation.py | 2 +- 17 files changed, 826 insertions(+), 5 deletions(-) create mode 100644 docs/my-website/docs/providers/runwayml/images.md delete mode 100644 litellm/llms/runway/__init__.py create mode 100644 litellm/llms/runwayml/__init__.py create mode 100644 litellm/llms/runwayml/cost_calculator.py create mode 100644 litellm/llms/runwayml/image_generation/__init__.py create mode 100644 litellm/llms/runwayml/image_generation/transformation.py rename litellm/llms/{runway => runwayml}/videos/__init__.py (100%) rename litellm/llms/{runway => runwayml}/videos/transformation.py (100%) rename tests/test_litellm/llms/{runway => runwayml}/videos/test_runway_video_transformation.py (99%) diff --git a/docs/my-website/docs/providers/runwayml/images.md b/docs/my-website/docs/providers/runwayml/images.md new file mode 100644 index 0000000000..00146d10ba --- /dev/null +++ b/docs/my-website/docs/providers/runwayml/images.md @@ -0,0 +1,198 @@ +# RunwayML - Image Generation + +## Overview + +| Property | Details | +|-------|-------| +| Description | RunwayML provides advanced AI-powered image generation with high-quality results | +| Provider Route on LiteLLM | `runwayml/` | +| Supported Operations | [`/images/generations`](#quick-start) | +| Link to Provider Doc | [RunwayML API ↗](https://docs.dev.runwayml.com/) | + +LiteLLM supports RunwayML's Gen-4 image generation API, allowing you to generate high-quality images from text prompts. + +## Quick Start + +```python showLineNumbers title="Basic Image Generation" +from litellm import image_generation +import os + +os.environ["RUNWAYML_API_KEY"] = "your-api-key" + +response = image_generation( + model="runwayml/gen4_image", + prompt="A serene mountain landscape at sunset", + size="1920x1080" +) + +print(response.data[0].url) +``` + +## Authentication + +Set your RunwayML API key: + +```python showLineNumbers title="Set API Key" +import os + +os.environ["RUNWAYML_API_KEY"] = "your-api-key" +``` + +## Supported Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `model` | string | Yes | Model to use (e.g., `runwayml/gen4_image`) | +| `prompt` | string | Yes | Text description for the image | +| `size` | string | No | Image dimensions (default: `1920x1080`) | + +### Supported Sizes + +- `1024x1024` +- `1792x1024` +- `1024x1792` +- `1920x1080` (default) +- `1080x1920` + +## Async Usage + +```python showLineNumbers title="Async Image Generation" +from litellm import aimage_generation +import os +import asyncio + +os.environ["RUNWAYML_API_KEY"] = "your-api-key" + +async def generate_image(): + response = await aimage_generation( + model="runwayml/gen4_image", + prompt="A futuristic city skyline at night", + size="1920x1080" + ) + + print(response.data[0].url) + +asyncio.run(generate_image()) +``` + +## LiteLLM Proxy Usage + +Add RunwayML to your proxy configuration: + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gen4-image + litellm_params: + model: runwayml/gen4_image + api_key: os.environ/RUNWAYML_API_KEY +``` + +Start the proxy: + +```bash +litellm --config /path/to/config.yaml +``` + +Generate images through the proxy: + +```bash showLineNumbers title="Proxy Request" +curl --location 'http://localhost:4000/v1/images/generations' \ +--header 'Content-Type: application/json' \ +--header 'x-litellm-api-key: sk-1234' \ +--data '{ + "model": "runwayml/gen4_image", + "prompt": "A serene mountain landscape at sunset", + "size": "1920x1080" +}' +``` + +## Supported Models + +| Model | Description | Default Size | +|-------|-------------|--------------| +| `runwayml/gen4_image` | High-quality image generation | 1920x1080 | + +## Cost Tracking + +LiteLLM automatically tracks RunwayML image generation costs: + +```python showLineNumbers title="Cost Tracking" +from litellm import image_generation, completion_cost + +response = image_generation( + model="runwayml/gen4_image", + prompt="A serene mountain landscape at sunset", + size="1920x1080" +) + +cost = completion_cost(completion_response=response) +print(f"Image generation cost: ${cost}") +``` + +## Supported Features + +| Feature | Supported | +|---------|-----------| +| Image Generation | ✅ | +| Cost Tracking | ✅ | +| Logging | ✅ | +| Fallbacks | ✅ | +| Load Balancing | ✅ | + + + +## How It Works + +RunwayML uses an asynchronous task-based API pattern. LiteLLM handles the polling and response transformation automatically. + +### Complete Flow Diagram + +```mermaid +sequenceDiagram + participant Client + box rgb(200, 220, 255) LiteLLM AI Gateway + participant LiteLLM + end + participant RunwayML as RunwayML API + + Client->>LiteLLM: POST /images/generations (OpenAI format) + Note over LiteLLM: Transform to RunwayML format + + LiteLLM->>RunwayML: POST v1/text_to_image + RunwayML-->>LiteLLM: 200 OK + task ID + + Note over LiteLLM: Automatic Polling + loop Every 2 seconds + LiteLLM->>RunwayML: GET v1/tasks/{task_id} + RunwayML-->>LiteLLM: Status: RUNNING + end + + LiteLLM->>RunwayML: GET v1/tasks/{task_id} + RunwayML-->>LiteLLM: Status: SUCCEEDED + image URL + + Note over LiteLLM: Transform to OpenAI format + LiteLLM-->>Client: Image Response (OpenAI format) +``` + +### What LiteLLM Does For You + +When you call `litellm.image_generation()` or `/v1/images/generations`: + +1. **Request Transformation**: Converts OpenAI image generation format → RunwayML format +2. **Submits Task**: Sends transformed request to RunwayML API +3. **Receives Task ID**: Captures the task ID from the initial response +4. **Automatic Polling**: + - Polls the task status endpoint every 2 seconds + - Continues until status is `SUCCEEDED` or `FAILED` + - Default timeout: 10 minutes (configurable via `RUNWAYML_POLLING_TIMEOUT`) +5. **Response Transformation**: Converts RunwayML format → OpenAI format +6. **Returns Result**: Sends unified OpenAI format response to client + +**Polling Configuration:** +- Default timeout: 600 seconds (10 minutes) +- Configurable via `RUNWAYML_POLLING_TIMEOUT` environment variable +- Uses sync (`time.sleep()`) or async (`await asyncio.sleep()`) based on call type + +:::info +**Typical processing time**: 10-30 seconds depending on image size and complexity +::: diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index ddd23ec481..0b6c31cc1f 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -585,6 +585,7 @@ const sidebars = { type: "category", label: "RunwayML", items: [ + "providers/runwayml/images", "providers/runwayml/videos", ] }, diff --git a/litellm/constants.py b/litellm/constants.py index 220e425068..d22772c7fb 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -86,6 +86,7 @@ MAX_TOKEN_TRIMMING_ATTEMPTS = int( ) # Maximum number of attempts to trim the message RUNWAYML_DEFAULT_API_VERSION = str(os.getenv("RUNWAYML_DEFAULT_API_VERSION", "2024-11-06")) +RUNWAYML_POLLING_TIMEOUT = int(os.getenv("RUNWAYML_POLLING_TIMEOUT", 600)) # 10 minutes default for image generation ########## Networking constants ############################################################## _DEFAULT_TTL_FOR_HTTPX_CLIENTS = 3600 # 1 hour, re-use the same httpx client for 1 hour diff --git a/litellm/images/main.py b/litellm/images/main.py index 5be5f99381..ce6da640f9 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -343,6 +343,7 @@ def image_generation( # noqa: PLR0915 litellm.LlmProviders.AIML, litellm.LlmProviders.GEMINI, litellm.LlmProviders.FAL_AI, + litellm.LlmProviders.RUNWAYML, ): if image_generation_config is None: raise ValueError( diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 99f3853d21..b55065352d 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -735,6 +735,15 @@ class CostCalculatorUtils: model=model, image_response=completion_response, ) + elif custom_llm_provider == litellm.LlmProviders.RUNWAYML.value: + from litellm.llms.runwayml.cost_calculator import ( + cost_calculator as runwayml_image_cost_calculator, + ) + + return runwayml_image_cost_calculator( + model=model, + image_response=completion_response, + ) else: return default_image_cost_calculator( model=model, diff --git a/litellm/llms/runway/__init__.py b/litellm/llms/runway/__init__.py deleted file mode 100644 index d922b8b072..0000000000 --- a/litellm/llms/runway/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# RunwayML integration for LiteLLM - diff --git a/litellm/llms/runwayml/__init__.py b/litellm/llms/runwayml/__init__.py new file mode 100644 index 0000000000..bf69b7b771 --- /dev/null +++ b/litellm/llms/runwayml/__init__.py @@ -0,0 +1,6 @@ +# RunwayML integration for LiteLLM + +from .cost_calculator import cost_calculator +from .videos.transformation import RunwayMLVideoConfig + +__all__ = ["RunwayMLVideoConfig", "cost_calculator"] diff --git a/litellm/llms/runwayml/cost_calculator.py b/litellm/llms/runwayml/cost_calculator.py new file mode 100644 index 0000000000..fa3cd26d08 --- /dev/null +++ b/litellm/llms/runwayml/cost_calculator.py @@ -0,0 +1,31 @@ +from typing import Any + +import litellm +from litellm.types.utils import ImageResponse + + +def cost_calculator( + model: str, + image_response: Any, +) -> float: + """ + RunwayML image generation cost calculator. + + RunwayML charges per image generated, not per pixel. + Pricing is stored in model_prices_and_context_window.json with output_cost_per_image. + """ + _model_info = litellm.get_model_info( + model=model, + custom_llm_provider=litellm.LlmProviders.RUNWAYML.value, + ) + output_cost_per_image: float = _model_info.get("output_cost_per_image") or 0.0 + num_images: int = 0 + if isinstance(image_response, ImageResponse): + if image_response.data: + num_images = len(image_response.data) + return output_cost_per_image * num_images + else: + raise ValueError( + f"image_response must be of type ImageResponse, got type={type(image_response)}" + ) + diff --git a/litellm/llms/runwayml/image_generation/__init__.py b/litellm/llms/runwayml/image_generation/__init__.py new file mode 100644 index 0000000000..548d6da782 --- /dev/null +++ b/litellm/llms/runwayml/image_generation/__init__.py @@ -0,0 +1,13 @@ +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) + +from .transformation import RunwayMLImageGenerationConfig + +__all__ = [ + "RunwayMLImageGenerationConfig", +] + + +def get_runwayml_image_generation_config(model: str) -> BaseImageGenerationConfig: + return RunwayMLImageGenerationConfig() diff --git a/litellm/llms/runwayml/image_generation/transformation.py b/litellm/llms/runwayml/image_generation/transformation.py new file mode 100644 index 0000000000..e92ffa8e9c --- /dev/null +++ b/litellm/llms/runwayml/image_generation/transformation.py @@ -0,0 +1,513 @@ +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import httpx + +from litellm._logging import verbose_logger +from litellm.constants import ( + RUNWAYML_DEFAULT_API_VERSION, + RUNWAYML_POLLING_TIMEOUT, +) +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIImageGenerationOptionalParams, +) +from litellm.types.utils import ImageObject, ImageResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class RunwayMLImageGenerationConfig(BaseImageGenerationConfig): + """ + Configuration for RunwayML image generation models. + """ + DEFAULT_BASE_URL: str = "https://api.dev.runwayml.com" + IMAGE_GENERATION_ENDPOINT: str = "v1/text_to_image" + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Get the complete url for the request + + Some providers need `model` in `api_base` + """ + complete_url: str = ( + api_base + or get_secret_str("RUNWAYML_API_BASE") + or self.DEFAULT_BASE_URL + ) + + complete_url = complete_url.rstrip("/") + if self.IMAGE_GENERATION_ENDPOINT: + complete_url = f"{complete_url}/{self.IMAGE_GENERATION_ENDPOINT}" + return complete_url + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + final_api_key: Optional[str] = ( + api_key or + get_secret_str("RUNWAYML_API_SECRET") or + get_secret_str("RUNWAYML_API_KEY") + ) + if not final_api_key: + raise ValueError("RUNWAYML_API_SECRET or RUNWAYML_API_KEY is not set") + + headers["Authorization"] = f"Bearer {final_api_key}" + headers["X-Runway-Version"] = RUNWAYML_DEFAULT_API_VERSION + return headers + + @staticmethod + def _transform_runwayml_response_to_openai( + response_data: Dict[str, Any], + model_response: ImageResponse, + ) -> ImageResponse: + """ + Transform RunwayML response format to OpenAI ImageResponse format. + + RunwayML response format (after polling): + { + "id": "task_123...", + "status": "SUCCEEDED", + "output": ["https://cloudfront.net/.../image.png"], + "completedAt": "2025-11-13T..." + } + + OpenAI ImageResponse format: + { + "data": [ + { + "url": "https://cloudfront.net/.../image.png", + "b64_json": null + } + ] + } + + Args: + response_data: JSON response from RunwayML (after polling completes) + model_response: ImageResponse object to populate + + Returns: + Populated ImageResponse in OpenAI format + """ + if not model_response.data: + model_response.data = [] + + # Handle RunwayML response format + # Response contains task.output with image URL(s) + output = response_data.get("output", []) + + if isinstance(output, list): + for image_item in output: + if isinstance(image_item, str): + # If output is a list of URL strings + model_response.data.append(ImageObject( + url=image_item, + b64_json=None, + )) + elif isinstance(image_item, dict): + # If output contains dict with url/b64_json + model_response.data.append(ImageObject( + url=image_item.get("url", None), + b64_json=image_item.get("b64_json", None), + )) + + return model_response + + @staticmethod + def _check_timeout(start_time: float, timeout_secs: float) -> None: + """ + Check if operation has timed out. + + Args: + start_time: Start time of the operation + timeout_secs: Timeout duration in seconds + + Raises: + TimeoutError: If operation has exceeded timeout + """ + if time.time() - start_time > timeout_secs: + raise TimeoutError( + f"RunwayML task polling timed out after {timeout_secs} seconds" + ) + + @staticmethod + def _check_task_status(response_data: Dict[str, Any]) -> str: + """ + Check RunwayML task status from response. + + RunwayML statuses: PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED, THROTTLED + + Args: + response_data: JSON response from RunwayML task endpoint + + Returns: + Normalized status string: "running", "succeeded", or raises on failure + + Raises: + ValueError: If task failed or status is unknown + """ + status = response_data.get("status", "").upper() + + verbose_logger.debug(f"RunwayML task status: {status}") + + if status == "SUCCEEDED": + return "succeeded" + elif status == "FAILED": + failure_reason = response_data.get("failure", "Unknown error") + failure_code = response_data.get("failureCode", "unknown") + raise ValueError( + f"RunwayML image generation failed: {failure_reason} (code: {failure_code})" + ) + elif status == "CANCELLED": + raise ValueError("RunwayML image generation was cancelled") + elif status in ["PENDING", "RUNNING", "THROTTLED"]: + return "running" + else: + raise ValueError(f"Unknown RunwayML task status: {status}") + + def _poll_task_sync( + self, + task_id: str, + api_base: str, + headers: Dict[str, str], + timeout_secs: float = 600, + ) -> httpx.Response: + """ + Poll RunwayML task until completion (sync). + + RunwayML POST returns immediately with a task that has status PENDING/RUNNING. + We need to poll GET /v1/tasks/{task_id} until status is SUCCEEDED or FAILED. + + Args: + task_id: The task ID to poll + api_base: Base URL for RunwayML API + headers: Request headers (including auth) + timeout_secs: Total timeout in seconds (default: 600s = 10 minutes) + + Returns: + Final response with completed task + """ + from litellm.llms.custom_httpx.http_handler import _get_httpx_client + + client = _get_httpx_client() + start_time = time.time() + + # Build task status URL + api_base = api_base.rstrip("/") + task_url = f"{api_base}/v1/tasks/{task_id}" + + verbose_logger.debug(f"Polling RunwayML task: {task_url}") + + while True: + self._check_timeout(start_time=start_time, timeout_secs=timeout_secs) + + # Poll the task status + response = client.get(url=task_url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Check task status + status = self._check_task_status(response_data=response_data) + + if status == "succeeded": + return response + elif status == "running": + # Wait before polling again (RunwayML recommends 1-2 second intervals) + time.sleep(2) + + async def _poll_task_async( + self, + task_id: str, + api_base: str, + headers: Dict[str, str], + timeout_secs: float = 600, + ) -> httpx.Response: + """ + Poll RunwayML task until completion (async). + + Args: + task_id: The task ID to poll + api_base: Base URL for RunwayML API + headers: Request headers (including auth) + timeout_secs: Total timeout in seconds (default: 600s = 10 minutes) + + Returns: + Final response with completed task + """ + import litellm + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + + client = get_async_httpx_client(llm_provider=litellm.LlmProviders.RUNWAYML) + start_time = time.time() + + # Build task status URL + api_base = api_base.rstrip("/") + task_url = f"{api_base}/v1/tasks/{task_id}" + + verbose_logger.debug(f"Polling RunwayML task (async): {task_url}") + + while True: + self._check_timeout(start_time=start_time, timeout_secs=timeout_secs) + + # Poll the task status + response = await client.get(url=task_url, headers=headers) + response.raise_for_status() + + response_data = response.json() + + # Check task status + status = self._check_task_status(response_data=response_data) + + if status == "succeeded": + return response + elif status == "running": + # Wait before polling again (RunwayML recommends 1-2 second intervals) + await asyncio.sleep(2) + + def transform_image_generation_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ImageResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ImageResponse: + """ + Transform the image generation response to the litellm image response. + + RunwayML returns a task immediately with status PENDING/RUNNING. + We need to poll the task until it completes (status SUCCEEDED). + + Initial response: + { + "id": "task_123...", + "status": "PENDING" | "RUNNING", + "createdAt": "2025-11-13T..." + } + + After polling: + { + "id": "task_123...", + "status": "SUCCEEDED", + "output": ["https://cloudfront.net/.../image.png"], + "completedAt": "2025-11-13T..." + } + """ + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Error transforming image generation response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + + verbose_logger.debug( + "RunwayML starting polling..." + ) + + # Get task ID + task_id = response_data.get("id") + if not task_id: + raise ValueError("RunwayML response missing task ID") + + # Get headers for polling (need auth) + poll_headers = { + "Authorization": raw_response.request.headers.get("Authorization", ""), + "X-Runway-Version": raw_response.request.headers.get("X-Runway-Version", RUNWAYML_DEFAULT_API_VERSION), + } + + # Poll until task completes + raw_response = self._poll_task_sync( + task_id=task_id, + api_base=self.DEFAULT_BASE_URL, + headers=poll_headers, + timeout_secs=RUNWAYML_POLLING_TIMEOUT, + ) + + # Update response_data with polled result + response_data = raw_response.json() + + verbose_logger.debug("RunwayML polling complete, transforming to OpenAI format") + + # Transform RunwayML response to OpenAI format + return self._transform_runwayml_response_to_openai( + response_data=response_data, + model_response=model_response, + ) + + async def async_transform_image_generation_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ImageResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ImageResponse: + """ + Async transform the image generation response to the litellm image response. + + RunwayML returns a task immediately with status PENDING/RUNNING. + We need to poll the task until it completes (status SUCCEEDED) using async polling. + """ + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Error transforming image generation response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + verbose_logger.debug( + "RunwayML starting polling (async)..." + ) + + # Get task ID + task_id = response_data.get("id") + if not task_id: + raise ValueError("RunwayML response missing task ID") + + # Get headers for polling (need auth) + poll_headers = { + "Authorization": raw_response.request.headers.get("Authorization", ""), + "X-Runway-Version": raw_response.request.headers.get("X-Runway-Version", RUNWAYML_DEFAULT_API_VERSION), + } + + # Poll until task completes (async) + raw_response = await self._poll_task_async( + task_id=task_id, + api_base=self.DEFAULT_BASE_URL, + headers=poll_headers, + timeout_secs=RUNWAYML_POLLING_TIMEOUT, + ) + + # Update response_data with polled result + response_data = raw_response.json() + + verbose_logger.debug("RunwayML polling complete (async), transforming to OpenAI format") + + # Transform RunwayML response to OpenAI format + return self._transform_runwayml_response_to_openai( + response_data=response_data, + model_response=model_response, + ) + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIImageGenerationOptionalParams]: + """ + Get supported OpenAI parameters for RunwayML image generation + """ + return [ + "size", + ] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + + # Map OpenAI 'size' parameter to RunwayML 'ratio' parameter + if "size" in non_default_params: + size = non_default_params["size"] + # Map common OpenAI sizes to RunwayML ratios + size_to_ratio_map = { + "1024x1024": "1024:1024", + "1792x1024": "1792:1024", + "1024x1792": "1024:1792", + "1920x1080": "1920:1080", + "1080x1920": "1080:1920", + } + optional_params["ratio"] = size_to_ratio_map.get(size, "1920:1080") + + for k in non_default_params.keys(): + if k not in optional_params.keys(): + if k in supported_params: + optional_params[k] = non_default_params[k] + elif drop_params: + pass + else: + raise ValueError( + f"Parameter {k} is not supported for model {model}. Supported parameters are {supported_params}. Set drop_params=True to drop unsupported parameters." + ) + + return optional_params + + def transform_image_generation_request( + self, + model: str, + prompt: str, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform the image generation request to the RunwayML image generation request body + + RunwayML expects: + - model: The model to use (e.g., 'gen4_image') + - promptText: The text prompt + - ratio: The aspect ratio (e.g., '1920:1080', '1080:1920', '1024:1024') + """ + runwayml_request_body = { + "model": model or "gen4_image", + "promptText": prompt, + } + + # Add any RunwayML-specific parameters + if "ratio" in optional_params: + runwayml_request_body["ratio"] = optional_params["ratio"] + else: + # Set default ratio if not provided + runwayml_request_body["ratio"] = "1920:1080" + + + # Add any other optional parameters + for k, v in optional_params.items(): + if k not in runwayml_request_body and k not in ["size"]: + runwayml_request_body[k] = v + + return runwayml_request_body + diff --git a/litellm/llms/runway/videos/__init__.py b/litellm/llms/runwayml/videos/__init__.py similarity index 100% rename from litellm/llms/runway/videos/__init__.py rename to litellm/llms/runwayml/videos/__init__.py diff --git a/litellm/llms/runway/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py similarity index 100% rename from litellm/llms/runway/videos/transformation.py rename to litellm/llms/runwayml/videos/transformation.py diff --git a/litellm/utils.py b/litellm/utils.py index 935edc9952..4b9c1d9051 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7635,6 +7635,12 @@ class ProviderConfigManager: ) return get_fal_ai_image_generation_config(model) + elif LlmProviders.RUNWAYML == provider: + from litellm.llms.runwayml.image_generation import ( + get_runwayml_image_generation_config, + ) + + return get_runwayml_image_generation_config(model) return None @staticmethod @@ -7661,7 +7667,7 @@ class ProviderConfigManager: return VertexAIVideoConfig() elif LlmProviders.RUNWAYML == provider: - from litellm.llms.runway.videos.transformation import RunwayMLVideoConfig + from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig return RunwayMLVideoConfig() return None diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 9094217b69..6c3be897f4 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1366,7 +1366,7 @@ "messages": false, "responses": false, "embeddings": false, - "image_generations": false, + "image_generations": true, "audio_transcriptions": false, "audio_speech": false, "moderations": false, diff --git a/tests/image_gen_tests/test_image_generation.py b/tests/image_gen_tests/test_image_generation.py index cca021ad16..a6fe842ebe 100644 --- a/tests/image_gen_tests/test_image_generation.py +++ b/tests/image_gen_tests/test_image_generation.py @@ -175,6 +175,10 @@ class TestGoogleImageGen(BaseImageGenTest): def get_base_image_generation_call_args(self) -> dict: return {"model": "gemini/imagen-4.0-generate-001"} +class TestRunwaymlImageGeneration(BaseImageGenTest): + def get_base_image_generation_call_args(self) -> dict: + return {"model": "runwayml/gen4_image"} + class TestAzureOpenAIDalle3(BaseImageGenTest): def get_base_image_generation_call_args(self) -> dict: diff --git a/tests/router_unit_tests/test_router_helper_utils.py b/tests/router_unit_tests/test_router_helper_utils.py index fe86d0abe7..7a5bfb31fe 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -1935,3 +1935,43 @@ async def test_asearch_with_fallbacks_helper_missing_search_provider(): original_generic_function=mock_original_function, query="test query" ) + + +def test_get_first_default_fallback(): + """Test _get_first_default_fallback method""" + # Test with default fallback ("*") + model_list = [ + { + "model_name": "gpt-3.5-turbo", + "litellm_params": {"model": "gpt-3.5-turbo", "api_key": "fake-key"}, + } + ] + + router = Router( + model_list=model_list, + fallbacks=[{"*": ["gpt-3.5-turbo"]}] + ) + + result = router._get_first_default_fallback() + assert result == "gpt-3.5-turbo" + + # Test with no fallbacks + router_no_fallbacks = Router(model_list=model_list) + result = router_no_fallbacks._get_first_default_fallback() + assert result is None + + # Test with fallbacks but no default + router_no_default = Router( + model_list=model_list, + fallbacks=[{"gpt-4": ["gpt-3.5-turbo"]}] + ) + result = router_no_default._get_first_default_fallback() + assert result is None + + # Test with empty default list + router_empty_list = Router( + model_list=model_list, + fallbacks=[{"*": []}] + ) + result = router_empty_list._get_first_default_fallback() + assert result is None diff --git a/tests/test_litellm/llms/runway/videos/test_runway_video_transformation.py b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py similarity index 99% rename from tests/test_litellm/llms/runway/videos/test_runway_video_transformation.py rename to tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py index bab94b9415..0edaf80766 100644 --- a/tests/test_litellm/llms/runway/videos/test_runway_video_transformation.py +++ b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py @@ -6,7 +6,7 @@ from unittest.mock import Mock import httpx import pytest -from litellm.llms.runway.videos.transformation import RunwayMLVideoConfig +from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig from litellm.types.router import GenericLiteLLMParams from litellm.types.videos.main import VideoObject