mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
2 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85552aa259 | ||
|
|
4c4606c88b |
9
cli.py
9
cli.py
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user