From 6ac2a02f1bd6fbdce6490d12a8ce8db1118b667b Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 14 Sep 2026 13:04:28 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Duzwo1tmEqD2kYYyNKm7xs --- mcp-config/sync.sh | 78 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/mcp-config/sync.sh b/mcp-config/sync.sh index 0bfdbfc..b5bd337 100644 --- a/mcp-config/sync.sh +++ b/mcp-config/sync.sh @@ -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)