mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Add a first-class `deepseek` upstream provider that needs only an API key; the base URL is fixed to https://api.deepseek.com. DEEPSEEK_API_KEY seeds it on startup. Models come from DeepSeek's own /models and are priced from a peak-rate table in routstr/upstream/deepseek.py. A listed model the table misses is imported disabled instead of taking a litellm/OpenRouter price, via a new GenericUpstreamProvider.use_fallback_pricing switch (default True, so other providers are unchanged). The node bills one flat price per model, so the table holds the peak rate and never bills below DeepSeek's cost. Thinking-mode reasoning_content is forwarded unchanged: DeepSeek requires it on requests that carry tools and ignores it otherwise. Remove the temporary DeepSeek V4 pricing shim and its startup call. The pinned litellm 1.101.2 already ships every key it filled.
269 lines
11 KiB
Python
269 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
|
|
from .base import BaseUpstreamProvider, _reported_provider
|
|
from .model_paths import public_provider_url
|
|
from .pricing_resolver import (
|
|
FallbackPricingResolver,
|
|
ResolvedPricing,
|
|
_as_float,
|
|
estimate_context_length,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ..core.db import UpstreamProviderRow
|
|
from ..payment.models import Model
|
|
|
|
from ..core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class GenericUpstreamProvider(BaseUpstreamProvider):
|
|
"""Generic upstream provider that can fetch models from any OpenAI-compatible API."""
|
|
|
|
provider_type = "generic"
|
|
default_base_url = "http://localhost:8888"
|
|
platform_url: str | None = None
|
|
# Subclasses that own an authoritative price table set this False so a model
|
|
# the table misses imports disabled instead of taking a litellm/OpenRouter
|
|
# price that may undercut the upstream's own rate.
|
|
use_fallback_pricing = True
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
api_key: str = "",
|
|
provider_fee: float = 1.01,
|
|
upstream_name: str | None = None,
|
|
):
|
|
"""Initialize generic provider.
|
|
|
|
Args:
|
|
base_url: Base URL of the upstream API endpoint
|
|
api_key: Optional API key for authentication
|
|
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
|
upstream_name: Optional name for the upstream provider
|
|
"""
|
|
self.upstream_name = upstream_name or "generic"
|
|
super().__init__(
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
provider_fee=provider_fee,
|
|
)
|
|
|
|
def _apply_provider_field(self, response_json: object) -> None:
|
|
"""Stamp ``"generic:<upstream host>"`` unless the upstream named itself.
|
|
|
|
A generic upstream is not a router, so nothing identifies the serving
|
|
endpoint in the payload; the base URL host fills that role.
|
|
"""
|
|
if not isinstance(response_json, dict):
|
|
return
|
|
if _reported_provider(response_json) is None:
|
|
response_json["provider"] = (
|
|
urlparse(public_provider_url(self.base_url)).hostname
|
|
or self.upstream_name
|
|
)
|
|
super()._apply_provider_field(response_json)
|
|
|
|
@classmethod
|
|
def _build_from_row(
|
|
cls, provider_row: "UpstreamProviderRow"
|
|
) -> "GenericUpstreamProvider":
|
|
return cls(
|
|
base_url=provider_row.base_url,
|
|
api_key=provider_row.api_key,
|
|
provider_fee=provider_row.provider_fee,
|
|
)
|
|
|
|
@classmethod
|
|
def get_provider_metadata(cls) -> dict[str, object]:
|
|
return {
|
|
"id": cls.provider_type,
|
|
"name": "Generic",
|
|
"default_base_url": cls.default_base_url,
|
|
"fixed_base_url": False,
|
|
"platform_url": cls.platform_url,
|
|
}
|
|
|
|
def _native_pricing(
|
|
self, model_id: str, model_spec: dict
|
|
) -> ResolvedPricing | None:
|
|
"""Read pricing/metadata from Venice's bespoke ``model_spec`` schema.
|
|
|
|
Returns ``None`` when the upstream reported no *usable* native price —
|
|
absent, non-numeric, negative, or both-zero — so the caller falls
|
|
through to the shared resolution chain instead of fabricating a number
|
|
or trusting a bogus one. This mirrors the money-safety guards the
|
|
litellm and OpenRouter rungs already apply: a both-zero price would
|
|
serve the model free, a negative one would credit the caller, and a
|
|
non-numeric string would otherwise throw and drop the whole catalog.
|
|
"""
|
|
pricing_info = model_spec.get("pricing", {})
|
|
input_usd = _as_float(pricing_info.get("input", {}).get("usd"))
|
|
output_usd = _as_float(pricing_info.get("output", {}).get("usd"))
|
|
if input_usd is None or output_usd is None:
|
|
return None
|
|
if input_usd < 0 or output_usd < 0 or (input_usd == 0 and output_usd == 0):
|
|
return None
|
|
|
|
# Venice ships a discounted cache-read rate as ``cache_input`` (e.g.
|
|
# deepseek-v4-1-flash: $0.0075/1M vs $0.375/1M input). Dropping it
|
|
# left ``input_cache_read`` at 0, which billing reads as "no cache
|
|
# rate" and falls back to the FULL input rate — a 50x overcharge on
|
|
# cache hits. A malformed/negative cache rate coerces to None and is
|
|
# treated as absent, never carried (same rule as the OpenRouter rung).
|
|
cache_read_usd = _as_float(pricing_info.get("cache_input", {}).get("usd"))
|
|
input_cache_read = (
|
|
cache_read_usd / 1_000_000
|
|
if cache_read_usd is not None and cache_read_usd > 0
|
|
else 0.0
|
|
)
|
|
|
|
capabilities = model_spec.get("capabilities", {})
|
|
input_modalities = ["text"]
|
|
if capabilities.get("supportsVision", False):
|
|
input_modalities.append("image")
|
|
|
|
return ResolvedPricing(
|
|
prompt=input_usd / 1_000_000,
|
|
completion=output_usd / 1_000_000,
|
|
context_length=model_spec.get("availableContextTokens"),
|
|
source="native",
|
|
input_cache_read=input_cache_read,
|
|
input_modalities=input_modalities,
|
|
)
|
|
|
|
async def fetch_models(self) -> list[Model]:
|
|
"""Fetch models from upstream API using /models endpoint."""
|
|
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
headers = {}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
response = await client.get(f"{self.base_url}/models", headers=headers)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
resolver = FallbackPricingResolver()
|
|
models_list = []
|
|
for model_data in data.get("data", []):
|
|
model_id = model_data.get("id", "")
|
|
if not model_id:
|
|
continue
|
|
|
|
model_name = model_data.get("name", model_id)
|
|
created = model_data.get("created", 0)
|
|
owned_by = model_data.get("owned_by", "unknown")
|
|
model_spec = model_data.get("model_spec", {})
|
|
|
|
resolved = self._native_pricing(model_id, model_spec)
|
|
if resolved is None and self.use_fallback_pricing:
|
|
resolved = await resolver.resolve(model_id)
|
|
|
|
if resolved is None:
|
|
# Fail closed: never invent a price. Import the model
|
|
# disabled with a warning so the operator can price it
|
|
# (the admin UI surfaces disabled remote models).
|
|
logger.warning(
|
|
f"No pricing source resolved for '{model_id}' from "
|
|
f"{self.upstream_name}; importing it disabled",
|
|
extra={"model_id": model_id, "base_url": self.base_url},
|
|
)
|
|
resolved = ResolvedPricing(
|
|
prompt=0.0,
|
|
completion=0.0,
|
|
context_length=None,
|
|
source="unresolved",
|
|
)
|
|
enabled = False
|
|
else:
|
|
enabled = True
|
|
|
|
# Prefer the source's own modality string (OpenRouter ships
|
|
# one, e.g. "text+image->text"); otherwise derive it from the
|
|
# captured input/output modalities in the same "in->out" shape
|
|
# rather than flattening vision models to "text->text".
|
|
modality = resolved.modality or (
|
|
f"{'+'.join(resolved.input_modalities)}"
|
|
f"->{'+'.join(resolved.output_modalities)}"
|
|
)
|
|
|
|
# A source can carry a price but no context (e.g. a litellm
|
|
# entry missing max_input_tokens); fall back to an id-based
|
|
# estimate so we never persist a zero-length window.
|
|
context_length = resolved.context_length or estimate_context_length(
|
|
model_id
|
|
)
|
|
|
|
spec_name = model_spec.get("name", model_name)
|
|
description = f"{spec_name}"
|
|
if owned_by != "unknown":
|
|
description += f" via {owned_by}"
|
|
|
|
models_list.append(
|
|
Model(
|
|
id=model_id,
|
|
name=spec_name,
|
|
created=created,
|
|
description=description,
|
|
context_length=context_length,
|
|
architecture=Architecture(
|
|
modality=modality,
|
|
input_modalities=resolved.input_modalities,
|
|
output_modalities=resolved.output_modalities,
|
|
tokenizer=resolved.tokenizer,
|
|
instruct_type=resolved.instruct_type,
|
|
),
|
|
pricing=Pricing(
|
|
prompt=resolved.prompt,
|
|
completion=resolved.completion,
|
|
request=0.0,
|
|
image=0.0,
|
|
web_search=0.0,
|
|
internal_reasoning=0.0,
|
|
input_cache_read=resolved.input_cache_read,
|
|
input_cache_write=resolved.input_cache_write,
|
|
),
|
|
sats_pricing=None,
|
|
per_request_limits=None,
|
|
top_provider=TopProvider(
|
|
context_length=context_length,
|
|
max_completion_tokens=(
|
|
resolved.max_completion_tokens
|
|
if resolved.max_completion_tokens is not None
|
|
else context_length // 2
|
|
),
|
|
is_moderated=bool(resolved.is_moderated),
|
|
),
|
|
enabled=enabled,
|
|
upstream_provider_id=None,
|
|
canonical_slug=None,
|
|
)
|
|
)
|
|
|
|
logger.info(
|
|
f"Fetched {len(models_list)} models from {self.upstream_name}",
|
|
extra={"model_count": len(models_list), "base_url": self.base_url},
|
|
)
|
|
return models_list
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to fetch models from {self.upstream_name} API: {e}",
|
|
extra={
|
|
"error": str(e),
|
|
"error_type": type(e).__name__,
|
|
"base_url": self.base_url,
|
|
},
|
|
)
|
|
return []
|