fix: model cost/info matches first substring key (gpt-4o-mini billed as gpt-4o) (#1439)

* fix: match model name to the longest known key, not the first substring

* test: model key matching prefers the longest specific key
This commit is contained in:
Afonso Coutinho
2026-06-04 03:05:37 +01:00
committed by GitHub
parent 2efebcc278
commit eac354629a
3 changed files with 74 additions and 12 deletions

View File

@@ -8,6 +8,7 @@ import { providerLogo } from './providers.js';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js'; import { bindMenuDismiss } from './escMenuStack.js';
import { matchModelKey } from './model/matchKey.js';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>'; const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>'; const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -532,11 +533,8 @@ export function modelColor(name) {
/** Look up model info (pricing + context) by substring match */ /** Look up model info (pricing + context) by substring match */
export function getModelInfo(modelName) { export function getModelInfo(modelName) {
if (!modelName) return null; if (!modelName) return null;
const name = modelName.toLowerCase(); const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
for (const [key, info] of Object.entries(MODEL_INFO)) { return key ? { key, ...MODEL_INFO[key] } : null;
if (name.includes(key)) return { key, ...info };
}
return null;
} }
function _fmtCtx(n) { function _fmtCtx(n) {
@@ -634,13 +632,10 @@ export function applyModelColor(roleEl, modelName) {
export function getModelCost(modelName, inputTokens, outputTokens) { export function getModelCost(modelName, inputTokens, outputTokens) {
if (!modelName) return null; if (!modelName) return null;
const name = modelName.toLowerCase(); const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
for (const [key, price] of Object.entries(MODEL_PRICING)) { if (!key) return null;
if (name.includes(key)) { const price = MODEL_PRICING[key];
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
}
}
return null;
} }
/** /**

View File

@@ -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;
}

View File

@@ -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