Compare commits

...

2 Commits

Author SHA1 Message Date
Teknium
85552aa259 fix: propagate model through credential pool path + add tests
The cherry-picked fix from PR #7916 placed model propagation after
the credential pool early-return in _resolve_named_custom_runtime(),
making it dead code when a pool is active (which happens whenever
custom_providers has an api_key that auto-seeds the pool).

- Inject model into pool_result before returning
- Add 5 regression tests covering direct path, pool path, empty
  model, and absent model scenarios
- Add 'model' to _VALID_CUSTOM_PROVIDER_FIELDS for config validation
2026-04-11 14:07:12 -07:00
0xFrank-eth
4c4606c88b fix(custom-providers): propagate model field from config to runtime so API receives the correct model name
Fixes #7828

When a custom_providers entry carries a `model` field, that value was
silently dropped by `_get_named_custom_provider` and
`_resolve_named_custom_runtime`.  Callers received a runtime dict with
`base_url`, `api_key`, and `api_mode` — but no `model`.

As a result, `hermes chat --model <provider-name>` sent the *provider
name* (e.g. "my-dashscope-provider") as the model string to the API
instead of the configured model (e.g. "qwen3.6-plus"), producing:

    Error code: 400 - {'error': {'message': 'Model Not Exist'}}

Setting the provider as the *default* model in config.yaml worked
because that path writes `model.default` and the agent reads it back
directly, bypassing the broken runtime resolution path.

Changes:

1. hermes_cli/runtime_provider.py — _get_named_custom_provider()
   Reads `entry.get("model")` and includes it in the result dict so
   the value is available to callers.

2. hermes_cli/runtime_provider.py — _resolve_named_custom_runtime()
   Propagates `custom_provider["model"]` into the returned runtime dict.

3. cli.py — _ensure_runtime_credentials()
   After resolving runtime, if `runtime["model"]` is set, assign it to
   `self.model` so the AIAgent is initialised with the correct model
   name rather than the provider name the user typed on the CLI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:02:29 -07:00
4 changed files with 136 additions and 2 deletions

9
cli.py
View File

@@ -2710,6 +2710,15 @@ class HermesCLI:
self.api_key = api_key self.api_key = api_key
self.base_url = base_url self.base_url = base_url
# When a custom_provider entry carries an explicit `model` field,
# use it as the effective model name. Without this, running
# `hermes chat --model <provider-name>` sends the provider name
# (e.g. "my-provider") as the model string to the API instead of
# the configured model (e.g. "qwen3.6-plus"), causing 400 errors.
runtime_model = runtime.get("model")
if runtime_model and isinstance(runtime_model, str):
self.model = runtime_model
# Normalize model for the resolved provider (e.g. swap non-Codex # Normalize model for the resolved provider (e.g. swap non-Codex
# models when provider is openai-codex). Fixes #651. # models when provider is openai-codex). Fixes #651.
model_changed = self._normalize_model_for_provider(resolved_provider) model_changed = self._normalize_model_for_provider(resolved_provider)

View File

@@ -1497,7 +1497,7 @@ _KNOWN_ROOT_KEYS = {
# Valid fields inside a custom_providers list entry # Valid fields inside a custom_providers list entry
_VALID_CUSTOM_PROVIDER_FIELDS = { _VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "models", "name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay", "context_length", "rate_limit_delay",
} }

View File

