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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## 0.18.2 — 2026-06-09

### Added — unified inference provider router

`clawmes/lib/inference.py` — a single `chat_completion()` entry point that
selects the OpenAI-compatible backend at call time, so tools no longer hardcode
one provider:

- Selection: `CLAWMES_LLM_PROVIDER` (`venice` | `opengateway`); when unset, auto
— Venice if `VENICE_API_KEY` is set, else OpenGateway.
- Provider-specific errors (`VeniceError` / `OpenGatewayError`) are normalized to
a single `InferenceError` (carries `.code` + `.provider`).
- The two existing inference call sites now route through it: the `/research`
narrative summary and the `/agent --ai` intent extractor (and therefore
`clawmes_info op=research`). With Venice configured, those run on Venice.

## 0.18.1 — 2026-06-09

### Fixed — Venice: distinguish "out of credits" from "bad auth"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,10 @@ OPENGATEWAY_MODEL= # optional default model id sent when callers omit model=
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)

# Inference provider selection for clawmes' own tools (research summary, /agent --ai).
# venice | opengateway. If unset: Venice when VENICE_API_KEY is present, else OpenGateway.
CLAWMES_LLM_PROVIDER=

# 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.18.1"
__version__ = "0.18.2"
10 changes: 3 additions & 7 deletions clawmes/commands/agent_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,7 @@ def _llm_extract(
state through a fabricated arg shape.
"""
try:
from clawmes.services.opengateway import (
OpenGatewayError,
get_opengateway_service,
)
from clawmes.lib.inference import InferenceError, chat_completion
except Exception: # noqa: BLE001
return [], failed_segments

Expand All @@ -253,20 +250,19 @@ def _llm_extract(
"Output exactly one line. No JSON. No commentary. No quotes."
)

svc = get_opengateway_service()
extra: list[dict[str, Any]] = []
still_failed: list[str] = []
for segment in failed_segments:
try:
resp = svc.chat_completion(
resp = chat_completion(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": segment},
],
temperature=0.0,
max_tokens=80,
)
except OpenGatewayError:
except InferenceError:
still_failed.append(segment)
continue
except Exception: # noqa: BLE001
Expand Down
8 changes: 4 additions & 4 deletions clawmes/commands/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,14 +304,15 @@ def _safe_float(v: Any) -> float | None:


def _llm_synthesize(report: dict[str, Any]) -> str:
"""Optional narrative summary via OpenGateway. Empty on any failure.
"""Optional narrative summary via the configured inference provider
(Venice / OpenGateway — see clawmes.lib.inference). Empty on any failure.

Strictly capped to 4 sentences in the prompt — we want a synthesis,
not a sales pitch. Failures fall back to the structured report
above without any visible error.
"""
try:
from clawmes.services.opengateway import get_opengateway_service
from clawmes.lib.inference import chat_completion
except Exception: # noqa: BLE001
return ""

Expand All @@ -323,8 +324,7 @@ def _llm_synthesize(report: dict[str, Any]) -> str:
)
user = json.dumps(report)
try:
svc = get_opengateway_service()
resp = svc.chat_completion(
resp = chat_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
Expand Down
88 changes: 88 additions & 0 deletions clawmes/lib/inference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Unified LLM inference router for clawmes tools.

Tools that need targeted inference outside the host Hermes agent loop (the
``/research`` narrative summary, the ``/agent --ai`` intent extractor, etc.)
call :func:`chat_completion` here instead of binding to one provider. The
backend is selected at call time:

1. ``CLAWMES_LLM_PROVIDER`` env — ``"venice"`` or ``"opengateway"`` (explicit).
2. Otherwise auto: **Venice** when ``VENICE_API_KEY`` is set, else OpenGateway.

Both providers expose the same OpenAI-compatible ``chat_completion`` signature
(see :mod:`clawmes.services.venice` / :mod:`clawmes.services.opengateway`);
their per-provider error types are normalized to :class:`InferenceError`.

This router is independent from Hermes' own conversational LLM — it's opt-in,
per-call inference for clawmes' own tools.
"""

from __future__ import annotations

import os
from typing import Any

__all__ = ["InferenceError", "chat_completion", "resolve_provider"]


class InferenceError(RuntimeError):
"""Provider-agnostic inference failure.

``code`` mirrors the underlying provider's classification (``bad_request``,
``model_not_found``, ``rate_limited``, ``no_credentials``,
``payment_required``, ``api_error``). ``provider`` names the backend that
raised it.
"""

def __init__(self, code: str, message: str, *, provider: str = "") -> None:
super().__init__(message)
self.code = code
self.message = message
self.provider = provider


def resolve_provider() -> str:
"""Return the active provider name: ``"venice"`` or ``"opengateway"``."""
choice = (os.environ.get("CLAWMES_LLM_PROVIDER") or "").strip().lower()
if choice in ("venice", "opengateway"):
return choice
if os.environ.get("VENICE_API_KEY"):
return "venice"
return "opengateway"


def chat_completion(
messages: list[dict[str, Any]],
*,
model: str | None = None,
**kw: Any,
) -> dict[str, Any]:
"""Route a non-streaming chat completion to the configured provider.

