mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
feat: add venice upstream provider
This commit is contained in:
@@ -13,6 +13,7 @@ from .ppqai import PPQAIUpstreamProvider
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
from .tinfoil import TinfoilUpstreamProvider
|
||||
from .typesafe import TypeSafeUpstreamProvider
|
||||
from .venice import VeniceUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -30,6 +31,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
RoutstrUpstreamProvider,
|
||||
TinfoilUpstreamProvider,
|
||||
TypeSafeUpstreamProvider,
|
||||
VeniceUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ``GET /models`` defaults to ``type=text``, which is why a Venice account
|
||||
# configured as a generic upstream never sees the rest of its catalog.
|
||||
_MODELS_TYPE_PARAM = "all"
|
||||
|
||||
# Families this proxy can both route and price. Image, audio, music and video
|
||||
# are billed per clip or per second and return no usage object to settle
|
||||
# against, so exposing them would hand out unpriced inference.
|
||||
_SUPPORTED_TYPES = frozenset({"text", "embedding"})
|
||||
|
||||
# Venice prices text in USD per million tokens; Routstr prices per token.
|
||||
_USD_PER_MILLION = 1_000_000.0
|
||||
|
||||
_ARCHITECTURES: dict[str, tuple[str, list[str], list[str]]] = {
|
||||
"text": ("text->text", ["text"], ["text"]),
|
||||
"embedding": ("text->embedding", ["text"], ["embedding"]),
|
||||
}
|
||||
|
||||
|
||||
def _usd(entry: Any) -> float | None:
|
||||
"""Read the USD leg of a Venice ``{usd, diem}`` price pair."""
|
||||
if isinstance(entry, dict):
|
||||
value = entry.get("usd")
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
class VeniceUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for the Venice.ai API.
|
||||
|
||||
Venice publishes a complete price book on its own catalog, so models are
|
||||
built from that rather than matched against OpenRouter, which has never
|
||||
heard of most of Venice's catalog.
|
||||
"""
|
||||
|
||||
provider_type = "venice"
|
||||
default_base_url = "https://api.venice.ai/api/v1"
|
||||
platform_url = "https://venice.ai/settings/api"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "VeniceUpstreamProvider":
|
||||
return cls(
|
||||
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": "Venice AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("venice/")
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
url = f"{self.base_url.rstrip('/')}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
url, params={"type": _MODELS_TYPE_PARAM}, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
try:
|
||||
payload = await self._fetch_provider_models()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching Venice models",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
models: list[Model] = []
|
||||
skipped: list[str] = []
|
||||
for entry in payload.get("data", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
model = self._parse_model(entry)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse Venice model",
|
||||
extra={
|
||||
"model_id": entry.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if model is None:
|
||||
skipped.append(str(entry.get("id", "unknown")))
|
||||
continue
|
||||
models.append(model)
|
||||
|
||||
if skipped:
|
||||
logger.debug(
|
||||
f"({len(skipped)}) Venice models skipped as unsupported or unpriced",
|
||||
extra={"skipped_models": skipped},
|
||||
)
|
||||
return models
|
||||
|
||||
def _parse_model(self, entry: dict[str, Any]) -> Model | None:
|
||||
model_type = entry.get("type")
|
||||
model_id = entry.get("id")
|
||||
spec = entry.get("model_spec")
|
||||
if not model_id or model_type not in _SUPPORTED_TYPES:
|
||||
return None
|
||||
if not isinstance(spec, dict) or spec.get("offline"):
|
||||
return None
|
||||
|
||||
pricing = self._parse_pricing(spec.get("pricing"))
|
||||
if pricing is None:
|
||||
return None
|
||||
|
||||
modality, input_modalities, output_modalities = _ARCHITECTURES[str(model_type)]
|
||||
capabilities = spec.get("capabilities")
|
||||
if (
|
||||
model_type == "text"
|
||||
and isinstance(capabilities, dict)
|
||||
and capabilities.get("supportsVision")
|
||||
):
|
||||
input_modalities = [*input_modalities, "image"]
|
||||
modality = "text+image->text"
|
||||
|
||||
context_length = spec.get("availableContextTokens")
|
||||
max_completion_tokens = spec.get("maxCompletionTokens")
|
||||
name = spec.get("name") or str(model_id)
|
||||
|
||||
return Model(
|
||||
id=str(model_id),
|
||||
name=str(name),
|
||||
created=int(entry.get("created") or 0),
|
||||
description=str(spec.get("description") or f"Venice {model_type} model"),
|
||||
context_length=int(context_length) if context_length else 0,
|
||||
architecture=Architecture(
|
||||
modality=modality,
|
||||
input_modalities=input_modalities,
|
||||
output_modalities=output_modalities,
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=pricing,
|
||||
top_provider=TopProvider(
|
||||
context_length=int(context_length) if context_length else None,
|
||||
max_completion_tokens=int(max_completion_tokens)
|
||||
if max_completion_tokens
|
||||
else None,
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_pricing(self, raw: Any) -> Pricing | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
# The ``extended`` tier some models charge past a context threshold is
|
||||
# ignored: billing it would overcharge every request staying under it.
|
||||
input_usd = _usd(raw.get("input"))
|
||||
if input_usd is None:
|
||||
return None
|
||||
return Pricing(
|
||||
prompt=input_usd / _USD_PER_MILLION,
|
||||
completion=(_usd(raw.get("output")) or 0.0) / _USD_PER_MILLION,
|
||||
input_cache_read=(_usd(raw.get("cache_input")) or 0.0) / _USD_PER_MILLION,
|
||||
input_cache_write=(_usd(raw.get("cache_write")) or 0.0) / _USD_PER_MILLION,
|
||||
)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Unit tests for ``VeniceUpstreamProvider.fetch_models``.
|
||||
|
||||
Venice answers ``/models`` with only its text catalog unless ``type`` is
|
||||
passed, which is why the same account configured as a generic upstream sees a
|
||||
different catalog. These tests pin that query parameter, the per-token pricing
|
||||
shape, and the families dropped as unpriceable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.venice import VeniceUpstreamProvider
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, payload: dict[str, Any], calls: list[dict[str, Any]]) -> None:
|
||||
self._payload = payload
|
||||
self._calls = calls
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(
|
||||
self,
|
||||
url: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> _FakeResponse:
|
||||
self._calls.append({"url": url, "params": params, "headers": headers})
|
||||
return _FakeResponse(self._payload)
|
||||
|
||||
|
||||
CATALOG: dict[str, Any] = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "venice-uncensored-1-2",
|
||||
"type": "text",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "Venice Uncensored 1.2",
|
||||
"availableContextTokens": 128000,
|
||||
"maxCompletionTokens": 8192,
|
||||
"capabilities": {"supportsVision": True},
|
||||
"pricing": {
|
||||
"input": {"usd": 0.2, "diem": 0.2},
|
||||
"output": {"usd": 0.9, "diem": 0.9},
|
||||
"cache_input": {"usd": 0.02, "diem": 0.02},
|
||||
"cache_write": {"usd": 0.25, "diem": 0.25},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "text-embedding-bge-m3",
|
||||
"type": "embedding",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "BGE m3",
|
||||
"availableContextTokens": 8192,
|
||||
"pricing": {"input": {"usd": 0.01, "diem": 0.01}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "unpriced-text",
|
||||
"type": "text",
|
||||
"created": 1727966436,
|
||||
"model_spec": {"name": "Unpriced", "pricing": {}},
|
||||
},
|
||||
{
|
||||
"id": "offline-model",
|
||||
"type": "text",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "Offline",
|
||||
"offline": True,
|
||||
"pricing": {"input": {"usd": 0.2, "diem": 0.2}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "venice-sd35",
|
||||
"type": "image",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "Venice SD35",
|
||||
"pricing": {"generation": {"usd": 0.01, "diem": 0.01}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "flux-2-max-edit",
|
||||
"type": "inpaint",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "FLUX.2 Max Edit",
|
||||
"pricing": {"inpaint": {"usd": 0.12, "diem": 0.12}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "tts-kokoro",
|
||||
"type": "tts",
|
||||
"created": 1727966436,
|
||||
"model_spec": {
|
||||
"name": "Kokoro",
|
||||
"pricing": {"input": {"usd": 3.5, "diem": 3.5}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "unpriced-video",
|
||||
"type": "video",
|
||||
"created": 1727966436,
|
||||
"model_spec": {"name": "Video"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _fetch(payload: dict[str, Any] = CATALOG) -> tuple[list[Any], list[dict[str, Any]]]:
|
||||
import asyncio
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
provider = VeniceUpstreamProvider(api_key="sk-test")
|
||||
with patch(
|
||||
"routstr.upstream.venice.httpx.AsyncClient",
|
||||
lambda *a, **kw: _FakeAsyncClient(payload, calls),
|
||||
):
|
||||
models = asyncio.run(provider.fetch_models())
|
||||
return models, calls
|
||||
|
||||
|
||||
def test_requests_every_model_family() -> None:
|
||||
_, calls = _fetch()
|
||||
assert calls[0]["params"] == {"type": "all"}
|
||||
assert calls[0]["url"] == "https://api.venice.ai/api/v1/models"
|
||||
assert calls[0]["headers"] == {"Authorization": "Bearer sk-test"}
|
||||
|
||||
|
||||
def test_text_pricing_is_per_token() -> None:
|
||||
models, _ = _fetch()
|
||||
model = next(m for m in models if m.id == "venice-uncensored-1-2")
|
||||
assert model.pricing.prompt == pytest.approx(0.2 / 1_000_000)
|
||||
assert model.pricing.completion == pytest.approx(0.9 / 1_000_000)
|
||||
assert model.pricing.input_cache_read == pytest.approx(0.02 / 1_000_000)
|
||||
assert model.pricing.input_cache_write == pytest.approx(0.25 / 1_000_000)
|
||||
assert model.context_length == 128000
|
||||
assert model.top_provider is not None
|
||||
assert model.top_provider.max_completion_tokens == 8192
|
||||
assert model.architecture.input_modalities == ["text", "image"]
|
||||
assert model.architecture.modality == "text+image->text"
|
||||
|
||||
|
||||
def test_embedding_models_are_listed() -> None:
|
||||
models, _ = _fetch()
|
||||
model = next(m for m in models if m.id == "text-embedding-bge-m3")
|
||||
assert model.architecture.output_modalities == ["embedding"]
|
||||
assert model.pricing.prompt == pytest.approx(0.01 / 1_000_000)
|
||||
assert model.pricing.completion == 0.0
|
||||
|
||||
|
||||
def test_families_billed_per_clip_are_dropped() -> None:
|
||||
"""Image, audio and video return no usage to settle against, so listing
|
||||
them here would hand out inference this provider cannot price."""
|
||||
models, _ = _fetch()
|
||||
ids = {m.id for m in models}
|
||||
assert "venice-sd35" not in ids
|
||||
assert "flux-2-max-edit" not in ids
|
||||
assert "tts-kokoro" not in ids
|
||||
assert "unpriced-video" not in ids
|
||||
|
||||
|
||||
def test_offline_and_unpriced_models_are_dropped() -> None:
|
||||
models, _ = _fetch()
|
||||
ids = {m.id for m in models}
|
||||
assert "offline-model" not in ids
|
||||
assert "unpriced-text" not in ids
|
||||
|
||||
|
||||
def test_model_name_drops_the_venice_prefix() -> None:
|
||||
provider = VeniceUpstreamProvider(api_key="sk-test")
|
||||
assert provider.transform_model_name("venice/venice-uncensored-1-2") == (
|
||||
"venice-uncensored-1-2"
|
||||
)
|
||||
assert provider.transform_model_name("venice-uncensored-1-2") == (
|
||||
"venice-uncensored-1-2"
|
||||
)
|
||||
|
||||
|
||||
def test_provider_metadata_pins_the_base_url() -> None:
|
||||
metadata = VeniceUpstreamProvider.get_provider_metadata()
|
||||
assert metadata["id"] == "venice"
|
||||
assert metadata["default_base_url"] == "https://api.venice.ai/api/v1"
|
||||
assert metadata["fixed_base_url"] is True
|
||||
|
||||
|
||||
def test_fetch_returns_empty_on_upstream_failure() -> None:
|
||||
provider = VeniceUpstreamProvider(api_key="sk-test")
|
||||
|
||||
with patch.object(
|
||||
VeniceUpstreamProvider,
|
||||
"_fetch_provider_models",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
import asyncio
|
||||
|
||||
assert asyncio.run(provider.fetch_models()) == []
|
||||
Reference in New Issue
Block a user