Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

## [Unreleased]

## [v0.51.210] — 2026-06-02 — Release GD (stage-batch1 — model-picker multi-slash fix + extensionless preview highlighting)

### Fixed
- Model picker no longer snaps to the wrong model when multiple multi-slash model IDs from the same proxy provider share the same base name. Exact-match priority in `_findModelInDropdown` and first-segment-only stripping in `_normalizeConfiguredModelKey` / `_norm_model_id` prevent collisions in selection, badge assignment, and configured-entry dedup (#3360, @b3nw).
- Workspace file previews now syntax-highlight common code/config filenames without useful extensions, including `Dockerfile`, `Dockerfile.*`, `Makefile`, `GNUmakefile`, `CMakeLists.txt`, `.gitignore`, and `.dockerignore` (#3365, @AJV20).

## [v0.51.209] — 2026-06-02 — Release GC (WebUI dashboard plugin system with iframe isolation)

### Added
Expand Down
17 changes: 10 additions & 7 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3076,9 +3076,9 @@ def _get_label_for_model(model_id: str, existing_groups: list) -> str:
if m.get("label") and _norm(str(m.get("id", ""))) == norm_lookup:
return m["label"]

# Fall back: capitalize each hyphen-separated word, preserve dots in version numbers.
# The catalog lookup above handles well-known models; this only fires for unlisted IDs.
bare = lookup_id.split("/")[-1] if "/" in lookup_id else lookup_id
# Fall back: strip only the first slash-segment (provider prefix),
# preserving vendor hierarchy for multi-slash IDs (#3360).
bare = lookup_id.split("/", 1)[1] if "/" in lookup_id else lookup_id
return " ".join(
w.upper() if (len(w) <= 3 and w.replace(".", "").isalnum() and not w.isdigit()) else w.capitalize()
for w in bare.replace("_", "-").split("-")
Expand Down Expand Up @@ -3231,11 +3231,14 @@ def _norm_model_id(model_id: str) -> str:
if s.startswith("@") and ":" in s:
parts = s.split(":")
s = parts[-1] or s
# Strip provider/model prefix (e.g., custom:jingdong/GLM-5 -> GLM-5).
# Same trailing-empty guard.
# Strip only the first slash-segment (provider prefix), preserving
# any remaining vendor hierarchy. Using parts[-1] here previously
# discarded ALL segments except the last, collapsing distinct
# multi-slash IDs like 'vendor_a/deepseek-v4-pro' and
# 'vendor_b/deepseek/deepseek-v4-pro' to the same key (#3360).
if "/" in s:
parts = s.split("/")
s = parts[-1] or s
stripped = s.split("/", 1)[1]
s = stripped or s
return s.replace("-", ".")

def _build_configured_model_badges() -> dict[str, dict[str, str]]:
Expand Down
40 changes: 34 additions & 6 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,20 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const options=Array.from(sel.options);
const opts=options.map(o=>o.value);
// 0. Exact match — highest priority when it doesn't conflict with a
// cross-provider preference (#3360, guarded for #1228/#1313).
// When all models share the same provider (e.g. a custom proxy),
// normalization can collapse distinct multi-slash IDs to the same key
// and options.find() returns whichever appears first in the DOM instead
// of the exact value. But when the exact option belongs to a *different*
// provider than the preferred one, we must fall through to the provider-
// aware match so rehydration doesn't snap to the wrong provider row.
if(opts.includes(modelId)){
const exactOpt=options.find(o=>o.value===modelId);
const exactProv=exactOpt?_getOptionProviderId(exactOpt).toLowerCase():'';
const pref=String(preferredProviderId||'').toLowerCase();
if(!pref || !exactProv || exactProv===pref) return modelId;
}
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
Expand All @@ -1074,8 +1088,7 @@ function _findModelInDropdown(modelId, sel, preferredProviderId){
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
if(providerMatch) return providerMatch.value;
}
// 2. Exact match
if(opts.includes(modelId)) return modelId;
// 2. Normalized match
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// If the request is provider-qualified (either explicit @provider:model or
Expand Down Expand Up @@ -1418,7 +1431,21 @@ function _normalizeConfiguredModelKey(modelId){
// Defensive: trailing-colon / trailing-slash falls back to the original key
// so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;}
if(s.includes('/')){const last=s.split('/').pop();s=last||s;}
// Strip provider-qualified prefixes that contain colons before the first
// slash (e.g. 'custom:llm-proxy/model' → 'model'). Without this, badge-
// key variants like 'custom:llm-proxy/opencode_go/deepseek-v4-pro' and the
// bare 'opencode_go/deepseek-v4-pro' produce different normalized keys and
// aren't deduped in the configured section (#3360).
if(s.includes('/')&&s.indexOf(':')!==-1&&s.indexOf(':')<s.indexOf('/')){
s=s.slice(s.indexOf('/')+1)||s;
}
// Strip only the first slash-segment (provider prefix), preserving any
// remaining vendor hierarchy. Using split('/').pop() here previously
// discarded ALL segments except the last, collapsing distinct multi-slash
// IDs like 'vendor_a/deepseek-v4-pro' and 'vendor_b/deepseek/deepseek-v4-pro'
// to the same key, causing badge misattribution and configured-entry
// suppression (#3360).
if(s.includes('/')) s=s.replace(/^[^/]+\//, '')||s;
return s.replace(/-/g,'.');
}

Expand Down Expand Up @@ -2860,16 +2887,17 @@ function getModelLabel(modelId){
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.split('/').pop()||rawId;
if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId;
return rest||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];
// Safe Ollama-tag fallback formatter before generic split('/').pop()
let _last = modelId.split('/').pop() || modelId;
// Safe Ollama-tag fallback: strip only the first slash-segment (provider
// prefix) so multi-slash IDs preserve their vendor hierarchy (#3360).
let _last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
Expand Down
8 changes: 8 additions & 0 deletions static/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,15 @@ const _PRISM_LANG_MAP={
diff:'diff',patch:'diff',
txt:'',log:'',csv:'',tsv:'',
};
const _PRISM_BASENAME_LANG_MAP={
'dockerfile':'docker','makefile':'makefile','gnumakefile':'makefile',
'cmakelists.txt':'cmake',
'.gitignore':'ignore','.dockerignore':'ignore',
};
function _prismLanguageForPath(path){
const base=String(path||'').split(/[\\/]/).pop().toLowerCase();
if(base.startsWith('dockerfile.')) return 'docker';
if(_PRISM_BASENAME_LANG_MAP[base]!==undefined) return _PRISM_BASENAME_LANG_MAP[base];
const ext=fileExt(path).replace(/^\./,'');
return _PRISM_LANG_MAP[ext]!==undefined?_PRISM_LANG_MAP[ext]:'plaintext';
}
Expand Down
19 changes: 19 additions & 0 deletions tests/test_issue3337_workspace_preview_highlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ def test_prism_language_map_covers_common_extensions():
assert "txt:''" in WORKSPACE_JS


def test_prism_language_path_fallback_covers_extensionless_code_filenames():
"""Common code/config filenames without useful extensions should still
activate Prism grammar selection in workspace previews (#3365).
"""
assert "_PRISM_BASENAME_LANG_MAP" in WORKSPACE_JS
expected = {
"Dockerfile": "docker",
"Makefile": "makefile",
"makefile": "makefile",
"GNUmakefile": "makefile",
"CMakeLists.txt": "cmake",
".gitignore": "ignore",
".dockerignore": "ignore",
}
for filename, language in expected.items():
assert f"{filename.lower()!r}:{language!r}" in WORKSPACE_JS
assert "base.startsWith('dockerfile.')" in WORKSPACE_JS


def test_plain_text_files_do_not_inherit_prior_file_highlighting():
"""Cross-file leak fix: a plain-text preview after a code preview must not
inherit the previous file's language. Two guards make this hold:
Expand Down
Loading
Loading