diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 063889e..9760665 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -8,6 +8,7 @@ import { providerLogo } from './providers.js'; import settingsModule from './settings.js'; import spinnerModule from './spinner.js'; import { bindMenuDismiss } from './escMenuStack.js'; +import { matchModelKey } from './model/matchKey.js'; const SEARCH_ICON = ''; const REPORT_ICON = ''; @@ -532,11 +533,8 @@ export function modelColor(name) { /** Look up model info (pricing + context) by substring match */ export function getModelInfo(modelName) { if (!modelName) return null; - const name = modelName.toLowerCase(); - for (const [key, info] of Object.entries(MODEL_INFO)) { - if (name.includes(key)) return { key, ...info }; - } - return null; + const key = matchModelKey(modelName, Object.keys(MODEL_INFO)); + return key ? { key, ...MODEL_INFO[key] } : null; } function _fmtCtx(n) { @@ -634,13 +632,10 @@ export function applyModelColor(roleEl, modelName) { export function getModelCost(modelName, inputTokens, outputTokens) { if (!modelName) return null; - const name = modelName.toLowerCase(); - for (const [key, price] of Object.entries(MODEL_PRICING)) { - if (name.includes(key)) { - return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; - } - } - return null; + const key = matchModelKey(modelName, Object.keys(MODEL_PRICING)); + if (!key) return null; + const price = MODEL_PRICING[key]; + return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; } /** diff --git a/static/js/model/matchKey.js b/static/js/model/matchKey.js new file mode 100644 index 0000000..3f1a8c9 --- /dev/null +++ b/static/js/model/matchKey.js @@ -0,0 +1,19 @@ +// static/js/model/matchKey.js +// +// Pure helper for matching a model name against a set of known keys. No DOM — +// safe to import anywhere and to unit-test under node. + +// Return the most specific (longest) key that is a substring of `name`, or null. +// Returning the first match instead made "gpt-4o-mini" match the shorter +// "gpt-4o" key — billing it at gpt-4o rates (~16x) and showing the wrong +// context window. +export function matchModelKey(name, keys) { + const n = (name || '').toLowerCase(); + let best = null; + for (const key of keys) { + if (n.includes(key) && (best === null || key.length > best.length)) { + best = key; + } + } + return best; +} diff --git a/tests/test_match_model_key_js.py b/tests/test_match_model_key_js.py new file mode 100644 index 0000000..7637096 --- /dev/null +++ b/tests/test_match_model_key_js.py @@ -0,0 +1,48 @@ +"""Pin matchModelKey (static/js/model/matchKey.js). + +Driven through `node --input-type=module` (same approach as test_compare_js.py); +skips when `node` is not installed. + +Regression: model name -> info/pricing lookups returned the FIRST substring +match, so "gpt-4o-mini" matched the shorter "gpt-4o" key and was billed at +gpt-4o rates (~16x) with the wrong context window. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HELPER = _REPO / "static" / "js" / "model" / "matchKey.js" +_HAS_NODE = shutil.which("node") is not None + +_KEYS = ["gpt-4o", "gpt-4o-mini", "gpt-4", "o1", "o1-mini", "o1-pro", "o3", "o3-mini"] + + +def _match(name): + js = ( + f"import {{ matchModelKey }} from '{_HELPER.as_posix()}';" + f"console.log(JSON.stringify(matchModelKey({json.dumps(name)}, {json.dumps(_KEYS)})));" + ) + 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") +def test_prefers_longest_specific_key(): + assert _match("gpt-4o-mini") == "gpt-4o-mini" + assert _match("o1-mini") == "o1-mini" + assert _match("o1-pro") == "o1-pro" + assert _match("o3-mini") == "o3-mini" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_base_model_and_unknown(): + assert _match("gpt-4o-2024-08-06") == "gpt-4o" + assert _match("some-unknown-model") is None