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:
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
19
static/js/model/matchKey.js
Normal file
19
static/js/model/matchKey.js
Normal 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;
|
||||||
|
}
|
||||||
48
tests/test_match_model_key_js.py
Normal file
48
tests/test_match_model_key_js.py
Normal 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
|
||||||
Reference in New Issue
Block a user