Merge branch 'master' into fix/7521-aux-picker-endpoint-error-groups

This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-09-10 22:07:56 -07:00
commit c5315bda6a
5 files changed
+432 -2

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
### Fixed
- **The OpenRouter setup wizard now offers Z.AI models under OpenRouter's own namespace instead of IDs that 404.** `_FALLBACK_MODELS` is authored for the *direct* provider endpoints, so its nine Z.AI entries carry the provider-native `zai/` prefix — and the OpenRouter onboarding setup reused that list verbatim. OpenRouter serves the same models as `z-ai/…`, so anyone who picked a Z.AI model during OpenRouter onboarding was handed an ID the provider does not recognise and their first message failed. The IDs are now translated at the OpenRouter projection boundary only; the fallback catalog and the direct Z.AI setup keep their own namespace. Thanks @webtecnica. (#7518, closes #7514)
- **Recovered "thinking" cards no longer multiply on every turn after context compaction.** When a compacted history left the display transcript longer than the model-facing result, a recovered reasoning-only row could be re-inserted *after* the new reply each turn — doubling the recovered block geometrically (1, 2, 4, 8…). Settlement now fixes the active-turn boundary before restoring reasoning metadata and only restores a historical reasoning row directly in front of its own API-safe successor (requiring a unique stable-id or exact-identity match), failing closed to the current-turn boundary when turn ownership can't be proven. The synchronous chat path mirrors the streaming path, so live and reloaded transcripts stay consistent. Thanks @carlotestor. (#7443, refs #7388)
- **Command-backed provider credentials no longer break the agent cache or discard conversation state between turns.** When a provider used `key_cmd`, the runtime supplied a lazy callable token source, but the cache signature tried to call `.encode()` on it; every turn therefore missed the cache and rebuilt the agent. Callable credentials now use a stable, secret-free cache marker while the existing runtime refresh path still receives the callable itself, so token resolution stays lazy and agent context is reused. Thanks @ArushKhasru. (#7398, closes #7396)
+30 -1
View File
@@ -35,6 +35,34 @@ from api.workspace import get_last_workspace, load_workspaces
logger = logging.getLogger(__name__)
# ── OpenRouter namespace translation (#7514) ────────────────────────────────
#
# ``_FALLBACK_MODELS`` (api/config.py) is authored for the *direct* provider
# endpoints, so its Z.AI entries carry the provider's own ``zai/`` prefix.
# OpenRouter serves the same models under its canonical ``z-ai/`` slug, so
# projecting that list into the OpenRouter setup verbatim handed the wizard
# ids that 404 on openrouter.ai (``zai/glm-5.3`` instead of
# ``z-ai/glm-5.3``). Translate at this projection boundary only — the
# fallback catalog and the direct ``zai`` setup keep their own namespace.
#
# Every other namespace in the fallback list is already OpenRouter-canonical:
# openai/, anthropic/, google/, deepseek/, qwen/, x-ai/, mistralai/, minimax/,
# openrouter/, tencent/, nvidia/, arcee-ai/ — hence a single mapping entry.
_OPENROUTER_NAMESPACE_MAP = {"zai/": "z-ai/"}
def _to_openrouter_namespace(model_id: str) -> str:
"""Return *model_id* with direct-provider prefixes mapped to OpenRouter's.
Ids whose namespace already matches OpenRouter (or that carry no
namespace) are returned unchanged.
"""
for direct_prefix, openrouter_prefix in _OPENROUTER_NAMESPACE_MAP.items():
if model_id.startswith(direct_prefix):
return openrouter_prefix + model_id[len(direct_prefix):]
return model_id
_SUPPORTED_PROVIDER_SETUPS = {
# ── Easy start ──────────────────────────────────────────────────────
"openrouter": {
@@ -43,7 +71,8 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_model": "anthropic/claude-sonnet-4.6",
"requires_base_url": False,
"models": [
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
for model in _FALLBACK_MODELS
],
"category": "easy_start",
"quick": True,
+12 -1
View File
@@ -12315,12 +12315,23 @@ function _buildAuxProviderOptions(sel,providers,currentProvider){
autoOpt.value='auto';autoOpt.textContent='auto ('+t('settings_aux_provider_auto')+')';
if(currentProvider==='auto'||!currentProvider) autoOpt.selected=true;
sel.appendChild(autoOpt);
let matched=currentProvider==='auto'||!currentProvider;
for(const p of providers){
const opt=document.createElement('option');
opt.value=p.slug;opt.textContent=p.name;
if(p.slug===currentProvider) opt.selected=true;
if(p.slug===currentProvider){opt.selected=true;matched=true;}
sel.appendChild(opt);
}
// The configured provider can be absent from the /api/models catalog (e.g. its
// group exposes no models). Keep it selectable: with no matching option the
// select falls back to its first entry ('auto') and the next Apply would
// persist that, silently discarding the configured value.
if(!matched&&currentProvider){
const configuredOpt=document.createElement('option');
configuredOpt.value=currentProvider;configuredOpt.textContent=currentProvider+' (configured)';
configuredOpt.selected=true;
sel.appendChild(configuredOpt);
}
}
function _buildAuxModelOptions(sel,provider,providers,currentModel){
+274
View File
@@ -312,6 +312,280 @@ console.log(JSON.stringify({
"Missing _buildAuxModelOptions() for model dropdown rebuild"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_configured_aux_provider_absent_from_catalog_is_preserved(self):
"""#7486: a configured provider missing from /api/models must survive Apply.
The aux provider <select> is populated with 'auto' plus the /api/models
catalog. When the configured provider is absent from that catalog (e.g.
its group is filtered out because it exposes no models), no option
matched, so the select fell back to its first entry ('auto') and the
next Apply persisted 'auto' — silently discarding the configured value.
"""
script = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[1], 'utf8');
function extract(name){
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 0;
while(i < src.length){
const ch = src[i];
if(ch === '{') depth += 1;
else if(ch === '}') {
depth -= 1;
if(depth === 0){
break;
}
}
i += 1;
}
if(depth !== 0) throw new Error(name + ' parse failed');
return src.slice(start, i + 1);
}
global.t = (key) => key;
// Minimal <select>/<option> model. The value getter mirrors real browser
// behavior: with no option explicitly selected a single-select reads back its
// first option, which is what made the downgrade silent.
function makeSelect(){
const sel = { options: [] };
Object.defineProperty(sel, 'innerHTML', {
get(){ return ''; },
set(v){ if(v === '') sel.options = []; },
});
sel.appendChild = (node) => { sel.options.push(node); return node; };
sel.insertBefore = (node, ref) => {
const idx = sel.options.indexOf(ref);
if(idx < 0) sel.options.push(node); else sel.options.splice(idx, 0, node);
return node;
};
Object.defineProperty(sel, 'value', {
get(){
const picked = sel.options.find((opt) => opt.selected);
if(picked) return picked.value;
return sel.options.length ? sel.options[0].value : '';
},
set(v){ sel.options.forEach((opt) => { opt.selected = opt.value === v; }); },
});
return sel;
}
global.document = {
createElement(tag){
if(String(tag).toLowerCase() === 'option'){
return { tagName: 'OPTION', value: '', textContent: '', selected: false };
}
return { tagName: String(tag).toUpperCase(), value: '', textContent: '' };
},
};
eval(extract('_buildAuxProviderOptions'));
const catalog = [
{slug: 'openai', name: 'OpenAI'},
{slug: 'anthropic', name: 'Anthropic'},
];
function build(providers, currentProvider){
const sel = makeSelect();
_buildAuxProviderOptions(sel, providers, currentProvider);
return {
values: sel.options.map((opt) => opt.value),
labels: sel.options.map((opt) => opt.textContent),
selected: sel.options.filter((opt) => opt.selected).map((opt) => opt.value),
value: sel.value,
};
}
console.log(JSON.stringify({
absent: build(catalog, 'custom-router'),
absentEmptyCatalog: build([], 'custom-router'),
present: build(catalog, 'anthropic'),
auto: build(catalog, 'auto'),
empty: build(catalog, ''),
}));
"""
proc = subprocess.run(
[NODE, "-e", script, str(PANELS_JS_PATH)],
capture_output=True,
text=True,
timeout=20,
)
assert proc.returncode == 0, f"node probe failed:\n{proc.stderr}"
result = json.loads(proc.stdout.strip().splitlines()[-1])
absent = result["absent"]
assert "custom-router" in absent["values"], (
"configured provider absent from the catalog must keep a selectable option"
)
assert absent["selected"] == ["custom-router"], (
f"configured option must be selected, got {absent['selected']}"
)
assert absent["value"] == "custom-router", (
"Apply must read back the configured provider, not the fallback first option"
)
assert "custom-router" in absent["labels"][-1]
assert result["absentEmptyCatalog"]["value"] == "custom-router"
assert result["present"]["value"] == "anthropic"
assert result["present"]["values"] == ["auto", "openai", "anthropic"]
assert result["present"]["selected"] == ["anthropic"]
assert result["auto"]["value"] == "auto"
assert result["empty"]["value"] == "auto"
assert result["empty"]["values"] == ["auto", "openai", "anthropic"]
def test_apply_does_not_downgrade_untouched_aux_row(self):
"""#7486: applying one row must not re-save another row's provider as 'auto'.
End-to-end over the Apply path: two task rows are built with the real
option builder, the user edits only the second row's model, and the
first row keeps a provider that is missing from the catalog. The first
row must not produce any POST at all — the destructive symptom was it
being re-saved with provider 'auto'.
"""
script = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[1], 'utf8');
function extract(name){
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 0;
while(i < src.length){
const ch = src[i];
if(ch === '{') depth += 1;
else if(ch === '}') {
depth -= 1;
if(depth === 0){
break;
}
}
i += 1;
}
if(depth !== 0) throw new Error(name + ' parse failed');
return src.slice(start, i + 1);
}
// Minimal <select>/<option> shim. The value getter mirrors the browser: a
// single select with nothing explicitly selected reads back its first option,
// which is why the downgrade was silent rather than an explicit error.
function makeOption(value, label){
return {
tagName: 'OPTION',
value: String(value),
textContent: label === undefined ? String(value) : label,
selected: false,
};
}
function makeSelect(){
const sel = {options: []};
Object.defineProperty(sel, 'innerHTML', {
get(){ return ''; },
set(v){ if(v === '') sel.options = []; },
});
sel.appendChild = (node) => { sel.options.push(node); return node; };
sel.insertBefore = (node, ref) => {
const idx = sel.options.indexOf(ref);
if(idx < 0) sel.options.push(node); else sel.options.splice(idx, 0, node);
return node;
};
Object.defineProperty(sel, 'value', {
get(){
const picked = sel.options.find(o => o.selected);
if(picked) return picked.value;
return sel.options.length ? sel.options[0].value : '';
},
set(v){ sel.options.forEach(o => { o.selected = (o.value === String(v)); }); },
});
return sel;
}
function setSelect(sel, values, chosen){
sel.innerHTML = '';
for(const v of values) sel.appendChild(makeOption(v));
sel.value = chosen;
return sel;
}
global.document = {
createElement(tag){
return String(tag).toLowerCase() === 'option'
? makeOption('', '')
: {tagName: String(tag).toUpperCase(), style: {}, appendChild(){}};
},
};
const els = {};
global.$ = (id) => els[id] || null;
global.t = (key) => key;
global.showToast = () => {};
global._loadAuxiliaryModels = () => {};
eval(extract('_buildAuxProviderOptions'));
const catalog = [{slug: 'openai', name: 'OpenAI'}];
// Persisted config: 'simple' is configured with a provider that is no longer
// present in /api/models; 'complex' uses a catalog provider.
global._auxTasks = [{task: 'simple'}, {task: 'complex'}];
global._auxOriginalConfig = {
simple: {provider: 'custom-router', model: 'gpt-x'},
complex: {provider: 'openai', model: 'gpt-5'},
};
els['aux-prov-simple'] = makeSelect();
els['aux-model-simple'] = setSelect(makeSelect(), ['gpt-x', '__custom__'], 'gpt-x');
els['aux-prov-complex'] = makeSelect();
els['aux-model-complex'] = setSelect(makeSelect(), ['gpt-5', 'gpt-5-mini', '__custom__'], 'gpt-5');
// The settings panel rebuilds every row's selects on load
_buildAuxProviderOptions(els['aux-prov-simple'], catalog, 'custom-router');
_buildAuxProviderOptions(els['aux-prov-complex'], catalog, 'openai');
// The user edits only the 'complex' row, then clicks Apply
els['aux-model-complex'].value = 'gpt-5-mini';
const posted = [];
global.api = async (path, opts) => { posted.push(JSON.parse(opts.body)); return {}; };
// _applyAuxModels is async: extract() slices from the `function` keyword, so
// re-add the modifier before evaluating the declaration.
eval('async ' + extract('_applyAuxModels'));
_applyAuxModels().then(() => {
console.log(JSON.stringify({posted, untouchedProvider: els['aux-prov-simple'].value}));
});
"""
proc = subprocess.run(
[NODE, "-e", script, str(PANELS_JS_PATH)],
capture_output=True,
text=True,
timeout=20,
)
assert proc.returncode == 0, f"node probe failed:\n{proc.stderr}"
result = json.loads(proc.stdout.strip().splitlines()[-1])
assert result["untouchedProvider"] == "custom-router", (
"the untouched row must still read back its configured provider"
)
assert result["posted"] == [
{
"scope": "auxiliary",
"task": "complex",
"provider": "openai",
"model": "gpt-5-mini",
}
], f"only the edited row may be saved, got {result['posted']}"
assert not any(p.get("provider") == "auto" for p in result["posted"]), (
"an untouched row must never be persisted as 'auto' (data loss)"
)
def test_custom_model_prompt(self):
"""Selecting 'Custom model…' must prompt for model ID."""
assert "__custom__" in PANELS_JS, (
@@ -0,0 +1,114 @@
"""Regression tests for #7514: OpenRouter onboarding must serve Z.AI models
under OpenRouter's canonical ``z-ai/`` namespace, not the direct provider's
``zai/`` namespace.
``_FALLBACK_MODELS`` (api/config.py) is authored for the *direct* provider
endpoints, and its Z.AI entries use that provider's own ``zai/`` prefix.
The OpenRouter onboarding setup reused the list verbatim, so the wizard
offered ids such as ``zai/glm-5.3`` — which 404 on openrouter.ai, where the
canonical slug is ``z-ai/glm-5.3``.
These tests pin the boundary:
1. The ``openrouter`` setup translates ``zai/`` → ``z-ai/`` and never
serves a ``zai/`` id.
2. The translation is namespace-only: same models, same order, same labels.
3. The direct ``zai`` setup keeps ``zai/`` (it talks to the Z.AI endpoint,
not to OpenRouter).
4. ``_FALLBACK_MODELS`` itself is never rewritten — other consumers still
see the direct-provider namespace.
"""
from __future__ import annotations
import api.config as config
import api.onboarding as onboarding
def _models(provider_id: str) -> list[dict]:
return onboarding._SUPPORTED_PROVIDER_SETUPS[provider_id]["models"]
def _ids(provider_id: str) -> list[str]:
return [model["id"] for model in _models(provider_id)]
def _expected_openrouter_id(model_id: str) -> str:
"""Independent reference implementation of the namespace translation."""
if model_id.startswith("zai/"):
return "z-ai/" + model_id[len("zai/") :]
return model_id
def test_openrouter_setup_never_serves_direct_zai_namespace():
served = [model_id for model_id in _ids("openrouter") if model_id.startswith("zai/")]
assert served == [], (
"OpenRouter onboarding served ids from the direct-provider namespace; "
f"these 404 on the OpenRouter API: {served}"
)
def test_openrouter_setup_exposes_z_ai_models():
ids = _ids("openrouter")
assert "z-ai/glm-5.3" in ids
assert "z-ai/glm-5.3-flash" in ids
expected = {
_expected_openrouter_id(model["id"])
for model in config._FALLBACK_MODELS
if model["id"].startswith("zai/")
}
assert expected, "expected the fallback catalog to carry Z.AI models"
assert expected.issubset(set(ids)), (
"every Z.AI model from the fallback catalog must be offered under the "
f"OpenRouter namespace; missing: {sorted(expected - set(ids))}"
)
def test_openrouter_projection_is_namespace_only():
"""The OpenRouter list stays a 1:1, order-preserving projection."""
fallback = config._FALLBACK_MODELS
openrouter = _models("openrouter")
assert len(openrouter) == len(fallback)
for source, projected in zip(fallback, openrouter, strict=True):
assert projected["label"] == source["label"]
assert projected["id"] == _expected_openrouter_id(source["id"])
def test_direct_zai_setup_keeps_direct_provider_ids():
"""The direct Z.AI setup talks to the Z.AI endpoint (base_url is set on the
setup), so it keeps its own bare provider ids and must not inherit the
OpenRouter translation."""
ids = _ids("zai")
assert ids == [model["id"] for model in config._PROVIDER_MODELS["zai"]]
assert "glm-5.3" in ids
assert [model_id for model_id in ids if model_id.startswith("z-ai/")] == []
def test_fallback_models_are_untouched():
ids = [model["id"] for model in config._FALLBACK_MODELS]
assert "zai/glm-5.3" in ids
assert [model_id for model_id in ids if model_id.startswith("z-ai/")] == []
def test_namespace_helper_is_a_noop_for_other_namespaces_and_bare_ids():
for model_id in (
"anthropic/claude-sonnet-4.6",
"x-ai/grok-4.20",
"minimax/MiniMax-M3",
"openrouter/elephant-alpha",
"gpt-4o",
):
assert onboarding._to_openrouter_namespace(model_id) == model_id
assert onboarding._to_openrouter_namespace("zai/glm-5.3") == "z-ai/glm-5.3"
def test_setup_catalog_serves_the_translated_namespace():
"""The payload the wizard consumes carries the OpenRouter namespace."""
catalog = onboarding._build_setup_catalog({})
entries = {provider["id"]: provider for provider in catalog["providers"]}
ids = [model["id"] for model in entries["openrouter"]["models"]]
assert [model_id for model_id in ids if model_id.startswith("zai/")] == []
assert "z-ai/glm-5.3" in ids