From c61ea7f7a7c4cafd2e1011144d96cbc7c841eee2 Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Sat, 11 Jul 2026 10:31:17 +0100 Subject: [PATCH] feat(model): add Requesty as a config-driven provider Requesty is an OpenAI-compatible LLM gateway that uses the same provider/model naming convention as OpenRouter, so this mirrors the existing OpenRouter provider. - configs/providers/requesty.yaml mirroring openrouter.yaml (base URL https://router.requesty.ai/v1, REQUESTY_API_KEY, provider/model ids) - RequestyProvider in adapters/models/factory.py (OpenAILike + attribution headers) registered in the provider factory - requesty added to the config.yaml provider registry and the manager preferred_order - REQUESTY_API_KEY in .env.example and Requesty documented in CONFIGURATION_GUIDE.md - config-loading tests validating requesty.yaml loads via the same loader path Signed-off-by: Thibault Jaigu --- .env.example | 5 ++ docs/CONFIGURATION_GUIDE.md | 18 +++-- python/configs/config.yaml | 4 + python/configs/providers/requesty.yaml | 58 ++++++++++++++ python/valuecell/adapters/models/factory.py | 77 +++++++++++++++++++ python/valuecell/config/manager.py | 1 + python/valuecell/config/tests/__init__.py | 0 .../config/tests/test_provider_config.py | 65 ++++++++++++++++ 8 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 python/configs/providers/requesty.yaml create mode 100644 python/valuecell/config/tests/__init__.py create mode 100644 python/valuecell/config/tests/test_provider_config.py diff --git a/.env.example b/.env.example index 29f49b461..5bb3ff4b4 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,11 @@ AGENT_DEBUG_MODE=false # Note: OpenRouter does not currently support embedding or reranker models. OPENROUTER_API_KEY= +# Get your API key from: https://app.requesty.ai/api-keys +# Requesty is an OpenAI-compatible LLM router; uses the same provider/model id +# convention as OpenRouter (e.g. openai/gpt-4o-mini). +REQUESTY_API_KEY= + # Get your API key from: https://ai.azure.com/ AZURE_OPENAI_API_KEY= AZURE_OPENAI_ENDPOINT= diff --git a/docs/CONFIGURATION_GUIDE.md b/docs/CONFIGURATION_GUIDE.md index 2605a65d1..4e28c9d7c 100644 --- a/docs/CONFIGURATION_GUIDE.md +++ b/docs/CONFIGURATION_GUIDE.md @@ -24,6 +24,7 @@ ValueCell supports multiple LLM providers. Choose at least one: | Provider | Sign Up | | --------------- | --------------------------------------------------- | | **OpenRouter** | [openrouter.ai](https://openrouter.ai/) | +| **Requesty** | [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys) | | **SiliconFlow** | [siliconflow.cn](https://www.siliconflow.cn/) | | **Google** | [ai.google.dev](https://ai.google.dev/) | | **OpenAI** | [platform.openai.com](https://platform.openai.com/) | @@ -86,6 +87,7 @@ python/ │ ├── config.{environment}.yaml # Environment-specific overrides │ ├── providers/ │ │ ├── openrouter.yaml # OpenRouter provider config +│ │ ├── requesty.yaml # Requesty provider config │ │ ├── siliconflow.yaml # SiliconFlow provider config │ │ ├── dashscope.yaml # DashScope (Alibaba Cloud) provider config │ │ └── other_provider.yaml @@ -191,6 +193,9 @@ models: openrouter: config_file: "providers/openrouter.yaml" api_key_env: "OPENROUTER_API_KEY" + requesty: + config_file: "providers/requesty.yaml" + api_key_env: "REQUESTY_API_KEY" siliconflow: config_file: "providers/siliconflow.yaml" api_key_env: "SILICONFLOW_API_KEY" @@ -327,12 +332,13 @@ ValueCell automatically selects a primary provider based on available API keys: The selection logic is implemented in `python/valuecell/config/manager.py`: 1. OpenRouter -2. SiliconFlow -3. Google -4. OpenAI -5. OpenAI-Compatible -6. Azure -7. Other configured providers (including DashScope, DeepSeek, etc.) +2. Requesty +3. SiliconFlow +4. Google +5. OpenAI +6. OpenAI-Compatible +7. Azure +8. Other configured providers (including DashScope, DeepSeek, etc.) Override this with an environment variable: diff --git a/python/configs/config.yaml b/python/configs/config.yaml index 504961762..688713ac2 100644 --- a/python/configs/config.yaml +++ b/python/configs/config.yaml @@ -23,6 +23,10 @@ models: config_file: "providers/openrouter.yaml" api_key_env: "OPENROUTER_API_KEY" + requesty: + config_file: "providers/requesty.yaml" + api_key_env: "REQUESTY_API_KEY" + siliconflow: config_file: "providers/siliconflow.yaml" api_key_env: "SILICONFLOW_API_KEY" diff --git a/python/configs/providers/requesty.yaml b/python/configs/providers/requesty.yaml new file mode 100644 index 000000000..343c2b9ae --- /dev/null +++ b/python/configs/providers/requesty.yaml @@ -0,0 +1,58 @@ +# ============================================ +# Requesty Provider Configuration +# ============================================ +name: "Requesty" +provider_type: "requesty" + +enabled: true # Default is true if not specified + +# Connection Configuration +connection: + base_url: "https://router.requesty.ai/v1" + api_key_env: "REQUESTY_API_KEY" + +# Default model if none specified +default_model: "openai/gpt-4o-mini" + +# Model Parameters Defaults +defaults: + temperature: 0.5 + +# Extra headers for Requesty API (used for analytics attribution) +extra_headers: + HTTP-Referer: "https://valuecell.ai" + X-Title: "ValueCell" + +# Available Models (commonly used) +models: + - id: "anthropic/claude-sonnet-4-5" + name: "Claude Sonnet 4.5" + - id: "openai/gpt-4o" + name: "GPT-4o" + - id: "openai/gpt-4o-mini" + name: "GPT-4o mini" + - id: "deepseek/deepseek-chat" + name: "DeepSeek Chat" + - id: "google/gemini-2.5-flash" + name: "Gemini 2.5 Flash" + +# ============================================ +# Embedding Models Configuration +# ============================================ +# Requesty exposes OpenAI-compatible embedding models +embedding: + # Default embedding model + default_model: "openai/text-embedding-3-small" + + # Default parameters + defaults: + dimensions: 1536 + encoding_format: "float" + + # Available embedding models + models: + - id: "openai/text-embedding-3-small" + name: "OpenAI Text Embedding 3 Small" + dimensions: 1536 + max_input: 8192 + description: "OpenAI Text Embedding Model" diff --git a/python/valuecell/adapters/models/factory.py b/python/valuecell/adapters/models/factory.py index 341b90525..9915c770f 100644 --- a/python/valuecell/adapters/models/factory.py +++ b/python/valuecell/adapters/models/factory.py @@ -116,6 +116,82 @@ def create_model(self, model_id: Optional[str] = None, **kwargs): ) +class RequestyProvider(ModelProvider): + """Requesty model provider + + Requesty (https://requesty.ai) is an OpenAI-compatible LLM router. It uses the + same ``provider/model`` id convention as OpenRouter (e.g. ``openai/gpt-4o-mini``) + and the same ``HTTP-Referer`` / ``X-Title`` analytics headers, with a fixed + OpenAI-compatible base URL (``https://router.requesty.ai/v1``). This mirrors + OpenRouterProvider, using agno's OpenAILike client since the endpoint is + OpenAI-compatible. + """ + + def create_model(self, model_id: Optional[str] = None, **kwargs): + """Create Requesty model via agno (OpenAI-compatible)""" + try: + from agno.models.openai import OpenAILike + except ImportError: + raise ImportError( + "agno package not installed. Install with: pip install agno" + ) + + # Use provided model_id or default + model_id = model_id or self.config.default_model + + # Merge parameters: provider defaults < kwargs + params = {**self.config.parameters, **kwargs} + + # Get extra headers from config (analytics attribution) + extra_headers = self.config.extra_config.get("extra_headers", {}) + + logger.info(f"Creating Requesty model: {model_id}") + + return OpenAILike( + id=model_id, + api_key=self.config.api_key, + base_url=self.config.base_url, + default_headers=extra_headers if extra_headers else None, + temperature=params.get("temperature"), + max_tokens=params.get("max_tokens"), + top_p=params.get("top_p"), + frequency_penalty=params.get("frequency_penalty"), + presence_penalty=params.get("presence_penalty"), + ) + + def create_embedder(self, model_id: Optional[str] = None, **kwargs): + """Create embedder via Requesty (OpenAI-compatible)""" + try: + from agno.knowledge.embedder.openai import OpenAIEmbedder + except ImportError: + raise ImportError("agno package not installed") + + # Use provided model_id or default embedding model + model_id = model_id or self.config.default_embedding_model + + if not model_id: + raise ValueError( + f"No embedding model specified for provider '{self.config.name}'" + ) + + # Merge parameters: provider embedding defaults < kwargs + params = {**self.config.embedding_parameters, **kwargs} + + logger.info(f"Creating Requesty embedder: {model_id}") + + return OpenAIEmbedder( + id=model_id, + api_key=self.config.api_key, + base_url=self.config.base_url, + dimensions=int(params.get("dimensions", 1536)), + encoding_format=params.get("encoding_format"), + ) + + def is_available(self) -> bool: + """Check if provider is available (needs both API key and base URL)""" + return bool(self.config.api_key and self.config.base_url) + + class GoogleProvider(ModelProvider): """Google Gemini model provider""" @@ -601,6 +677,7 @@ class ModelFactory: # Registry of provider classes _providers: Dict[str, type[ModelProvider]] = { "openrouter": OpenRouterProvider, + "requesty": RequestyProvider, "google": GoogleProvider, "azure": AzureProvider, "siliconflow": SiliconFlowProvider, diff --git a/python/valuecell/config/manager.py b/python/valuecell/config/manager.py index 5da57fd19..790397870 100644 --- a/python/valuecell/config/manager.py +++ b/python/valuecell/config/manager.py @@ -130,6 +130,7 @@ def primary_provider(self) -> str: # Priority order for auto-selection preferred_order = [ "openrouter", + "requesty", "siliconflow", "google", "openai", diff --git a/python/valuecell/config/tests/__init__.py b/python/valuecell/config/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/config/tests/test_provider_config.py b/python/valuecell/config/tests/test_provider_config.py new file mode 100644 index 000000000..4ba6c2a10 --- /dev/null +++ b/python/valuecell/config/tests/test_provider_config.py @@ -0,0 +1,65 @@ +"""Tests for provider configuration loading. + +Validates that config-driven providers (openrouter, requesty) load through the +shared ConfigLoader / ConfigManager path with the expected connection settings. +""" + +import os + +import pytest + +from valuecell.config.loader import ConfigLoader +from valuecell.config.manager import ConfigManager + + +@pytest.fixture() +def manager() -> ConfigManager: + loader = ConfigLoader() + return ConfigManager(loader=loader) + + +def test_requesty_provider_is_discovered(): + """requesty.yaml is picked up by the provider glob, like openrouter.yaml.""" + loader = ConfigLoader() + providers = loader.list_providers() + assert "openrouter" in providers + assert "requesty" in providers + + +def test_requesty_provider_config_matches_expected(manager: ConfigManager): + """Requesty loads through the same code path as OpenRouter with its + fixed OpenAI-compatible base URL, API-key env var, and analytics headers.""" + config = manager.get_provider_config("requesty") + + assert config is not None + assert config.name == "requesty" + assert config.enabled is True + assert config.base_url == "https://router.requesty.ai/v1" + assert config.default_model == "openai/gpt-4o-mini" + + # provider/model id convention, mirroring OpenRouter + model_ids = {m["id"] for m in config.models} + assert "openai/gpt-4o-mini" in model_ids + assert all("/" in mid for mid in model_ids) + + # Analytics headers mirror OpenRouter's HTTP-Referer / X-Title + extra_headers = config.extra_config.get("extra_headers", {}) + assert extra_headers.get("X-Title") == "ValueCell" + assert "HTTP-Referer" in extra_headers + + +def test_requesty_reads_api_key_env(manager: ConfigManager, monkeypatch): + """REQUESTY_API_KEY is resolved into the provider config.""" + monkeypatch.setenv("REQUESTY_API_KEY", "sk-test-requesty") + # Fresh manager to avoid loader cache picking up an earlier (empty) value + fresh = ConfigManager(loader=ConfigLoader()) + config = fresh.get_provider_config("requesty") + assert config is not None + assert config.api_key == "sk-test-requesty" + + +def test_requesty_registered_in_factory(): + """The requesty provider name maps to a provider class in the factory.""" + from valuecell.adapters.models.factory import ModelFactory, RequestyProvider + + assert ModelFactory._providers.get("requesty") is RequestyProvider