mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-16 23:01:06 +02:00
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 <thibault.jaigu@gmail.com>
This commit is contained in:
parent
e32413588e
commit
2ff7ea4cb6
10 changed files with 273 additions and 2 deletions
|
|
@ -15,6 +15,7 @@ from app.db import Connection, Model, ModelSource
|
||||||
from app.services.model_resolver import ensure_v1, to_litellm
|
from app.services.model_resolver import ensure_v1, to_litellm
|
||||||
from app.services.openrouter_model_normalizer import normalize_openrouter_models
|
from app.services.openrouter_model_normalizer import normalize_openrouter_models
|
||||||
from app.services.provider_registry import Transport, provider_label, spec_for
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -148,7 +149,7 @@ async def verify_connection(conn: Connection) -> VerifyResult:
|
||||||
|
|
||||||
if spec.transport == Transport.OLLAMA and base_url:
|
if spec.transport == Transport.OLLAMA and base_url:
|
||||||
url = f"{base_url.rstrip('/')}/api/version"
|
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"
|
url = f"{ensure_v1(base_url)}/models"
|
||||||
elif spec.discovery == "anthropic_models" and base_url:
|
elif spec.discovery == "anthropic_models" and base_url:
|
||||||
url = f"{base_url.rstrip('/')}/models"
|
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", []))
|
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]]:
|
def _litellm_static_models(conn: Connection) -> list[dict[str, Any]]:
|
||||||
provider = conn.provider
|
provider = conn.provider
|
||||||
prefix = spec_for(provider).litellm_prefix or 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)
|
results = await _ollama_tags_then_show(conn)
|
||||||
elif spec.discovery == "openrouter":
|
elif spec.discovery == "openrouter":
|
||||||
results = await _openrouter_models(conn)
|
results = await _openrouter_models(conn)
|
||||||
|
elif spec.discovery == "requesty":
|
||||||
|
results = await _requesty_models(conn)
|
||||||
elif spec.discovery == "anthropic_models":
|
elif spec.discovery == "anthropic_models":
|
||||||
results = await _discover_anthropic_models(conn)
|
results = await _discover_anthropic_models(conn)
|
||||||
elif spec.discovery == "openai_models":
|
elif spec.discovery == "openai_models":
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ DiscoveryKind = Literal[
|
||||||
"anthropic_models",
|
"anthropic_models",
|
||||||
"bedrock_models",
|
"bedrock_models",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
|
"requesty",
|
||||||
"static",
|
"static",
|
||||||
"none",
|
"none",
|
||||||
]
|
]
|
||||||
|
|
@ -78,6 +79,15 @@ REGISTRY: dict[str, ProviderSpec] = {
|
||||||
"bearer",
|
"bearer",
|
||||||
"OpenRouter",
|
"OpenRouter",
|
||||||
),
|
),
|
||||||
|
"requesty": ProviderSpec(
|
||||||
|
Transport.OPENAI_COMPATIBLE,
|
||||||
|
"openai",
|
||||||
|
"requesty",
|
||||||
|
"https://router.requesty.ai/v1",
|
||||||
|
False,
|
||||||
|
"bearer",
|
||||||
|
"Requesty",
|
||||||
|
),
|
||||||
"openai_compatible": ProviderSpec(
|
"openai_compatible": ProviderSpec(
|
||||||
Transport.OPENAI_COMPATIBLE,
|
Transport.OPENAI_COMPATIBLE,
|
||||||
"openai",
|
"openai",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ PROVIDER_PRESTIGE_YAML: dict[str, int] = {
|
||||||
"perplexity": 28,
|
"perplexity": 28,
|
||||||
"bedrock": 28,
|
"bedrock": 28,
|
||||||
"openrouter": 25,
|
"openrouter": 25,
|
||||||
|
"requesty": 25,
|
||||||
"ollama_chat": 12,
|
"ollama_chat": 12,
|
||||||
"custom": 12,
|
"custom": 12,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
surfsense_backend/app/services/requesty_model_normalizer.py
Normal file
123
surfsense_backend/app/services/requesty_model_normalizer.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -27,6 +27,7 @@ export { default as PerplexityIcon } from "./perplexity.svg";
|
||||||
export { default as QwenIcon } from "./qwen.svg";
|
export { default as QwenIcon } from "./qwen.svg";
|
||||||
export { default as RecraftIcon } from "./recraft.svg";
|
export { default as RecraftIcon } from "./recraft.svg";
|
||||||
export { default as ReplicateIcon } from "./replicate.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 SambaNovaIcon } from "./sambanova.svg";
|
||||||
export { default as TogetherAiIcon } from "./togetherai.svg";
|
export { default as TogetherAiIcon } from "./togetherai.svg";
|
||||||
export { default as VertexAiIcon } from "./vertexai.svg";
|
export { default as VertexAiIcon } from "./vertexai.svg";
|
||||||
|
|
|
||||||
1
surfsense_web/components/icons/providers/requesty.svg
Normal file
1
surfsense_web/components/icons/providers/requesty.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 2a1 1 0 000 2h2.086l-5.293 5.293a1 1 0 001.414 1.414L18 5.414V7.5a1 1 0 102 0v-4.5a1 1 0 00-1-1h-4.5zM4 6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4a1 1 0 10-2 0v4H4V8h4a1 1 0 000-2H4z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
|
|
@ -11,7 +11,12 @@ function baseUrlHint(provider: string) {
|
||||||
if (provider === "openai_compatible") {
|
if (provider === "openai_compatible") {
|
||||||
return "Enter the full endpoint URL.";
|
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 "Override only if you route through a proxy or gateway.";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export const PROVIDER_ORDER = [
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"azure",
|
"azure",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
|
"requesty",
|
||||||
"ollama_chat",
|
"ollama_chat",
|
||||||
"lm_studio",
|
"lm_studio",
|
||||||
"openai_compatible",
|
"openai_compatible",
|
||||||
|
|
@ -43,6 +44,12 @@ export const PROVIDER_DISPLAY: Record<
|
||||||
iconKey: "openrouter",
|
iconKey: "openrouter",
|
||||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
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" },
|
vertex_ai: { name: "Gemini", subtitle: "Google Cloud Vertex AI", iconKey: "vertex_ai" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
QwenIcon,
|
QwenIcon,
|
||||||
RecraftIcon,
|
RecraftIcon,
|
||||||
ReplicateIcon,
|
ReplicateIcon,
|
||||||
|
RequestyIcon,
|
||||||
SambaNovaIcon,
|
SambaNovaIcon,
|
||||||
TogetherAiIcon,
|
TogetherAiIcon,
|
||||||
VertexAiIcon,
|
VertexAiIcon,
|
||||||
|
|
@ -117,6 +118,8 @@ export function getProviderIcon(
|
||||||
return <RecraftIcon className={cn(className)} />;
|
return <RecraftIcon className={cn(className)} />;
|
||||||
case "REPLICATE":
|
case "REPLICATE":
|
||||||
return <ReplicateIcon className={cn(className)} />;
|
return <ReplicateIcon className={cn(className)} />;
|
||||||
|
case "REQUESTY":
|
||||||
|
return <RequestyIcon className={cn(className)} />;
|
||||||
case "SAMBANOVA":
|
case "SAMBANOVA":
|
||||||
return <SambaNovaIcon className={cn(className)} />;
|
return <SambaNovaIcon className={cn(className)} />;
|
||||||
case "TOGETHER_AI":
|
case "TOGETHER_AI":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue