Merge pull request #770 from Routstr/feat/venice-provider

feat: venice provider with anthropic web-search translation
This commit is contained in:
9qeklajc
2026-09-26 02:25:14 +02:00
committed by GitHub
7 changed files with 1039 additions and 1 deletions
+2
View File
@@ -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"""
+11
View File
@@ -2537,6 +2537,16 @@ class BaseUpstreamProvider:
) -> dict:
return await messages_dispatch.aggregate_anthropic_events_to_message(iterator)
def adapt_messages_request(self, body: dict, model_obj: Model) -> str:
"""Rewrite an allowlisted /v1/messages body for this upstream.
Returns a suffix appended to the upstream model name, empty when the
provider needs none. Subclasses override this to express an Anthropic
feature the upstream spells differently; the base forwards the body
untouched.
"""
return ""
async def _dispatch_anthropic_messages(
self,
request_body: bytes | None,
@@ -2551,6 +2561,7 @@ class BaseUpstreamProvider:
api_key=self.api_key,
provider_prefix=self.get_litellm_provider_prefix(),
transform_model_name=self.transform_model_name,
adapt_request=lambda body: self.adapt_messages_request(body, model_obj),
log_extra=log_extra,
)
+9 -1
View File
@@ -458,6 +458,7 @@ async def dispatch_anthropic_messages(
api_key: str,
provider_prefix: str,
transform_model_name: Callable[[str], str],
adapt_request: Callable[[dict], str] | None = None,
log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]:
"""Call ``litellm.anthropic.messages.acreate`` and return
@@ -465,6 +466,11 @@ async def dispatch_anthropic_messages(
Shared by the bearer-key and x-cashu paths. Raises :class:`UpstreamError`
on bad input or upstream failure.
``adapt_request`` is the provider's last word on the allowlisted body: it
may rewrite it in place and returns a suffix for the upstream model name,
which is how a provider expresses a feature litellm would otherwise
translate into a parameter the upstream rejects.
"""
if not request_body:
raise UpstreamError("Missing request body for /v1/messages", status_code=400)
@@ -499,13 +505,15 @@ async def dispatch_anthropic_messages(
)
body = {k: v for k, v in body.items() if k in ALLOWED_MESSAGES_REQUEST_FIELDS}
model_suffix = adapt_request(body) if adapt_request else ""
# Convention: `model.id` is the canonical upstream model name;
# `forwarded_model_id` is the public alias the internal API exposes
# and echoes back to the client.
requested_model = (
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
upstream_model = transform_model_name(model_obj.id)
upstream_model = f"{transform_model_name(model_obj.id)}{model_suffix}"
litellm_model = f"{provider_prefix}{upstream_model}"
kwargs: dict = {
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from ..core.exceptions import UpstreamError
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"]),
}
# Venice runs search itself and reports it back through ``venice_parameters``;
# it has no Anthropic-shaped server tool and rejects the ``web_search_options``
# that litellm's Anthropic adapter derives from one. ``auto`` matches Anthropic
# semantics, where declaring the tool leaves the decision to the model.
# Citations are asked for because litellm's Anthropic response translation
# carries no ``venice_parameters``, so the inline ``^n^`` markers Venice writes
# into the text are the only way a caller sees that sources were used.
_WEB_SEARCH_SUFFIX = ":enable_web_search=auto&enable_web_citations=true"
# Anthropic web-search constraints with no Venice equivalent. Honouring the
# request means enforcing them, so a request that sets one is refused rather
# than answered by a search that ignored it. ``max_uses`` is absent on purpose:
# ``auto`` runs at most one search per request, so any cap of 1 or more is
# already met, while domain filters and location would be silently ignored.
# Only ``max_uses: 0``, a request for no search at all, cannot be honoured.
_UNENFORCEABLE_WEB_SEARCH_KEYS = frozenset(
{"allowed_domains", "blocked_domains", "user_location"}
)
def _is_web_search_tool(tool: Any) -> bool:
"""An Anthropic server-side web-search tool, by either of its markers.
Matches litellm's own detection (``litellm/llms/anthropic/
experimental_pass_through/adapters/transformation.py``), so every tool it
would turn into ``web_search_options`` is caught here first.
"""
if not isinstance(tool, dict):
return False
tool_type = tool.get("type")
return (
isinstance(tool_type, str) and tool_type.startswith("web_search")
) or tool.get("name") == "web_search"
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/")
def adapt_messages_request(self, body: dict, model_obj: Model) -> str:
"""Trade an Anthropic web-search tool for Venice's own search switch.
Left in the body, litellm's Anthropic adapter rewrites the tool into a
top-level ``web_search_options``, which Venice answers with a 400. The
tool is lifted out here and the same intent re-expressed as a model
feature suffix, the one form of ``venice_parameters`` that survives
that adapter.
"""
tools = body.get("tools")
if not isinstance(tools, list):
return ""
search_tools = [tool for tool in tools if _is_web_search_tool(tool)]
if not search_tools:
return ""
# A key carrying null or an empty list states no constraint, so it is
# read as absent rather than refused. ``auto`` runs at most one search,
# so only an integer ``max_uses`` of one or more is known to be met.
unenforceable = sorted(
{
key
for tool in search_tools
for key, value in tool.items()
if (
key in _UNENFORCEABLE_WEB_SEARCH_KEYS
and value is not None
and value != []
)
or (
key == "max_uses"
and value is not None
and not (
isinstance(value, int)
and not isinstance(value, bool)
and value >= 1
)
)
}
)
if unenforceable:
raise UpstreamError(
"Venice web search cannot honour these Anthropic web_search "
f"options: {', '.join(unenforceable)}",
status_code=400,
code="UNSUPPORTED_WEB_SEARCH_OPTION",
details={"unsupported_options": unenforceable},
)
tool_choice = body.get("tool_choice")
if isinstance(tool_choice, dict) and tool_choice.get("name") == "web_search":
raise UpstreamError(
"Venice web search cannot be forced through tool_choice; it is "
"decided by the model",
status_code=400,
code="UNSUPPORTED_WEB_SEARCH_OPTION",
details={"unsupported_options": ["tool_choice"]},
)
remaining = [tool for tool in tools if not _is_web_search_tool(tool)]
if remaining:
# A caller's ``tool_choice: any`` is kept and litellm maps it to
# OpenAI ``required``, so one of the remaining function tools must
# now be called where Anthropic would have let a search satisfy it.
# Deliberate: OpenRouter never rewrites tool_choice for web search
# either, and guessing an alternative would change caller intent.
body["tools"] = remaining
else:
body.pop("tools", None)
# tool_choice without tools is rejected by OpenAI-shaped upstreams.
body.pop("tool_choice", None)
return _WEB_SEARCH_SUFFIX
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"), str(model_type))
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, model_type: str) -> 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"))
output_usd = _usd(raw.get("output"))
# Embeddings produce no completion tokens, so only they may omit an
# output price. Anywhere else a missing or all-zero price would serve
# completions free and a negative one would credit the caller, the
# same guards ``generic.py`` applies to this price book.
if output_usd is None and model_type == "embedding":
output_usd = 0.0
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
return Pricing(
prompt=input_usd / _USD_PER_MILLION,
completion=output_usd / _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,131 @@
"""What Routstr actually puts on the wire for a Venice web-search request.
The unit tests stop at the kwargs handed to litellm. Everything that produced
the reported ``400 Unrecognized key(s) in object: 'web_search_options'``
happened *after* that point, inside litellm's Anthropic adapter, so this test
runs the whole dispatch against a loopback OpenAI-compatible server and reads
the bytes Venice would have received.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Iterator
import pytest
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.litellm_routing import configure_litellm
from routstr.upstream.venice import VeniceUpstreamProvider
_CHUNKS = [
{
"id": "chatcmpl-1",
"object": "chat.completion.chunk",
"created": 0,
"model": "deepseek-v4-flash-0731",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}],
},
{
"id": "chatcmpl-1",
"object": "chat.completion.chunk",
"created": 0,
"model": "deepseek-v4-flash-0731",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
},
]
@pytest.fixture
def upstream() -> Iterator[tuple[str, dict[str, Any]]]:
"""A loopback stand-in for ``api.venice.ai`` that records one request."""
captured: dict[str, Any] = {}
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 - http.server's spelling
length = int(self.headers.get("Content-Length", 0))
captured["path"] = self.path
captured["body"] = json.loads(self.rfile.read(length))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for chunk in _CHUNKS:
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
def log_message(self, *args: Any) -> None:
return None
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1", captured
finally:
server.shutdown()
thread.join(timeout=5)
def _model() -> Model:
return Model(
id="deepseek-v4-flash-0731",
name="deepseek-v4-flash-0731",
created=0,
description="",
context_length=8192,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Unknown",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
@pytest.mark.asyncio
async def test_web_search_request_reaches_venice_in_its_own_shape(
upstream: tuple[str, dict[str, Any]],
) -> None:
base_url, captured = upstream
# The app applies this at startup; without it litellm posts the Anthropic
# body to /responses, which Venice serves only in alpha.
configure_litellm()
provider = VeniceUpstreamProvider(api_key="sk-test")
provider.base_url = base_url
await provider._dispatch_anthropic_messages(
request_body=json.dumps(
{
"model": "venice/deepseek-v4-flash-0731",
"messages": [{"role": "user", "content": "what shipped today?"}],
"max_tokens": 64,
"stream": True,
"tools": [
{"type": "web_search_20250305", "name": "web_search"},
{
"name": "lookup",
"description": "Look something up",
"input_schema": {"type": "object", "properties": {}},
},
],
}
).encode(),
model_obj=_model(),
)
body = captured["body"]
assert captured["path"] == "/v1/chat/completions"
# The reported 400, at the only place it could be observed.
assert "web_search_options" not in body
assert body["model"] == (
"deepseek-v4-flash-0731:enable_web_search=auto&enable_web_citations=true"
)
# The function tool still travels, in OpenAI's shape.
assert [tool["function"]["name"] for tool in body["tools"]] == ["lookup"]
+293
View File
@@ -0,0 +1,293 @@
"""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 _priced_entry(model_id: str, model_type: str, pricing: dict[str, Any]) -> dict:
return {
"id": model_id,
"type": model_type,
"created": 1727966436,
"model_spec": {"name": model_id, "pricing": pricing},
}
@pytest.mark.parametrize(
"pricing",
[
pytest.param({"input": {"usd": 0.2, "diem": 0.2}}, id="missing-output"),
pytest.param(
{"input": {"usd": 0.0, "diem": 0.0}, "output": {"usd": 0.0, "diem": 0.0}},
id="both-zero",
),
pytest.param(
{"input": {"usd": -0.2, "diem": 0.2}, "output": {"usd": 0.9, "diem": 0.9}},
id="negative-input",
),
pytest.param(
{"input": {"usd": 0.2, "diem": 0.2}, "output": {"usd": -0.9, "diem": 0.9}},
id="negative-output",
),
],
)
def test_text_models_that_would_bill_free_or_negative_are_dropped(
pricing: dict[str, Any],
) -> None:
models, _ = _fetch(
{"object": "list", "data": [_priced_entry("bad-text", "text", pricing)]}
)
assert models == []
def test_embedding_with_only_an_input_price_is_listed() -> None:
models, _ = _fetch(
{
"object": "list",
"data": [
_priced_entry("emb", "embedding", {"input": {"usd": 0.05, "diem": 0}})
],
}
)
assert [m.id for m in models] == ["emb"]
assert models[0].pricing.prompt == pytest.approx(0.05 / 1_000_000)
assert models[0].pricing.completion == 0.0
def test_embedding_with_a_negative_price_is_dropped() -> None:
models, _ = _fetch(
{
"object": "list",
"data": [
_priced_entry("emb", "embedding", {"input": {"usd": -0.05, "diem": 0}})
],
}
)
assert models == []
def test_text_model_with_one_zero_price_is_listed() -> None:
"""Only both-zero is free; a free prompt with a paid completion is priced."""
pricing = {"input": {"usd": 0.0, "diem": 0}, "output": {"usd": 0.9, "diem": 0}}
models, _ = _fetch(
{"object": "list", "data": [_priced_entry("t", "text", pricing)]}
)
assert [m.id for m in models] == ["t"]
assert models[0].pricing.completion == pytest.approx(0.9 / 1_000_000)
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()) == []
+281
View File
@@ -0,0 +1,281 @@
"""Venice web search over ``/v1/messages``.
litellm's Anthropic adapter rewrites an Anthropic server-side web-search tool
into a top-level ``web_search_options``, which Venice rejects with
``400 Unrecognized key(s) in object: 'web_search_options'``. These tests pin
the trade: the tool is lifted out of the body and the same intent re-expressed
as a Venice model feature suffix.
"""
from __future__ import annotations
import json
from typing import Any, AsyncIterator
from unittest.mock import AsyncMock, patch
import pytest
from routstr.core.exceptions import UpstreamError
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.base import BaseUpstreamProvider
from routstr.upstream.venice import VeniceUpstreamProvider
WEB_SEARCH_TOOL = {"type": "web_search_20250305", "name": "web_search"}
FUNCTION_TOOL = {
"name": "lookup",
"description": "Look something up",
"input_schema": {"type": "object", "properties": {}},
}
def _model(model_id: str = "deepseek-v4-flash-0731") -> Model:
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=8192,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Unknown",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
def _body(**extra: Any) -> dict[str, Any]:
return {
"messages": [{"role": "user", "content": "what shipped today?"}],
"max_tokens": 64,
**extra,
}
async def _dispatch(provider: BaseUpstreamProvider, body: dict[str, Any]) -> dict:
"""Run the real dispatcher, capturing the kwargs litellm would receive."""
captured: dict[str, Any] = {}
async def empty_iter() -> AsyncIterator[dict]:
if False:
yield {}
async def fake_acreate(**kwargs: Any) -> AsyncIterator[dict]:
captured.update(kwargs)
return empty_iter()
with patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(side_effect=fake_acreate),
):
await provider._dispatch_anthropic_messages(
request_body=json.dumps(
{"model": "venice/x", "stream": True, **body}
).encode(),
model_obj=_model(),
)
return captured
@pytest.mark.asyncio
async def test_web_search_tool_never_reaches_venice_as_web_search_options() -> None:
"""The reported 400: the derived parameter must not be sent at all."""
provider = VeniceUpstreamProvider(api_key="sk-test")
kwargs = await _dispatch(provider, _body(tools=[WEB_SEARCH_TOOL]))
assert "web_search_options" not in kwargs
assert "tools" not in kwargs
assert kwargs["model"] == (
"openai/deepseek-v4-flash-0731:enable_web_search=auto&enable_web_citations=true"
)
assert kwargs["api_base"] == "https://api.venice.ai/api/v1"
@pytest.mark.asyncio
async def test_function_tools_survive_alongside_web_search() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
kwargs = await _dispatch(
provider,
_body(
tools=[WEB_SEARCH_TOOL, FUNCTION_TOOL],
tool_choice={"type": "tool", "name": "lookup"},
),
)
assert kwargs["tools"] == [FUNCTION_TOOL]
assert kwargs["tool_choice"] == {"type": "tool", "name": "lookup"}
assert "web_search_options" not in kwargs
assert kwargs["model"].endswith(":enable_web_search=auto&enable_web_citations=true")
@pytest.mark.asyncio
async def test_requests_without_web_search_are_untouched() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
kwargs = await _dispatch(provider, _body(tools=[FUNCTION_TOOL]))
assert kwargs["model"] == "openai/deepseek-v4-flash-0731"
assert kwargs["tools"] == [FUNCTION_TOOL]
@pytest.mark.asyncio
async def test_other_providers_keep_their_existing_behaviour() -> None:
"""The base hook is a no-op, so no non-Venice upstream changes shape."""
provider = BaseUpstreamProvider(base_url="http://test", api_key="k")
kwargs = await _dispatch(provider, _body(tools=[WEB_SEARCH_TOOL]))
assert kwargs["model"] == "openai/deepseek-v4-flash-0731"
assert kwargs["tools"] == [WEB_SEARCH_TOOL]
@pytest.mark.parametrize(
"tool",
[
{
"type": "web_search_20250305",
"name": "web_search",
"allowed_domains": ["example.com"],
},
{"type": "web_search_20250305", "name": "web_search", "blocked_domains": ["x"]},
{
"type": "web_search_20250305",
"name": "web_search",
"user_location": {"type": "approximate", "country": "DE"},
},
],
)
def test_constraints_venice_cannot_enforce_are_refused(tool: dict[str, Any]) -> None:
"""Better an explicit 400 than a search that quietly ignored the limit."""
provider = VeniceUpstreamProvider(api_key="sk-test")
with pytest.raises(UpstreamError) as excinfo:
provider.adapt_messages_request(_body(tools=[tool]), _model())
assert excinfo.value.status_code == 400
assert excinfo.value.code == "UNSUPPORTED_WEB_SEARCH_OPTION"
@pytest.mark.parametrize(
"tool",
[
{"type": "web_search_20250305", "name": "web_search", "max_uses": None},
{"type": "web_search_20250305", "name": "web_search", "allowed_domains": []},
],
)
def test_constraint_keys_stating_nothing_are_read_as_absent(
tool: dict[str, Any],
) -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
assert provider.adapt_messages_request(_body(tools=[tool]), _model()) != ""
def test_forcing_web_search_through_tool_choice_is_refused() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
body = _body(
tools=[WEB_SEARCH_TOOL],
tool_choice={"type": "tool", "name": "web_search"},
)
with pytest.raises(UpstreamError) as excinfo:
provider.adapt_messages_request(body, _model())
assert excinfo.value.status_code == 400
assert excinfo.value.code == "UNSUPPORTED_WEB_SEARCH_OPTION"
assert excinfo.value.details == {"unsupported_options": ["tool_choice"]}
def test_web_search_only_request_drops_tool_choice() -> None:
"""Without tools left, a surviving tool_choice is rejected upstream."""
provider = VeniceUpstreamProvider(api_key="sk-test")
body = _body(tools=[WEB_SEARCH_TOOL], tool_choice={"type": "auto"})
provider.adapt_messages_request(body, _model())
assert "tools" not in body
assert "tool_choice" not in body
@pytest.mark.asyncio
async def test_claude_code_web_search_tool_is_accepted() -> None:
"""Claude Code always sends ``max_uses: 8``; Venice's single ``auto``
search already stays under any cap of one or more."""
provider = VeniceUpstreamProvider(api_key="sk-test")
tool = {
"type": "web_search_20250305",
"name": "web_search",
"allowed_domains": None,
"blocked_domains": None,
"max_uses": 8,
}
kwargs = await _dispatch(provider, _body(tools=[tool]))
assert "web_search_options" not in kwargs
assert "tools" not in kwargs
assert kwargs["model"] == (
"openai/deepseek-v4-flash-0731:enable_web_search=auto&enable_web_citations=true"
)
@pytest.mark.parametrize("max_uses", [1, None])
def test_max_uses_of_one_or_absent_is_accepted(max_uses: Any) -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
tool = {"type": "web_search_20250305", "name": "web_search", "max_uses": max_uses}
assert provider.adapt_messages_request(_body(tools=[tool]), _model()) != ""
@pytest.mark.parametrize("max_uses", [0, -1, 1.5, True, "0", "8"])
def test_max_uses_other_than_a_positive_integer_is_refused(max_uses: Any) -> None:
"""``auto`` may still search, so a cap below one cannot be met, and a
malformed cap cannot be shown to be met."""
provider = VeniceUpstreamProvider(api_key="sk-test")
tool = {"type": "web_search_20250305", "name": "web_search", "max_uses": max_uses}
with pytest.raises(UpstreamError) as excinfo:
provider.adapt_messages_request(_body(tools=[tool]), _model())
assert excinfo.value.status_code == 400
assert excinfo.value.code == "UNSUPPORTED_WEB_SEARCH_OPTION"
assert excinfo.value.details == {"unsupported_options": ["max_uses"]}
def test_tool_named_web_search_without_the_type_marker_is_caught() -> None:
"""litellm matches on either marker, so this one would also be rewritten."""
provider = VeniceUpstreamProvider(api_key="sk-test")
body = _body(tools=[{"name": "web_search"}])
assert provider.adapt_messages_request(body, _model()) != ""
assert "tools" not in body
def test_litellm_adapter_derives_no_web_search_options_from_the_adapted_body() -> None:
"""The fix at its cause: run the real litellm translation over the body
this provider produces and assert the rejected key is never derived."""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( # noqa: E501
LiteLLMAnthropicMessagesAdapter,
)
provider = VeniceUpstreamProvider(api_key="sk-test")
adapter = LiteLLMAnthropicMessagesAdapter() # type: ignore[no-untyped-call]
body = _body(tools=[WEB_SEARCH_TOOL, FUNCTION_TOOL])
def translate(request: dict[str, Any]) -> dict:
# litellm types the request as a TypedDict; these bodies are built
# from client JSON, so they are plain dicts at this seam.
translated, _ = adapter.translate_anthropic_to_openai(request) # type: ignore[arg-type]
return dict(translated)
# Unadapted, litellm derives the parameter Venice rejects.
before = translate({"model": "m", **_body(tools=[WEB_SEARCH_TOOL])})
assert "web_search_options" in before
provider.adapt_messages_request(body, _model())
assert "web_search_options" not in translate({"model": "m", **body})