Merge pull request #3415 from nesquena/release/stage-batch1

Release GE — v0.51.211 (reasoning heuristics + /model shortest-match + Copilot env-token filter)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-02 10:28:27 -07:00
commit f12608487f
4 files changed
+140 -15

No files matched your search

+7
View File
@@ -3,6 +3,13 @@
## [Unreleased]
## [v0.51.211] — 2026-06-02 — Release GE (stage-batch1 — reasoning heuristics + /model shortest-match + Copilot env-token filter)
### Fixed
- Generalized reasoning-effort capability checks in `_candidate_supports_reasoning` to target whole model families (GPT-5+, Claude 4/3.7, Qwen-3, Kimi, Minimax, Mimo, GLM, Step, and DeepSeek) instead of anchoring on hardcoded version numbers or vendor formats. This prevents the thinking-level configuration selector from being hidden on custom providers, new model releases, or when names carry suffixes like `-free` or `:free` (common on integrations such as Kilo Code or OpenCode Zen). The GPT heuristic is now version-anchored (5+) to avoid falsely enabling reasoning_effort for gpt-4o/4.1/3.5 on aggregator providers (#3379, @b3nw, closes #3377).
- The `/model` slash command no longer selects a longer model variant when a shorter name is a prefix of it (e.g. `/model mimo-v2.5` selecting `mimo-v2.5-pro`). The fuzzy fallback now prefers an exact id/label match and otherwise the shortest matching option, applied to both the main and bare-name (`provider/...`) fallbacks (#3394, @vanshaj-pahwa, closes #3368).
- `GITHUB_TOKEN` and `GH_TOKEN` environment variables are now filtered from the Copilot credential pool alongside the seeded `gh`-CLI token, so a classic PAT (`ghp_*`) auto-detected from the environment no longer makes Copilot appear in the model picker when the Copilot API can't use it. User-specific `COPILOT_GITHUB_TOKEN` is still respected (#3382, @happy5318).
## [v0.51.210] — 2026-06-02 — Release GD (stage-batch1 — model-picker multi-slash fix + extensionless preview highlighting)
### Fixed
+49 -5
View File
@@ -1250,14 +1250,29 @@ _PROVIDER_MODELS = {
_AMBIENT_GH_CLI_MARKERS = frozenset({"gh_cli", "gh auth token"})
# Environment variable sources that are auto-detected and should be filtered
# when the token is a classic PAT (ghp_*) that Copilot API doesn't support.
# Note: COPILOT_GITHUB_TOKEN is NOT included here - it's user-specific config.
_AMBIENT_GH_ENV_SOURCES = frozenset({"env:github_token", "env:gh_token"})
def _is_ambient_gh_cli_entry(source: str, label: str, key_source: str) -> bool:
"""True when a credential-pool entry is a seeded gh-cli token rather than
one the user added explicitly. Filter these so Copilot doesn't appear in
the dropdown just because `gh` is installed on the system.
Also filters GITHUB_TOKEN and GH_TOKEN env var entries, which are
auto-detected from the environment and should not cause Copilot to appear
in the picker when the token is a classic PAT (ghp_*) that Copilot API
doesn't support.
Note: COPILOT_GITHUB_TOKEN is NOT filtered - it's user-specific config
that should always be respected.
"""
source_lower = source.strip().lower()
return (
source.strip().lower() in _AMBIENT_GH_CLI_MARKERS
source_lower in _AMBIENT_GH_CLI_MARKERS
or source_lower in _AMBIENT_GH_ENV_SOURCES
or label.strip().lower() == "gh auth token"
or key_source.strip().lower() == "gh auth token"
)
@@ -2136,15 +2151,44 @@ def _candidate_supports_reasoning(candidate: str) -> bool:
if "thinking" in token_set or "reasoning" in token_set:
return True
if "gpt" in token_set or normalized.startswith("gpt"):
# Restrict to GPT-5+ (exclude GPT-4o/4.1/3.5 — reasoning_effort unsupported)
m = re.search(r"gpt-(\d+)", normalized)
if m and int(m.group(1)) >= 5:
return True
return False
if normalized in {"o1", "o3", "o4"} or normalized.startswith(("o1-", "o3-", "o4-")):
return True
if normalized.startswith(("kimi-k2", "kimi-thinking", "claude-3", "claude-4")):
if "claude" in token_set or normalized.startswith("claude"):
# Restrict to Claude 4+ or Claude 3.7+ (exclude Claude 3.0/3.5)
match = re.search(r"claude.*?(\d+)(?:\D+(\d+))?", normalized)
if match:
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
if major >= 4 or (major == 3 and minor >= 7):
return True
return False
if "qwen" in token_set or normalized.startswith("qwen"):
# Restrict to Qwen 3+ (exclude Qwen 2/2.5)
match = re.search(r"qwen.*?(\d+)(?:\D+(\d+))?", normalized)
if match:
major = int(match.group(1))
if major >= 3:
return True
return False
if "kimi" in token_set or normalized.startswith("kimi"):
return True
if normalized.startswith("qwen3") or "qwen3" in token_set:
if "minimax" in token_set or normalized.startswith("minimax"):
return True
if normalized.startswith(("deepseek-v3", "deepseek-v4", "deepseek-r1", "deepseek-r2")):
if "mimo" in token_set or normalized.startswith("mimo"):
return True
if len(tokens) >= 2 and tokens[0] == "deepseek" and tokens[1] in {"v3", "v4", "r1", "r2"}:
if "glm" in token_set or normalized.startswith("glm"):
return True
if "step" in token_set or normalized.startswith("step"):
return True
if normalized.startswith(("deepseek-v", "deepseek-r")):
return True
if len(tokens) >= 2 and tokens[0] == "deepseek" and tokens[1].startswith(("v", "r")):
return True
return False
+20 -10
View File
@@ -322,6 +322,24 @@ function cmdClear(){
showToast(t('conversation_cleared'));
}
// Find the best matching model <option> for a slash-command query.
// Returns an exact id/label match if present, otherwise the shortest option
// whose value or label contains the query. Preferring the shortest match keeps
// a specific query like "mimo-v2.5" from being shadowed by a longer variant
// such as "mimo-v2.5-pro". See issue #3368.
function _bestModelMatch(options,query){
let best=null;
for(const opt of options){
const value=opt.value.toLowerCase();
const text=opt.textContent.toLowerCase();
if(value===query||text===query) return opt.value;
if(value.includes(query)||text.includes(query)){
if(best===null||opt.value.length<best.length) best=opt.value;
}
}
return best;
}
async function cmdModel(args){
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
@@ -348,21 +366,13 @@ async function cmdModel(args){
let match=(typeof _findModelInDropdown==='function')?_findModelInDropdown(q,sel,preferred):null;
// Fallback: fuzzy match across all options
if(!match){
for(const opt of sel.options){
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
match=opt.value;break;
}
}
match=_bestModelMatch(sel.options,q);
}
// Fallback: if q has provider/ prefix (e.g. "deepseek/deepseek-v4-flash"),
// try the bare model name (which is how options appear for the active provider)
if(!match && q.includes('/')){
const bare=q.slice(q.lastIndexOf('/')+1);
for(const opt of sel.options){
if(opt.value.toLowerCase().includes(bare)||opt.textContent.toLowerCase().includes(bare)){
match=opt.value;break;
}
}
match=_bestModelMatch(sel.options,bare);
// Cross-provider fallback: if still no match, the model is from a
// different provider not in the dropdown. Call /api/session/update directly.
if(!match && S&&S.session&&S.session.session_id){
@@ -143,3 +143,67 @@ def test_openrouter_slash_prefix_unaffected():
provider_id="openrouter",
)
assert set(efforts) >= {"low", "medium", "high"}
def test_generalized_model_families_and_suffixed_ids():
test_models = [
# GPT
("gpt-5.5", "custom:newapi"),
("gpt-6-ultra", "custom:newapi"),
# Claude
("claude-sonnet-4-6-free", "opencode-zen"),
("claude-opus-4-7:free", "kilocode"),
("claude-sonnet-3-7-free", "opencode-zen"),
# Qwen
("qwen-3-coder-free", "opencode-zen"),
("qwen-4-coder:free", "opencode-zen"),
# Minimax
("minimax-m2.5-free", "opencode-zen"),
("minimax-m3-pro", "custom:newapi"),
# Mimo
("mimo-v2.5-free", "opencode-zen"),
("mimo-v3-pro", "custom:newapi"),
# GLM
("glm-5.1:free", "kilocode"),
("glm-6-pro", "custom:newapi"),
# Step
("step-1.5:free", "kilocode"),
("step-2-pro", "custom:newapi"),
# DeepSeek
("deepseek-v5-free", "custom:newapi"),
("deepseek-r3:free", "kilocode"),
# Kimi
("kimi-k2.6-free", "opencode-zen"),
("kimi-k3-pro:free", "kilocode")
]
for model_id, provider_id in test_models:
efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id=provider_id)
assert set(efforts) >= {"low", "medium", "high"}, (
f"Failed: {model_id} via {provider_id} should resolve reasoning support"
)
def test_unsupported_model_families_and_versions():
unsupported_models = [
# GPT: only 5+ supports reasoning_effort — gpt-4o/4.1/3.5 must be excluded
("gpt-4o", "opencode-zen"),
("gpt-4o-mini", "opencode-zen"),
("gpt-4.1", "kilocode"),
("gpt-4-turbo", "custom:newapi"),
("gpt-3.5-turbo", "opencode-zen"),
# Claude
("claude-sonnet-3.5", "opencode-zen"),
("claude-opus-3-5-free", "kilocode"),
# Qwen
("qwen-2.5-coder-free", "opencode-zen"),
("qwen-2-7b-instruct", "custom:newapi"),
]
for model_id, provider_id in unsupported_models:
efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id=provider_id)
assert efforts == [], (
f"Failed: {model_id} via {provider_id} should NOT resolve reasoning support"
)