mcp-config: coerce /model/info numbers, so a string price can't break opencode

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
This commit is contained in:
2026-09-14 13:04:28 -04:00
co-authored by Claude Opus 5
parent d42d0b29ff
commit 6ac2a02f1b
+72 -6
View File
@@ -315,21 +315,50 @@ OUTPUT_RESERVE_CAP = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
CONTEXT_SAFETY_MARGIN = 1024 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): def spec_for(mi):
spec = {} spec = {}
limit = {} limit = {}
if mi.get("max_input_tokens"): max_in, max_out = num(mi.get("max_input_tokens")), num(mi.get("max_output_tokens"))
limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN) if max_in and max_in > 0:
if mi.get("max_output_tokens"): limit["context"] = int(max(1, max_in - CONTEXT_SAFETY_MARGIN))
limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP) if max_out and max_out > 0:
limit["output"] = int(min(max_out, OUTPUT_RESERVE_CAP))
if limit: if limit:
spec["limit"] = limit spec["limit"] = limit
# LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens. # LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens.
cost = {} cost = {}
for src, dst in (("input_cost_per_token", "input"), for src, dst in (("input_cost_per_token", "input"),
("output_cost_per_token", "output")): ("output_cost_per_token", "output")):
if mi.get(src) is not None: per_token = num(mi.get(src))
cost[dst] = mi[src] * 1_000_000 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: if cost:
spec["cost"] = cost spec["cost"] = cost
for src, dst in (("supports_function_calling", "tool_call"), for src, dst in (("supports_function_calling", "tool_call"),
@@ -378,10 +407,47 @@ if not models:
except Exception: except Exception:
pass 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) prov = cfg.get("provider", {}).get(provider)
if prov is not None and models: if prov is not None and models:
prov["models"] = 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)) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2) json.dump(cfg, f, indent=2)