Skip to content

fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent#35056

Open
banditburai wants to merge 5 commits into
NousResearch:mainfrom
banditburai:fix/custom-provider-resolution
Open

fix(provider): unify provider/credential resolution — config.yaml as single source, fail-closed on custom intent#35056
banditburai wants to merge 5 commits into
NousResearch:mainfrom
banditburai:fix/custom-provider-resolution

Conversation

@banditburai

Copy link
Copy Markdown
Contributor

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 ResolvedProvider value object → one alias table → one /v1 normalizer → 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

Issue Symptom / root cause Fix (file:area) Proof
#3263 Model-switcher loses persisted config; no pre-fill for custom endpoints; probe errors swallowed main.py (switch + classified probe), config.py (model surface) test_probe_classification, test_model_flow_custom_probe, test_config_model_surface
#4600 Custom base_url drops /v1 on chat completions provider_resolution.normalize_base_url via runtime_provider._finalize_runtime test_provider_resolution, test_runtime_provider_resolution
#5358 Gateway + CLI ignore model.provider, fall back to OpenRouter when OPENROUTER_API_KEY set auth.resolve_provider (ambient short-circuit demoted below per-provider scan) test_provider_resolution_cross_path
#8919 Custom provider config ignored at runtime; api_base dropped; OPENAI_BASE_URL silently consulted config._normalize_model_api_base, runtime_provider, auxiliary_client (warn) test_config_model_surface, test_clear_stale_base_url, test_auxiliary_custom_runtime_delegation
#12146 Runs fall back to OpenRouter despite provider=custom (alias-table drift) provider_resolution.canonicalize_provider (single table), auth.py, models.py picker test_provider_resolution (alias parity), test_auxiliary_named_custom_providers
#13489 ACP provider=custom resolves the wrong credential acp_adapter/session.py threads explicit_base_url into resolution test_session_explicit_base_url

⚠ Behavior changes

Most users unaffected — these matter only if you relied on env-based routing or incidental keys.

  • OPENAI_BASE_URL no longer routes — ignored for routing, warned when set. [potentially breaking] → move it to model.base_url (or model.api_base) in config.yaml.
  • Ambient OPENAI_API_KEY / OPENROUTER_API_KEY short-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 with provider: openrouter.
  • openai-api is skipped in auto-detect so a bare OPENAI_API_KEY still falls to the OpenRouter last-resort. [behavior change] → set provider: openai-api explicitly to use direct OpenAI.
  • Declared custom provider with no resolvable base_url AND no usable key now raises AuthError(code="custom_provider_unresolved") instead of silently hitting openrouter.ai with an empty key. [breaking — now errors] → supply a base_url or key. The established key-present → OpenRouter fallback is unchanged.
  • Bare custom/local base_url now gains exactly one /v1 (chat); anthropic endpoints never gain /v1. [low risk]

Back-compat

  • ResolvedProvider supports dict-style reads (get / [] / in) and as_dict() stays byte-compatible with the legacy runtime dict — existing consumers untouched.
  • model.api_base is a permanent accepted alias for model.base_url (folded at load).
  • All explicit 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

  • New leaf module hermes_cli/provider_resolution.py — pure, offline, import-light; the single source of truth.
  • ResolvedProvider frozen value object resolved once per lifecycle and carried on the agent (provenance: base_url_source / key_source).
  • Resolution memoized on args + config.yaml mtime/size + env fingerprint; only static sources cached — pool / OAuth / portal / process bypass so expiring credentials stay live.
  • Fail-closed scoped exactly to "custom intent + no base_url + no usable key" (preserves the key-present fallback).
  • api_mode detector / _parse_api_mode / VALID_API_MODES consolidated 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.

@banditburai banditburai force-pushed the fix/custom-provider-resolution branch from b5d5be5 to f0d1e1d Compare May 30, 2026 00:46
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cli CLI entry point, hermes_cli/, setup wizard comp/agent Core agent loop, run_agent.py, prompt builder comp/acp Agent Communication Protocol adapter area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles labels May 30, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles comp/acp Agent Communication Protocol adapter comp/agent Core agent loop, run_agent.py, prompt builder comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

2 participants