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:
+72
-6
@@ -315,21 +315,50 @@ OUTPUT_RESERVE_CAP = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
|
||||
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 = {}
|
||||
if mi.get("max_input_tokens"):
|
||||
limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN)
|
||||
if mi.get("max_output_tokens"):
|
||||
limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP)
|
||||
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")):
|
||||
if mi.get(src) is not None:
|
||||
cost[dst] = mi[src] * 1_000_000
|
||||
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"),
|
||||
@@ -378,10 +407,47 @@ if not 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)
|
||||
|
||||
Reference in New Issue
Block a user