Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
```
Expand All @@ -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`.

</details>
Expand Down
1 change: 1 addition & 0 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions core/providers/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions core/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions deepcode_config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"apiKey": "",
"apiBase": "https://openrouter.ai/api/v1"
},
"requesty": {
"apiKey": "",
"apiBase": "https://router.requesty.ai/v1"
},
"gemini": {
"apiKey": ""
},
Expand Down
13 changes: 13 additions & 0 deletions new_ui/backend/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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``."""
Expand Down
252 changes: 252 additions & 0 deletions new_ui/backend/services/requesty_models.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading