From e71c3e1f9717135d03f712790d328078e90592ad Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 2 Sep 2026 10:44:47 -0300 Subject: [PATCH] fix(picker): stop truncating model names with colons --- static/ui.js | 32 ++++-- ...test_issue7240_custom_model_colon_label.py | 102 ++++++++++++++++++ 2 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue7240_custom_model_colon_label.py diff --git a/static/ui.js b/static/ui.js index e2c7b690..ea5678ee 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7386,15 +7386,35 @@ function _stripDottedModelPrefix(bare){ function getModelLabel(modelId){ if(!modelId) return 'Unknown'; const rawId=String(modelId||''); - // Preserve custom gateway model IDs exactly as configured. + // Preserve custom gateway model IDs exactly as configured. A custom id is + // `@custom:` in the plain custom lane or `@custom::` for + // a named custom provider; the provider slug may itself be an endpoint + // authority (e.g. `custom:10.8.71.41:8080`). The model portion may contain + // colons (tag/variant suffixes like `:free`, `:31b`, `:397b`) and vendor + // slashes, so only the leading provider segment is peeled (#7240). // Examples: - // @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B - // @custom:qwen397b-64k -> qwen397b-64k + // @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B + // @custom:omni:kg/stepfun/step-3.7-flash:free -> kg/stepfun/step-3.7-flash:free + // @custom:qwen397b-64k -> qwen397b-64k if(rawId.startsWith('@custom:')){ const rest=rawId.slice('@custom:'.length); - if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId; - if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId; - return rest||rawId; + const sep=rest.indexOf(':'); + if(sep<0) return rest||rawId; + let model=rest.slice(sep+1); + // Endpoint-style slug (`custom:10.8.71.41:8080:model`): the `:port` belongs + // to the provider segment, mirroring the host:port slug check in + // api/config.py, so it is consumed before the model label starts. + const portMatch=/^(\d{1,5}):/.exec(model); + if(portMatch){ + const port=Number(portMatch[1]); + if(port>=1&&port<=65535){ + const host=rest.slice(0,sep).toLowerCase(); + if(host==='localhost'||host.includes('.')||/^\d{1,3}(\.\d{1,3}){3}$/.test(host)){ + model=model.slice(portMatch[0].length); + } + } + } + return model||rawId; } // Check dynamic labels first, then fall back to splitting the ID if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; diff --git a/tests/test_issue7240_custom_model_colon_label.py b/tests/test_issue7240_custom_model_colon_label.py new file mode 100644 index 00000000..e501864c --- /dev/null +++ b/tests/test_issue7240_custom_model_colon_label.py @@ -0,0 +1,102 @@ +"""Regression test for #7240 — model dropdown truncates custom model names +containing colons. + +getModelLabel() derived the label of a ``@custom:`` id from the substring +following the LAST ``:``, so a tag/variant suffix like ``:free``/``:31b``/ +``:397b`` inside the model's own name became the whole label +(``@custom:omni:kg/stepfun/step-3.7-flash:free`` rendered as ``free``). + +The fix peels only the leading provider segment (``@custom::`` — the +```` is itself optional in the plain custom lane, and may be an endpoint +authority ``host:port``), leaving the full configured model id as the label. + +Runs the live getModelLabel() via Node so drift between the test and the real +code is caught immediately. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + +_DRIVER = r""" +const fs = require('fs'); +const ui = fs.readFileSync(process.argv[1], 'utf8'); +// Slice getModelLabel() by function boundaries (regex literals inside it defeat +// a naive brace counter, so bound it by the next top-level function instead). +const start = ui.indexOf('function getModelLabel('); +if (start < 0) throw new Error('getModelLabel not found'); +const after = ui.indexOf('\nfunction _gatewayProviderName(', start); +if (after < 0) throw new Error('getModelLabel end boundary not found'); +const fnSrc = ui.slice(start, after); +const _dynamicModelLabels = {}; +function _fmtOllamaLabel(s){ return s; } +// getModelLabel() calls the dotted Bedrock/Vertex prefix normalizer, which lives +// just above it with two Sets it closes over. Pull all three in, or the eval'd +// function ReferenceErrors on the first dotted id. +const _stripStart = ui.indexOf('const _BEDROCK_REGION_PREFIXES'); +const _stripEnd = ui.indexOf('function getModelLabel('); +if (_stripStart < 0 || _stripStart > _stripEnd) throw new Error('_stripDottedModelPrefix block not found'); +eval(ui.slice(_stripStart, _stripEnd)); +eval(fnSrc); +const out = {}; +for (const m of JSON.parse(process.argv[2])) out[m] = getModelLabel(m); +process.stdout.write(JSON.stringify(out)); +""" + + +def _labels(model_ids): + proc = subprocess.run( + [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)], + capture_output=True, text=True, timeout=30, + ) + assert proc.returncode == 0, f"node driver failed: {proc.stderr}" + return json.loads(proc.stdout) + + +def test_custom_model_label_keeps_colon_tag_suffixes(): + """#7240: the full configured model id is the label — a trailing ``:tag`` + inside the model name must never become the whole label.""" + out = _labels([ + # Repro ids from the issue: tag/variant suffix after the model name. + "@custom:omni:kg/stepfun/step-3.7-flash:free", + "@custom:omni:kg/tencent/hy3:free", + "@custom:omni:ollamacloud/gemma4:31b", + "@custom:omni:ollamacloud/qwen3.5:397b", + # Non-colon model names must keep working. + "@custom:omni:gemini/gemini-3.1-flash-lite", + "@custom:ai_gateway:Qwen3.6-35B-A3B", + ]) + assert out["@custom:omni:kg/stepfun/step-3.7-flash:free"] == "kg/stepfun/step-3.7-flash:free" + assert out["@custom:omni:kg/tencent/hy3:free"] == "kg/tencent/hy3:free" + assert out["@custom:omni:ollamacloud/gemma4:31b"] == "ollamacloud/gemma4:31b" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:omni:gemini/gemini-3.1-flash-lite"] == "gemini/gemini-3.1-flash-lite" + assert out["@custom:ai_gateway:Qwen3.6-35B-A3B"] == "Qwen3.6-35B-A3B" + + +def test_custom_model_label_plain_lane_and_host_port_slugs(): + """#7240 regression guards: the plain custom lane (no provider slug) keeps + the whole remainder, and an endpoint-authority slug (``host:port``) is + consumed as provider plumbing rather than leaking into the label.""" + out = _labels([ + # Plain custom lane: '@custom:' with no slug separator. + "@custom:qwen397b-64k", + # Endpoint-style slug: ':port' belongs to the provider segment. + "@custom:10.8.71.41:8080:Qwen3-235B", + "@custom:localhost:11434:llama3", + # A digit-leading model after a NAMED slug is not a port — must not be + # peeled as if the slug were host:port. + "@custom:omni:11434:Qwen3", + ]) + assert out["@custom:qwen397b-64k"] == "qwen397b-64k" + assert out["@custom:10.8.71.41:8080:Qwen3-235B"] == "Qwen3-235B" + assert out["@custom:localhost:11434:llama3"] == "llama3" + assert out["@custom:omni:11434:Qwen3"] == "11434:Qwen3"