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