From 2ff7ea4cb6b1e11857e2404e8850d2ff9641fbdd Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Mon, 13 Jul 2026 09:42:30 +0100 Subject: [PATCH] feat: add Requesty as a model provider Add Requesty (https://requesty.ai), an OpenAI-compatible LLM router, as a model provider by mirroring the existing OpenRouter integration. Backend: - app/services/requesty_model_normalizer.py: normalizes Requesty's /v1/models catalogue, mapping its flat capability booleans (supports_tool_calling/ supports_vision/supports_image_generation) and context_window field onto the shared normalized shape (Requesty differs from OpenRouter's architecture + supported_parameters + context_length layout) - provider_registry.py: Requesty ProviderSpec (OpenAI-compatible, base URL https://router.requesty.ai/v1, REQUESTY_API_KEY bearer auth) - model_connection_service.py: key verification + live model discovery - quality_score.py: Requesty score entry - unit tests mirroring the OpenRouter normalizer coverage Frontend: - Requesty provider icon + registration, metadata entry, and base-url hint Signed-off-by: Thibault Jaigu --- .../app/services/model_connection_service.py | 15 ++- .../app/services/provider_registry.py | 10 ++ .../app/services/quality_score.py | 1 + .../app/services/requesty_model_normalizer.py | 123 ++++++++++++++++++ .../test_requesty_model_normalizer.py | 107 +++++++++++++++ .../components/icons/providers/index.ts | 1 + .../components/icons/providers/requesty.svg | 1 + .../default-connect-form.tsx | 7 +- .../model-connections/provider-metadata.tsx | 7 + surfsense_web/lib/provider-icons.tsx | 3 + 10 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 surfsense_backend/app/services/requesty_model_normalizer.py create mode 100644 surfsense_backend/tests/unit/services/test_requesty_model_normalizer.py create mode 100644 surfsense_web/components/icons/providers/requesty.svg diff --git a/surfsense_backend/app/services/model_connection_service.py b/surfsense_backend/app/services/model_connection_service.py index cdfd1d7255..54d0a3c3f3 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.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 67d1c4db40..3461500e60 100644 --- a/surfsense_backend/app/services/provider_registry.py +++ b/surfsense_backend/app/services/provider_registry.py @@ -23,6 +23,7 @@ class Transport(StrEnum): "anthropic_models", "bedrock_models", "openrouter", + "requesty", "static", "none", ] @@ -78,6 +79,15 @@ class 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 737dd7c2f4..1aa3e7edab 100644 --- a/surfsense_backend/app/services/quality_score.py +++ b/surfsense_backend/app/services/quality_score.py @@ -123,6 +123,7 @@ "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 0000000000..7710eb1743 --- /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 0000000000..36b867923c --- /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 5c8276e62c..f03fbec689 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 0000000000..a804601b66 --- /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 e3111202d7..91f31f2f03 100644 --- a/surfsense_web/components/settings/model-connections/default-connect-form.tsx +++ b/surfsense_web/components/settings/model-connections/default-connect-form.tsx @@ -11,7 +11,12 @@ function baseUrlHint(provider: string) { if (provider === "openai_compatible") { return "Enter the full endpoint URL."; } - 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 8b8a877b95..e6c0e8cd87 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", @@ -43,6 +44,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 d3e7997205..20016fdf29 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":