From cb44ab52bba3e46c59f4d13eead401270bd90d30 Mon Sep 17 00:00:00 2001 From: clawnchdev Date: Tue, 9 Jun 2026 19:18:39 -0400 Subject: [PATCH] feat(services): add Venice AI as an OpenAI-compatible inference provider (v0.18.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Venice (https://docs.venice.ai/models/overview) is a privacy-first, OpenAI- compatible provider. Adds a `venice` service alongside OpenGateway so tools can run targeted inference (classifiers, summarizers, extraction) outside the host Hermes agent loop. * services/venice.py: base https://api.venice.ai/api/v1, VENICE_API_KEY (required — Venice 402s unauthenticated calls via x402) + optional VENICE_MODEL. Non-streaming chat completions; classifies OpenAI-style envelopes AND Venice's flat {"error": "..."} / HTTP 402 auth challenge → no_credentials. * services/__init__.py: registered (6e), independent from Hermes' main LLM. * lib/http.py: allowlist api.venice.ai. * README: documented VENICE_API_KEY / VENICE_MODEL. Verified live against the Venice API (402 auth path → no_credentials, flat error extracted) + full unit coverage of success + every error branch. Gate: 4629 passed, 8 skipped; 100% coverage (16,361 stmts, 0 missing); ruff check + format clean; plugin.yaml byte-identical. Tool count unchanged (53). --- CHANGELOG.md | 21 ++ README.md | 4 + clawmes/_version.py | 2 +- clawmes/lib/http.py | 4 +- clawmes/plugin.yaml | 2 +- clawmes/services/__init__.py | 6 + clawmes/services/venice.py | 248 ++++++++++++++++++ plugin.yaml | 2 +- pyproject.toml | 2 +- tests/lib/test_http.py | 5 + tests/services/test_venice.py | 467 ++++++++++++++++++++++++++++++++++ 11 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 clawmes/services/venice.py create mode 100644 tests/services/test_venice.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0e59..134591c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +## 0.18.0 — 2026-06-09 + +### Added — Venice AI inference provider + +A new `venice` service (`clawmes/services/venice.py`) — an OpenAI-compatible +client for Venice AI (https://docs.venice.ai/models/overview), alongside the +existing OpenGateway provider. Lets tools run targeted inference (classifiers, +summarizers, structured-extraction helpers) outside the host Hermes agent loop. + +- Base URL `https://api.venice.ai/api/v1`; configured via `VENICE_API_KEY` + (required — Venice answers unauthenticated calls with HTTP 402 / x402) and an + optional `VENICE_MODEL` default. Non-streaming chat completions only. +- Robust error classification including Venice's flat `{"error": "..."}` + HTTP + 402 auth challenge (→ `no_credentials`) in addition to OpenAI-style envelopes. +- `api.venice.ai` added to the network allowlist. +- Independent from Hermes' main conversational LLM (that's a Hermes-level + concern); this is opt-in, per-call inference for clawmes tools. + +Verified live against the Venice API (the 402 auth path) and with full unit +coverage of the success + every error path. + ## 0.17.3 — 2026-06-02 ### Fixed — clawmes couldn't reach the Clawnch backend in production diff --git a/README.md b/README.md index 20797c3..61d1187 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,10 @@ OPENGATEWAY_API_KEY= # ogw_live_… — recommended; service runs unauthenticat # during the gitlawb partnership window (auth optional today) OPENGATEWAY_MODEL= # optional default model id sent when callers omit model= +# Venice AI (privacy-first, OpenAI-compatible — https://docs.venice.ai/models/overview) +VENICE_API_KEY= # required — Venice answers unauthenticated calls with HTTP 402 (x402) +VENICE_MODEL= # optional default model id (catalog: GET https://api.venice.ai/api/v1/models) + # Market data + analytics COINGECKO_API_KEY= HERD_ACCESS_TOKEN= diff --git a/clawmes/_version.py b/clawmes/_version.py index 4cdfc46..5e9bd89 100644 --- a/clawmes/_version.py +++ b/clawmes/_version.py @@ -7,4 +7,4 @@ * Tooling that does not want to incur a full package import """ -__version__ = "0.17.3" +__version__ = "0.18.0" diff --git a/clawmes/lib/http.py b/clawmes/lib/http.py index 1b107f3..0036cf0 100644 --- a/clawmes/lib/http.py +++ b/clawmes/lib/http.py @@ -90,8 +90,10 @@ # Bankr "api.bankr.bot", "llm.bankr.bot", - # LLM inference gateway (gitlawb opengateway — see services.opengateway) + # LLM inference gateways (OpenAI-compatible) — see services.opengateway + # and services.venice. "opengateway.gitlawb.com", + "api.venice.ai", # Agent-economy peers — see services.bv7x and the A2A protocol # support in tools.a2a_call. bv7x.ai exposes a JSON-RPC 2.0 A2A # endpoint as well as a public REST oracle. diff --git a/clawmes/plugin.yaml b/clawmes/plugin.yaml index e226a51..bab7ec8 100644 --- a/clawmes/plugin.yaml +++ b/clawmes/plugin.yaml @@ -1,5 +1,5 @@ name: clawmes -version: 0.17.3 +version: 0.18.0 description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation. author: Clawnch kind: standalone diff --git a/clawmes/services/__init__.py b/clawmes/services/__init__.py index 08be686..b5fdef4 100644 --- a/clawmes/services/__init__.py +++ b/clawmes/services/__init__.py @@ -64,6 +64,7 @@ def start_all() -> None: from clawmes.services.sniper_scheduler import get_sniper_scheduler_service from clawmes.services.token_decimals import get_token_decimals_service from clawmes.services.token_gate import get_token_gate_service + from clawmes.services.venice import get_venice_service from clawmes.services.wallet import get_wallet_service from clawmes.services.wc_notifications import get_wc_notification_consumer from clawmes.services.zerox import get_zerox_service @@ -136,6 +137,11 @@ def start_all() -> None: # Hermes agent loop. Independent from Hermes' main LLM — # the agent's conversational inference is owned upstream. get_opengateway_service, + # 6e. Venice AI — OpenAI-compatible privacy-first inference provider. + # Same role as OpenGateway (targeted inference for tools), independent + # from Hermes' main LLM. Requires VENICE_API_KEY (Venice answers + # unauthenticated calls with HTTP 402 / x402). + get_venice_service, # 7. Background daemons — last so they pick up everything above. get_scheduler, # ticking=True; needs cron driver to actually fire # 7a. DCA scheduler — ticking=True. Fires due /dca schedules on diff --git a/clawmes/services/venice.py b/clawmes/services/venice.py new file mode 100644 index 0000000..a1b0f5b --- /dev/null +++ b/clawmes/services/venice.py @@ -0,0 +1,248 @@ +"""OpenAI-compatible LLM client for Venice AI. + +Venice (``https://venice.ai``) is a privacy-first inference provider with an +OpenAI-compatible API at ``https://api.venice.ai/api/v1``. clawmes ships a +first-class client (alongside :mod:`clawmes.services.opengateway`) so tools that +need targeted inference outside the host Hermes agent loop — classifiers, +summarizers, structured-extraction helpers — can use Venice without each tool +wiring its own ``httpx`` client. + +Scope of this service: + + * **Non-streaming chat completions only.** Streaming SSE is out of scope; + clients that need streaming should use Hermes' own LLM client. + * **Venice endpoint only.** The base URL is hardcoded, matching the + convention used by the 0x / CoinGecko / LiFi / OpenGateway services. + * **Independent from the host Hermes LLM.** This does *not* reroute the + agent's main conversational inference — that's a Hermes-level concern + (``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` etc.). + +Authentication: ``VENICE_API_KEY`` env var (``Bearer`` token from the Venice +dashboard). **Required** — unlike OpenGateway, Venice does not serve free +unauthenticated traffic: a request without a key is answered with HTTP ``402`` +(Venice's x402 pay-per-call challenge), surfaced here as +``VeniceError("no_credentials", …)``. The service still starts without the key +(logging a warning) so the rest of clawmes loads cleanly. + +Default model: ``VENICE_MODEL`` env var; can be overridden per call via the +``model=`` keyword. If neither is set, :meth:`chat_completion` raises +``VeniceError("bad_request", …)``. The current catalog is public at +``GET https://api.venice.ai/api/v1/models`` (and https://docs.venice.ai/models/overview). +""" + +from __future__ import annotations + +import os +import threading +from typing import Any + +from clawmes.lib.http import http_post +from clawmes.lib.logger import logger_for +from clawmes.services._base import Service + +_log = logger_for("services.venice") + +# Venice's canonical OpenAI-compatible endpoint. +_BASE_URL = "https://api.venice.ai/api/v1" + + +class VeniceError(RuntimeError): + """Raised on Venice API failures. + + ``code`` classification (preferred sources in order: ``error.code`` / + ``error.type`` in an OpenAI-style envelope, then HTTP status, then keyword + match on the raised exception string): + + * ``bad_request`` — caller-side error: empty messages, no resolvable + model, OpenAI ``invalid_request_error``, HTTP 400. + * ``model_not_found`` — OpenAI ``unsupported_model`` / ``model_not_found`` + code, HTTP 404, or a message containing both "model" and "not found". + * ``rate_limited`` — OpenAI ``rate_limit_exceeded`` / ``rate_limit_error``, + HTTP 429. + * ``no_credentials`` — HTTP 401 / 403, or HTTP 402 (Venice's x402 + "authentication required" / pay-per-call challenge), or OpenAI + ``authentication_error`` / ``permission_denied``. + * ``api_error`` — generic upstream failure. + """ + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class VeniceService(Service): + id = "clawmes.venice" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._api_key: str | None = None + self._default_model: str | None = None + + def start(self) -> None: + with self._lock: + self._api_key = os.environ.get("VENICE_API_KEY") or None + self._default_model = os.environ.get("VENICE_MODEL") or None + if self._api_key: + _log.info( + "venice service started (auth=key, default_model=%s)", + self._default_model or "", + ) + else: + _log.warning( + "venice service started UNAUTHENTICATED (no VENICE_API_KEY); Venice " + "requires a key — chat completions will fail with HTTP 402 until one " + "is set (default_model=%s)", + self._default_model or "", + ) + + def stop(self) -> None: + with self._lock: + self._api_key = None + self._default_model = None + + def health(self) -> dict[str, Any]: + with self._lock: + return { + "id": self.id, + "status": "authenticated" if self._api_key else "unauthenticated", + "default_model": self._default_model, + } + + def chat_completion( + self, + messages: list[dict[str, Any]], + *, + model: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + timeout: float = 60.0, + **extra: Any, + ) -> dict[str, Any]: + """Send a non-streaming chat completion request. + + ``messages`` is the standard OpenAI ``[{role, content}, ...]`` list. + ``model`` overrides the env-configured default. ``extra`` passes through + to the upstream JSON body unchanged — useful for ``top_p``, ``stop``, + ``response_format``, and Venice's own ``venice_parameters``. + + Returns the parsed OpenAI chat completion envelope. Streaming is + explicitly disabled — pass ``stream=True`` and we raise ``bad_request``. + """ + if not messages: + raise VeniceError("bad_request", "messages list must be non-empty") + if extra.get("stream"): + raise VeniceError( + "bad_request", + "streaming is not supported by this service; use Hermes' LLM client", + ) + + with self._lock: + api_key = self._api_key + default_model = self._default_model + + resolved_model = model or default_model + if not resolved_model: + raise VeniceError( + "bad_request", + "no model specified and VENICE_MODEL env var is not set", + ) + + body: dict[str, Any] = {"model": resolved_model, "messages": messages} + if temperature is not None: + body["temperature"] = temperature + if max_tokens is not None: + body["max_tokens"] = max_tokens + body.update(extra) + + return self._call("/chat/completions", body, api_key=api_key, timeout=timeout) + + def _call( + self, + path: str, + body: dict[str, Any], + *, + api_key: str | None, + timeout: float, + ) -> dict[str, Any]: + headers: dict[str, str] = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + url = _BASE_URL + path + try: + response = http_post(url, json=body, headers=headers, timeout=timeout) + except Exception as exc: # noqa: BLE001 — classify below + # ``clawmes.lib.http.http_post`` calls ``raise_for_status`` on + # non-2xx, which discards the body. The original response is still + # on ``exc.response``; recover the structured error envelope. + body_dict: dict[str, Any] | None = None + resp = getattr(exc, "response", None) + if resp is not None: + try: + parsed = resp.json() + except Exception: # noqa: BLE001 — body might not be JSON + parsed = None + if isinstance(parsed, dict): + body_dict = parsed + + # OpenAI-style nested error envelope (Venice uses this for most + # request errors, e.g. model issues). + if body_dict is not None and isinstance(body_dict.get("error"), dict): + raise self._classify_envelope(body_dict["error"]) from exc + + # Venice's auth/payment errors are a flat ``{"error": "..."}`` with + # an HTTP 402 x402 challenge. Classify by status code in the + # exception string (lib/http embeds it), with a keyword fallback. + msg = str(exc).lower() + flat = body_dict.get("error") if isinstance(body_dict, dict) else None + detail = flat if isinstance(flat, str) and flat else str(exc) + if "402" in msg or "401" in msg or "403" in msg or "authentication required" in msg: + raise VeniceError("no_credentials", detail) from exc + if "429" in msg or "rate limit" in msg: + raise VeniceError("rate_limited", detail) from exc + if "404" in msg or ("model" in msg and "not found" in msg): + raise VeniceError("model_not_found", detail) from exc + if "400" in msg: + raise VeniceError("bad_request", detail) from exc + raise VeniceError("api_error", f"venice request failed: {detail}") from exc + + if not isinstance(response, dict): + raise VeniceError( + "api_error", + f"venice returned non-dict response: {type(response).__name__}", + ) + # Defensive: some upstreams return 2xx with an error envelope in the body. + if isinstance(response.get("error"), dict): + raise self._classify_envelope(response["error"]) + return response + + @staticmethod + def _classify_envelope(err: dict[str, Any]) -> VeniceError: + """Classify an OpenAI-style error envelope into a VeniceError.""" + message = str(err.get("message") or "") + err_type = str(err.get("type") or "").lower() + err_code = str(err.get("code") or "").lower() + display = f"venice error ({err_code or err_type or 'unknown'}): {message}" + + if err_code in {"unsupported_model", "model_not_found"}: + return VeniceError("model_not_found", display) + if err_code == "rate_limit_exceeded" or err_type == "rate_limit_error": + return VeniceError("rate_limited", display) + if err_type in {"authentication_error", "permission_denied"}: + return VeniceError("no_credentials", display) + if err_type == "invalid_request_error": + msg_lower = message.lower() + if "model" in msg_lower and ("not found" in msg_lower or "unsupported" in msg_lower): + return VeniceError("model_not_found", display) + return VeniceError("bad_request", display) + return VeniceError("api_error", display) + + +_instance: VeniceService | None = None + + +def get_venice_service() -> VeniceService: + global _instance + if _instance is None: + _instance = VeniceService() + return _instance diff --git a/plugin.yaml b/plugin.yaml index e226a51..bab7ec8 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: clawmes -version: 0.17.3 +version: 0.18.0 description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation. author: Clawnch kind: standalone diff --git a/pyproject.toml b/pyproject.toml index 34e693f..4cd3534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clawmes" -version = "0.17.3" +version = "0.18.0" description = "Hermes Agent plugin for crypto: wallets, DEX trading, lending and staking, governance, on-chain automation." readme = "README.md" license = { text = "MIT" } diff --git a/tests/lib/test_http.py b/tests/lib/test_http.py index dad5dd0..4c2df34 100644 --- a/tests/lib/test_http.py +++ b/tests/lib/test_http.py @@ -30,6 +30,11 @@ def test_allows_clawnch_apex_and_www(self): _check_allowlist("https://clawn.ch/api/agents/register") _check_allowlist("https://www.clawn.ch/api/agents/register") + def test_allows_llm_inference_gateways(self): + # OpenAI-compatible inference providers (services.opengateway / venice). + _check_allowlist("https://opengateway.gitlawb.com/v1/chat/completions") + _check_allowlist("https://api.venice.ai/api/v1/chat/completions") + def test_rejects_unknown_host(self): with pytest.raises(NetworkAllowlistError, match="not on the clawmes network allowlist"): _check_allowlist("https://evil.example.com/whatever") diff --git a/tests/services/test_venice.py b/tests/services/test_venice.py new file mode 100644 index 0000000..a8c6417 --- /dev/null +++ b/tests/services/test_venice.py @@ -0,0 +1,467 @@ +"""Tests for clawmes.services.venice.""" + +from __future__ import annotations + +import pytest + +from clawmes.services import venice as venice_module +from clawmes.services.venice import ( + VeniceError, + VeniceService, + get_venice_service, +) + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + monkeypatch.setattr(venice_module, "_instance", None) + monkeypatch.delenv("VENICE_API_KEY", raising=False) + monkeypatch.delenv("VENICE_MODEL", raising=False) + + +@pytest.fixture +def fake_http(monkeypatch): + class FakeHttp: + def __init__(self): + self.calls: list[dict] = [] + self.responses: list = [] + + def __call__(self, url, *, json=None, headers=None, timeout=30.0, **kw): + self.calls.append({"url": url, "json": json, "headers": headers, "timeout": timeout}) + if not self.responses: + raise AssertionError("no fake response queued") + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + fake = FakeHttp() + monkeypatch.setattr(venice_module, "http_post", fake) + return fake + + +@pytest.fixture +def svc(monkeypatch): + monkeypatch.setenv("VENICE_API_KEY", "venice_test_key") + monkeypatch.setenv("VENICE_MODEL", "zai-org-glm-4.6") + s = VeniceService() + s.start() + return s + + +def _err_with_body(msg, body): + """Build an httpx-style exception with ``.response.json()`` -> ``body``.""" + + class _FakeResponse: + def __init__(self, body): + self._body = body + + def json(self): + if isinstance(self._body, Exception): + raise self._body + return self._body + + class _FakeHTTPError(RuntimeError): + def __init__(self, msg, body): + super().__init__(msg) + self.response = _FakeResponse(body) + + return _FakeHTTPError(msg, body) + + +class TestStartStop: + def test_start_no_key_or_model(self): + s = VeniceService() + s.start() + assert s._api_key is None + assert s._default_model is None + + def test_start_with_key(self, monkeypatch): + monkeypatch.setenv("VENICE_API_KEY", "venice_abc") + s = VeniceService() + s.start() + assert s._api_key == "venice_abc" + + def test_start_with_model(self, monkeypatch): + monkeypatch.setenv("VENICE_MODEL", "zai-org-glm-5") + s = VeniceService() + s.start() + assert s._default_model == "zai-org-glm-5" + + def test_start_empty_string_normalized_to_none(self, monkeypatch): + monkeypatch.setenv("VENICE_API_KEY", "") + monkeypatch.setenv("VENICE_MODEL", "") + s = VeniceService() + s.start() + assert s._api_key is None + assert s._default_model is None + + def test_stop_clears_state(self, svc): + svc.stop() + assert svc._api_key is None + assert svc._default_model is None + + +class TestHealth: + def test_authenticated(self, svc): + h = svc.health() + assert h["id"] == "clawmes.venice" + assert h["status"] == "authenticated" + assert h["default_model"] == "zai-org-glm-4.6" + + def test_unauthenticated(self): + s = VeniceService() + s.start() + h = s.health() + assert h["status"] == "unauthenticated" + assert h["default_model"] is None + + +class TestChatCompletionValidation: + def test_empty_messages(self, svc): + with pytest.raises(VeniceError) as exc_info: + svc.chat_completion([]) + assert exc_info.value.code == "bad_request" + assert "non-empty" in exc_info.value.message + + def test_streaming_rejected(self, svc): + with pytest.raises(VeniceError) as exc_info: + svc.chat_completion([{"role": "user", "content": "hi"}], stream=True) + assert exc_info.value.code == "bad_request" + assert "streaming" in exc_info.value.message + + def test_no_model_anywhere(self, monkeypatch): + monkeypatch.setenv("VENICE_API_KEY", "venice_abc") + s = VeniceService() + s.start() + with pytest.raises(VeniceError) as exc_info: + s.chat_completion([{"role": "user", "content": "hi"}]) + assert exc_info.value.code == "bad_request" + assert "model" in exc_info.value.message + + +class TestChatCompletionRequest: + def _ok(self): + return { + "id": "chatcmpl-v", + "object": "chat.completion", + "model": "zai-org-glm-4.6", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hello"}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, + } + + def test_basic_call_uses_env_default_model(self, svc, fake_http): + fake_http.responses.append(self._ok()) + result = svc.chat_completion([{"role": "user", "content": "hi"}]) + assert result["choices"][0]["message"]["content"] == "hello" + sent = fake_http.calls[0]["json"] + assert sent["model"] == "zai-org-glm-4.6" + assert sent["messages"] == [{"role": "user", "content": "hi"}] + + def test_model_arg_overrides_default(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}], model="other-model") + assert fake_http.calls[0]["json"]["model"] == "other-model" + + def test_model_arg_only_no_default(self, monkeypatch, fake_http): + monkeypatch.setenv("VENICE_API_KEY", "venice_abc") + s = VeniceService() + s.start() + fake_http.responses.append(self._ok()) + s.chat_completion([{"role": "user", "content": "hi"}], model="explicit-model") + assert fake_http.calls[0]["json"]["model"] == "explicit-model" + + def test_temperature_passed(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}], temperature=0.2) + assert fake_http.calls[0]["json"]["temperature"] == 0.2 + + def test_temperature_omitted_when_none(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}]) + assert "temperature" not in fake_http.calls[0]["json"] + + def test_max_tokens_passed(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}], max_tokens=128) + assert fake_http.calls[0]["json"]["max_tokens"] == 128 + + def test_max_tokens_omitted_when_none(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}]) + assert "max_tokens" not in fake_http.calls[0]["json"] + + def test_extra_kwargs_passed_through(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion( + [{"role": "user", "content": "hi"}], + top_p=0.9, + venice_parameters={"include_venice_system_prompt": False}, + ) + sent = fake_http.calls[0]["json"] + assert sent["top_p"] == 0.9 + assert sent["venice_parameters"] == {"include_venice_system_prompt": False} + + def test_bearer_auth_header(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}]) + headers = fake_http.calls[0]["headers"] + assert headers["Authorization"] == "Bearer venice_test_key" + assert headers["Content-Type"] == "application/json" + + def test_unauth_call_sends_without_auth_header(self, monkeypatch, fake_http): + monkeypatch.setenv("VENICE_MODEL", "zai-org-glm-4.6") + s = VeniceService() + s.start() + fake_http.responses.append(self._ok()) + s.chat_completion([{"role": "user", "content": "hi"}]) + assert "Authorization" not in fake_http.calls[0]["headers"] + + def test_request_url(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}]) + assert fake_http.calls[0]["url"] == "https://api.venice.ai/api/v1/chat/completions" + + def test_custom_timeout(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}], timeout=5.0) + assert fake_http.calls[0]["timeout"] == 5.0 + + def test_default_timeout(self, svc, fake_http): + fake_http.responses.append(self._ok()) + svc.chat_completion([{"role": "user", "content": "hi"}]) + assert fake_http.calls[0]["timeout"] == 60.0 + + +class TestErrorClassificationSubstring: + def _call(self, svc): + return svc.chat_completion([{"role": "user", "content": "hi"}]) + + def test_payment_required_via_402(self, svc, fake_http): + # Venice's x402 unauthenticated response. + fake_http.responses.append(RuntimeError("Client error '402 Payment Required' for url")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_unauthorized_via_401(self, svc, fake_http): + fake_http.responses.append(RuntimeError("HTTP 401 Unauthorized")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_forbidden_via_403(self, svc, fake_http): + fake_http.responses.append(RuntimeError("HTTP 403 Forbidden")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_no_credentials_via_keyword(self, svc, fake_http): + fake_http.responses.append(RuntimeError("authentication required")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_rate_limit_via_429(self, svc, fake_http): + fake_http.responses.append(RuntimeError("HTTP 429 Too Many Requests")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "rate_limited" + + def test_rate_limit_via_keyword(self, svc, fake_http): + fake_http.responses.append(RuntimeError("upstream rate limit hit")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "rate_limited" + + def test_model_not_found_via_404(self, svc, fake_http): + fake_http.responses.append(RuntimeError("HTTP 404 Not Found")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "model_not_found" + + def test_model_not_found_via_keyword(self, svc, fake_http): + fake_http.responses.append(RuntimeError("the model 'xyz' was not found")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "model_not_found" + + def test_bad_request_via_400(self, svc, fake_http): + fake_http.responses.append(RuntimeError("HTTP 400 Bad Request: bad params")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "bad_request" + + def test_generic_failure(self, svc, fake_http): + fake_http.responses.append(RuntimeError("connection reset")) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "api_error" + + def test_non_dict_response(self, svc, fake_http): + fake_http.responses.append("not a dict") + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "api_error" + + +class TestErrorEnvelope: + """2xx response carrying an OpenAI-style error dict in the body.""" + + def _call(self, svc): + return svc.chat_completion([{"role": "user", "content": "hi"}]) + + def test_rate_via_type(self, svc, fake_http): + fake_http.responses.append({"error": {"message": "limit", "type": "rate_limit_error"}}) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "rate_limited" + + def test_rate_via_code(self, svc, fake_http): + fake_http.responses.append({"error": {"message": "limit", "code": "rate_limit_exceeded"}}) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "rate_limited" + + def test_unsupported_model(self, svc, fake_http): + fake_http.responses.append( + { + "error": { + "message": "Unsupported model: foo", + "type": "invalid_request_error", + "code": "unsupported_model", + } + } + ) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "model_not_found" + assert "unsupported_model" in exc_info.value.message + assert "Unsupported model" in exc_info.value.message + + def test_model_not_found_code(self, svc, fake_http): + fake_http.responses.append( + {"error": {"message": "no such model", "code": "model_not_found"}} + ) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "model_not_found" + + def test_auth_via_type(self, svc, fake_http): + fake_http.responses.append( + {"error": {"message": "bad token", "type": "authentication_error"}} + ) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_permission_denied(self, svc, fake_http): + fake_http.responses.append({"error": {"message": "no access", "type": "permission_denied"}}) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + + def test_invalid_request_model_via_message(self, svc, fake_http): + fake_http.responses.append( + { + "error": { + "message": "The model 'gpt-7' was not found", + "type": "invalid_request_error", + } + } + ) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "model_not_found" + + def test_invalid_request_plain(self, svc, fake_http): + fake_http.responses.append( + {"error": {"message": "invalid temperature", "type": "invalid_request_error"}} + ) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "bad_request" + + def test_generic_envelope(self, svc, fake_http): + fake_http.responses.append({"error": {"message": "mishap", "type": "server_error"}}) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "api_error" + + def test_missing_fields(self, svc, fake_http): + fake_http.responses.append({"error": {}}) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "api_error" + + def test_non_dict_error_field_ignored(self, svc, fake_http): + # `error` is a string in a 2xx body -> returned as-is, not raised. + fake_http.responses.append({"error": "not a dict", "choices": [{"x": 1}]}) + result = self._call(svc) + assert result["choices"] == [{"x": 1}] + + +class TestStructuredErrorBodyExtraction: + """Recover the error envelope from a raised exception's ``.response``.""" + + def _call(self, svc): + return svc.chat_completion([{"role": "user", "content": "hi"}]) + + def test_pulls_structured_body_and_classifies(self, svc, fake_http): + exc = _err_with_body( + "Client error '400 Bad Request' for url", + { + "error": { + "message": "Unsupported model: foo", + "type": "invalid_request_error", + "code": "unsupported_model", + } + }, + ) + fake_http.responses.append(exc) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + # Structured body (model_not_found) beats the "400" substring (bad_request). + assert exc_info.value.code == "model_not_found" + assert "Unsupported model" in exc_info.value.message + + def test_venice_flat_error_402(self, svc, fake_http): + # Venice's real unauth body: flat {"error": "Authentication required"} + 402. + exc = _err_with_body( + "Client error '402 Payment Required' for url", + {"x402Version": 2, "error": "Authentication required"}, + ) + fake_http.responses.append(exc) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "no_credentials" + # The flat error string is surfaced as the detail. + assert exc_info.value.message == "Authentication required" + + def test_response_json_raises_falls_through_to_substring(self, svc, fake_http): + exc = _err_with_body("Client error '400 Bad Request' for url", ValueError("not json")) + fake_http.responses.append(exc) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "bad_request" + + def test_response_json_returns_non_dict(self, svc, fake_http): + exc = _err_with_body("Client error '400 Bad Request' for url", ["not", "a", "dict"]) + fake_http.responses.append(exc) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "bad_request" + + def test_response_json_dict_without_error_field(self, svc, fake_http): + exc = _err_with_body("Client error '400 Bad Request' for url", {"detail": "something else"}) + fake_http.responses.append(exc) + with pytest.raises(VeniceError) as exc_info: + self._call(svc) + assert exc_info.value.code == "bad_request" + + +class TestSingleton: + def test_returns_same_instance(self): + a = get_venice_service() + b = get_venice_service() + assert a is b