Skip to content
Merged
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
15 changes: 14 additions & 1 deletion surfsense_backend/app/services/model_connection_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
10 changes: 10 additions & 0 deletions surfsense_backend/app/services/provider_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Transport(StrEnum):
"anthropic_models",
"bedrock_models",
"openrouter",
"requesty",
"static",
"none",
]
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions surfsense_backend/app/services/quality_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"perplexity": 28,
"bedrock": 28,
"openrouter": 25,
"requesty": 25,
"ollama_chat": 12,
"custom": 12,
}
Expand Down
123 changes: 123 additions & 0 deletions surfsense_backend/app/services/requesty_model_normalizer.py
Original file line number Diff line number Diff line change
@@ -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",
]
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions surfsense_web/components/icons/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions surfsense_web/components/icons/providers/requesty.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const PROVIDER_ORDER = [
"bedrock",
"azure",
"openrouter",
"requesty",
"ollama_chat",
"lm_studio",
"openai_compatible",
Expand Down Expand Up @@ -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" },
};

Expand Down
3 changes: 3 additions & 0 deletions surfsense_web/lib/provider-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
QwenIcon,
RecraftIcon,
ReplicateIcon,
RequestyIcon,
SambaNovaIcon,
TogetherAiIcon,
VertexAiIcon,
Expand Down Expand Up @@ -117,6 +118,8 @@ export function getProviderIcon(
return <RecraftIcon className={cn(className)} />;
case "REPLICATE":
return <ReplicateIcon className={cn(className)} />;
case "REQUESTY":
return <RequestyIcon className={cn(className)} />;
case "SAMBANOVA":
return <SambaNovaIcon className={cn(className)} />;
case "TOGETHER_AI":
Expand Down