From 69a1c841471b821eebf48d599804e3468ec3bcf3 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 4 Jun 2026 20:05:10 +0400 Subject: [PATCH] feat(buy-x402): add --set-default so the agent self-adopts paid/ as primary After a persistent inference buy publishes paid/ in LiteLLM, the agent adopts it as its own primary chat model in-pod via native 'hermes config set model.default' (atomic write, per-request re-read, no restart, no host CLI, no new RBAC). Includes a LiteLLM /v1/models existence guard, an auto-refill safety warning, and a PyYAML fallback writer. Validated by a design+adversarial workflow and a live CLI smoke against a running obol-agent: buy --set-default flips config.yaml model.default to paid/AEON-7/... and the next agent chat settled via the x402-buyer pool (spent 0->1) with no restart; rollback verified. --- internal/embed/skills/buy-x402/SKILL.md | 2 + internal/embed/skills/buy-x402/scripts/buy.py | 161 +++++++++++++++++- 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index b8ebdf87..c0dcef36 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -10,6 +10,7 @@ Purchase access to remote x402-gated services. There are two flows, picked by us - **`pay `** — single-shot. Probe the URL, sign **one** payment authorization, attach `X-PAYMENT`, send the request, return the response. Stateless. Use for `type:http` services and any one-off purchase. Max loss = price of one request. - **`buy `** — pre-payment budget. Pre-sign **N** authorizations, declare them in a `PurchaseRequest` CR, let the `x402-buyer` sidecar spend them transparently as the agent calls the model through LiteLLM at `paid/`. Use for long-running paid inference. Max loss = N × price; runtime path holds zero signer access. +- **`buy --model --set-default`** — same as `buy` above, then adopt `paid/` as the agent's **own primary model**, in-pod, by itself: an atomic `hermes config set model.default` that Hermes re-reads per request (effective next chat turn, **no restart**, no host-side `obol model prefer`/`obol model sync`). Refuses if the model isn't selectable in LiteLLM. Pair with `--auto-refill` so the primary model doesn't brick when the pre-signed pool empties. Both flows auto-detect the token + transfer method from the seller's 402 response. Currently supported: **USDC via EIP-3009** (Base Sepolia, Base Mainnet, Ethereum Mainnet) and **OBOL via Permit2** (Ethereum Mainnet). @@ -150,6 +151,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maint | `probe [--model ] [--type http\|inference] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing | | `pay [--type http\|inference] [--method GET\|POST] [--data ]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send | | `buy --endpoint --model [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/` | +| `buy --endpoint --model --set-default [--auto-refill]` | As above, then set `paid/` as the agent's own primary model in-pod (no restart, no host CLI) | | `process \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status | | `list` | List purchased providers + remaining auth counts | | `status ` | Check sidecar pod status + remaining auths | diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index 7c482983..b0f2c1a0 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -18,7 +18,7 @@ buy --endpoint --model Pre-sign + author PurchaseRequest [--budget ] [--count ] [--auto-refill[=true|false]] [--refill-threshold ] - [--refill-count ] + [--refill-count ] [--set-default] list List purchased providers + remaining auths status Check sidecar health + remaining auths process |--all Reconcile auto-refill policies @@ -32,6 +32,8 @@ import json import os import secrets +import shutil +import subprocess import sys import time import urllib.error @@ -1527,6 +1529,160 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None, opts=None): print(f"The model is now available as: paid/{model_id}") print("Use 'process --all' from a heartbeat/cron loop to reconcile auto-refill policies.") + if ready and opts.get("set_default"): + print() + _set_agent_default_model(model_id, auto_refill) + + +# --------------------------------------------------------------------------- +# Set-default: adopt a freshly-bought paid/ as the agent's primary model +# --------------------------------------------------------------------------- + +HERMES_CONFIG_PATH = "/data/.hermes/config.yaml" +HERMES_BIN_CANDIDATES = ("/opt/hermes/.venv/bin/hermes",) + + +def _find_hermes_bin(): + """Locate the in-pod Hermes binary, or None.""" + for cand in HERMES_BIN_CANDIDATES: + if os.path.isfile(cand) and os.access(cand, os.X_OK): + return cand + return shutil.which("hermes") + + +def _read_hermes_model_cfg(): + """Return (base_url, api_key) from the Hermes config, or (None, None).""" + try: + import yaml # PyYAML ships in the agent runtime + + with open(HERMES_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + model = data.get("model") or {} + return model.get("base_url"), model.get("api_key") + except Exception: + return None, None + + +def _litellm_has_model(alias): + """Best-effort check that `alias` is published in LiteLLM /v1/models. + + Returns True if confirmed present OR if the check could not run (the + PurchaseRequest already reconciled Ready, which implies publication). + Returns False only when LiteLLM answered and the alias is absent. + """ + base_url, api_key = _read_hermes_model_cfg() + if not base_url: + return True # cannot verify; rely on PurchaseRequest Ready + url = base_url.rstrip("/") + "/models" + try: + req = urllib.request.Request( + url, headers={"Authorization": "Bearer " + (api_key or "")} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + payload = json.loads(resp.read().decode()) + ids = {m.get("id") for m in payload.get("data", [])} + if alias in ids: + return True + print(f" LiteLLM /v1/models does not list {alias!r}.", file=sys.stderr) + return False + except Exception as exc: + print( + f" (could not query LiteLLM /v1/models: {exc}; " + f"relying on PurchaseRequest Ready)", + file=sys.stderr, + ) + return True + + +def _set_default_via_yaml(alias): + """Fallback writer: set model.default in the Hermes config, preserving siblings.""" + try: + import yaml + + with open(HERMES_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + model = data.setdefault("model", {}) + model["default"] = alias + tmp = HERMES_CONFIG_PATH + ".tmp" + with open(tmp, "w") as f: + yaml.safe_dump(data, f, default_flow_style=False, sort_keys=True) + os.replace(tmp, HERMES_CONFIG_PATH) + print( + f" Agent default model set to {alias!r} via config edit " + f"(effective next chat turn)." + ) + return True + except Exception as exc: + print(f" Failed to set agent default model via config edit: {exc}", file=sys.stderr) + return False + + +def _set_agent_default_model(model_id, auto_refill): + """Adopt paid/ as the Hermes primary model, in-pod, no restart. + + Prefers the native `hermes config set` writer (atomic write + per-request + re-read => no restart); falls back to a direct YAML edit. Refuses if the + model is not actually selectable in LiteLLM. + """ + alias = f"paid/{model_id}" + if not os.path.isfile(HERMES_CONFIG_PATH): + print( + f" --set-default skipped: {HERMES_CONFIG_PATH} not found " + f"(only the Hermes runtime is supported).", + file=sys.stderr, + ) + return False + # Safety: a paid primary model bricks chat once the pre-signed pool empties. + if not (auto_refill and auto_refill.get("enabled")): + print( + " WARNING: --set-default without --auto-refill. Once this is your primary", + file=sys.stderr, + ) + print( + " model, every chat turn fails when the pre-signed pool empties.", + file=sys.stderr, + ) + print( + " Re-run with --auto-refill, or run 'process --all' on a schedule.", + file=sys.stderr, + ) + # Existence guard: never point the agent at an unpublished model. + if not _litellm_has_model(alias): + print( + f" Refusing --set-default: {alias!r} is not selectable in LiteLLM; " + f"leaving the agent default unchanged.", + file=sys.stderr, + ) + return False + # Primary path: native Hermes writer (atomic; per-request re-read, no restart). + hermes_bin = _find_hermes_bin() + if hermes_bin: + try: + res = subprocess.run( + [hermes_bin, "config", "set", "model.default", alias], + capture_output=True, + text=True, + timeout=30, + ) + if res.returncode == 0: + print( + f" Agent default model set to {alias!r} " + f"(effective next chat turn; no restart)." + ) + return True + detail = (res.stderr or res.stdout or "").strip() + print( + f" 'hermes config set' failed (rc={res.returncode}): {detail}; " + f"falling back to config edit.", + file=sys.stderr, + ) + except Exception as exc: + print( + f" 'hermes config set' error: {exc}; falling back to config edit.", + file=sys.stderr, + ) + return _set_default_via_yaml(alias) + # --------------------------------------------------------------------------- # Refill @@ -1955,7 +2111,8 @@ def usage(): print(" buy --endpoint --model Pre-sign + configure paid/") print(" [--budget ] [--count ]") print(" [--auto-refill[=true|false]] [--refill-threshold ]") - print(" [--refill-count ]") + print(" [--refill-count ] [--set-default]") + print(" --set-default inference only: adopt paid/ as the agent's primary model") print(" list List purchased providers") print(" status Check sidecar + auths") print(" process | --all Reconcile auto-refill policies")