fix(onboarding): restore provider profiles on switch - #239
Conversation
keyKQ
left a comment
There was a problem hiding this comment.
Solid fix for the core of #189 — switching cloud→local no longer disables the router, and I confirmed the secret hygiene holds: _provider_router_snapshot strips judge_api_key, literal api_key never reaches a profile, and "sk-literal-secret" does not appear anywhere in to_toml_dict(). Full suite is green on this branch (7310 passed, 27 skipped), plus ruff check and mypy (591 files).
My concerns are all about the shape of what gets persisted, not the fix itself. Three are worth resolving before merge; everything is reproduced below and inline.
1. Saved profiles freeze the shipped tier tables, so a model-ID bump stops reaching people.
to_toml_dict() (config.py:2035) deliberately drops agentos_router.tiers when they equal _router_tier_profile_defaults(tier_profile) — that elision is what lets a bump of the shipped model IDs propagate to existing installs. provider_profiles[*].agentos_router goes around it:
live router on deepseek -> 'tiers' key written?: False | tier_profile: deepseek
saved deepseek profile -> 'tiers' key written?: True | tier_profile: deepseek
frozen: {'c0': 'deepseek-v4-flash', 'c1': 'deepseek-v4-flash',
'c2': 'deepseek-v4-pro', 'c3': 'deepseek-v4-pro'}
Bump those IDs next release and anyone who has ever switched providers gets the old ones back the moment they switch home. (~3.8 KB of frozen tier tables per profile in config.toml, too.)
2. The snapshot is the whole AgentOSRouterConfig, but most of it isn't provider-scoped.
on ollama, safety_net_threshold = 0.3
operator retunes on deepseek to = 0.9
after switching back to ollama = 0.3 <- retune silently lost
Same for strategy, rollout_phase, auto_thinking, judge_short_circuit_*, kv_cache_anti_downgrade_enabled, complaint_upgrade_enabled, require_router_runtime. Only tier_profile / tiers / default_tier are genuinely per-provider.
3. restored_profile compares against a non-normalised old_provider — one-word fix, but it currently lets a mixed-case llm.provider skip reconcile entirely and leave the router pointing at another provider's models. Repro inline.
Rest is smaller: a phantom profile written for the never-configured default provider, an explicit model= being ignored by the restored router, a literal-api_key dead end, and a dead enabled = True line.
| class ProviderProfileConfig(BaseModel): | ||
| """Restorable non-secret LLM and router settings for one provider. | ||
|
|
||
| Literal API credentials deliberately remain outside this snapshot. An | ||
| ``api_key_env`` reference is safe to preserve; direct API keys continue to | ||
| use the active provider configuration and existing secret-handling paths. | ||
| """ | ||
|
|
||
| model: str | ||
| api_key_env: str = "" | ||
| base_url: str = "" | ||
| proxy: str = "" | ||
| max_tokens: int = 0 | ||
| thinking: str | None = None | ||
| provider_routing: dict[str, str] = Field(default_factory=dict) | ||
| agentos_router: AgentOSRouterConfig |
There was a problem hiding this comment.
Two structural issues with what this model persists.
(a) It freezes tier tables that to_toml_dict is careful not to freeze.
to_toml_dict at line 2035 pops agentos_router.tiers when they exactly match _router_tier_profile_defaults(tier_profile). That elision is load-bearing: it's how a bump of the shipped model IDs reaches installs that already have a config.toml. Nesting a full AgentOSRouterConfig inside provider_profiles bypasses it —
live router on deepseek -> 'tiers' key written?: False | tier_profile: deepseek
saved deepseek profile -> 'tiers' key written?: True | tier_profile: deepseek
frozen: {'c0': 'deepseek-v4-flash', 'c1': 'deepseek-v4-flash',
'c2': 'deepseek-v4-pro', 'c3': 'deepseek-v4-pro'}
So the next time the onboarding router models are bumped, anyone who has switched providers gets last release's IDs restored under them. Silently. Either run the same elision over each profile in to_toml_dict, or — cleaner — store tier_profile plus only operator-authored tier overrides, and re-derive the rest on restore.
(b) Most of AgentOSRouterConfig is a global preference, not a per-provider one.
on ollama, safety_net_threshold = 0.3
operator retunes on deepseek to = 0.9
after switching back to ollama = 0.3 <- retune silently lost
pilot.safety_net_threshold has nothing to do with which provider is active, and neither do strategy, rollout_phase, auto_thinking, judge_input_max_chars, judge_short_circuit_enabled, judge_timeout_seconds, require_router_runtime, kv_cache_anti_downgrade_enabled, complaint_upgrade_enabled, upgrade_to_c3_compaction_enabled. An operator who tunes any of them while on provider B loses the tuning the moment they switch to A. That's a worse failure than the bug being fixed, because it's invisible — no warning, no diff, the value just reverts.
The fields that genuinely need per-provider memory are tier_profile, tiers, default_tier, and the judge target (judge_model / judge_provider / judge_base_url). I'd narrow ProviderProfileConfig.agentos_router to those rather than snapshotting the whole model.
(c) minor, but it bites operators: model and agentos_router are required, so a hand-edited or half-written profile makes the gateway refuse to start:
{'model': 'x'} -> ValidationError: provider_profiles.ollama.agentos_router Field required
{'agentos_router': {}} -> ValidationError: provider_profiles.ollama.model Field required
This is machine-written state living in a file operators do edit by hand. Strict-required fields on recoverable cache-like state trade a startup failure for a lost preference — worth defaults plus tolerant parsing (drop an unparseable profile, warn, carry on).
| provider_routing=dict(config.llm.provider_routing), | ||
| agentos_router=_provider_router_snapshot(config.agentos_router), | ||
| ) | ||
| restored_profile = provider_profiles.get(provider_id) if provider_id != old_provider else None |
There was a problem hiding this comment.
bug — old_provider here is the raw value (str(config.llm.provider or ""), line 444), not the normalised one. Every other comparison in this function uses active_provider (line 375, strip + lower). GatewayConfig doesn't normalise llm.provider on load, so a config with provider = "OpenCap" (or a stray trailing space) is reachable:
cfg = GatewayConfig(llm={"provider": "OpenCap", "model": "glm-5.2",
"api_key_env": "OPENCAP_API_KEY"})
res = upsert_llm_provider(cfg, provider_id="opencap", model="glm-5.3-new")new llm.model : glm-5.3-new
router tier c1 : gpt-5.6-luna <- openrouter's default, on an opencap install
profiles : ['opencap']
"opencap" != "OpenCap" is True, so restored_profile picks up the snapshot taken from the same provider two lines above, which sends us down the restored_profile is not None branch and skips _reconcile_router_profile_for_provider entirely. tier_profile never gets set to opencap and the tiers stay on whatever was there before.
| restored_profile = provider_profiles.get(provider_id) if provider_id != old_provider else None | |
| restored_profile = provider_profiles.get(provider_id) if provider_id != active_provider else None |
| if restored_profile is not None: | ||
| new_cfg.agentos_router = restored_profile.agentos_router.model_copy(deep=True) | ||
| reconcile_warnings: list[str] = [] | ||
| else: | ||
| reconcile_warnings = _reconcile_router_profile_for_provider( | ||
| new_cfg, | ||
| provider_id, | ||
| model=model_clean, | ||
| old_provider=old_provider, | ||
| old_model=old_model, | ||
| ) |
There was a problem hiding this comment.
Restoring a profile skips reconcile wholesale, which means an explicitly-passed model= is honoured for llm.model and ignored by the router:
a = upsert_llm_provider(GatewayConfig(), provider_id="ollama", model="qwen3.5:9b").config
b = upsert_llm_provider(a, provider_id="deepseek", model="deepseek-chat",
api_key_env="DEEPSEEK_API_KEY").config
back = upsert_llm_provider(b, provider_id="ollama", model="llama4:70b").configllm.model : llama4:70b
router tier c1 : {'provider': 'ollama', 'model': 'qwen3.5:9b', ...}
router tier c3 : {'provider': 'ollama', 'model': 'qwen3.5:9b', ...}
For a local provider that's a functional break, not just cosmetic: the whole point of the is_local_provider branch below is that local providers build no per-tier client, so every tier has to be pinned to llm.model. The runtime degrade guard won't rescue it either — it pins on provider mismatch, and the provider matches here. The router will keep asking ollama for a model the operator just replaced.
And from the setup UI this is exactly the flow you'd expect to hit: pick ollama again, pick a different model, submit.
Suggest treating an explicit model as authoritative — restore the profile, then still run the reconcile (or at least the local-pin) when model_clean differs from restored_profile.model. Note the restore path also hard-codes reconcile_warnings = [], so any warning the reconcile would have produced is dropped on the floor.
| provider_profiles = { | ||
| str(provider).strip().lower(): profile.model_copy(deep=True) | ||
| for provider, profile in config.provider_profiles.items() | ||
| if str(provider).strip() | ||
| } | ||
| if old_provider and old_model: | ||
| provider_profiles[old_provider.strip().lower()] = ProviderProfileConfig( | ||
| model=old_model.strip(), | ||
| api_key_env=str(config.llm.api_key_env or "").strip(), | ||
| base_url=str(config.llm.base_url or "").strip(), | ||
| proxy=str(config.llm.proxy or "").strip(), | ||
| max_tokens=config.llm.max_tokens, | ||
| thinking=config.llm.thinking, | ||
| provider_routing=dict(config.llm.provider_routing), | ||
| agentos_router=_provider_router_snapshot(config.agentos_router), | ||
| ) |
There was a problem hiding this comment.
A profile gets written for the default provider even when nobody ever configured it. GatewayConfig() ships provider="openrouter", model="openai/gpt-5.6-luna", so the very first upsert_llm_provider on a fresh install snapshots that as if it were a real setup:
upsert_llm_provider(GatewayConfig(), provider_id="ollama", model="qwen3.5:9b").config
# provider_profiles: ['openrouter'] -> model 'openai/gpt-5.6-luna'Right now it's invisible, because that value happens to equal _router_default_model_for_provider("openrouter", None). It stops being invisible the moment the shipped default moves: the operator later picks openrouter, gets the install-time model restored instead of the current recommended one, and _router_default_model_for_provider never runs. Combined with the frozen-tiers issue on ProviderProfileConfig, a fresh install ends up permanently pinned to whatever shipped the day it was installed.
Worth gating the snapshot on "this provider was actually configured" rather than "llm.provider and llm.model are non-empty" — they're non-empty by default. The field defaults are a usable signal, or config.config_path / the presence of credentials.
Also: nothing ever prunes this dict, so it grows one entry per provider ever touched and no reset path clears it.
| router_payload = cfg.agentos_router.model_dump(mode="python") | ||
| router_payload["tier_profile"] = None | ||
| router_payload = cfg.agentos_router.model_dump(mode="python") | ||
| router_payload["enabled"] = True |
There was a problem hiding this comment.
This line is dead — the function already returned at line 177:
if not getattr(cfg.agentos_router, "enabled", True):
return []so enabled is always True by the time we get here. Verified: an operator who set mode="disabled" and then switches to a local provider keeps enabled = False, which is the correct behaviour and is not coming from this line.
That matters because the PR summary says this fixes switches "so the router stays enabled" — the actual fix is dropping and not current_profile from the is_local_provider condition, which is what stops a cloud→local switch from falling through to the enabled = False branch at line 211. Worth deleting the line so the next reader doesn't conclude this force-enables (and go looking for why disabled-mode survives).
| if tiers in ( | ||
| _openrouter_tiers(), | ||
| _bankr_tiers(), | ||
| _opencap_tiers(), | ||
| *(_router_tier_profile_defaults(profile) for profile in ROUTER_TIER_PROFILE_IDS), | ||
| ): | ||
| return True |
There was a problem hiding this comment.
nit: this rebuilds every shipped tier profile on each call, inside a tuple that's only used for an in test. _tiers_are_machine_written_defaults runs on every provider switch, so it's not hot, but the generator inside the tuple literal also makes the membership check quietly O(profiles) dict comparisons.
A module-level constant (or functools.cached helper) reads better and matches how _openrouter_tiers() / _bankr_tiers() are used elsewhere.
Also, now that the shipped tier profiles are covered generically, _openrouter_tiers(), _bankr_tiers() and _opencap_tiers() are likely redundant here if those three ids are in ROUTER_TIER_PROFILE_IDS — worth checking and collapsing to one source of truth.
| #### Provider-switch profiles | ||
|
|
||
| When onboarding switches to another LLM provider, AgentOS saves a profile for | ||
| the provider being left and restores it when you return. A profile contains the | ||
| active model, router mode and settings (including text/image tiers, Smart | ||
| Routing judge model and endpoint, and Pilot settings), plus non-secret | ||
| connection settings such as `base_url`, `proxy`, `api_key_env`, and provider | ||
| routing preferences. Profiles are persisted in `config.toml`. | ||
|
|
||
| Literal `api_key` values and local `judge_api_key` values are not copied into a | ||
| profile. Prefer environment-variable references for credentials you need to |
There was a problem hiding this comment.
The credential caveat is documented, which I appreciate — but the actual failure is harder than "prefer env references" implies. A literal api_key makes the return trip fail, it doesn't just lose the key:
a = upsert_llm_provider(GatewayConfig(), provider_id="deepseek",
model="deepseek-chat", api_key="sk-literal-secret").config
b = upsert_llm_provider(a, provider_id="ollama", model="qwen3.5:9b").config
upsert_llm_provider(b, provider_id="deepseek")
# ValueError: provider 'deepseek' requires an api_keyFrom the setup UI that reads as a dead end: the profile restores model, base_url, proxy and routing, and then the mutation rejects the whole thing. Two things would help — say plainly in the docs that returning to a provider configured with a literal key requires re-entering it, and raise something the UI can act on ("provider 'deepseek' has a saved profile but no stored credential; re-enter the API key") rather than the generic message, which reads as "you never configured this".
Worth also stating here what the profile does not preserve on the router side, since it silently overwrites global router preferences on restore — see the ProviderProfileConfig comment.
|
Merged — thanks @thanhtan1105. The cloud→local fix is right, and dropping I couldn't push to this branch (
Full details and repros are in #240. Your commit is preserved in the history — this is a follow-up, not a replacement. |
Summary
llm_judge, disabled-mode, text-tier, and image-tier configuration across switchesValidation
uv run pytest tests/test_onboarding/test_mutations.py tests/test_provider_bankr.py -quv run ruff check src testsuv run mypy src/agentos --show-error-codesnpm --prefix frontend run checkuv build --wheelFixes #189
Refs #188