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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
63
tests/test_local_endpoint_js.py
Normal file
63
tests/test_local_endpoint_js.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user