diff --git a/static/ui.js b/static/ui.js index ea5678ee..37742c5a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7386,6 +7386,15 @@ function _stripDottedModelPrefix(bare){ function getModelLabel(modelId){ if(!modelId) return 'Unknown'; const rawId=String(modelId||''); + // The catalog is the authority on model identity: the backend knows the real + // provider/model split and ships an exact `m.label` per routing id, so a + // catalogued id — including a plain-lane `@custom:` id whose model contains + // colons (`@custom:ollamacloud/qwen3.5:397b`) — renders verbatim. The + // string parsing below is only a legacy fallback for ids the catalog has + // never seen (pre-hydration, stale sessions, removed providers), where a + // first-colon split alone cannot tell a `@custom::` from a + // plain-lane `@custom:` (#7240). + if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // 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 @@ -7400,6 +7409,12 @@ function getModelLabel(modelId){ const rest=rawId.slice('@custom:'.length); const sep=rest.indexOf(':'); if(sep<0) return rest||rawId; + // A provider slug is a config key or a host:port authority — it never + // contains a `/`. A slash-bearing first segment is therefore the model + // itself in the plain custom lane (`@custom:ollamacloud/qwen3.5:397b` + // must render the whole remainder, not just `397b`), mirroring the + // `/`-means-routable rule api/config.py applies when building ids. + if(rest.slice(0,sep).includes('/')) 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 @@ -7416,8 +7431,6 @@ function getModelLabel(modelId){ } return model||rawId; } - // Check dynamic labels first, then fall back to splitting the ID - if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // Static fallback for common models const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'}; if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; diff --git a/tests/test_issue7240_custom_model_colon_label.py b/tests/test_issue7240_custom_model_colon_label.py index e501864c..342ef70e 100644 --- a/tests/test_issue7240_custom_model_colon_label.py +++ b/tests/test_issue7240_custom_model_colon_label.py @@ -6,9 +6,11 @@ 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. +The fix makes the catalog the authority — a catalogued routing id renders its +exact `m.label` (the backend knows the real provider/model split). The string +peel is only a legacy fallback for ids the catalog has never seen, where a +provider slug (a config key or host:port authority) is distinguished from a +plain-lane model by its lack of a `/`. Runs the live getModelLabel() via Node so drift between the test and the real code is caught immediately. @@ -36,7 +38,9 @@ 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 = {}; +// Optional pre-hydrated catalog: {routingId: label}. Absent -> empty, which +// exercises the string-only legacy fallback. +const _dynamicModelLabels = process.argv[3] ? JSON.parse(process.argv[3]) : {}; 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 @@ -52,9 +56,12 @@ process.stdout.write(JSON.stringify(out)); """ -def _labels(model_ids): +def _labels(model_ids, catalog=None): + argv = [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)] + if catalog is not None: + argv.append(json.dumps(catalog)) proc = subprocess.run( - [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)], + argv, capture_output=True, text=True, timeout=30, ) assert proc.returncode == 0, f"node driver failed: {proc.stderr}" @@ -100,3 +107,48 @@ def test_custom_model_label_plain_lane_and_host_port_slugs(): 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" + + +def test_plain_custom_lane_colon_bearing_model_slash_segment(): + """#7240: a plain-lane ``@custom:`` whose first segment + is slash-bearing (`ollamacloud/qwen3.5:397b`) is the model itself — a + provider slug never contains a `/`, so it must not be peeled as one.""" + out = _labels([ + "@custom:ollamacloud/qwen3.5:397b", + "@custom:ollamacloud/gemma4:31b", + "@custom:some-vendor/step-3.7-flash:free", + ]) + assert out["@custom:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:ollamacloud/gemma4:31b"] == "ollamacloud/gemma4:31b" + assert out["@custom:some-vendor/step-3.7-flash:free"] == "some-vendor/step-3.7-flash:free" + + +def test_catalog_label_wins_over_legacy_peel(): + """Post-hydration: the catalog ships the exact backend label per routing + id, so a catalogued id renders verbatim even where the string peel alone + would be ambiguous or lossy.""" + catalog = { + # Plain lane, colon-bearing model: the backend knows this is ONE model. + "@custom:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b", + # Named lane, same native model: distinct routing id, distinct entry. + "@custom:omni:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b", + # A custom display label is preserved exactly. + "@custom:ai_gateway:Qwen3.6-35B-A3B": "My Qwen Gateway", + } + out = _labels(list(catalog), catalog=catalog) + assert out["@custom:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:ai_gateway:Qwen3.6-35B-A3B"] == "My Qwen Gateway" + + +def test_collision_labels_stay_distinct_by_full_routing_id(): + """Two providers exposing the same native model stay distinguishable: the + catalog keys labels by the FULL routing id, never by the bare model.""" + catalog = { + "@custom:omni:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b (omni)", + "@custom:ai_gateway:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b (gateway)", + } + out = _labels(list(catalog), catalog=catalog) + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b (omni)" + assert out["@custom:ai_gateway:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b (gateway)" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] != out["@custom:ai_gateway:ollamacloud/qwen3.5:397b"]