diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f1456b1fde8..ffcde450a49 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -384,6 +384,7 @@ jobs: tests/unit/llms/voyage tests/unit/llms/wandb tests/unit/llms/watsonx + tests/unit/llms/webwright tests/unit/llms/xai tests/unit/llms/xinference tests/unit/llms/you_com diff --git a/README.md b/README.md index 31a93bf8c67..8e15702a32f 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th | [VoyageAI by MongoDB (`voyage`)](https://docs.litellm.ai/docs/providers/voyage) | | | | ✅ | | | | | | | | [WandB Inference (`wandb`)](https://docs.litellm.ai/docs/providers/wandb_inference) | ✅ | ✅ | ✅ | | | | | | | | | [Watsonx Text (`watsonx_text`)](https://docs.litellm.ai/docs/providers/watsonx) | ✅ | ✅ | ✅ | | | | | | | | +| [WebWright (`webwright`)](https://docs.litellm.ai/docs/providers/webwright) | ✅ | ✅ | ✅ | | | | | | | | | [xAI (`xai`)](https://docs.litellm.ai/docs/providers/xai) | ✅ | ✅ | ✅ | | | | | | | | | [Xinference (`xinference`)](https://docs.litellm.ai/docs/providers/xinference) | | | | ✅ | | | | | | | | [ZeroGPU (`zerogpu`)](https://docs.litellm.ai/docs/providers/zerogpu) | ✅ | ✅ | ✅ | | | | | | | | diff --git a/litellm/__init__.py b/litellm/__init__.py index 8e5dd68a186..657f8cfc057 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -696,6 +696,7 @@ opencode_go_models: Final[set[str]] = set() # mutable-ok: populated when the sh lambda_ai_models: Set = set() inception_models: Set = set() hyperbolic_models: Set = set() +webwright_models: set[str] = set() # mutable-ok: mirrors the sibling provider model sets black_forest_labs_models: Set = set() recraft_models: Set = set() cometapi_models: Set = set() @@ -1001,6 +1002,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje inception_models.add(key) elif value.get("litellm_provider") == "hyperbolic": hyperbolic_models.add(key) + elif value.get("litellm_provider") == "webwright": + webwright_models.add(key) elif value.get("litellm_provider") == "black_forest_labs": black_forest_labs_models.add(key) elif value.get("litellm_provider") == "recraft": @@ -1310,6 +1313,7 @@ def _build_models_by_provider() -> dict: "lambda_ai": lambda_ai_models, "inception": inception_models, "hyperbolic": hyperbolic_models, + "webwright": webwright_models, "black_forest_labs": black_forest_labs_models, "recraft": recraft_models, "cometapi": cometapi_models, @@ -2305,6 +2309,9 @@ if TYPE_CHECKING: from .llms.hyperbolic.chat.transformation import ( HyperbolicChatConfig as HyperbolicChatConfig, ) + from .llms.webwright.chat.transformation import ( + WebwrightChatConfig as WebwrightChatConfig, + ) from .llms.vercel_ai_gateway.chat.transformation import ( VercelAIGatewayConfig as VercelAIGatewayConfig, ) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index bf903e23bc0..f2e9b72db32 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -359,6 +359,7 @@ LLM_CONFIG_NAMES: Final = ( "LambdaAIChatConfig", "InceptionChatConfig", "HyperbolicChatConfig", + "WebwrightChatConfig", "VercelAIGatewayConfig", "EdenAIChatConfig", "EdenAIResponsesAPIConfig", @@ -1325,6 +1326,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = { ".llms.hyperbolic.chat.transformation", "HyperbolicChatConfig", ), + "WebwrightChatConfig": ( + ".llms.webwright.chat.transformation", + "WebwrightChatConfig", + ), "VercelAIGatewayConfig": ( ".llms.vercel_ai_gateway.chat.transformation", "VercelAIGatewayConfig", diff --git a/litellm/constants.py b/litellm/constants.py index 8d05115e76e..9894dfa3bfb 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1078,6 +1078,7 @@ openai_compatible_endpoints: Final[list[str]] = [ "https://api.lambda.ai/v1", "https://api.inceptionlabs.ai/v1", "https://api.hyperbolic.xyz/v1", + "https://webwright.ai/v1", "https://ai-gateway.helicone.ai/", "https://ai-gateway.vercel.sh/v1", "https://api.edenai.run/v3", @@ -1160,6 +1161,7 @@ openai_compatible_providers: Final[list[str]] = [ "lambda_ai", "inception", "hyperbolic", + "webwright", "vercel_ai_gateway", "aiml", "edenai", @@ -1209,6 +1211,7 @@ openai_text_completion_compatible_providers: Final[list] = [ # providers that s "v0", "lambda_ai", "hyperbolic", + "webwright", "wandb", ] _openai_like_providers: Final[list[str]] = [ diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 1ffb5e2f8e5..34dba44a3cf 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -412,6 +412,9 @@ def get_llm_provider( elif endpoint == "https://api.hyperbolic.xyz/v1": custom_llm_provider = "hyperbolic" dynamic_api_key = get_secret_str("HYPERBOLIC_API_KEY") + elif endpoint == "https://webwright.ai/v1": + custom_llm_provider = "webwright" # rebind-ok: mirrors the sibling endpoint dispatch + dynamic_api_key = get_secret_str("WEBWRIGHT_API_KEY") elif endpoint == "https://ai-gateway.vercel.sh/v1": custom_llm_provider = "vercel_ai_gateway" dynamic_api_key = get_secret_str("VERCEL_AI_GATEWAY_API_KEY") @@ -951,6 +954,11 @@ def _get_openai_compatible_provider_info( api_base, dynamic_api_key, ) = litellm.HyperbolicChatConfig().get_openai_compatible_provider_info(api_base, api_key) + elif custom_llm_provider == "webwright": + ( + api_base, # rebind-ok: mirrors the sibling OpenAI-compatible provider dispatch + dynamic_api_key, # rebind-ok: mirrors the sibling OpenAI-compatible provider dispatch + ) = litellm.WebwrightChatConfig().get_openai_compatible_provider_info(api_base, api_key) elif custom_llm_provider == "vercel_ai_gateway": ( api_base, diff --git a/litellm/llms/webwright/__init__.py b/litellm/llms/webwright/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webwright/chat/__init__.py b/litellm/llms/webwright/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webwright/chat/transformation.py b/litellm/llms/webwright/chat/transformation.py new file mode 100644 index 00000000000..3f5c99f09c0 --- /dev/null +++ b/litellm/llms/webwright/chat/transformation.py @@ -0,0 +1,59 @@ +""" +Translate from OpenAI's `/v1/chat/completions` to WebWright's `/v1/chat/completions` +""" + +from typing import Final + +from litellm.secret_managers.main import get_secret_str + +from ...openai_like.chat.transformation import OpenAILikeChatConfig + + +class WebwrightChatConfig(OpenAILikeChatConfig): + """ + WebWright is OpenAI-compatible with standard endpoints. + """ + + @property + def custom_llm_provider(self) -> str | None: + return "webwright" + + def _get_openai_compatible_provider_info( + self, api_base: str | None, api_key: str | None + ) -> tuple[str | None, str | None]: + # WebWright is openai compatible, we just need to set the api_base + resolved_api_base: Final = ( + api_base + or get_secret_str("WEBWRIGHT_API_BASE") + or "https://webwright.ai/v1" # Default WebWright API base URL + ) + dynamic_api_key: Final = api_key or get_secret_str("WEBWRIGHT_API_KEY") + return resolved_api_base, dynamic_api_key + + def get_openai_compatible_provider_info( + self, + api_base: str | None, + api_key: str | None, + ) -> tuple[str | None, str | None]: + return self._get_openai_compatible_provider_info(api_base, api_key) + + def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: matches the base provider signature + """ + WebWright supports the OpenAI-compatible chat completion parameters that + its published model abilities reference declares. + Reference: https://webwright.ai/models + Reasoning is declared in the abilities reference, but no caller-facing + reasoning field or allowed values are published, so it is not advertised. + """ + return [ + "messages", # Required + "model", # Required + "stream", # Optional + "temperature", # Optional + "top_p", # Optional + "max_tokens", # Optional + "stop", # Optional + "tools", # Optional + "tool_choice", # Optional + "response_format", # Optional + ] diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 5ded2fa5f5e..6445eb931b8 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2071,6 +2071,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "WEBWRIGHT", + "provider_display_name": "WebWright", + "litellm_provider": "webwright", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": null, + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": null, + "required": false, + "field_type": "password", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "wren-r1" + }, { "provider": "Infinity", "provider_display_name": "Infinity", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ba29a67a36c..50679cc33ed 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4732,6 +4732,7 @@ class LlmProviders(str, Enum): OPENSEARCH = "opensearch" HELICONE = "helicone" HYPERBOLIC = "hyperbolic" + WEBWRIGHT = "webwright" RECRAFT = "recraft" FAL_AI = "fal_ai" STABILITY = "stability" diff --git a/litellm/utils.py b/litellm/utils.py index 53b02ebddac..4e5fa7a2895 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9936,6 +9936,7 @@ class ProviderConfigManager: LlmProviders.HEROKU: (lambda: litellm.HerokuChatConfig(), False), LlmProviders.OCI: (lambda: litellm.OCIChatConfig(), False), LlmProviders.HYPERBOLIC: (lambda: litellm.HyperbolicChatConfig(), False), + LlmProviders.WEBWRIGHT: (lambda: litellm.WebwrightChatConfig(), False), LlmProviders.OVHCLOUD: (lambda: litellm.OVHCloudChatConfig(), False), LlmProviders.AMAZON_NOVA: (lambda: litellm.AmazonNovaChatConfig(), False), LlmProviders.LANGGRAPH: ( diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00fee0a0d5a..05f02de3e58 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -4437,6 +4437,24 @@ "generateContent": true } }, + "webwright": { + "display_name": "WebWright (`webwright`)", + "url": "https://docs.litellm.ai/docs/providers/webwright", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false, + "interactions": false + } + }, "watsonx_text": { "display_name": "Watsonx Text (`watsonx_text`)", "url": "https://docs.litellm.ai/docs/providers/watsonx", diff --git a/tests/unit/llms/webwright/__init__.py b/tests/unit/llms/webwright/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webwright/chat/__init__.py b/tests/unit/llms/webwright/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webwright/chat/test_webwright_transformation.py b/tests/unit/llms/webwright/chat/test_webwright_transformation.py new file mode 100644 index 00000000000..468230d2dab --- /dev/null +++ b/tests/unit/llms/webwright/chat/test_webwright_transformation.py @@ -0,0 +1,105 @@ +""" +Unit tests for the WebWright provider configuration. + +WebWright exposes an OpenAI-compatible chat completions API at +https://webwright.ai/v1 and serves the `wren-r1` model, so the provider is a +thin configuration over the shared OpenAI-like transformation. +""" + +import os +from unittest import mock + +import litellm + +from litellm.llms.webwright.chat.transformation import WebwrightChatConfig + + +def test_get_llm_provider_webwright() -> None: + """Test that get_llm_provider correctly identifies WebWright""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + # Test with webwright/model-name format + model, provider, api_key, api_base = get_llm_provider("webwright/wren-r1") + assert model == "wren-r1" + assert provider == "webwright" + + # Test with api_base containing the WebWright endpoint + model, provider, api_key, api_base = get_llm_provider("wren-r1", api_base="https://webwright.ai/v1") + assert model == "wren-r1" + assert provider == "webwright" + assert api_base == "https://webwright.ai/v1" + + +def test_webwright_config_initialization() -> None: + """Test WebwrightChatConfig initializes correctly""" + config = WebwrightChatConfig() + assert config.custom_llm_provider == "webwright" + + +def test_webwright_get_openai_compatible_provider_info() -> None: + """Test WebWright API base and key handling""" + config = WebwrightChatConfig() + + with mock.patch.dict(os.environ, {}, clear=True): + api_base, api_key = config.get_openai_compatible_provider_info(None, None) + assert api_base == "https://webwright.ai/v1" + assert api_key is None + + with mock.patch.dict( + os.environ, + { + "WEBWRIGHT_API_KEY": "env-key", + "WEBWRIGHT_API_BASE": "https://env.webwright.ai/v1", + }, + ): + api_base, api_key = config.get_openai_compatible_provider_info(None, None) + assert api_base == "https://env.webwright.ai/v1" + assert api_key == "env-key" + + # Explicit arguments take precedence over the environment + api_base, api_key = config.get_openai_compatible_provider_info("https://custom.webwright.ai/v1", "param-key") + assert api_base == "https://custom.webwright.ai/v1" + assert api_key == "param-key" + + +def test_webwright_in_provider_lists() -> None: + """Test that webwright is registered in all necessary provider lists""" + assert "webwright" in litellm.openai_compatible_providers + assert "webwright" in litellm.openai_text_completion_compatible_providers + assert "webwright" in litellm.provider_list + assert "https://webwright.ai/v1" in litellm.openai_compatible_endpoints + + +def test_webwright_supported_params() -> None: + """Test that supported OpenAI parameters are correctly configured""" + config = WebwrightChatConfig() + supported_params = config.get_supported_openai_params("webwright/wren-r1") + + for param in ( + "messages", + "model", + "stream", + "temperature", + "top_p", + "max_tokens", + "stop", + "tools", + "tool_choice", + "response_format", + ): + assert param in supported_params + + for unsupported in ("frequency_penalty", "presence_penalty", "n", "seed", "user", "reasoning_effort"): + assert unsupported not in supported_params + + +def test_webwright_completion_call() -> None: + """Test the completion call structure for WebWright with a mocked response""" + response = litellm.completion( + model="webwright/wren-r1", + messages=[{"role": "user", "content": "Hello!"}], + api_key="test-key", + mock_response="Hi there!", + ) + assert response is not None + assert response.choices[0].message.content == "Hi there!"