feat(llms): add WebWright provider for the wren-r1 model

WebWright exposes an OpenAI-compatible chat completions API at
https://webwright.ai/v1 and serves the wren-r1 model. Add a WebwrightChatConfig
that builds on the shared OpenAI-like transformation, modeled on the existing
hyperbolic provider.

- litellm/llms/webwright/chat/transformation.py: base URL and API key handling
- register the provider in constants, the LlmProviders enum, the provider
  config manager, get_llm_provider, and the lazy import registry
- tests/unit/llms/webwright/chat: unit tests for provider resolution, provider
  info, provider lists, supported params, and a mocked completion call
This commit is contained in:
T145 2026-10-15 20:38:44 +00:00
parent ad83e3d3c8
commit 3d79ba461b
12 changed files with 185 additions and 0 deletions

View file

@ -701,6 +701,7 @@ morph_models: Set = set()
lambda_ai_models: Set = set()
inception_models: Set = set()
hyperbolic_models: Set = set()
webwright_models: Set = set()
black_forest_labs_models: Set = set()
recraft_models: Set = set()
cometapi_models: Set = set()
@ -975,6 +976,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":
@ -1252,6 +1255,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,
@ -2213,6 +2217,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,
)

View file

@ -349,6 +349,7 @@ LLM_CONFIG_NAMES: Final = (
"LambdaAIChatConfig",
"InceptionChatConfig",
"HyperbolicChatConfig",
"WebwrightChatConfig",
"VercelAIGatewayConfig",
"EdenAIChatConfig",
"EdenAIResponsesAPIConfig",
@ -1302,6 +1303,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",

View file

@ -1007,6 +1007,7 @@ openai_compatible_endpoints: Final[list] = [
"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",
@ -1082,6 +1083,7 @@ openai_compatible_providers: Final[list] = [
"lambda_ai",
"inception",
"hyperbolic",
"webwright",
"vercel_ai_gateway",
"aiml",
"edenai",
@ -1127,6 +1129,7 @@ openai_text_completion_compatible_providers: Final[list] = [ # providers that s
"v0",
"lambda_ai",
"hyperbolic",
"webwright",
"wandb",
]
_openai_like_providers: Final[list[str]] = [

View file

@ -391,6 +391,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"
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")
@ -913,6 +916,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,
dynamic_api_key,
) = litellm.WebwrightChatConfig().get_openai_compatible_provider_info(api_base, api_key)
elif custom_llm_provider == "vercel_ai_gateway":
(
api_base,

View file

View file

View file

@ -0,0 +1,61 @@
"""
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
api_base = (
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 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:
"""
WebWright supports the standard OpenAI chat completion parameters.
Reference: https://webwright.ai/v1
"""
return [
"messages", # Required
"model", # Required
"stream", # Optional
"temperature", # Optional
"top_p", # Optional
"max_tokens", # Optional
"frequency_penalty", # Optional
"presence_penalty", # Optional
"stop", # Optional
"n", # Optional
"tools", # Optional
"tool_choice", # Optional
"response_format", # Optional
"seed", # Optional
"user", # Optional
]

View file

@ -4342,6 +4342,7 @@ class LlmProviders(str, Enum):
MONGODB = "mongodb"
HELICONE = "helicone"
HYPERBOLIC = "hyperbolic"
WEBWRIGHT = "webwright"
RECRAFT = "recraft"
FAL_AI = "fal_ai"
STABILITY = "stability"

View file

@ -8762,6 +8762,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: (

View file

View file

@ -0,0 +1,99 @@
"""
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",
"max_tokens",
"tools",
"tool_choice",
):
assert param 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!"