From c755c58f01ea79288d455ff2726606890e7471ab Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Sun, 12 Jul 2026 09:44:46 +0100 Subject: [PATCH] feat(providers): add Requesty as an OpenAI-compatible provider Requesty is an OpenAI-compatible LLM gateway using the same provider/model naming as OpenRouter, so this mirrors the existing OpenRouter provider on the generic openai_compat path. - core/providers/registry.py: requesty ProviderSpec (base URL https://router.requesty.ai/v1, REQUESTY_API_KEY, is_gateway) - core/providers/openai_compat.py: HTTP-Referer/X-Title attribution headers - new_ui/backend/services/requesty_models.py: models catalog service mapping Requesty's context_window / supports_* / flat price fields to the internal shape - new_ui/backend/api/routes/config.py: GET /config/requesty/models - core/config.py + deepcode_config.json.example + README provider list - tests mirroring the openrouter provider + models normalization Verified: ruff clean, 8 new tests pass; live completion through the provider code path and /v1/models both succeed against router.requesty.ai. Signed-off-by: Thibault Jaigu --- README.md | 11 +- core/config.py | 1 + core/providers/openai_compat.py | 15 ++ core/providers/registry.py | 11 + deepcode_config.json.example | 4 + new_ui/backend/api/routes/config.py | 13 ++ new_ui/backend/services/requesty_models.py | 252 +++++++++++++++++++++ tests/test_requesty_provider.py | 128 +++++++++++ 8 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 new_ui/backend/services/requesty_models.py create mode 100644 tests/test_requesty_provider.py diff --git a/README.md b/README.md index 480ff7cc..56e572f4 100644 --- a/README.md +++ b/README.md @@ -785,7 +785,8 @@ Any OpenAI-compatible endpoint is supported by overriding `apiBase` on the match }, "providers": { "openai": { "apiKey": "your_openai_api_key" }, - "openrouter": { "apiKey": "your_openrouter_key", "apiBase": "https://openrouter.ai/api/v1" } + "openrouter": { "apiKey": "your_openrouter_key", "apiBase": "https://openrouter.ai/api/v1" }, + "requesty": { "apiKey": "your_requesty_key", "apiBase": "https://router.requesty.ai/v1" } } } ``` @@ -797,6 +798,14 @@ to search the live OpenRouter catalog and update the Default, Planning, and Implementation models without editing this file manually. Saving from the UI reloads the runtime for newly started workflows. +[Requesty](https://requesty.ai) is another OpenAI-compatible gateway wired the +same way: set `providers.requesty.apiKey` (from +[app.requesty.ai/api-keys](https://app.requesty.ai/api-keys)) and use +`provider/model` slugs such as `openai/gpt-4o-mini` or +`anthropic/claude-sonnet-4-5`. See the +[Requesty docs](https://docs.requesty.ai) and +[model list](https://app.requesty.ai/router/list) for available ids. + > **🔐 Never commit `deepcode_config.json`.** It is already in `.gitignore`. diff --git a/core/config.py b/core/config.py index d9ca8b0a..44701231 100644 --- a/core/config.py +++ b/core/config.py @@ -141,6 +141,7 @@ class ProvidersConfig(_Base): custom: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + requesty: ProviderConfig = Field(default_factory=ProviderConfig) anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) deepseek: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/core/providers/openai_compat.py b/core/providers/openai_compat.py index 4c83ef7c..1a364cd6 100644 --- a/core/providers/openai_compat.py +++ b/core/providers/openai_compat.py @@ -63,6 +63,10 @@ "X-OpenRouter-Title": "DeepCode", "X-OpenRouter-Categories": "cli-agent,research-agent", } +_DEFAULT_REQUESTY_HEADERS = { + "HTTP-Referer": "https://github.com/HKUDS/DeepCode", + "X-Title": "DeepCode", +} # Per-model thinking / reasoning quirks now live declaratively in # ``core.providers.model_compat`` (resolved via ``resolve_model_compat``); # this module only assembles requests from the resolved value. @@ -146,6 +150,15 @@ def _uses_openrouter_attribution( return bool(api_base and "openrouter" in api_base.lower()) +def _uses_requesty_attribution( + spec: "ProviderSpec | None", api_base: str | None +) -> bool: + """Apply DeepCode attribution headers to Requesty requests by default.""" + if spec and spec.name == "requesty": + return True + return bool(api_base and "requesty" in api_base.lower()) + + _RESPONSES_FAILURE_THRESHOLD = 3 _RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes @@ -220,6 +233,8 @@ def __init__( default_headers = {"x-session-affinity": uuid.uuid4().hex} if _uses_openrouter_attribution(spec, effective_base): default_headers.update(_DEFAULT_OPENROUTER_HEADERS) + if _uses_requesty_attribution(spec, effective_base): + default_headers.update(_DEFAULT_REQUESTY_HEADERS) if extra_headers: default_headers.update(extra_headers) diff --git a/core/providers/registry.py b/core/providers/registry.py index 36c2bc94..d53ac19e 100644 --- a/core/providers/registry.py +++ b/core/providers/registry.py @@ -81,6 +81,17 @@ def label(self) -> str: default_api_base="https://openrouter.ai/api/v1", supports_prompt_caching=True, ), + ProviderSpec( + name="requesty", + keywords=("requesty",), + env_key="REQUESTY_API_KEY", + display_name="Requesty", + backend="openai_compat", + is_gateway=True, + detect_by_base_keyword="requesty", + default_api_base="https://router.requesty.ai/v1", + supports_prompt_caching=True, + ), ProviderSpec( name="anthropic", keywords=("anthropic", "claude"), diff --git a/deepcode_config.json.example b/deepcode_config.json.example index b672cfaa..1d3362b7 100644 --- a/deepcode_config.json.example +++ b/deepcode_config.json.example @@ -34,6 +34,10 @@ "apiKey": "", "apiBase": "https://openrouter.ai/api/v1" }, + "requesty": { + "apiKey": "", + "apiBase": "https://router.requesty.ai/v1" + }, "gemini": { "apiKey": "" }, diff --git a/new_ui/backend/api/routes/config.py b/new_ui/backend/api/routes/config.py index e64b330f..2d515fa0 100644 --- a/new_ui/backend/api/routes/config.py +++ b/new_ui/backend/api/routes/config.py @@ -25,6 +25,7 @@ list_available_providers, ) from services.openrouter_models import list_openrouter_models +from services.requesty_models import list_requesty_models from models.requests import LLMModelsUpdateRequest, LLMProviderUpdateRequest from models.responses import ( ConfigResponse, @@ -72,6 +73,18 @@ async def get_openrouter_models( ) +@router.get("/requesty/models", response_model=OpenRouterModelsResponse) +async def get_requesty_models( + supported_parameters: str | None = None, + force_refresh: bool = False, +): + """Return Requesty model ids and metadata for the settings UI.""" + return list_requesty_models( + supported_parameters=supported_parameters, + force_refresh=force_refresh, + ) + + @router.put("/llm-provider") async def set_llm_provider(request: LLMProviderUpdateRequest): """Force a specific provider for all phases by setting ``agents.defaults.provider``.""" diff --git a/new_ui/backend/services/requesty_models.py b/new_ui/backend/services/requesty_models.py new file mode 100644 index 00000000..51245a05 --- /dev/null +++ b/new_ui/backend/services/requesty_models.py @@ -0,0 +1,252 @@ +"""Requesty model catalog helpers for the settings UI. + +Mirror of :mod:`services.openrouter_models` for the Requesty router. The +Requesty ``/v1/models`` endpoint returns an OpenAI-shaped payload, but its +capability metadata differs from OpenRouter's: context size lives in +``context_window`` (not ``context_length``), capabilities are exposed as the +booleans ``supports_tool_calling`` / ``supports_reasoning`` / +``supports_vision`` (not a ``supported_parameters`` array), and prices are flat +per-token USD values. :func:`_normalize_model` maps those onto the same shape +the settings UI already consumes for OpenRouter. A small local cache keeps the +settings page usable when the router is slow or temporarily unavailable. +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +from settings import get_api_key + + +REQUESTY_MODELS_URL = "https://router.requesty.ai/v1/models" +CACHE_PATH = Path.home() / ".deepcode" / "cache" / "requesty_models.json" +CACHE_TTL_SECONDS = 24 * 60 * 60 + +SEED_MODELS: list[dict[str, Any]] = [ + { + "id": "openai/gpt-4o-mini", + "name": "GPT-4o mini", + "context_length": 128000, + "top_provider": {"max_completion_tokens": 16384}, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {}, + "expiration_date": None, + "source": "seed", + }, + { + "id": "openai/gpt-4o", + "name": "GPT-4o", + "context_length": 128000, + "top_provider": {"max_completion_tokens": 16384}, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {}, + "expiration_date": None, + "source": "seed", + }, + { + "id": "anthropic/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "context_length": 200000, + "top_provider": {"max_completion_tokens": 64000}, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {}, + "expiration_date": None, + "source": "seed", + }, + { + "id": "google/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "context_length": 1048576, + "top_provider": {"max_completion_tokens": 65536}, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {}, + "expiration_date": None, + "source": "seed", + }, + { + "id": "deepseek/deepseek-chat", + "name": "DeepSeek Chat", + "context_length": 128000, + "top_provider": {"max_completion_tokens": 64000}, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {}, + "expiration_date": None, + "source": "seed", + }, +] + + +def list_requesty_models( + *, + supported_parameters: str | None = None, + force_refresh: bool = False, +) -> dict[str, Any]: + """Return Requesty models from live API, cache, or curated seed data.""" + cached = _read_cache() + if not force_refresh and cached and not _cache_expired(cached): + return _filter_response(cached, supported_parameters=supported_parameters) + + api_key = get_api_key("requesty") + if api_key: + live = _fetch_live_models(api_key) + if live is not None: + _write_cache(live) + return _filter_response( + live, supported_parameters=supported_parameters + ) + + if cached: + stale = dict(cached) + stale["source"] = "cache" + stale["stale"] = True + return _filter_response(stale, supported_parameters=supported_parameters) + + return _seed_response(supported_parameters=supported_parameters) + + +def _fetch_live_models(api_key: str) -> dict[str, Any] | None: + request = urllib.request.Request( + REQUESTY_MODELS_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.URLError, json.JSONDecodeError): + return None + + models = [ + _normalize_model(item, source="requesty") for item in payload.get("data", []) + ] + return { + "models": sorted(models, key=lambda item: item["id"]), + "source": "requesty", + "cached_at": int(time.time()), + "stale": False, + } + + +def _read_cache() -> dict[str, Any] | None: + try: + if not CACHE_PATH.exists(): + return None + payload = json.loads(CACHE_PATH.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or not isinstance(payload.get("models"), list): + return None + return payload + except (OSError, json.JSONDecodeError): + return None + + +def _write_cache(payload: dict[str, Any]) -> None: + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + CACHE_PATH.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _cache_expired(payload: dict[str, Any]) -> bool: + cached_at = int(payload.get("cached_at") or 0) + return cached_at <= 0 or time.time() - cached_at > CACHE_TTL_SECONDS + + +def _seed_response(*, supported_parameters: str | None = None) -> dict[str, Any]: + payload = { + "models": [_normalize_model(item, source="seed") for item in SEED_MODELS], + "source": "seed", + "cached_at": None, + "stale": False, + } + return _filter_response(payload, supported_parameters=supported_parameters) + + +def _filter_response( + payload: dict[str, Any], + *, + supported_parameters: str | None = None, +) -> dict[str, Any]: + required = { + item.strip() for item in (supported_parameters or "").split(",") if item.strip() + } + if not required: + return payload + models = [ + model + for model in payload.get("models", []) + if required.issubset(set(model.get("supported_parameters") or [])) + ] + return {**payload, "models": models} + + +def _derive_supported_parameters(item: dict[str, Any]) -> list[str]: + """Map Requesty's capability booleans onto OpenRouter-style parameter names. + + Requesty exposes ``supports_tool_calling`` / ``supports_reasoning`` / + ``supports_vision`` booleans instead of a ``supported_parameters`` array. + ``temperature`` and ``max_tokens`` are accepted by every chat model on the + router, so they are always advertised. + """ + params = ["temperature", "max_tokens"] + if item.get("supports_tool_calling"): + params.append("tools") + if item.get("supports_reasoning"): + params.append("reasoning") + return params + + +def _normalize_model(item: dict[str, Any], *, source: str) -> dict[str, Any]: + # Requesty uses ``context_window``; fall back to ``context_length`` for the + # seed rows and for any OpenRouter-shaped payloads. + context_length = item.get("context_window") + if context_length is None: + context_length = item.get("context_length") + + top_provider = item.get("top_provider") or {} + max_completion_tokens = top_provider.get("max_completion_tokens") + if max_completion_tokens is None: + max_completion_tokens = item.get("max_output_tokens") + + if "supported_parameters" in item: + supported_parameters = list(item.get("supported_parameters") or []) + else: + supported_parameters = _derive_supported_parameters(item) + + # Requesty prices are flat per-token USD values (``input_price`` / + # ``output_price``); OpenRouter nests them under ``pricing``. + pricing = item.get("pricing") or {} + prompt_price = pricing.get("prompt") + if prompt_price is None: + prompt_price = item.get("input_price") + completion_price = pricing.get("completion") + if completion_price is None: + completion_price = item.get("output_price") + + return { + "id": str(item.get("id") or ""), + "name": str(item.get("name") or item.get("id") or ""), + "context_length": context_length, + "top_provider": { + "context_length": context_length, + "max_completion_tokens": max_completion_tokens, + "is_moderated": top_provider.get("is_moderated"), + }, + "supported_parameters": supported_parameters, + "pricing": { + "prompt": prompt_price, + "completion": completion_price, + "request": pricing.get("request"), + }, + "expiration_date": item.get("expiration_date"), + "source": source, + } diff --git a/tests/test_requesty_provider.py b/tests/test_requesty_provider.py new file mode 100644 index 00000000..2332cd92 --- /dev/null +++ b/tests/test_requesty_provider.py @@ -0,0 +1,128 @@ +"""Pin the Requesty provider as a mirror of the OpenRouter provider. + +The Requesty router is an OpenAI-compatible gateway wired on the same generic +``openai_compat`` path as OpenRouter. These tests assert that the registry +entry mirrors OpenRouter where it should (backend, gateway flag, prompt +caching, ``provider/model`` naming) while pinning the Requesty-specific base +URL / env var, and that the model-catalog normalizer maps Requesty's +capability shape (``context_window`` + ``supports_*`` booleans) onto the same +fields the settings UI already consumes for OpenRouter. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +BACKEND = ROOT / "new_ui" / "backend" +if str(BACKEND) not in sys.path: + sys.path.insert(0, str(BACKEND)) + +from core.providers.registry import find_by_model, find_by_name # noqa: E402 + +REQUESTY = find_by_name("requesty") +OPENROUTER = find_by_name("openrouter") + + +# ---- registry -------------------------------------------------------------- + + +def test_requesty_is_registered() -> None: + assert REQUESTY is not None + assert REQUESTY.name == "requesty" + assert REQUESTY.display_name == "Requesty" + + +def test_requesty_mirrors_openrouter_generic_wiring() -> None: + assert OPENROUTER is not None and REQUESTY is not None + # Same generic OpenAI-compatible gateway path as OpenRouter. + assert REQUESTY.backend == OPENROUTER.backend == "openai_compat" + assert REQUESTY.is_gateway is True + assert REQUESTY.supports_prompt_caching is True + assert REQUESTY.is_local is False + assert REQUESTY.is_oauth is False + + +def test_requesty_provider_specific_endpoint() -> None: + assert REQUESTY is not None + assert REQUESTY.default_api_base == "https://router.requesty.ai/v1" + assert REQUESTY.env_key == "REQUESTY_API_KEY" + assert REQUESTY.detect_by_base_keyword == "requesty" + + +def test_requesty_does_not_borrow_openrouter_key_prefix() -> None: + # OpenRouter keys start with ``sk-or-``; Requesty keys do not, so the + # prefix heuristic must not be copied over. + assert REQUESTY is not None + assert REQUESTY.detect_by_key_prefix == "" + + +def test_requesty_shares_provider_slash_model_naming() -> None: + # ``provider/model`` slugs resolve to the owning provider (openai/anthropic + # /...), exactly like OpenRouter -- Requesty adds no new namespace. + for model in ("openai/gpt-4o-mini", "anthropic/claude-sonnet-4-5"): + spec = find_by_model(model) + assert spec is not None + assert spec.name in {"openai", "anthropic"} + + +# ---- model catalog normalization ------------------------------------------ + + +def test_normalize_maps_requesty_capability_shape() -> None: + from services.requesty_models import _normalize_model + + raw = { + "id": "openai/gpt-4o-mini", + "context_window": 128000, + "max_output_tokens": 16384, + "supports_tool_calling": True, + "supports_reasoning": False, + "supports_vision": True, + "input_price": 0.00000015, + "output_price": 0.0000006, + } + model = _normalize_model(raw, source="requesty") + + # context_window -> context_length + assert model["context_length"] == 128000 + assert model["top_provider"]["context_length"] == 128000 + assert model["top_provider"]["max_completion_tokens"] == 16384 + # supports_* booleans -> supported_parameters array + assert "tools" in model["supported_parameters"] + assert "temperature" in model["supported_parameters"] + assert "max_tokens" in model["supported_parameters"] + assert "reasoning" not in model["supported_parameters"] + # flat per-token prices -> nested pricing + assert model["pricing"]["prompt"] == 0.00000015 + assert model["pricing"]["completion"] == 0.0000006 + assert model["source"] == "requesty" + + +def test_normalize_still_accepts_openrouter_shape() -> None: + from services.requesty_models import _normalize_model + + raw = { + "id": "z-ai/glm-5.1", + "context_length": 65536, + "supported_parameters": ["temperature", "max_tokens", "tools"], + "pricing": {"prompt": "0.1", "completion": "0.2"}, + } + model = _normalize_model(raw, source="seed") + assert model["context_length"] == 65536 + assert model["supported_parameters"] == ["temperature", "max_tokens", "tools"] + assert model["pricing"]["prompt"] == "0.1" + + +def test_seed_response_is_returned_without_key(monkeypatch) -> None: + import services.requesty_models as rm + + monkeypatch.setattr(rm, "get_api_key", lambda _provider: None) + monkeypatch.setattr(rm, "_read_cache", lambda: None) + payload = rm.list_requesty_models() + assert payload["source"] == "seed" + assert payload["models"] + assert all(m["id"] for m in payload["models"])