From a857d2016d8b122ae9f31fbe0c8e10f123198be3 Mon Sep 17 00:00:00 2001 From: nsgds <161509862+nsgds@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:47:58 +0800 Subject: [PATCH] fix: don't bill self-hosted models reached by a container/service hostname (#596) * fix(cost): treat dotless container hostnames as local (free) getModelCost() substring-matches model names against a cloud price table, so a self-hosted 'nemotron'/'llama' model was billed at cloud rates. isLocalEndpoint() only recognized IPs / localhost / .local, not bare Docker service names (nim-nano, llamaswap), so the local-is-free guard missed them. A single-label hostname (no dot) can never be a public API -> treat as local. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cost): isLocalEndpoint classifies service names local, cloud FQDNs billable Covers @pewdiepie-archdaemon's requested cases: llamaswap/nim-nano + localhost/private-IPs/.local => local (free); api.openai.com/openrouter.ai/etc => not local. Drives the real function via node --input-type=module (same approach as test_reply_recipients_js.py), skips when node is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- static/js/chatRenderer.js | 6 ++++ tests/test_local_endpoint_js.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/test_local_endpoint_js.py diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 5c18e74..400632f 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -659,6 +659,12 @@ export function isLocalEndpoint(url) { if (!host) return true; if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true; if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true; + // A single-label hostname (no dot) is an internal/Docker service name + // (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname — + // never a public API, which always needs an FQDN. Treat as local → free. + // (Without this, container-name endpoints get billed at cloud rates because + // the pricing table matches on a name substring, e.g. "nemotron".) + if (!host.includes('.')) return true; if (/^127\./.test(host)) return true; if (/^10\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; diff --git a/tests/test_local_endpoint_js.py b/tests/test_local_endpoint_js.py new file mode 100644 index 0000000..29a0066 --- /dev/null +++ b/tests/test_local_endpoint_js.py @@ -0,0 +1,63 @@ +"""Pin the billing/display classifier `isLocalEndpoint` in chatRenderer.js. + +Self-hosted endpoints reached by a bare Docker/Compose service name (e.g. +`http://llamaswap:8000`) must classify as LOCAL so they aren't priced at cloud +rates against the substring-matched MODEL_PRICING table. Cloud FQDNs must stay +billable. + +Driven through `node --input-type=module` against the real function (extracted +from source — chatRenderer.js can't be imported standalone since it pulls in +browser-only modules), same spirit as test_reply_recipients_js.py. Skips when +`node` is not installed rather than failing. +""" +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_SRC = _REPO / "static" / "js" / "chatRenderer.js" +_HAS_NODE = shutil.which("node") is not None + + +def _is_local(url: str) -> bool: + src = _SRC.read_text(encoding="utf-8") + m = re.search(r"export function isLocalEndpoint\(.*?\n\}", src, re.DOTALL) + assert m, "isLocalEndpoint not found in chatRenderer.js" + fn = m.group(0).replace("export function", "function", 1) + js = fn + f"\nconsole.log(JSON.stringify(isLocalEndpoint({json.dumps(url)})));" + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +@pytest.mark.parametrize("url", [ + "http://llamaswap:8000", # bare Docker/Compose service name + "http://nim-nano:8000/v1", + "http://localhost:7000", + "http://127.0.0.1:11434", + "http://192.168.50.244", # private ranges + "http://10.0.0.5:8080", + "http://172.16.0.9", + "http://server.local", # mDNS / .local +]) +def test_self_hosted_endpoints_classify_local(url): + assert _is_local(url) is True, f"{url} should be treated as local (free)" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +@pytest.mark.parametrize("url", [ + "https://api.openai.com/v1", + "https://openrouter.ai/api/v1", + "https://api.anthropic.com", + "https://generativelanguage.googleapis.com", +]) +def test_cloud_endpoints_classify_billable(url): + assert _is_local(url) is False, f"{url} should NOT be treated as local"