``model`` defaults to the provider's own env-configured default
(``VENICE_MODEL`` / ``OPENGATEWAY_MODEL``). Provider failures are raised as
:class:`InferenceError`; non-provider exceptions (import / transport) are
left to propagate so callers' broad handlers see them unchanged.
"""
provider = resolve_provider()
if provider == "venice":
from clawmes.services.venice import VeniceError, get_venice_service

svc = get_venice_service()
err_type: type[Exception] = VeniceError
else:
from clawmes.services.opengateway import (
OpenGatewayError,
get_opengateway_service,
)

svc = get_opengateway_service()
err_type = OpenGatewayError

try:
return svc.chat_completion(messages, model=model, **kw)
except err_type as exc:
raise InferenceError(
getattr(exc, "code", "api_error"),
str(getattr(exc, "message", exc)),
provider=provider,
) from exc
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.18.1
version: 0.18.2
description: Hermes Agent for crypto. Wallet, swaps, DeFi, launches, automation.
author: Clawnch
kind: standalone
Expand Down
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.18.1
version: 0.18.2
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.18.1"
version = "0.18.2"
description = "Hermes Agent plugin for crypto: wallets, DEX trading, lending and staking, governance, on-chain automation."
readme = "README.md"
license = { text = "MIT" }
Expand Down
6 changes: 3 additions & 3 deletions tests/commands/test_agent_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,14 @@ def test_generic_exception(self, fake_opengateway):
assert still == ["x"]

def test_import_failure_returns_failed(self, monkeypatch):
"""If the OpenGateway module can't be imported at all, return originals."""
"""If the inference router can't be imported at all, return originals."""
import builtins

original_import = builtins.__import__

def _block(name, *args, **kw):
if name == "clawmes.services.opengateway":
raise ImportError("no opengateway")
if name == "clawmes.lib.inference":
raise ImportError("no inference")
return original_import(name, *args, **kw)

monkeypatch.setattr(builtins, "__import__", _block)
Expand Down
8 changes: 4 additions & 4 deletions tests/commands/test_v014_additions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1455,15 +1455,15 @@ def test_with_clawnch_launch(self, fake_http, fake_defi_price):


class TestLlmSynthesize:
def test_no_opengateway_returns_empty(self, monkeypatch):
# Force ImportError on opengateway.
def test_no_inference_router_returns_empty(self, monkeypatch):
# Force ImportError on the inference router.
import builtins

original_import = builtins.__import__

def _block(name, *args, **kw):
if name == "clawmes.services.opengateway":
raise ImportError("no opengateway")
if name == "clawmes.lib.inference":
raise ImportError("no inference")
return original_import(name, *args, **kw)

