diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e9a394..5c571f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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" diff --git a/README.md b/README.md index 61d1187..3047211 100644 --- a/README.md +++ b/README.md @@ -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= diff --git a/clawmes/_version.py b/clawmes/_version.py index 82c87f8..049a2a5 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.18.1" +__version__ = "0.18.2" diff --git a/clawmes/commands/agent_plan.py b/clawmes/commands/agent_plan.py index 877c0e5..f9943b1 100644 --- a/clawmes/commands/agent_plan.py +++ b/clawmes/commands/agent_plan.py @@ -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 @@ -253,12 +250,11 @@ 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}, @@ -266,7 +262,7 @@ def _llm_extract( temperature=0.0, max_tokens=80, ) - except OpenGatewayError: + except InferenceError: still_failed.append(segment) continue except Exception: # noqa: BLE001 diff --git a/clawmes/commands/research.py b/clawmes/commands/research.py index d201a5a..0cc6f39 100644 --- a/clawmes/commands/research.py +++ b/clawmes/commands/research.py @@ -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 "" @@ -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}, diff --git a/clawmes/lib/inference.py b/clawmes/lib/inference.py new file mode 100644 index 0000000..91a3d17 --- /dev/null +++ b/clawmes/lib/inference.py @@ -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 diff --git a/clawmes/plugin.yaml b/clawmes/plugin.yaml index e12e1c3..46cbb9e 100644 --- a/clawmes/plugin.yaml +++ b/clawmes/plugin.yaml @@ -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 diff --git a/plugin.yaml b/plugin.yaml index e12e1c3..46cbb9e 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e13add9..7dafb3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/commands/test_agent_ai.py b/tests/commands/test_agent_ai.py index 4c847d2..40a8d6b 100644 --- a/tests/commands/test_agent_ai.py +++ b/tests/commands/test_agent_ai.py @@ -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) diff --git a/tests/commands/test_v014_additions.py b/tests/commands/test_v014_additions.py index 763db12..0f9b358 100644 --- a/tests/commands/test_v014_additions.py +++ b/tests/commands/test_v014_additions.py @@ -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) diff --git a/tests/lib/test_inference.py b/tests/lib/test_inference.py new file mode 100644 index 0000000..d95c42c --- /dev/null +++ b/tests/lib/test_inference.py @@ -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 == ""