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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user