monkeypatch.setattr(builtins, "__import__", _block)
Expand Down
113 changes: 113 additions & 0 deletions tests/lib/test_inference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Tests for clawmes.lib.inference (provider router)."""

from __future__ import annotations

from typing import Any

import pytest

from clawmes.lib.inference import InferenceError, chat_completion, resolve_provider


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("CLAWMES_LLM_PROVIDER", raising=False)
monkeypatch.delenv("VENICE_API_KEY", raising=False)


class _Fake:
def __init__(self, response=None, raises=None):
self.response = response
self.raises = raises
self.calls: list[dict[str, Any]] = []

def chat_completion(self, messages, **kw):
self.calls.append({"messages": messages, "kw": kw})
if self.raises is not None:
raise self.raises
return self.response


def _patch_opengateway(monkeypatch, fake):
import clawmes.services.opengateway as og

monkeypatch.setattr(og, "get_opengateway_service", lambda: fake)


def _patch_venice(monkeypatch, fake):
import clawmes.services.venice as v

monkeypatch.setattr(v, "get_venice_service", lambda: fake)


class TestResolveProvider:
def test_explicit_venice(self, monkeypatch):
monkeypatch.setenv("CLAWMES_LLM_PROVIDER", "venice")
assert resolve_provider() == "venice"

def test_explicit_opengateway_case_insensitive(self, monkeypatch):
monkeypatch.setenv("CLAWMES_LLM_PROVIDER", "OpenGateway")
assert resolve_provider() == "opengateway"

def test_invalid_choice_falls_to_auto(self, monkeypatch):
monkeypatch.setenv("CLAWMES_LLM_PROVIDER", "bogus")
assert resolve_provider() == "opengateway" # no VENICE_API_KEY

def test_auto_venice_when_key_set(self, monkeypatch):
monkeypatch.setenv("VENICE_API_KEY", "venice_k")
assert resolve_provider() == "venice"

def test_auto_opengateway_default(self):
assert resolve_provider() == "opengateway"


class TestChatCompletion:
def test_opengateway_success(self, monkeypatch):
fake = _Fake(response={"choices": [{"message": {"content": "ok"}}]})
_patch_opengateway(monkeypatch, fake)
r = chat_completion([{"role": "user", "content": "hi"}], temperature=0.2)
assert r["choices"][0]["message"]["content"] == "ok"
# model defaults to None (let the provider use its env default).
assert fake.calls[0]["kw"]["model"] is None
assert fake.calls[0]["kw"]["temperature"] == 0.2

def test_opengateway_error_translated(self, monkeypatch):
from clawmes.services.opengateway import OpenGatewayError

_patch_opengateway(monkeypatch, _Fake(raises=OpenGatewayError("rate_limited", "slow down")))
with pytest.raises(InferenceError) as exc:
chat_completion([{"role": "user", "content": "hi"}])
assert exc.value.code == "rate_limited"
assert exc.value.provider == "opengateway"
assert "slow down" in exc.value.message

def test_venice_success(self, monkeypatch):
monkeypatch.setenv("CLAWMES_LLM_PROVIDER", "venice")
fake = _Fake(response={"choices": [{"message": {"content": "vv"}}]})
_patch_venice(monkeypatch, fake)
r = chat_completion([{"role": "user", "content": "hi"}], model="venice-uncensored")
assert r["choices"][0]["message"]["content"] == "vv"
assert fake.calls[0]["kw"]["model"] == "venice-uncensored"

def test_venice_error_translated(self, monkeypatch):
monkeypatch.setenv("CLAWMES_LLM_PROVIDER", "venice")
from clawmes.services.venice import VeniceError

_patch_venice(monkeypatch, _Fake(raises=VeniceError("payment_required", "add credits")))
with pytest.raises(InferenceError) as exc:
chat_completion([{"role": "user", "content": "hi"}])
assert exc.value.code == "payment_required"
assert exc.value.provider == "venice"
assert "add credits" in exc.value.message


class TestInferenceError:
def test_attrs(self):
e = InferenceError("bad_request", "nope", provider="venice")
assert e.code == "bad_request"
assert e.message == "nope"
assert e.provider == "venice"
assert str(e) == "nope"

def test_default_provider(self):
assert InferenceError("api_error", "x").provider == ""
Loading