Merge branch 'master' into fix/7521-aux-picker-endpoint-error-groups
This commit is contained in:
5 files changed
+432
-2
No files matched your search
@@ -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
@@ -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
@@ -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&¤tProvider){
|
||||
const configuredOpt=document.createElement('option');
|
||||
configuredOpt.value=currentProvider;configuredOpt.textContent=currentProvider+' (configured)';
|
||||
configuredOpt.selected=true;
|
||||
sel.appendChild(configuredOpt);
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAuxModelOptions(sel,provider,providers,currentModel){
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user