Folds in the in-flight local-checkout work and fixes the context accounting
on top of it. Both halves are the same bug seen from two ends: clients were
sending requests larger than the backend's window and getting hard
ContextWindowExceededError 400s, intermittently, in a way that looked like
the model or the GPU misbehaving.
Already in flight here (unchanged in intent):
- sync.sh prefers the LOCAL CHECKOUT over re-downloading from Gitea, and
install.sh records the checkout path so it can be found. That recording
is load-bearing: without it sync.sh cannot find the repo, silently falls
back to Gitea, and every local edit to opencode.gateway.json is ignored.
Observed on this box — ~/.config/mcp-sync/source did not exist, the
default ~/.dotfiles is not where the checkout lives, and the Gitea base
still served models:{}.
- the hermes custom-provider wiring.
Fixed on top:
opencode had NO context limit on ANY gateway model, not a wrong one. Two
faults compounded. /model/info 403s for a day-to-day virtual key (only
management-scoped keys may read it), which sync.sh treats as best-effort and
falls back to names-only — as designed and as commented. But the merge then
did `prov["models"] = models`, replacing the map wholesale, so limits
declared in the base config were discarded too and there was no floor left.
Base specs are now a floor that live /model/info overrides field by field:
discovery still wins where present, so this adds no drift, and a 403 no
longer strips the limits. Verified: ornith 195584/32768, the qwens
261120/32768.
hermes' model map named `qwen3-vl-30b-a3b` at 262144 — a model the gateway
stopped serving when Ornith replaced it — while ornith-1.0-35b, the model
actually on cg-1 AND the only one with a window smaller than its neighbours
(196608), had no entry at all and inherited Hermes' default. The one model
needing a lower ceiling was the one not declaring one, which is exactly the
shape that fails only once a session grows.
NOTE for other boxes: hermes reads ~/.hermes/config.yaml, which install.sh
produces by MERGING gateway.yaml. Editing gateway.yaml alone changes nothing
until install.sh re-runs — this machine's live config was patched by hand.
Not addressed here, deliberately: the gateway-side context_window_fallback
(ai-k8s-iac). It is wired but inert while cg-2 is scaled down, so preventing
the oversized request client-side is what actually works today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
268 lines
11 KiB
Bash
Executable File
268 lines
11 KiB
Bash
Executable File
#!/usr/bin/env sh
|
|
# hermes/install.sh — install Hermes Agent by Nous Research, and point it at
|
|
# the covert-gpt LiteLLM gateway.
|
|
#
|
|
# WHY: Hermes requires Python 3.11+ and creates a self-contained venv at
|
|
# ~/.hermes. The official installer handles the complexity of managing its
|
|
# own uv-based Python environment. This module simply runs the official
|
|
# installer with --skip-setup so it completes in non-interactive mode.
|
|
# Everything under $HOME, survives Kasm redeploys.
|
|
#
|
|
# It then merges hermes/gateway.yaml into ~/.hermes/config.yaml, so a fresh box
|
|
# gets a Hermes that can actually talk to the gateway. Without that step Hermes
|
|
# installs and then points at nothing — the asymmetry mcp-config/ does not have,
|
|
# since opencode rebuilds its config from Gitea on every shell via sync.sh.
|
|
#
|
|
# No secrets are handled here: gateway.yaml references the key by NAME
|
|
# (key_env: LITELLM_KEY) and mcp-config/sync.sh exports it. Run
|
|
# mcp-config/install.sh first.
|
|
set -eu
|
|
|
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
|
|
|
|
# shellcheck source=../lib/fetch.sh
|
|
. "$REPO_ROOT/lib/fetch.sh"
|
|
|
|
say() { printf '%s\n' "$*" >&2; }
|
|
|
|
# Hermes Agent version (synced with PyPI/npm).
|
|
HERMES_VERSION="v0.18.2"
|
|
|
|
# --- 1. the binary --------------------------------------------------------
|
|
# Check if hermes is already installed at the correct version using the
|
|
# .versions stamp pattern from bin/install.sh. NOTE: this only skips the
|
|
# DOWNLOAD — the config merge below still runs, so a config fix ships to an
|
|
# already-installed box on the next bootstrap.
|
|
#
|
|
# BUT: if the LiteLLM key has changed, Hermes may have the old key cached in its
|
|
# config or local state. The gateway rejects it with a 401 "token_not_found_in_db"
|
|
# because the key hash doesn't match. Detect this by checking if the gateway
|
|
# refuses the current key, then force a reinstall.
|
|
CURRENT_KEY_HASH=""
|
|
|
|
if [ -f "$HOME/.config/mcp-sync/key" ]; then
|
|
CURRENT_KEY_HASH="$(sha256sum "$HOME/.config/mcp-sync/key" 2>/dev/null | cut -d' ' -f1)"
|
|
fi
|
|
|
|
# Check if we need to force reinstall due to key change (compare against last-seen hash)
|
|
PREVIOUS_KEY_HASH=""
|
|
if [ -f "$HOME/.local/bin/.versions/hermes-key-hash" ]; then
|
|
PREVIOUS_KEY_HASH="$(cat "$HOME/.local/bin/.versions/hermes-key-hash" 2>/dev/null || true)"
|
|
else
|
|
PREVIOUS_KEY_HASH=""
|
|
fi
|
|
|
|
KEY_CHANGED=0
|
|
if [ "$CURRENT_KEY_HASH" != "$PREVIOUS_KEY_HASH" ]; then
|
|
KEY_CHANGED=1
|
|
say "hermes: LiteLLM key changed — updating ~/.hermes/.env and restarting Hermes."
|
|
fi
|
|
|
|
# Update the .env file if the key changed (Hermes reads its keys from here)
|
|
if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then
|
|
# Read the current key from mcp-sync
|
|
NEW_KEY="$(tr -d ' \t\r\n' < "$HOME/.config/mcp-sync/key")"
|
|
# Update LITELLM_KEY in .env (or add it if missing)
|
|
if [ -f "$HOME/.hermes/.env" ]; then
|
|
# Use a temporary file to avoid in-place edit issues
|
|
if command -v sed >/dev/null 2>&1; then
|
|
sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$NEW_KEY|" "$HOME/.hermes/.env"
|
|
else
|
|
# Fallback: awk
|
|
awk -v key="$NEW_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env"
|
|
fi
|
|
say "hermes: Updated LITELLM_KEY in ~/.hermes/.env"
|
|
else
|
|
# Create .env if it doesn't exist
|
|
printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env"
|
|
say "hermes: Created ~/.hermes/.env with LITELLM_KEY"
|
|
fi
|
|
chmod 0600 "$HOME/.hermes/.env"
|
|
fi
|
|
|
|
# Kill any running Hermes process so it restarts with the new key
|
|
if [ "$KEY_CHANGED" -eq 1 ]; then
|
|
if [ -f "$HOME/.hermes/.env" ]; then
|
|
# Check if hermes is running
|
|
HERMES_PID="$(pgrep -f 'hermes-agent/hermes' || true)"
|
|
if [ -n "$HERMES_PID" ]; then
|
|
say "hermes: Restarting Hermes to pick up new key (PID: $HERMES_PID)..."
|
|
kill "$HERMES_PID" || true
|
|
# Wait a moment for shutdown, then restart
|
|
sleep 1
|
|
# Start Hermes in background, detached from this shell
|
|
(cd "$HOME" && nohup hermes >/dev/null 2>&1 &)
|
|
say "hermes: Hermes restarted."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ] && [ "$KEY_CHANGED" -eq 0 ]; then
|
|
say "hermes-agent $HERMES_VERSION already installed."
|
|
else
|
|
say "Installing hermes-agent via official installer..."
|
|
# Run the official installer in non-interactive mode, skipping the setup
|
|
# wizard. The installer creates ~/.hermes/venvs/hermes with its own
|
|
# uv-managed Python.
|
|
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive --skip-setup
|
|
|
|
# Verify installation by checking the binary exists AND produces expected output.
|
|
if command -v hermes >/dev/null 2>&1; then
|
|
mkdir -p "$HOME/.local/bin/.versions"
|
|
printf '%s\n' "$HERMES_VERSION" >"$HOME/.local/bin/.versions/hermes"
|
|
say "Installed hermes-agent $HERMES_VERSION successfully."
|
|
else
|
|
say "ERROR: hermes CLI not found after install"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Record the current key hash for next time
|
|
mkdir -p "$HOME/.local/bin/.versions"
|
|
printf '%s\n' "$CURRENT_KEY_HASH" >"$HOME/.local/bin/.versions/hermes-key-hash"
|
|
|
|
# --- point it at the gateway ----------------------------------------------
|
|
# Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and
|
|
# deliberately NON-DESTRUCTIVE: config.yaml is a large personal file
|
|
# (personalities, display prefs, toolsets) that Hermes also rewrites itself, so
|
|
# this replaces ONLY the covert-gpt custom_providers entry and never rewrites
|
|
# the rest. model.default / model.provider are seeded only when unset, so a
|
|
# deliberate model choice is preserved across bootstraps.
|
|
#
|
|
# PyYAML is needed to merge. Prefer the system python3; fall back to Hermes' own
|
|
# venv, which always has it. If neither works the config step is skipped with a
|
|
# warning rather than failing bootstrap — the binary is still installed, and a
|
|
# missing provider is a far smaller problem than a broken box.
|
|
GATEWAY_YAML="$SCRIPT_DIR/gateway.yaml"
|
|
HERMES_CONFIG="$HOME/.hermes/config.yaml"
|
|
|
|
PY=""
|
|
for _c in python3 "$HOME/.hermes/venvs/hermes/bin/python" "$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
|
if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'import yaml' >/dev/null 2>&1; then PY="$_c"; break; fi
|
|
done
|
|
|
|
if [ -z "$PY" ]; then
|
|
say "WARNING: no python3 with PyYAML found — skipping Hermes gateway config."
|
|
say " Install PyYAML and re-run $0 to finish wiring the gateway."
|
|
elif [ ! -f "$GATEWAY_YAML" ]; then
|
|
say "WARNING: $GATEWAY_YAML missing — skipping Hermes gateway config."
|
|
else
|
|
HERMES_GATEWAY_YAML="$GATEWAY_YAML" HERMES_CONFIG_PATH="$HERMES_CONFIG" "$PY" - <<'PYEOF'
|
|
import os, sys, tempfile
|
|
import yaml
|
|
|
|
gw_path = os.environ["HERMES_GATEWAY_YAML"]
|
|
cfg_path = os.environ["HERMES_CONFIG_PATH"]
|
|
|
|
with open(gw_path) as f:
|
|
gw = yaml.safe_load(f) or {}
|
|
entries = gw.get("custom_providers") or []
|
|
if not entries:
|
|
print("hermes: gateway.yaml has no custom_providers — nothing to do", file=sys.stderr)
|
|
raise SystemExit(0)
|
|
|
|
cfg = {}
|
|
if os.path.exists(cfg_path):
|
|
try:
|
|
with open(cfg_path) as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
except Exception as e:
|
|
# A corrupt config is the user's to fix; never overwrite it blind.
|
|
print(f"hermes: {cfg_path} is not valid YAML ({e}) — skipping", file=sys.stderr)
|
|
raise SystemExit(0)
|
|
if not isinstance(cfg, dict):
|
|
print(f"hermes: {cfg_path} is not a mapping — skipping", file=sys.stderr)
|
|
raise SystemExit(0)
|
|
|
|
existing = cfg.get("custom_providers")
|
|
if not isinstance(existing, list):
|
|
existing = []
|
|
|
|
changed = []
|
|
for entry in entries:
|
|
name = entry.get("name")
|
|
if not name:
|
|
continue
|
|
for i, cur in enumerate(existing):
|
|
if isinstance(cur, dict) and cur.get("name") == name:
|
|
if cur != entry:
|
|
existing[i] = entry
|
|
changed.append(f"updated custom_providers[{name}]")
|
|
break
|
|
else:
|
|
existing.append(entry)
|
|
changed.append(f"added custom_providers[{name}]")
|
|
cfg["custom_providers"] = existing
|
|
|
|
# `default` is a BARE model id and `provider` is the custom:<name> form; both
|
|
# are the user's to change, so they are seeded on a fresh box and otherwise left
|
|
# alone — EXCEPT to migrate the old masquerade, below.
|
|
primary = entries[0]["name"]
|
|
model = cfg.get("model")
|
|
if not isinstance(model, dict):
|
|
model = {}
|
|
|
|
gw_url = (entries[0].get("base_url") or "").rstrip("/")
|
|
cur_provider = (model.get("provider") or "").strip()
|
|
cur_base = (model.get("base_url") or "").rstrip("/")
|
|
|
|
# MIGRATE the old masquerade: `provider: openrouter` + `base_url: <this
|
|
# gateway>`. That shape routed requests here but left the picker listing
|
|
# openrouter.ai's catalog, so leaving it in place would silently preserve the
|
|
# exact bug this module exists to fix. `base_url` pointing at OUR gateway is the
|
|
# fingerprint — a genuine OpenRouter user never has that. Any OTHER provider is
|
|
# a deliberate choice and is never touched.
|
|
if gw_url and cur_base == gw_url and cur_provider in ("", "openrouter", "custom"):
|
|
if cur_provider != f"custom:{primary}":
|
|
model["provider"] = f"custom:{primary}"
|
|
changed.append(
|
|
f"migrated model.provider {cur_provider or '<unset>'} -> custom:{primary}"
|
|
)
|
|
elif not cur_provider:
|
|
model["provider"] = f"custom:{primary}"
|
|
changed.append(f"set model.provider=custom:{primary}")
|
|
|
|
if not model.get("default"):
|
|
first_model = next(iter((entries[0].get("models") or {})), None)
|
|
if first_model:
|
|
model["default"] = first_model
|
|
changed.append(f"set model.default={first_model}")
|
|
|
|
# model.base_url is ignored once a named custom provider supplies it, and a
|
|
# stale value here is an active footgun — drop it if it points at this gateway.
|
|
if gw_url and cur_base == gw_url:
|
|
model.pop("base_url", None)
|
|
changed.append("removed dead model.base_url")
|
|
cfg["model"] = model
|
|
|
|
if not changed:
|
|
print("hermes: gateway config already current", file=sys.stderr)
|
|
raise SystemExit(0)
|
|
|
|
d = os.path.dirname(cfg_path) or "."
|
|
os.makedirs(d, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=d)
|
|
with os.fdopen(fd, "w") as f:
|
|
yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, cfg_path)
|
|
for c in changed:
|
|
print(f"hermes: {c}", file=sys.stderr)
|
|
PYEOF
|
|
|
|
# The provider is useless without the key. mcp-config/sync.sh exports it; warn
|
|
# rather than fail, since bootstrap ordering may put mcp-config after this.
|
|
if [ -z "${LITELLM_KEY:-}" ] && [ ! -f "$HOME/.config/mcp-sync/key" ]; then
|
|
say "NOTE: LITELLM_KEY is unset and ~/.config/mcp-sync/key is missing."
|
|
say " Run mcp-config/install.sh, or Hermes will 401 against the gateway."
|
|
fi
|
|
# The old OpenRouter masquerade actively breaks the model picker (it lists
|
|
# openrouter.ai's catalog, not the gateway's), so flag it if it lingers.
|
|
if [ -n "${OPENROUTER_API_KEY:-}" ] && [ "${OPENROUTER_API_KEY:-}" = "${LITELLM_KEY:-x}" ]; then
|
|
say "NOTE: OPENROUTER_API_KEY is set to the LiteLLM key in your environment."
|
|
say " That is the old masquerade; unset it in ~/.hermes/.env so the"
|
|
say " model picker shows the gateway's models instead of openrouter.ai's."
|
|
fi
|
|
fi
|