fix(config): restore custom providers after v11→v12 migration

The v11→v12 migration converts custom_providers (list) into providers
(dict), then deletes the list. But all runtime resolvers read from
custom_providers — after migration, named custom endpoints silently stop
resolving and fallback chains fail with AuthError.

Add get_compatible_custom_providers() that reads from both config schemas
(legacy custom_providers list + v12+ providers dict), normalizes entries,
deduplicates, and returns a unified list. Update ALL consumers:

- hermes_cli/runtime_provider.py: _get_named_custom_provider() + key_env
- hermes_cli/auth_commands.py: credential pool provider names
- hermes_cli/main.py: model picker + _model_flow_named_custom()
- agent/auxiliary_client.py: key_env + custom_entry model fallback
- agent/credential_pool.py: _iter_custom_providers()
- cli.py + gateway/run.py: /model switch custom_providers passthrough
- run_agent.py + gateway/run.py: per-model context_length lookup

Also: use config.pop() instead of del for safer migration, fix stale
_config_version assertions in tests, add pool mock to codex test.

Co-authored-by: 墨綠BG <s5460703@gmail.com>
Closes #8776, salvaged from PR #8814
This commit is contained in:
墨綠BG 2026-04-13 05:26:32 -07:00 committed by Teknium
parent 0dd26c9495
commit c449cd1af5
11 changed files with 495 additions and 72 deletions

View file

@ -10,6 +10,7 @@ from hermes_cli.config import (
DEFAULT_CONFIG,
get_hermes_home,
ensure_hermes_home,
get_compatible_custom_providers,
load_config,
load_env,
migrate_config,
@ -424,6 +425,146 @@ class TestAnthropicTokenMigration:
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
class TestCustomProviderCompatibility:
"""Custom provider compatibility across legacy and v12+ config schemas."""
def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 11,
"model": {
"default": "openai/gpt-5.4",
"provider": "openrouter",
},
"custom_providers": [
{
"name": "OpenAI Direct",
"base_url": "https://api.openai.com/v1",
"api_key": "test-key",
"api_mode": "codex_responses",
"model": "gpt-5-mini",
}
],
"fallback_providers": [
{"provider": "openai-direct", "model": "gpt-5-mini"}
],
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 17
assert raw["providers"]["openai-direct"] == {
"api": "https://api.openai.com/v1",
"api_key": "test-key",
"default_model": "gpt-5-mini",
"name": "OpenAI Direct",
"transport": "codex_responses",
}
# custom_providers removed by migration — runtime reads via compat layer
assert "custom_providers" not in raw
def test_providers_dict_resolves_at_runtime(self, tmp_path):
"""After migration deleted custom_providers, get_compatible_custom_providers
still finds entries from the providers dict."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"providers": {
"openai-direct": {
"api": "https://api.openai.com/v1",
"api_key": "test-key",
"default_model": "gpt-5-mini",
"name": "OpenAI Direct",
"transport": "codex_responses",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert len(compatible) == 1
assert compatible[0]["name"] == "OpenAI Direct"
assert compatible[0]["base_url"] == "https://api.openai.com/v1"
assert compatible[0]["provider_key"] == "openai-direct"
assert compatible[0]["api_mode"] == "codex_responses"
def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"providers": {
"my-provider": {
"name": "My Provider",
"api": "https://api.example.com/v1",
"url": "https://url.example.com/v1",
"base_url": "https://base.example.com/v1",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert compatible == [
{
"name": "My Provider",
"base_url": "https://api.example.com/v1",
"provider_key": "my-provider",
}
]
def test_dedup_across_legacy_and_providers(self, tmp_path):
"""Same name+url in both schemas should not produce duplicates."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"custom_providers": [
{
"name": "OpenAI Direct",
"base_url": "https://api.openai.com/v1",
"api_key": "legacy-key",
}
],
"providers": {
"openai-direct": {
"api": "https://api.openai.com/v1",
"api_key": "new-key",
"name": "OpenAI Direct",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert len(compatible) == 1
# Legacy entry wins (read first)
assert compatible[0]["api_key"] == "legacy-key"
class TestInterimAssistantMessageConfig:
"""Test the explicit gateway interim-message config gate."""
@ -441,6 +582,6 @@ class TestInterimAssistantMessageConfig:
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 16
assert raw["_config_version"] == 17
assert raw["display"]["tool_progress"] == "off"
assert raw["display"]["interim_assistant_messages"] is True