diff --git a/surfsense_backend/app/services/model_connection_service.py b/surfsense_backend/app/services/model_connection_service.py
index cdfd1d725..54d0a3c3f 100644
--- a/surfsense_backend/app/services/model_connection_service.py
+++ b/surfsense_backend/app/services/model_connection_service.py
@@ -15,6 +15,7 @@ from app.db import Connection, Model, ModelSource
from app.services.model_resolver import ensure_v1, to_litellm
from app.services.openrouter_model_normalizer import normalize_openrouter_models
from app.services.provider_registry import Transport, provider_label, spec_for
+from app.services.requesty_model_normalizer import normalize_requesty_models
logger = logging.getLogger(__name__)
@@ -148,7 +149,7 @@ async def verify_connection(conn: Connection) -> VerifyResult:
if spec.transport == Transport.OLLAMA and base_url:
url = f"{base_url.rstrip('/')}/api/version"
- elif spec.discovery in {"openai_models", "openrouter"} and base_url:
+ elif spec.discovery in {"openai_models", "openrouter", "requesty"} and base_url:
url = f"{ensure_v1(base_url)}/models"
elif spec.discovery == "anthropic_models" and base_url:
url = f"{base_url.rstrip('/')}/models"
@@ -363,6 +364,16 @@ async def _openrouter_models(conn: Connection) -> list[dict[str, Any]]:
return normalize_openrouter_models(response.json().get("data", []))
+async def _requesty_models(conn: Connection) -> list[dict[str, Any]]:
+ base_url = _base_url_or_default(conn) or "https://router.requesty.ai/v1"
+ async with httpx.AsyncClient(timeout=DISCOVERY_TIMEOUT_SECONDS) as client:
+ response = await client.get(
+ f"{ensure_v1(base_url)}/models", headers=_auth_headers(conn)
+ )
+ response.raise_for_status()
+ return normalize_requesty_models(response.json().get("data", []))
+
+
def _litellm_static_models(conn: Connection) -> list[dict[str, Any]]:
provider = conn.provider
prefix = spec_for(provider).litellm_prefix or provider
@@ -446,6 +457,8 @@ async def discover_models(conn: Connection) -> list[dict[str, Any]]:
results = await _ollama_tags_then_show(conn)
elif spec.discovery == "openrouter":
results = await _openrouter_models(conn)
+ elif spec.discovery == "requesty":
+ results = await _requesty_models(conn)
elif spec.discovery == "anthropic_models":
results = await _discover_anthropic_models(conn)
elif spec.discovery == "openai_models":
diff --git a/surfsense_backend/app/services/provider_registry.py b/surfsense_backend/app/services/provider_registry.py
index 6392e38c3..87c1afee3 100644
--- a/surfsense_backend/app/services/provider_registry.py
+++ b/surfsense_backend/app/services/provider_registry.py
@@ -23,6 +23,7 @@ DiscoveryKind = Literal[
"anthropic_models",
"bedrock_models",
"openrouter",
+ "requesty",
"static",
"none",
]
@@ -78,6 +79,15 @@ REGISTRY: dict[str, ProviderSpec] = {
"bearer",
"OpenRouter",
),
+ "requesty": ProviderSpec(
+ Transport.OPENAI_COMPATIBLE,
+ "openai",
+ "requesty",
+ "https://router.requesty.ai/v1",
+ False,
+ "bearer",
+ "Requesty",
+ ),
"openai_compatible": ProviderSpec(
Transport.OPENAI_COMPATIBLE,
"openai",
diff --git a/surfsense_backend/app/services/quality_score.py b/surfsense_backend/app/services/quality_score.py
index 737dd7c2f..1aa3e7eda 100644
--- a/surfsense_backend/app/services/quality_score.py
+++ b/surfsense_backend/app/services/quality_score.py
@@ -123,6 +123,7 @@ PROVIDER_PRESTIGE_YAML: dict[str, int] = {
"perplexity": 28,
"bedrock": 28,
"openrouter": 25,
+ "requesty": 25,
"ollama_chat": 12,
"custom": 12,
}
diff --git a/surfsense_backend/app/services/requesty_model_normalizer.py b/surfsense_backend/app/services/requesty_model_normalizer.py
new file mode 100644
index 000000000..7710eb174
--- /dev/null
+++ b/surfsense_backend/app/services/requesty_model_normalizer.py
@@ -0,0 +1,123 @@
+"""Shared Requesty model normalization.
+
+Requesty (https://router.requesty.ai) is an OpenAI-compatible LLM router.
+Its ``/v1/models`` catalogue carries richer, Requesty-specific capability
+metadata than a generic OpenAI-compatible ``/models`` response, so keep all
+Requesty filtering and capability extraction here -- mirroring
+``openrouter_model_normalizer`` -- so GLOBAL catalogue generation and BYOK
+discovery agree.
+
+Unlike OpenRouter, Requesty exposes capabilities as flat booleans
+(``supports_tool_calling`` / ``supports_reasoning`` / ``supports_vision`` /
+``supports_image_generation``) rather than an ``architecture`` block plus a
+``supported_parameters`` array, and it reports context size as
+``context_window`` rather than ``context_length``. This module maps those
+fields onto the same normalized shape the rest of the backend consumes.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.db import ModelSource
+
+MIN_CONTEXT_LENGTH = 100_000
+
+EXCLUDED_PROVIDER_SLUGS: set[str] = {"amazon"}
+EXCLUDED_MODEL_IDS: set[str] = set()
+EXCLUDED_MODEL_SUFFIXES: tuple[str, ...] = ("-deep-research",)
+
+
+def is_image_output_model(model: dict[str, Any]) -> bool:
+ return bool(model.get("supports_image_generation"))
+
+
+def is_text_output_model(model: dict[str, Any]) -> bool:
+ # Requesty entries are chat-completion models (``api == "chat"``). Treat a
+ # model as text output whenever it is not an image-generation model.
+ return not is_image_output_model(model)
+
+
+def supports_image_input(model: dict[str, Any]) -> bool:
+ return bool(model.get("supports_vision"))
+
+
+def supports_tool_calling(model: dict[str, Any]) -> bool:
+ return bool(model.get("supports_tool_calling"))
+
+
+def has_sufficient_context(model: dict[str, Any]) -> bool:
+ return int(model.get("context_window") or 0) >= MIN_CONTEXT_LENGTH
+
+
+def is_compatible_provider(model: dict[str, Any]) -> bool:
+ model_id = str(model.get("id") or "")
+ slug = model_id.split("/", 1)[0] if "/" in model_id else ""
+ return slug not in EXCLUDED_PROVIDER_SLUGS
+
+
+def is_allowed_model(model: dict[str, Any]) -> bool:
+ model_id = str(model.get("id") or "")
+ if model_id in EXCLUDED_MODEL_IDS:
+ return False
+ base_id = model_id.split(":")[0]
+ return not base_id.endswith(EXCLUDED_MODEL_SUFFIXES)
+
+
+def is_requesty_chat_model(model: dict[str, Any]) -> bool:
+ return (
+ "/" in str(model.get("id") or "")
+ and is_text_output_model(model)
+ and supports_tool_calling(model)
+ and has_sufficient_context(model)
+ and is_compatible_provider(model)
+ and is_allowed_model(model)
+ )
+
+
+def is_requesty_image_model(model: dict[str, Any]) -> bool:
+ return (
+ "/" in str(model.get("id") or "")
+ and is_image_output_model(model)
+ and is_compatible_provider(model)
+ and is_allowed_model(model)
+ )
+
+
+def normalize_requesty_models(
+ raw_models: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ normalized: list[dict[str, Any]] = []
+ for model in raw_models:
+ if not is_requesty_chat_model(model):
+ continue
+ model_id = str(model.get("id") or "")
+ normalized.append(
+ {
+ "model_id": model_id,
+ "display_name": model.get("name") or model_id,
+ "source": ModelSource.DISCOVERED,
+ "supports_chat": True,
+ "max_input_tokens": model.get("context_window"),
+ "supports_image_input": supports_image_input(model),
+ "supports_tools": supports_tool_calling(model),
+ "supports_image_generation": False,
+ "metadata": model,
+ }
+ )
+ return normalized
+
+
+__all__ = [
+ "MIN_CONTEXT_LENGTH",
+ "has_sufficient_context",
+ "is_allowed_model",
+ "is_compatible_provider",
+ "is_image_output_model",
+ "is_requesty_chat_model",
+ "is_requesty_image_model",
+ "is_text_output_model",
+ "normalize_requesty_models",
+ "supports_image_input",
+ "supports_tool_calling",
+]
diff --git a/surfsense_backend/tests/unit/services/test_requesty_model_normalizer.py b/surfsense_backend/tests/unit/services/test_requesty_model_normalizer.py
new file mode 100644
index 000000000..36b867923
--- /dev/null
+++ b/surfsense_backend/tests/unit/services/test_requesty_model_normalizer.py
@@ -0,0 +1,107 @@
+"""Unit tests for Requesty model normalization.
+
+Mirrors the OpenRouter normalizer coverage but exercises Requesty's flat
+boolean capability fields (``supports_tool_calling`` / ``supports_vision``)
+and ``context_window`` sizing.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.services.requesty_model_normalizer import (
+ is_requesty_chat_model,
+ is_requesty_image_model,
+ normalize_requesty_models,
+ supports_image_input,
+ supports_tool_calling,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def _requesty_model(
+ *,
+ model_id: str,
+ context_window: int = 128_000,
+ tools: bool = True,
+ vision: bool = False,
+ image_generation: bool = False,
+ name: str | None = None,
+) -> dict:
+ """Return a synthetic Requesty ``/v1/models`` entry.
+
+ Only the fields the normalizer inspects are populated; the live payload
+ carries many more (pricing, ``supports_caching``, ``description``, ...).
+ """
+ return {
+ "id": model_id,
+ "name": name or model_id,
+ "api": "chat",
+ "object": "model",
+ "context_window": context_window,
+ "supports_tool_calling": tools,
+ "supports_vision": vision,
+ "supports_image_generation": image_generation,
+ }
+
+
+def test_chat_model_requires_slash_tools_and_context():
+ assert is_requesty_chat_model(_requesty_model(model_id="openai/gpt-4o-mini"))
+ assert not is_requesty_chat_model(
+ _requesty_model(model_id="openai/gpt-4o-mini", tools=False)
+ )
+ assert not is_requesty_chat_model(
+ _requesty_model(model_id="openai/gpt-4o-mini", context_window=8_000)
+ )
+ assert not is_requesty_chat_model(_requesty_model(model_id="bare-model"))
+
+
+def test_excluded_provider_slug_is_filtered():
+ assert not is_requesty_chat_model(
+ _requesty_model(model_id="amazon/nova-pro-v1")
+ )
+
+
+def test_image_generation_models_excluded_from_chat_and_flagged():
+ image_model = _requesty_model(
+ model_id="google/gemini-2.5-flash-image", image_generation=True
+ )
+ assert not is_requesty_chat_model(image_model)
+ assert is_requesty_image_model(image_model)
+
+
+def test_capability_helpers_read_flat_booleans():
+ model = _requesty_model(
+ model_id="anthropic/claude-sonnet-4-5", vision=True, tools=True
+ )
+ assert supports_image_input(model) is True
+ assert supports_tool_calling(model) is True
+
+
+def test_normalize_maps_context_window_and_capabilities():
+ normalized = normalize_requesty_models(
+ [
+ _requesty_model(
+ model_id="openai/gpt-4o-mini",
+ context_window=128_000,
+ vision=True,
+ name="GPT-4o mini",
+ ),
+ _requesty_model(model_id="openai/gpt-4o-mini", tools=False),
+ _requesty_model(
+ model_id="black-forest-labs/flux", image_generation=True
+ ),
+ ]
+ )
+
+ assert len(normalized) == 1
+ entry = normalized[0]
+ assert entry["model_id"] == "openai/gpt-4o-mini"
+ assert entry["display_name"] == "GPT-4o mini"
+ assert entry["supports_chat"] is True
+ assert entry["max_input_tokens"] == 128_000
+ assert entry["supports_image_input"] is True
+ assert entry["supports_tools"] is True
+ assert entry["supports_image_generation"] is False
+ assert entry["metadata"]["id"] == "openai/gpt-4o-mini"
diff --git a/surfsense_web/components/icons/providers/index.ts b/surfsense_web/components/icons/providers/index.ts
index 5c8276e62..f03fbec68 100644
--- a/surfsense_web/components/icons/providers/index.ts
+++ b/surfsense_web/components/icons/providers/index.ts
@@ -27,6 +27,7 @@ export { default as PerplexityIcon } from "./perplexity.svg";
export { default as QwenIcon } from "./qwen.svg";
export { default as RecraftIcon } from "./recraft.svg";
export { default as ReplicateIcon } from "./replicate.svg";
+export { default as RequestyIcon } from "./requesty.svg";
export { default as SambaNovaIcon } from "./sambanova.svg";
export { default as TogetherAiIcon } from "./togetherai.svg";
export { default as VertexAiIcon } from "./vertexai.svg";
diff --git a/surfsense_web/components/icons/providers/requesty.svg b/surfsense_web/components/icons/providers/requesty.svg
new file mode 100644
index 000000000..a804601b6
--- /dev/null
+++ b/surfsense_web/components/icons/providers/requesty.svg
@@ -0,0 +1 @@
+
diff --git a/surfsense_web/components/settings/model-connections/default-connect-form.tsx b/surfsense_web/components/settings/model-connections/default-connect-form.tsx
index 6ca96ced2..bc832f718 100644
--- a/surfsense_web/components/settings/model-connections/default-connect-form.tsx
+++ b/surfsense_web/components/settings/model-connections/default-connect-form.tsx
@@ -14,7 +14,12 @@ function baseUrlHint(provider: string) {
if (provider === "openai_compatible_raw") {
return "Enter the exact chat-completions API base URL. SurfSense will not append /v1.";
}
- if (provider === "openai" || provider === "anthropic" || provider === "openrouter") {
+ if (
+ provider === "openai" ||
+ provider === "anthropic" ||
+ provider === "openrouter" ||
+ provider === "requesty"
+ ) {
return "Override only if you route through a proxy or gateway.";
}
return undefined;
diff --git a/surfsense_web/components/settings/model-connections/provider-metadata.tsx b/surfsense_web/components/settings/model-connections/provider-metadata.tsx
index 0a5cc53c8..aa8879fe1 100644
--- a/surfsense_web/components/settings/model-connections/provider-metadata.tsx
+++ b/surfsense_web/components/settings/model-connections/provider-metadata.tsx
@@ -7,6 +7,7 @@ export const PROVIDER_ORDER = [
"bedrock",
"azure",
"openrouter",
+ "requesty",
"ollama_chat",
"lm_studio",
"openai_compatible",
@@ -49,6 +50,12 @@ export const PROVIDER_DISPLAY: Record<
iconKey: "openrouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
+ requesty: {
+ name: "Requesty",
+ subtitle: "Requesty",
+ iconKey: "requesty",
+ defaultBaseUrl: "https://router.requesty.ai/v1",
+ },
vertex_ai: { name: "Gemini", subtitle: "Google Cloud Vertex AI", iconKey: "vertex_ai" },
};
diff --git a/surfsense_web/lib/provider-icons.tsx b/surfsense_web/lib/provider-icons.tsx
index d3e799720..20016fdf2 100644
--- a/surfsense_web/lib/provider-icons.tsx
+++ b/surfsense_web/lib/provider-icons.tsx
@@ -29,6 +29,7 @@ import {
QwenIcon,
RecraftIcon,
ReplicateIcon,
+ RequestyIcon,
SambaNovaIcon,
TogetherAiIcon,
VertexAiIcon,
@@ -117,6 +118,8 @@ export function getProviderIcon(
return ;
case "REPLICATE":
return ;
+ case "REQUESTY":
+ return ;
case "SAMBANOVA":
return ;
case "TOGETHER_AI":