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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion clawmes/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
* Tooling that does not want to incur a full package import
"""

__version__ = "0.17.3"
__version__ = "0.18.0"
4 changes: 3 additions & 1 deletion clawmes/lib/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion clawmes/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions clawmes/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
248 changes: 248 additions & 0 deletions clawmes/services/venice.py
Original file line number Diff line number Diff line change
@@ -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 "<unset>",
)
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 "<unset>",
)

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
2 changes: 1 addition & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
5 changes: 5 additions & 0 deletions tests/lib/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading