fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent - #35056
Conversation
b5d5be5 to
f0d1e1d
Compare
…ovider Pure, offline, import-light leaf module: ResolvedProvider value object, canonicalize_provider (single alias table), normalize_base_url (one /v1 normalizer), and the path-scoped api_mode detector / select_api_mode ladder. Foundation for the unified resolver.
…-closed; demote ambient-key fallback (NousResearch#5358 NousResearch#4600 NousResearch#12146) One staged resolver returns the ResolvedProvider object with provenance and memoization (static sources only; pool/OAuth/process bypass). Routes both alias tables through canonicalize_provider (NousResearch#12146). Moves the ambient OPENAI/OPENROUTER short-circuit below the per-provider scan so a configured vendor key wins (NousResearch#5358). Applies the /v1 normalizer once, gated to custom (NousResearch#4600). Fails closed with AuthError(custom_provider_unresolved) when custom intent has no resolvable endpoint and no usable key (preserving the key-present NousResearch#14676 fallback).
…NousResearch#8919) Folds model.api_base into model.base_url at load (permanent accepted alias) and warns once on unknown model.* keys, so config.yaml is the single source of truth for the endpoint and a stale OPENAI_BASE_URL is no longer silently consulted.
…stence (NousResearch#3263); picker aliases via canonicalize_provider (NousResearch#12146) /model classifies probe failures and warns-and-confirms instead of swallowing them, pre-fills custom-endpoint fields, and persists {provider,base_url,api_key} atomically. The model picker's alias table is overlaid on canonicalize_provider so it can no longer drift from runtime resolution (NousResearch#12146).
…read ACP explicit_base_url (NousResearch#13489 NousResearch#5358) The agent carries the resolved ResolvedProvider (with provenance) instead of re-deriving credentials per request. The auxiliary client delegates wholesale to the unified resolver (dropping its duplicate alias table and OPENAI_BASE_URL routing) and reaches named custom providers whose name canonicalizes to custom. ACP sessions thread explicit_base_url + target_model into resolution so a custom provider's base_url is honored and a built-in re-derives its own (NousResearch#13489).
f0d1e1d to
d7da060
Compare
|
Thanks for this, @banditburai — genuinely impressive scope and the writeup (per-issue root-cause table, provenance-tracked Closing it, though, because the drift it targets has since been fixed piecemeal on current #4600 — #5358 — config #8919 — custom provider config ignored at runtime — Already closed. #12146 — falls to OpenRouter despite #13489 — ACP #3263 — model-switcher persistence + custom-endpoint prefill — Also: the "3 divergent api_mode copies" are already consolidated on Why the two behavior changes are regressions, not fixes:
Mechanically the branch is also ~3852 commits behind If you hit a specific resolution scenario that current |
Parallel provider/credential resolution paths (CLI, gateway, ACP, auxiliary) had drifted, and OpenRouter was the terminal fallback in every one — so a configured custom/local provider was silently dropped to OpenRouter or had credentials mixed from another provider. This collapses them into one offline resolver → one
ResolvedProvidervalue object → one alias table → one/v1normalizer → one path-scoped api_mode detector, fail-closed on declared custom intent, with zero network during resolution. Closes 6 issues, supersedes 1 PR.Issues fixed
main.py(switch + classified probe),config.py(model surface)test_probe_classification,test_model_flow_custom_probe,test_config_model_surface/v1on chat completionsprovider_resolution.normalize_base_urlviaruntime_provider._finalize_runtimetest_provider_resolution,test_runtime_provider_resolutionmodel.provider, fall back to OpenRouter whenOPENROUTER_API_KEYsetauth.resolve_provider(ambient short-circuit demoted below per-provider scan)test_provider_resolution_cross_pathapi_basedropped;OPENAI_BASE_URLsilently consultedconfig._normalize_model_api_base,runtime_provider,auxiliary_client(warn)test_config_model_surface,test_clear_stale_base_url,test_auxiliary_custom_runtime_delegationprovider=custom(alias-table drift)provider_resolution.canonicalize_provider(single table),auth.py,models.pypickertest_provider_resolution(alias parity),test_auxiliary_named_custom_providersprovider=customresolves the wrong credentialacp_adapter/session.pythreadsexplicit_base_urlinto resolutiontest_session_explicit_base_url⚠ Behavior changes
Most users unaffected — these matter only if you relied on env-based routing or incidental keys.
OPENAI_BASE_URLno longer routes — ignored for routing, warned when set. [potentially breaking] → move it tomodel.base_url(ormodel.api_base) in config.yaml.OPENAI_API_KEY/OPENROUTER_API_KEYshort-circuit moved below the per-provider scan. [breaking for incidental-key setups] a configured vendor key (e.g.DEEPSEEK_API_KEY) now wins; force OpenRouter withprovider: openrouter.openai-apiis skipped in auto-detect so a bareOPENAI_API_KEYstill falls to the OpenRouter last-resort. [behavior change] → setprovider: openai-apiexplicitly to use direct OpenAI.AuthError(code="custom_provider_unresolved")instead of silently hittingopenrouter.aiwith an empty key. [breaking — now errors] → supply a base_url or key. The established key-present → OpenRouter fallback is unchanged./v1(chat); anthropic endpoints never gain/v1. [low risk]Back-compat
ResolvedProvidersupports dict-style reads (get/[]/in) andas_dict()stays byte-compatible with the legacy runtime dict — existing consumers untouched.model.api_baseis a permanent accepted alias formodel.base_url(folded at load).provider:values (openai-api, openrouter, anthropic, vendors) remain reachable.Security
Preserves host-gated key selection from security advisory GHSA-76xc-57q6-vm5m — env keys only sent to matching hosts; lookalike/path-spoof hosts rejected. The new path-scoped api_mode detector additionally resists query/fragment spoofing the old full-URL detector allowed.
Design
hermes_cli/provider_resolution.py— pure, offline, import-light; the single source of truth.ResolvedProviderfrozen value object resolved once per lifecycle and carried on the agent (provenance:base_url_source/key_source)._parse_api_mode/VALID_API_MODESconsolidated into the leaf (eliminates the 3 previously-divergent copies; locked by an identity test).Resolves upstream:
Closes #3263
Closes #4600
Closes #5358
Closes #8919
Closes #12146
Closes #13489
Supersedes #17072 — strict superset of its LM-Studio runtime slice, without its ~20k LOC of unrelated divergence and with full test coverage.