The litellm-helm chart renders the proxy config with Go's toYaml, which writes
0.000005 as `5e-06`. LiteLLM loads it with PyYAML (YAML 1.1), which only reads
an exponent float that contains a dot, so /model/info returns input_cost_per_token
as the STRING "5e-06" for opus/sonnet/haiku while 2.5e-05 stays a float.
`"5e-06" * 1_000_000` is string repetition in Python: sync.sh wrote a 20 MB
opencode.gateway.json with a string cost, opencode rejected the whole config
("Expected number"), and every new shell redeployed it.
- num(): accept int/float/numeric strings, drop anything non-finite or non-numeric
- clean_spec(): validate every model spec before writing, whatever its source,
so the discovery-failed carry-over path heals an already-corrupted file
instead of preserving it
- refuse to deploy a config over 1 MB; keep the last-good file
Tested against the live gateway: costs render 5/25, 3/15, 1/5 $/1M, and
`opencode models covert-gpt` lists all 7 models; with no key, the 20 MB
corrupted file heals to 2.8 KB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Duzwo1tmEqD2kYYyNKm7xs
1021 lines
44 KiB
Bash
1021 lines
44 KiB
Bash
#!/usr/bin/env sh
|
|
# mcp-sync — refresh the shared LiteLLM-gateway opencode config on the laptop,
|
|
# auto-discover the gateway's models, deploy the shared skills, and export the
|
|
# user's LiteLLM key.
|
|
#
|
|
# Sourced from the user's shell rc (installed by install.sh) so it runs at the
|
|
# start of every interactive shell ("session start"). It is intentionally:
|
|
# - FAST and SILENT on success (no output unless something is wrong)
|
|
# - NON-FATAL: a network/Gitea hiccup must never break the user's shell, so
|
|
# every failure path just returns, leaving the last-good config in place.
|
|
#
|
|
# What it does:
|
|
# 1. Read the shared opencode.gateway.json (the single source of truth: the
|
|
# covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS
|
|
# THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over
|
|
# authenticated git, so re-downloading a file that is sitting on disk is
|
|
# both redundant and, historically, the ONLY reason the repo had to be
|
|
# world-readable. A box with no checkout falls back to the Gitea API with
|
|
# $GITEA_KEY. The file is identical for all users — EVERY credential
|
|
# (LiteLLM key, Gitea PAT, Proxmox token) is injected via {env:VAR} and
|
|
# never baked into the file.
|
|
# 2. Auto-discover models: query the gateway's /v1/models with the user's
|
|
# key and fill the provider's "models" map. New models exposed on the
|
|
# gateway appear on every box at the next sync — no config edit anywhere.
|
|
# (opencode cannot list models from a custom provider itself; the map
|
|
# must be materialized client-side.) MCP servers need no discovery step:
|
|
# they are declared in the config and run locally under `npx`, each with
|
|
# the user's OWN credentials. The gateway hosts no MCP servers at all —
|
|
# see mcp-config/README.md for why (user-scoped APIs, no shared tokens).
|
|
# 3. Deploy the repo's skills/ tree to ~/.config/mcp-sync/skills. Skills are
|
|
# plain files, so there is nothing on the gateway to discover — the repo
|
|
# IS the source of truth, and the tree is fetched whole (repo tarball).
|
|
# They land in mcp-sync's OWN directory, not ~/.config/opencode/skills,
|
|
# so the atomic replace below can never eat a skill the user wrote by
|
|
# hand; opencode picks them up via skills.paths in the shared config.
|
|
# 4. Drop the result at ~/.config/opencode/opencode.gateway.json and export
|
|
# OPENCODE_CONFIG pointing at it. opencode does NOT auto-load extra files
|
|
# in its config dir — it only merges known locations, and OPENCODE_CONFIG
|
|
# is merged between the global opencode.json and any project config. The
|
|
# user's own opencode.json and local-only MCP servers are never touched.
|
|
# 5. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY}
|
|
# resolves.
|
|
|
|
# --- config (overridable via env before sourcing) -------------------------
|
|
# The local checkout is the preferred source for BOTH the config and the
|
|
# skills; these URLs are the no-checkout fallback only. They are Gitea *API*
|
|
# endpoints on purpose: the web /raw/ route answers an unauthorized request
|
|
# with a 200 and an HTML login page, which `curl -f` treats as success and
|
|
# happily writes over the file it was fetching. The API answers with an honest
|
|
# 401/403, so -f can be trusted.
|
|
MCP_SYNC_API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}"
|
|
MCP_SYNC_REF="${MCP_SYNC_REF:-main}"
|
|
MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-$MCP_SYNC_API_BASE/raw/mcp-config/opencode.gateway.json?ref=$MCP_SYNC_REF}"
|
|
MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}"
|
|
# Where the checkout is. install.sh records the tree it ran from; failing that
|
|
# we look where bootstrap.sh clones to.
|
|
MCP_SYNC_REPO="${MCP_SYNC_REPO:-}"
|
|
MCP_SYNC_REPO_FILE="${MCP_SYNC_REPO_FILE:-$MCP_SYNC_HOME/source}"
|
|
MCP_SYNC_DOTFILES_DIR="${MCP_SYNC_DOTFILES_DIR:-$HOME/.dotfiles}"
|
|
MCP_SYNC_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}"
|
|
MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}"
|
|
# Provider whose models are auto-discovered, and the gateway base URL.
|
|
MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}"
|
|
MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
|
|
# Skills. Taken from the checkout like the config; with no checkout we fall
|
|
# back to the repo tarball and lift out its skills/ subtree (a skill is a folder
|
|
# of files, so unlike the config there is no single raw URL to pull). Set
|
|
# MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree;
|
|
# see zsh/zshrc.local.example.
|
|
MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-$MCP_SYNC_API_BASE/archive/$MCP_SYNC_REF.tar.gz}"
|
|
MCP_SYNC_SKILLS_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}"
|
|
MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}"
|
|
# Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set
|
|
# MCP_SYNC_HERMES_CONFIG to empty to opt out entirely.
|
|
MCP_SYNC_HERMES_CONFIG="${MCP_SYNC_HERMES_CONFIG-$HOME/.hermes/config.yaml}"
|
|
# Names we rendered last time, so a server REMOVED upstream can be removed here
|
|
# without touching MCP servers the user added to Hermes by hand.
|
|
MCP_SYNC_HERMES_MANIFEST="${MCP_SYNC_HERMES_MANIFEST:-$MCP_SYNC_HOME/hermes-managed.json}"
|
|
# Model limits for the non-opencode clients. The gateway's /v1/model/info is
|
|
# cached here on every successful fetch, because the refresh above is throttled
|
|
# and a throttled shell never fetches — the renderers must still have something
|
|
# to render from.
|
|
MCP_SYNC_MODELINFO_CACHE="${MCP_SYNC_MODELINFO_CACHE:-$MCP_SYNC_HOME/model-info.json}"
|
|
# Per-model FIELDS we set in Hermes last time, so a field can be retracted
|
|
# without touching fields the user set by hand. Separate from the MCP manifest.
|
|
MCP_SYNC_HERMES_MODELS_MANIFEST="${MCP_SYNC_HERMES_MODELS_MANIFEST:-$MCP_SYNC_HOME/hermes-models-managed.json}"
|
|
# pi. Set MCP_SYNC_PI_MODELS to empty to opt out entirely.
|
|
MCP_SYNC_PI_MODELS="${MCP_SYNC_PI_MODELS-$HOME/.pi/agent/models.json}"
|
|
MCP_SYNC_PI_SETTINGS="${MCP_SYNC_PI_SETTINGS:-$HOME/.pi/agent/settings.json}"
|
|
MCP_SYNC_PI_MANIFEST="${MCP_SYNC_PI_MANIFEST:-$MCP_SYNC_HOME/pi-managed.json}"
|
|
# The most output any client is told it may reserve/request. ONE value, read by
|
|
# the opencode, Hermes and pi renderers alike, so they cannot drift apart on it.
|
|
MCP_SYNC_OUTPUT_RESERVE_CAP="${MCP_SYNC_OUTPUT_RESERVE_CAP:-32768}"
|
|
# Throttle: only re-sync if the deployed copy is older than this many seconds
|
|
# (default 1h). Keeps every new shell from hammering Gitea/the gateway.
|
|
MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}"
|
|
|
|
# --- export the per-user key (first: model discovery needs it) -------------
|
|
_mcp_sync_export_key() {
|
|
[ -f "$MCP_SYNC_KEYFILE" ] || return 0
|
|
# Strip whitespace/newline; export so {env:LITELLM_KEY} resolves in opencode.
|
|
LITELLM_KEY="$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE" 2>/dev/null)"
|
|
[ -n "$LITELLM_KEY" ] && export LITELLM_KEY
|
|
}
|
|
|
|
# --- locate the local checkout (the preferred source) ----------------------
|
|
# Prints the repo root on stdout, or fails. Order: explicit override, the path
|
|
# install.sh recorded, then bootstrap.sh's clone location. A directory only
|
|
# counts if it actually holds the config, so a half-deleted tree falls through
|
|
# to the network rather than deploying nothing.
|
|
_mcp_sync_find_repo() {
|
|
for _cand in "$MCP_SYNC_REPO" \
|
|
"$([ -f "$MCP_SYNC_REPO_FILE" ] && cat "$MCP_SYNC_REPO_FILE" 2>/dev/null)" \
|
|
"$MCP_SYNC_DOTFILES_DIR"; do
|
|
[ -n "$_cand" ] || continue
|
|
if [ -f "$_cand/mcp-config/opencode.gateway.json" ]; then
|
|
printf '%s\n' "$_cand"
|
|
unset _cand
|
|
return 0
|
|
fi
|
|
done
|
|
unset _cand
|
|
return 1
|
|
}
|
|
|
|
# git, authenticated with the Gitea PAT when we have one, so a PRIVATE repo
|
|
# works. The token goes through the environment rather than `git -c ...`
|
|
# because argv is world-readable in `ps`.
|
|
_mcp_sync_git() {
|
|
if [ -n "${GITEA_KEY:-}" ]; then
|
|
GIT_CONFIG_COUNT=1 \
|
|
GIT_CONFIG_KEY_0=http.extraHeader \
|
|
GIT_CONFIG_VALUE_0="Authorization: token $GITEA_KEY" \
|
|
git "$@"
|
|
else
|
|
git "$@"
|
|
fi
|
|
}
|
|
|
|
# Refresh the checkout so a pushed config change actually lands. ONLY for the
|
|
# bootstrap-managed clone: a recorded DEV checkout (someone's ~/source/dotfiles)
|
|
# must never be pulled out from under them by a shell hook. Skipped unless the
|
|
# tree is clean and sitting on its default branch; quiet and non-fatal always.
|
|
_mcp_sync_pull() {
|
|
[ "$1" = "$MCP_SYNC_DOTFILES_DIR" ] || return 0
|
|
[ -d "$1/.git" ] || return 0
|
|
command -v git >/dev/null 2>&1 || return 0
|
|
[ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ] || return 0
|
|
_branch="$(git -C "$1" symbolic-ref --short -q HEAD 2>/dev/null)" || return 0
|
|
_default="$(git -C "$1" symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null)"
|
|
_default="${_default#origin/}"
|
|
[ -n "$_default" ] || _default=main
|
|
[ "$_branch" = "$_default" ] || { unset _branch _default; return 0; }
|
|
unset _branch _default
|
|
_mcp_sync_git -C "$1" pull --ff-only --quiet >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# curl, with the Gitea PAT when we have one. Used only on the no-checkout path.
|
|
_mcp_sync_curl() {
|
|
if [ -n "${GITEA_KEY:-}" ]; then
|
|
curl -fsS -m "${3:-8}" -H "Authorization: token $GITEA_KEY" "$1" -o "$2" 2>/dev/null
|
|
else
|
|
curl -fsS -m "${3:-8}" "$1" -o "$2" 2>/dev/null
|
|
fi
|
|
}
|
|
|
|
# --- refresh config + discover models (throttled, non-fatal) ---------------
|
|
_mcp_sync_refresh() {
|
|
mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0
|
|
|
|
# Skip if we synced recently (mtime within TTL). `find -mmin` is portable
|
|
# enough; fall back to always-sync if find is unavailable.
|
|
if [ -f "$MCP_SYNC_DEST" ] && command -v find >/dev/null 2>&1; then
|
|
if find "$MCP_SYNC_DEST" -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# 1. Get the shared base config. THE CHECKOUT WINS: it is already on disk,
|
|
# was fetched over authenticated git, and needs no network at all. Only a
|
|
# box with no checkout goes to Gitea. On total failure we fall back to the
|
|
# currently deployed file so model discovery can still refresh it.
|
|
_base=""
|
|
_repo="$(_mcp_sync_find_repo)" || _repo=""
|
|
if [ -n "$_repo" ]; then
|
|
_mcp_sync_pull "$_repo"
|
|
if [ -s "$_repo/mcp-config/opencode.gateway.json" ]; then
|
|
_base="$(mktemp 2>/dev/null)" || return 0
|
|
cp "$_repo/mcp-config/opencode.gateway.json" "$_base" 2>/dev/null ||
|
|
{ rm -f "$_base"; _base=""; }
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$_base" ]; then
|
|
command -v curl >/dev/null 2>&1 || return 0
|
|
_base="$(mktemp 2>/dev/null)" || return 0
|
|
if ! _mcp_sync_curl "$MCP_SYNC_RAW_URL" "$_base" || [ ! -s "$_base" ]; then
|
|
rm -f "$_base"; _base=""
|
|
[ -f "$MCP_SYNC_DEST" ] || return 0
|
|
elif [ "$(head -c 1 "$_base")" != "{" ]; then
|
|
# A 200 that is not the config. The API endpoints we default to return an
|
|
# honest 401/403, so this only fires for an override still pointing at
|
|
# Gitea's web /raw/ route, which answers with a 200 HTML login page that
|
|
# -f cannot detect. The python step below would reject it and keep the
|
|
# last-good file — correct, but SILENTLY, leaving the config frozen with
|
|
# nothing to show for it. Say so instead.
|
|
rm -f "$_base"; _base=""
|
|
printf 'mcp-sync: %s did not return JSON (Gitea login page?).\n' "$MCP_SYNC_RAW_URL" >&2
|
|
printf 'mcp-sync: set GITEA_KEY, or point MCP_SYNC_REPO at a checkout.\n' >&2
|
|
# Bump the deployed file's mtime so the TTL throttles this warning to once
|
|
# an hour instead of printing it at the top of every single shell.
|
|
[ -f "$MCP_SYNC_DEST" ] && touch "$MCP_SYNC_DEST" 2>/dev/null
|
|
[ -f "$MCP_SYNC_DEST" ] || return 0
|
|
fi
|
|
fi
|
|
|
|
# 2. Discover the gateway's models with the user's key (best-effort).
|
|
_models=""
|
|
if [ -n "${LITELLM_KEY:-}" ]; then
|
|
_models="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \
|
|
"$MCP_SYNC_GATEWAY/v1/models" 2>/dev/null)" || _models=""
|
|
fi
|
|
|
|
# 2b. Enrich with per-model metadata. /v1/models returns ONLY ids — no context
|
|
# window, no cost, no capability flags — and opencode needs those or it
|
|
# runs every model on defaults (it cannot manage truncation without a
|
|
# context limit, and won't know a model can call tools). /model/info has
|
|
# them. Best-effort: a key still scoped to llm_api_routes 403s here, and
|
|
# we fall back to names-only, exactly as before.
|
|
# VERSIONED PATH FIRST. LiteLLM serves this metadata at both /model/info and
|
|
# /v1/model/info, but a virtual key's allowed_routes is matched on the exact
|
|
# path — granting "/v1/model/info" does NOT also grant "/model/info". Since
|
|
# MCP_SYNC_GATEWAY has no /v1 suffix, the unversioned path is what this used
|
|
# to request, so a key scoped to the versioned route still got a 403 and
|
|
# still silently fell back to names-only. Try both, versioned first, so it
|
|
# works whichever way the key is scoped.
|
|
_info=""
|
|
if [ -n "${LITELLM_KEY:-}" ]; then
|
|
for _mi_path in /v1/model/info /model/info; do
|
|
_info="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \
|
|
"$MCP_SYNC_GATEWAY$_mi_path" 2>/dev/null)" || _info=""
|
|
[ -n "$_info" ] && break
|
|
done
|
|
unset _mi_path
|
|
fi
|
|
|
|
# Cache it for the Hermes and pi renderers — but only a response that really
|
|
# is model info. curl -f already rejects a 403, yet a 200 carrying an error
|
|
# page or an empty list is possible, and caching that would strip every
|
|
# client's limits until the next good fetch. A bad fetch leaves the last good
|
|
# cache in place.
|
|
if [ -n "$_info" ] && command -v python3 >/dev/null 2>&1; then
|
|
MCP_SYNC_PY_INFO="$_info" MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
|
|
python3 - <<'PYEOF' 2>/dev/null || true
|
|
import json, os, tempfile
|
|
try:
|
|
data = json.loads(os.environ["MCP_SYNC_PY_INFO"])
|
|
rows = data.get("data")
|
|
except Exception:
|
|
raise SystemExit(0)
|
|
if not isinstance(rows, list) or not any(
|
|
isinstance(r, dict) and r.get("model_name") for r in rows):
|
|
raise SystemExit(0)
|
|
cache = os.environ["MCP_SYNC_PY_CACHE"]
|
|
d = os.path.dirname(cache) or "."
|
|
os.makedirs(d, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=d)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump({"data": rows}, f)
|
|
os.replace(tmp, cache)
|
|
PYEOF
|
|
fi
|
|
|
|
# 3. Merge and deploy. python3 validates the base (don't clobber good config
|
|
# with an error page), injects the discovered models, and — when discovery
|
|
# failed — carries over the previous deployed models map so a gateway
|
|
# hiccup never empties the model list. Without python3, deploy the raw
|
|
# base as-is (no discovery).
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
MCP_SYNC_BASE="$_base" MCP_SYNC_MODELS="$_models" MCP_SYNC_MODELINFO="$_info" \
|
|
MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
|
|
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
|
|
python3 - <<'PYEOF' 2>/dev/null || true
|
|
import json, os, tempfile
|
|
|
|
dest = os.environ["MCP_SYNC_PY_DEST"]
|
|
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
|
|
|
|
def load(path):
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
base_path = os.environ.get("MCP_SYNC_BASE") or dest
|
|
try:
|
|
cfg = load(base_path)
|
|
except Exception:
|
|
raise SystemExit(0) # invalid base: keep last-good deployed config
|
|
|
|
# LiteLLM /model/info -> opencode's per-model spec. Only fields the gateway
|
|
# actually reports are emitted; a model LiteLLM knows nothing about (a
|
|
# self-hosted vLLM entry with no model_info in the gateway's model_list) simply
|
|
# stays {}, which is what every model got before this existed.
|
|
# opencode's limit.output is a RESERVE it subtracts from limit.context to decide
|
|
# when to compact — NOT a server-side cap. LiteLLM reports max_output_tokens as
|
|
# the whole window for self-hosted vLLM (vLLM caps TOTAL context, not output
|
|
# separately — see charts/inference/values.yaml model_info), so copying it
|
|
# verbatim made opencode reserve the entire window and target an input of
|
|
# context - output. Cap the reserve at a generous agent-output budget instead.
|
|
OUTPUT_RESERVE_CAP = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
|
|
# opencode targets input == context - output EXACTLY, and the server counts one
|
|
# more token than opencode does, landing at context + 1 and getting a hard 400
|
|
# (ContextWindowExceededError). Observed on qwen3-coder-next: 196609 in + 65536
|
|
# out = 262145 vs a 262144 window, one token over, every time. Hold a margin
|
|
# back so opencode's target can never sit on the boundary.
|
|
CONTEXT_SAFETY_MARGIN = 1024
|
|
|
|
|
|
# NEVER trust /model/info's number TYPES. The litellm-helm chart renders the
|
|
# proxy config with Go's toYaml, which writes 0.000005 as `5e-06`; LiteLLM
|
|
# parses it with PyYAML (YAML 1.1), which only reads an exponent float if it has
|
|
# a dot. So `5e-06` arrives as the STRING "5e-06" while `2.5e-05` is a float.
|
|
# `"5e-06" * 1_000_000` is not an error in Python — it is the string repeated a
|
|
# million times. That wrote a 20 MB opencode.gateway.json with a string where
|
|
# opencode requires a number, opencode refused to start, and every new shell
|
|
# redeployed it. Coerce every number through here; anything that is not a
|
|
# finite number is dropped, never passed through.
|
|
def num(v):
|
|
if isinstance(v, bool):
|
|
return None
|
|
if isinstance(v, (int, float)):
|
|
f = float(v)
|
|
elif isinstance(v, str) and len(v) <= 64:
|
|
try:
|
|
f = float(v.strip())
|
|
except ValueError:
|
|
return None
|
|
else:
|
|
return None
|
|
if f != f or f in (float("inf"), float("-inf")):
|
|
return None
|
|
return int(f) if isinstance(v, int) or f.is_integer() else f
|
|
|
|
|
|
def spec_for(mi):
|
|
spec = {}
|
|
limit = {}
|
|
max_in, max_out = num(mi.get("max_input_tokens")), num(mi.get("max_output_tokens"))
|
|
if max_in and max_in > 0:
|
|
limit["context"] = int(max(1, max_in - CONTEXT_SAFETY_MARGIN))
|
|
if max_out and max_out > 0:
|
|
limit["output"] = int(min(max_out, OUTPUT_RESERVE_CAP))
|
|
if limit:
|
|
spec["limit"] = limit
|
|
# LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens.
|
|
cost = {}
|
|
for src, dst in (("input_cost_per_token", "input"),
|
|
("output_cost_per_token", "output")):
|
|
per_token = num(mi.get(src))
|
|
if per_token is not None and per_token >= 0:
|
|
# round() so 5e-06 * 1e6 is 5.0, not 4.999999999999999.
|
|
cost[dst] = round(per_token * 1_000_000, 6)
|
|
if cost:
|
|
spec["cost"] = cost
|
|
for src, dst in (("supports_function_calling", "tool_call"),
|
|
("supports_reasoning", "reasoning"),
|
|
("supports_vision", "attachment")):
|
|
if mi.get(src) is not None:
|
|
spec[dst] = bool(mi[src])
|
|
return spec
|
|
|
|
info = {}
|
|
try:
|
|
for entry in (json.loads(os.environ.get("MCP_SYNC_MODELINFO") or "{}")
|
|
.get("data", [])):
|
|
name = entry.get("model_name")
|
|
if name:
|
|
info[name] = spec_for(entry.get("model_info") or {})
|
|
except Exception:
|
|
pass
|
|
|
|
# Per-model specs declared in the BASE config are a FLOOR; live /model/info
|
|
# overrides them field by field. This matters because the /model/info fetch
|
|
# above is best-effort and 403s for any key not scoped to management routes —
|
|
# which is the normal case for a day-to-day virtual key. Before this merge that
|
|
# fallback silently produced `{}` for every model, i.e. no context limit at
|
|
# all, and opencode then ran the whole gateway on its built-in defaults and
|
|
# walked straight into ContextWindowExceededError 400s on the one model whose
|
|
# window is smaller than those defaults. Declaring limits in the base file did
|
|
# not help either, because `prov["models"] = models` replaced the map wholesale.
|
|
# Live data still wins wherever it is present, so this reintroduces no drift.
|
|
base_models = {}
|
|
try:
|
|
base_models = (cfg.get("provider", {}).get(provider) or {}).get("models") or {}
|
|
except Exception:
|
|
pass
|
|
|
|
models = {}
|
|
try:
|
|
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
|
|
models = {m["id"]: {**(base_models.get(m["id"]) or {}), **dict(info.get(m["id"], {}))}
|
|
for m in data.get("data", []) if m.get("id")}
|
|
except Exception:
|
|
pass
|
|
if not models:
|
|
try: # discovery failed: carry over the previous deployed map
|
|
models = load(dest)["provider"][provider]["models"]
|
|
except Exception:
|
|
pass
|
|
|
|
# Validate EVERY spec before it is written, whatever its source. The carry-over
|
|
# path above re-reads the deployed file, so a bad value that was ever written
|
|
# (e.g. the 20 MB repeated-string cost) would otherwise be preserved forever;
|
|
# this makes the next sync heal it instead. opencode rejects the WHOLE config
|
|
# over one wrongly typed field, so dropping a bad field is always the better
|
|
# failure.
|
|
def clean_spec(spec):
|
|
if not isinstance(spec, dict):
|
|
return {}
|
|
out = {}
|
|
for group, keys, cast in (("limit", ("context", "output"), int),
|
|
("cost", ("input", "output", "cache_read", "cache_write"), float)):
|
|
vals = {}
|
|
for k in keys:
|
|
v = num((spec.get(group) or {}).get(k)) if isinstance(spec.get(group), dict) else None
|
|
if v is not None and v >= 0:
|
|
vals[k] = cast(v)
|
|
if vals:
|
|
out[group] = vals
|
|
for k, v in spec.items():
|
|
if k in ("limit", "cost"):
|
|
continue
|
|
if isinstance(v, (bool, int, float)) or (isinstance(v, str) and len(v) <= 256):
|
|
out[k] = v
|
|
elif isinstance(v, (dict, list)) and len(json.dumps(v)) <= 4096:
|
|
out[k] = v
|
|
return out
|
|
|
|
models = {k: clean_spec(v) for k, v in (models or {}).items()
|
|
if isinstance(k, str) and len(k) <= 256}
|
|
|
|
prov = cfg.get("provider", {}).get(provider)
|
|
if prov is not None and models:
|
|
prov["models"] = models
|
|
|
|
# Last line of defence: the shared config is a few KB. Anything wildly larger is
|
|
# a bug upstream of here, and deploying it is what breaks opencode — keep the
|
|
# last-good file (or none) instead.
|
|
if len(json.dumps(cfg)) > 1_000_000:
|
|
raise SystemExit(0)
|
|
|
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(cfg, f, indent=2)
|
|
os.replace(tmp, dest)
|
|
PYEOF
|
|
elif [ -n "$_base" ]; then
|
|
# No python3: the base config is deployed verbatim, so its "models" map
|
|
# stays EMPTY and opencode shows the provider with zero models. That looks
|
|
# like a broken gateway rather than a missing dependency, so say so — this
|
|
# is the one case worth breaking the silence for.
|
|
mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true
|
|
_base=""
|
|
printf 'mcp-sync: python3 not found — models cannot be discovered.\n' >&2
|
|
printf 'mcp-sync: install python3 (bootstrap.sh does), then open a new shell.\n' >&2
|
|
fi
|
|
[ -n "$_base" ] && rm -f "$_base"
|
|
}
|
|
|
|
# --- render the SAME MCP servers into Hermes' config (non-fatal) -----------
|
|
#
|
|
# WHY THIS EXISTS: the MCP servers are defined ONCE, in opencode.gateway.json's
|
|
# `mcp` block (see README: "all MCP servers are defined here"). Hermes speaks a
|
|
# different schema, so without this it would need a second copy of every server
|
|
# — two definitions to drift apart, one pinned to gitea-mcp@0.0.10 and the other
|
|
# to whatever got typed. Instead we translate the one definition into Hermes'
|
|
# shape, the same way this file already translates LiteLLM's /model/info into
|
|
# opencode's. Add a server to Gitea once and BOTH clients get it.
|
|
#
|
|
# It is NOT a shared server process: MCP stdio servers are subprocesses, spawned
|
|
# per client over stdin/stdout and dying with it. Each client running its own
|
|
# `npx -y <pkg>` is the design — same package, same pinned version, same
|
|
# per-user credential, same identity. There is no daemon to share.
|
|
#
|
|
# Reads the DEPLOYED opencode config (post-refresh), so it always reflects what
|
|
# Gitea just served. No-ops silently when Hermes isn't installed.
|
|
_mcp_sync_hermes() {
|
|
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
|
|
# Hermes not installed (or never run) — nothing to configure, stay silent.
|
|
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
|
|
[ -f "$MCP_SYNC_DEST" ] || return 0
|
|
command -v python3 >/dev/null 2>&1 || return 0
|
|
# PyYAML is needed to touch config.yaml at all. Unlike models, a missing MCP
|
|
# block is invisible rather than obviously broken, so say something.
|
|
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
|
|
printf 'mcp-sync: python3 has no PyYAML — Hermes MCP servers not synced.\n' >&2
|
|
return 0
|
|
fi
|
|
|
|
MCP_SYNC_PY_SRC="$MCP_SYNC_DEST" \
|
|
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
|
|
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MANIFEST" \
|
|
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
|
|
python3 - <<'PYEOF' 2>/dev/null || true
|
|
import json, os, re, tempfile
|
|
import yaml
|
|
|
|
src = os.environ["MCP_SYNC_PY_SRC"]
|
|
dest = os.environ["MCP_SYNC_PY_HERMES"]
|
|
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
|
|
|
|
try:
|
|
with open(src) as f:
|
|
oc = json.load(f)
|
|
except Exception:
|
|
raise SystemExit(0) # bad/incomplete opencode config: leave Hermes alone
|
|
|
|
# opencode's "{env:VAR}" -> Hermes' "${VAR}". Both are by-NAME references
|
|
# resolved at runtime, so no secret is ever written to either file.
|
|
_ENV_REF = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
|
|
|
|
def _refs(v):
|
|
if isinstance(v, str):
|
|
return _ENV_REF.sub(r"${\1}", v)
|
|
if isinstance(v, dict):
|
|
return {k: _refs(x) for k, x in v.items()}
|
|
if isinstance(v, list):
|
|
return [_refs(x) for x in v]
|
|
return v
|
|
|
|
|
|
def spec_for(name, s):
|
|
if not isinstance(s, dict):
|
|
return None
|
|
# opencode defaults enabled to true; so does Hermes. Honour an explicit off.
|
|
if s.get("enabled") is False:
|
|
return None
|
|
kind = s.get("type", "local")
|
|
if kind == "remote":
|
|
url = s.get("url")
|
|
if not url:
|
|
return None
|
|
out = {"url": url}
|
|
headers = s.get("headers")
|
|
if isinstance(headers, dict) and headers:
|
|
out["headers"] = _refs(headers)
|
|
return out
|
|
# local/stdio. opencode: command is a LIST (argv). Hermes: command is a
|
|
# STRING and args is a separate list -- a list here stringifies to
|
|
# "['npx', '-y']" and fails at exec, so the split is mandatory.
|
|
cmd = s.get("command")
|
|
if not isinstance(cmd, list) or not cmd:
|
|
return None
|
|
out = {"command": str(cmd[0])}
|
|
if len(cmd) > 1:
|
|
out["args"] = [str(a) for a in cmd[1:]]
|
|
env = s.get("environment")
|
|
if isinstance(env, dict) and env:
|
|
# Hermes hands the child only a filtered baseline env, so every secret
|
|
# the server needs MUST be passed explicitly here.
|
|
out["env"] = _refs(env)
|
|
return out
|
|
|
|
|
|
rendered = {}
|
|
for name, s in (oc.get("mcp") or {}).items():
|
|
spec = spec_for(name, s)
|
|
if spec is not None:
|
|
rendered[name] = spec
|
|
if not rendered:
|
|
raise SystemExit(0)
|
|
|
|
try:
|
|
with open(dest) as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
raise SystemExit(0) # never overwrite a config we cannot parse
|
|
if not isinstance(cfg, dict):
|
|
raise SystemExit(0)
|
|
|
|
current = cfg.get("mcp_servers")
|
|
if not isinstance(current, dict):
|
|
current = {}
|
|
merged = dict(current)
|
|
|
|
# Drop servers we rendered before that upstream has since removed. Anything the
|
|
# user added to Hermes themselves was never in the manifest, so it survives.
|
|
try:
|
|
with open(manifest_path) as f:
|
|
prev = json.load(f)
|
|
prev = prev if isinstance(prev, list) else []
|
|
except Exception:
|
|
prev = []
|
|
for gone in set(prev) - set(rendered):
|
|
merged.pop(gone, None)
|
|
|
|
merged.update(rendered)
|
|
|
|
if merged != current:
|
|
cfg["mcp_servers"] = merged
|
|
d = os.path.dirname(dest) or "."
|
|
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, dest)
|
|
|
|
os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(manifest_path) or ".")
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(sorted(rendered), f)
|
|
os.replace(tmp, manifest_path)
|
|
PYEOF
|
|
}
|
|
|
|
# --- render the gateway's MODEL LIMITS into Hermes' config (non-fatal) ------
|
|
#
|
|
# WHY THIS EXISTS: OpenAI's /v1/models schema carries no context window, so a
|
|
# client that only lists models cannot know how big a request it may send.
|
|
# opencode gets the window from /v1/model/info above. Hermes does not consult
|
|
# that endpoint at all — it takes context_length from a static `models:` map
|
|
# under its custom provider — so the number used to be hand-copied into
|
|
# hermes/gateway.yaml and kept "in sync" by nobody. It drifted: the map named a
|
|
# model the gateway had stopped serving, while the model that replaced it (and
|
|
# the only one with a SMALLER window than its neighbours) had no entry at all.
|
|
# Short turns were fine; long tasks hit ContextWindowExceededError 400s.
|
|
#
|
|
# NO MARGIN, deliberately, and this is the one number that differs from the
|
|
# opencode renderer. opencode reserves limit.output and targets an input of
|
|
# context - output, so it needs headroom. Hermes omits max_tokens entirely, so
|
|
# vLLM bounds the output by whatever context is left: the raw window is right.
|
|
#
|
|
# OWNERSHIP IS PER FIELD, not per model. The manifest records which fields this
|
|
# set on which model. A field is retracted only if WE set it and the gateway no
|
|
# longer declares it (a model removed upstream, or supports_vision flipped off
|
|
# because a backend fell back to language-only — see the drift warning on
|
|
# supports_vision in ai-k8s-iac's values.yaml). Anything the user typed into the
|
|
# map by hand was never in the manifest and is never touched.
|
|
#
|
|
# Only fills in the provider install.sh created; never creates one.
|
|
_mcp_sync_hermes_models() {
|
|
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
|
|
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
|
|
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
|
|
command -v python3 >/dev/null 2>&1 || return 0
|
|
python3 -c 'import yaml' >/dev/null 2>&1 || return 0 # warned about by _mcp_sync_hermes
|
|
|
|
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
|
|
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
|
|
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MODELS_MANIFEST" \
|
|
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
|
|
python3 - <<'PYEOF' 2>/dev/null || true
|
|
import json, os, tempfile
|
|
import yaml
|
|
|
|
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
|
|
dest = os.environ["MCP_SYNC_PY_HERMES"]
|
|
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
|
|
|
|
try:
|
|
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
|
|
rows = json.load(f).get("data") or []
|
|
except Exception:
|
|
raise SystemExit(0)
|
|
|
|
rendered = {}
|
|
for r in rows:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
name = r.get("model_name")
|
|
mi = r.get("model_info") or {}
|
|
window = mi.get("max_input_tokens")
|
|
# A wildcard route (claude-*) declares no window. Never invent one: a guessed
|
|
# context_length is worse than letting Hermes use its own default.
|
|
if not name or not isinstance(window, int) or window <= 0:
|
|
continue
|
|
fields = {"context_length": window}
|
|
if mi.get("supports_vision") is True:
|
|
fields["supports_vision"] = True
|
|
rendered[name] = fields
|
|
# A gateway that suddenly declares no windows at all is broken, not empty. Do
|
|
# not treat that as "retract everything".
|
|
if not rendered:
|
|
raise SystemExit(0)
|
|
|
|
try:
|
|
with open(dest) as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
raise SystemExit(0) # never overwrite a config we cannot parse
|
|
if not isinstance(cfg, dict) or not isinstance(cfg.get("custom_providers"), list):
|
|
raise SystemExit(0)
|
|
target = next((p for p in cfg["custom_providers"]
|
|
if isinstance(p, dict) and p.get("name") == provider), None)
|
|
if target is None:
|
|
raise SystemExit(0)
|
|
|
|
models = target.get("models")
|
|
if not isinstance(models, dict):
|
|
models = {}
|
|
before = json.dumps(models, sort_keys=True, default=str)
|
|
|
|
try:
|
|
with open(manifest_path) as f:
|
|
prev = json.load(f)
|
|
prev = prev if isinstance(prev, dict) else {}
|
|
except Exception:
|
|
prev = {}
|
|
|
|
for name, owned in prev.items():
|
|
entry = models.get(name)
|
|
if not isinstance(owned, list) or not isinstance(entry, dict):
|
|
continue
|
|
still = rendered.get(name, {})
|
|
for field in owned:
|
|
if field not in still:
|
|
entry.pop(field, None)
|
|
if not entry:
|
|
models.pop(name, None)
|
|
|
|
for name, fields in rendered.items():
|
|
entry = models.get(name)
|
|
entry = entry if isinstance(entry, dict) else {}
|
|
entry.update(fields)
|
|
models[name] = entry
|
|
|
|
if json.dumps(models, sort_keys=True, default=str) != before:
|
|
target["models"] = models
|
|
d = os.path.dirname(dest) or "."
|
|
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, dest)
|
|
|
|
d = os.path.dirname(manifest_path) or "."
|
|
os.makedirs(d, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=d)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump({n: sorted(fl) for n, fl in rendered.items()}, f, sort_keys=True)
|
|
os.replace(tmp, manifest_path)
|
|
PYEOF
|
|
}
|
|
|
|
# --- render the gateway's MODEL LIMITS into pi's models.json (non-fatal) ----
|
|
#
|
|
# Same reason as Hermes: pi reads a static models.json and never asks the
|
|
# gateway, so contextWindow/maxTokens were hand-copied and drifted.
|
|
#
|
|
# THE INVARIANT, because getting this wrong is a guaranteed 400 rather than a
|
|
# degraded answer. pi compacts when contextTokens > contextWindow - reserveTokens
|
|
# (compaction.js shouldCompact) and then sends max_tokens = maxTokens. vLLM
|
|
# counts prompt + max_tokens against its window. So the largest request pi can
|
|
# make is (window - reserveTokens) + maxTokens, and it fits only if
|
|
#
|
|
# maxTokens <= reserveTokens - margin
|
|
#
|
|
# This used to be violated outright: pi's default reserve is 16384 while
|
|
# maxTokens was set to 32768, so a full context sent 196608 - 16384 + 32768 =
|
|
# 212992 tokens at a 196608 window.
|
|
#
|
|
# maxTokens is DERIVED FROM the reserve, rather than the reserve being raised to
|
|
# fit maxTokens. reserveTokens is one global setting shared by every provider pi
|
|
# knows about; raising it to suit this gateway would silently shrink usable
|
|
# context for all of them. Capping maxTokens per model keeps the fix local, and
|
|
# means raising reserveTokens in settings.json is how you buy back output.
|
|
#
|
|
# OWNERSHIP: the manifest records models this ADDED and models it added "image"
|
|
# to. contextWindow and maxTokens are always managed on gateway models, because
|
|
# a stale value there is the bug. Models the user created are never removed, and
|
|
# a user's own "image" is never taken away — only one this added.
|
|
#
|
|
# Only fills in the provider the user configured; never creates one, because a
|
|
# provider needs a baseUrl and key this does not own.
|
|
_mcp_sync_pi() {
|
|
[ -n "${MCP_SYNC_PI_MODELS:-}" ] || return 0
|
|
[ -f "$MCP_SYNC_PI_MODELS" ] || return 0
|
|
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
|
|
command -v python3 >/dev/null 2>&1 || return 0
|
|
|
|
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
|
|
MCP_SYNC_PY_PI="$MCP_SYNC_PI_MODELS" \
|
|
MCP_SYNC_PY_PI_SETTINGS="$MCP_SYNC_PI_SETTINGS" \
|
|
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_PI_MANIFEST" \
|
|
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
|
|
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
|
|
python3 - <<'PYEOF' 2>/dev/null || true
|
|
import json, os, tempfile
|
|
|
|
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
|
|
dest = os.environ["MCP_SYNC_PY_PI"]
|
|
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
|
|
cap = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
|
|
# Headroom between pi's compaction point and the server's hard limit. pi counts
|
|
# from the server's own usage.totalTokens, so this only has to absorb the new
|
|
# message appended since the last measured turn.
|
|
MARGIN = 2048
|
|
# pi's built-in default when compaction.reserveTokens is unset.
|
|
PI_DEFAULT_RESERVE = 16384
|
|
|
|
try:
|
|
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
|
|
rows = json.load(f).get("data") or []
|
|
except Exception:
|
|
raise SystemExit(0)
|
|
|
|
reserve = PI_DEFAULT_RESERVE
|
|
try:
|
|
with open(os.environ["MCP_SYNC_PY_PI_SETTINGS"]) as f:
|
|
r = (json.load(f).get("compaction") or {}).get("reserveTokens")
|
|
if isinstance(r, int) and r > 0:
|
|
reserve = r
|
|
except Exception:
|
|
pass
|
|
ceiling = reserve - MARGIN
|
|
if ceiling <= 0:
|
|
raise SystemExit(0) # no output is provably safe under this reserve; do not guess
|
|
|
|
rendered = {}
|
|
for r in rows:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
name = r.get("model_name")
|
|
mi = r.get("model_info") or {}
|
|
window = mi.get("max_input_tokens")
|
|
if not name or not isinstance(window, int) or window <= 0:
|
|
continue
|
|
out = mi.get("max_output_tokens")
|
|
# LiteLLM's max_output_tokens is the WHOLE window for a vLLM backend (it caps
|
|
# total context, not output), so it is an upper bound, never a value to copy.
|
|
out = out if isinstance(out, int) and out > 0 else cap
|
|
rendered[name] = {
|
|
"contextWindow": window,
|
|
"maxTokens": min(out, cap, ceiling),
|
|
"vision": mi.get("supports_vision") is True,
|
|
}
|
|
if not rendered:
|
|
raise SystemExit(0)
|
|
|
|
try:
|
|
with open(dest) as f:
|
|
cfg = json.load(f)
|
|
except Exception:
|
|
raise SystemExit(0)
|
|
prov = (cfg.get("providers") or {}).get(provider) if isinstance(cfg, dict) else None
|
|
if not isinstance(prov, dict) or not isinstance(prov.get("models"), list):
|
|
raise SystemExit(0)
|
|
before = json.dumps(cfg, sort_keys=True)
|
|
|
|
try:
|
|
with open(manifest_path) as f:
|
|
prev = json.load(f)
|
|
prev = prev if isinstance(prev, dict) else {}
|
|
except Exception:
|
|
prev = {}
|
|
added_prev = set(prev.get("added") or [])
|
|
image_prev = set(prev.get("image") or [])
|
|
|
|
# Drop models WE added that the gateway no longer serves. A user-created entry
|
|
# is never removed, even if the gateway stops listing it.
|
|
models = [m for m in prov["models"]
|
|
if not (isinstance(m, dict) and m.get("id") in added_prev
|
|
and m.get("id") not in rendered)]
|
|
by_id = {m.get("id"): m for m in models if isinstance(m, dict)}
|
|
|
|
added_now, image_now = set(), set()
|
|
for name, f in rendered.items():
|
|
m = by_id.get(name)
|
|
if m is None:
|
|
m = {"id": name, "name": name, "reasoning": False, "input": ["text"],
|
|
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}
|
|
models.append(m)
|
|
by_id[name] = m
|
|
added_now.add(name)
|
|
elif name in added_prev:
|
|
added_now.add(name)
|
|
m["contextWindow"] = f["contextWindow"]
|
|
m["maxTokens"] = f["maxTokens"]
|
|
inp = m.get("input") if isinstance(m.get("input"), list) else ["text"]
|
|
if f["vision"]:
|
|
if "image" not in inp:
|
|
inp = inp + ["image"]
|
|
image_now.add(name)
|
|
elif name in image_prev:
|
|
image_now.add(name)
|
|
elif name in image_prev and "image" in inp:
|
|
# We added it and the gateway no longer claims vision: take it back, or
|
|
# pi keeps sending image parts the backend will reject.
|
|
inp = [x for x in inp if x != "image"]
|
|
m["input"] = inp
|
|
|
|
prov["models"] = models
|
|
if json.dumps(cfg, sort_keys=True) != before:
|
|
d = os.path.dirname(dest) or "."
|
|
fd, tmp = tempfile.mkstemp(dir=d)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(cfg, f, indent=2)
|
|
f.write("\n")
|
|
os.chmod(tmp, 0o600) # the provider block can carry an apiKey
|
|
os.replace(tmp, dest)
|
|
|
|
d = os.path.dirname(manifest_path) or "."
|
|
os.makedirs(d, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=d)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump({"added": sorted(added_now), "image": sorted(image_now)}, f)
|
|
os.replace(tmp, manifest_path)
|
|
PYEOF
|
|
}
|
|
|
|
# --- deploy the shared skills tree (throttled, non-fatal) ------------------
|
|
_mcp_sync_skills() {
|
|
# Same TTL as the config, tracked on the deployed directory's own mtime.
|
|
if [ -d "$MCP_SYNC_SKILLS_DEST" ] && command -v find >/dev/null 2>&1; then
|
|
if find "$MCP_SYNC_SKILLS_DEST" -maxdepth 0 -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
mkdir -p "$MCP_SYNC_HOME" 2>/dev/null || return 0
|
|
# Stage inside MCP_SYNC_HOME (not /tmp) so the publish below is a same-
|
|
# filesystem rename rather than a copy that can be seen half-finished.
|
|
_work="$(mktemp -d "$MCP_SYNC_HOME/.skills.XXXXXX" 2>/dev/null)" || return 0
|
|
|
|
_tree=""
|
|
case "$MCP_SYNC_SKILLS_SRC" in
|
|
/*|file://*)
|
|
# Working checkout: the source is the skills/ dir itself.
|
|
_tree="${MCP_SYNC_SKILLS_SRC#file://}"
|
|
;;
|
|
*)
|
|
# Same rule as the config: the checkout is the source of truth when there
|
|
# is one, so no tarball download and no anonymous read required.
|
|
_repo="$(_mcp_sync_find_repo)" || _repo=""
|
|
if [ -n "$_repo" ] && [ -d "$_repo/$MCP_SYNC_SKILLS_SUBDIR" ]; then
|
|
_tree="$_repo/$MCP_SYNC_SKILLS_SUBDIR"
|
|
elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 &&
|
|
_mcp_sync_curl "$MCP_SYNC_SKILLS_SRC" "$_work/repo.tgz" 15 &&
|
|
[ -s "$_work/repo.tgz" ] &&
|
|
mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null
|
|
then
|
|
# Gitea wraps the archive in a single top-level dir whose name we don't
|
|
# want to hardcode; find the skills/ subtree instead of assuming depth.
|
|
_tree="$(find "$_work/x" -maxdepth 2 -type d -name "$MCP_SYNC_SKILLS_SUBDIR" 2>/dev/null | head -1)"
|
|
fi
|
|
;;
|
|
esac
|
|
if [ -z "$_tree" ] || [ ! -d "$_tree" ]; then rm -rf "$_work"; return 0; fi
|
|
|
|
mkdir -p "$_work/stage" 2>/dev/null || { rm -rf "$_work"; return 0; }
|
|
cp -R "$_tree/." "$_work/stage/" 2>/dev/null || { rm -rf "$_work"; return 0; }
|
|
# Never replace a good tree with an empty one (truncated fetch, empty subdir).
|
|
if [ -z "$(ls -A "$_work/stage" 2>/dev/null)" ]; then rm -rf "$_work"; return 0; fi
|
|
|
|
# Publish: swap the new tree in, keeping the old one until the move lands so a
|
|
# failure leaves the previous skills intact rather than no skills at all.
|
|
rm -rf "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null
|
|
if [ -d "$MCP_SYNC_SKILLS_DEST" ]; then
|
|
mv "$MCP_SYNC_SKILLS_DEST" "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null || { rm -rf "$_work"; return 0; }
|
|
fi
|
|
if ! mv "$_work/stage" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null; then
|
|
mv "$MCP_SYNC_SKILLS_DEST.old" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null
|
|
fi
|
|
rm -rf "$MCP_SYNC_SKILLS_DEST.old" "$_work" 2>/dev/null
|
|
}
|
|
|
|
_mcp_sync_export_key
|
|
_mcp_sync_refresh
|
|
# After refresh: renders the just-deployed MCP servers into Hermes' schema.
|
|
_mcp_sync_hermes
|
|
# Then the gateway's model limits into the two clients that do not discover
|
|
# them on their own. Both run from the cache, so a throttled shell still
|
|
# converges on the last known good values.
|
|
_mcp_sync_hermes_models
|
|
_mcp_sync_pi
|
|
_mcp_sync_skills
|
|
|
|
# opencode only merges config from known locations; point OPENCODE_CONFIG at
|
|
# the synced file so it is layered on top of the user's global opencode.json.
|
|
[ -f "$MCP_SYNC_DEST" ] && export OPENCODE_CONFIG="$MCP_SYNC_DEST"
|
|
|
|
# --- mcp-sync: force a refresh now, ignoring the TTL ------------------------
|
|
# The automatic sync above runs ONLY at shell start and is throttled to
|
|
# MCP_SYNC_TTL (1h). So a model or MCP server added on the gateway can take up
|
|
# to an hour AND a new terminal before it shows up in opencode -- which reads
|
|
# as "my new model isn't there" when in fact the gateway is fine and the local
|
|
# config is just stale. Run `mcp-sync` to pull it immediately.
|
|
#
|
|
# The name is `mcp-sync`, but the DEFINITION below uses an underscore: a hyphen
|
|
# is not a legal function name in POSIX sh, and dash rejects it at PARSE time --
|
|
# i.e. it aborts the whole file, not just this line. install.sh sources sync.sh
|
|
# under /bin/sh (dash on Debian-family), so a bare `mcp-sync() {` here silently
|
|
# killed install.sh's "deploy the config now" step for every install. An alias
|
|
# is resolved lazily, so it costs nothing to parse and keeps the typed name.
|
|
MCP_SYNC_SELF="${MCP_SYNC_SELF:-$HOME/.config/mcp-sync/sync.sh}"
|
|
mcp_sync() {
|
|
[ -f "$MCP_SYNC_SELF" ] || {
|
|
printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1
|
|
}
|
|
# Re-source with the throttle disabled; this redefines and re-runs everything.
|
|
# shellcheck disable=SC1090
|
|
MCP_SYNC_TTL=0 . "$MCP_SYNC_SELF" || return 1
|
|
command -v python3 >/dev/null 2>&1 || { printf 'mcp-sync: refreshed.\n' >&2; return 0; }
|
|
MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
|
|
python3 - <<'PYEOF' >&2
|
|
import json, os
|
|
cfg = json.load(open(os.environ["MCP_SYNC_PY_DEST"]))
|
|
models = cfg.get("provider", {}).get(os.environ["MCP_SYNC_PY_PROVIDER"], {}).get("models", {})
|
|
print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}")
|
|
PYEOF
|
|
}
|
|
alias mcp-sync=mcp_sync 2>/dev/null || true
|
|
|
|
unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills \
|
|
_mcp_sync_find_repo _mcp_sync_pull _mcp_sync_curl _mcp_sync_git 2>/dev/null || true
|
|
# This file is SOURCED into the user's interactive shell, so scratch variables
|
|
# would otherwise linger in their environment for the rest of the session.
|
|
unset _base _repo _models _info _work _tree _cand _branch _default 2>/dev/null || true
|