@@ -304,6 +304,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
api_mode = _parse_api_mode(entry.get("api_mode")) api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode: if api_mode:
result["api_mode"] = api_mode result["api_mode"] = api_mode
model_name = str(entry.get("model", "") or "").strip()
if model_name:
result["model"] = model_name
return result return result
return None return None
@@ -329,6 +332,11 @@ def _resolve_named_custom_runtime(
# Check if a credential pool exists for this custom endpoint # Check if a credential pool exists for this custom endpoint
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode")) pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"))
if pool_result: if pool_result:
# Propagate the model name even when using pooled credentials —
# the pool doesn't know about the custom_providers model field.
model_name = custom_provider.get("model")
if model_name:
pool_result["model"] = model_name
return pool_result return pool_result
api_key_candidates = [ api_key_candidates = [
@@ -339,7 +347,7 @@ def _resolve_named_custom_runtime(
] ]
api_key = next((candidate for candidate in api_key_candidates if has_usable_secret(candidate)), "") api_key = next((candidate for candidate in api_key_candidates if has_usable_secret(candidate)), "")
return { result = {
"provider": "custom", "provider": "custom",
"api_mode": custom_provider.get("api_mode") "api_mode": custom_provider.get("api_mode")
or _detect_api_mode_for_url(base_url) or _detect_api_mode_for_url(base_url)
@@ -348,6 +356,11 @@ def _resolve_named_custom_runtime(
"api_key": api_key or "no-key-required", "api_key": api_key or "no-key-required",
"source": f"custom_provider:{custom_provider.get('name', requested_provider)}", "source": f"custom_provider:{custom_provider.get('name', requested_provider)}",
} }
# Propagate the model name so callers can override self.model when the
# provider name differs from the actual model string the API expects.
if custom_provider.get("model"):
result["model"] = custom_provider["model"]
return result
def _resolve_openrouter_runtime( def _resolve_openrouter_runtime(

View File

@@ -1214,3 +1214,115 @@ def test_openrouter_provider_not_affected_by_custom_fix(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="openrouter") resolved = rp.resolve_runtime_provider(requested="openrouter")
assert resolved["provider"] == "openrouter" assert resolved["provider"] == "openrouter"
# ------------------------------------------------------------------
# fix #7828 — custom_providers model field must propagate to runtime
# ------------------------------------------------------------------
def test_get_named_custom_provider_includes_model(monkeypatch):
"""_get_named_custom_provider should include the model field from config."""
monkeypatch.setattr(rp, "load_config", lambda: {
"custom_providers": [{
"name": "my-dashscope",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "test-key",
"api_mode": "chat_completions",
"model": "qwen3.6-plus",
}],
})
result = rp._get_named_custom_provider("my-dashscope")
assert result is not None
assert result["model"] == "qwen3.6-plus"
def test_get_named_custom_provider_excludes_empty_model(monkeypatch):
"""Empty or whitespace-only model field should not appear in result."""
for model_val in ["", " ", None]:
entry = {
"name": "test-ep",
"base_url": "https://example.com/v1",
"api_key": "key",
}
if model_val is not None:
entry["model"] = model_val
monkeypatch.setattr(rp, "load_config", lambda e=entry: {
"custom_providers": [e],
})
result = rp._get_named_custom_provider("test-ep")
assert result is not None
assert "model" not in result, (
f"model field {model_val!r} should not be included in result"
)
def test_named_custom_runtime_propagates_model_direct_path(monkeypatch):
"""Model should propagate through the direct (non-pool) resolution path."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-server",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
"model": "qwen3.6-plus",
},
)
# Ensure pool doesn't intercept
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="my-server")
assert resolved["model"] == "qwen3.6-plus"
assert resolved["provider"] == "custom"
def test_named_custom_runtime_propagates_model_pool_path(monkeypatch):
"""Model should propagate even when credential pool handles credentials."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-server",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
"model": "qwen3.6-plus",
},
)
# Pool returns a result (intercepting the normal path)
monkeypatch.setattr(
rp, "_try_resolve_from_custom_pool",
lambda *a, **k: {
"provider": "custom",
"api_mode": "chat_completions",
"base_url": "http://localhost:8000/v1",
"api_key": "pool-key",
"source": "pool:custom:my-server",
},
)
resolved = rp.resolve_runtime_provider(requested="my-server")
assert resolved["model"] == "qwen3.6-plus", (
"model must be injected into pool result"
)
assert resolved["api_key"] == "pool-key", "pool credentials should be used"
def test_named_custom_runtime_no_model_when_absent(monkeypatch):
"""When custom_providers entry has no model field, runtime should not either."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-server",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
},
)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="my-server")
assert "model" not in resolved