Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 135 additions & 9 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,23 +425,149 @@ def _custom_provider_extra_body_for_agent(
return fallback


def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, Any]]) -> None:
def _providers_entry_ci(
providers_cfg: Dict[str, Any], key: str
) -> Optional[Dict[str, Any]]:
"""Return ``providers.<key>`` with case-insensitive key match."""
if not isinstance(providers_cfg, dict) or not key:
return None
if key in providers_cfg and isinstance(providers_cfg[key], dict):
return providers_cfg[key]
key_l = key.lower()
for raw_key, entry in providers_cfg.items():
if str(raw_key).strip().lower() == key_l and isinstance(entry, dict):
return entry
return None


def _provider_lookup_keys(provider: str) -> List[str]:
"""Ordered keys to probe under ``providers:`` for a session provider.

Prefer the session's exact provider string first, then the canonical
profile name, then registered aliases. That way a user who pins
``providers.dashscope.extra_body`` still wins after Hermes resolves the
session provider to the canonical ``alibaba`` profile (and vice versa).
"""
provider_norm = (provider or "").strip().lower()
if not provider_norm or provider_norm == "custom" or provider_norm.startswith("custom:"):
return []

keys: List[str] = [provider_norm]
try:
from providers import get_provider_profile

profile = get_provider_profile(provider_norm)
except Exception:
profile = None

if profile is not None:
canonical = (profile.name or "").strip().lower()
if canonical and canonical not in keys:
keys.append(canonical)
for alias in getattr(profile, "aliases", ()) or ():
alias_norm = str(alias or "").strip().lower()
if alias_norm and alias_norm not in keys:
keys.append(alias_norm)
return keys


def _builtin_provider_extra_body_for_agent(
*,
provider: str,
providers_cfg: Any,
) -> Optional[Dict[str, Any]]:
"""Read ``providers.<name>.extra_body`` for a first-class / built-in provider.

Schema contract (distinct from named custom endpoints):
- Keys under ``providers:`` that include a base URL (``api`` / ``base_url`` /
``url``) are **named custom endpoints** and are handled by
:func:`_custom_provider_extra_body_for_agent` via
``get_compatible_custom_providers``.
- Keys that match a built-in profile name or alias may carry a partial
entry with only ``extra_body`` (no URL required). Those are resolved
here once at agent setup and merged into ``request_overrides``.

Lookup order: exact session provider string β†’ canonical profile name β†’
aliases. First hit with a non-empty ``extra_body`` dict wins.
"""
if not isinstance(providers_cfg, dict):
return None

for key in _provider_lookup_keys(provider):
entry = _providers_entry_ci(providers_cfg, key)
if not isinstance(entry, dict):
continue
# Named custom endpoints with a URL are owned by the custom path β€”
# skip them here so we never double-apply or steal a custom entry
# that simply shares a name with a built-in profile.
has_url = False
for url_key in ("base_url", "url", "api"):
raw_url = entry.get(url_key)
if isinstance(raw_url, str) and raw_url.strip():
has_url = True
break
if has_url:
continue
extra_body = entry.get("extra_body")
if isinstance(extra_body, dict) and extra_body:
return dict(extra_body)
return None


def _apply_extra_body_to_request_overrides(
agent, extra_body: Dict[str, Any]
) -> None:
"""Merge *extra_body* under agent.request_overrides; caller keys win."""
overrides = dict(getattr(agent, "request_overrides", {}) or {})
merged_extra_body = dict(extra_body)
existing_extra_body = overrides.get("extra_body")
if isinstance(existing_extra_body, dict):
merged_extra_body.update(existing_extra_body)
overrides["extra_body"] = merged_extra_body
agent.request_overrides = overrides


def _merge_custom_provider_extra_body(
agent,
custom_providers: List[Dict[str, Any]],
agent_cfg: Optional[Dict[str, Any]] = None,
) -> None:
"""Resolve provider ``extra_body`` once at agent setup into request_overrides.

Order:
1. Named custom endpoints (``custom`` / ``custom:<key>``) via *custom_providers*.
2. Else first-class / built-in profile overrides from
``providers.<name|alias>.extra_body`` (no URL required).

Existing ``request_overrides.extra_body`` always wins on key conflict.
"""
extra_body = _custom_provider_extra_body_for_agent(
provider=agent.provider,
model=agent.model,
base_url=agent.base_url,
custom_providers=custom_providers,
)

if not extra_body:
providers_cfg = None
if isinstance(agent_cfg, dict):
providers_cfg = agent_cfg.get("providers")
else:
try:
from hermes_cli.config import load_config

providers_cfg = load_config().get("providers")
except Exception:
providers_cfg = None
extra_body = _builtin_provider_extra_body_for_agent(
provider=getattr(agent, "provider", "") or "",
providers_cfg=providers_cfg,
)

if not extra_body:
return

overrides = dict(getattr(agent, "request_overrides", {}) or {})
merged_extra_body = dict(extra_body)
existing_extra_body = overrides.get("extra_body")
if isinstance(existing_extra_body, dict):
merged_extra_body.update(existing_extra_body)
overrides["extra_body"] = merged_extra_body
agent.request_overrides = overrides
_apply_extra_body_to_request_overrides(agent, extra_body)


def init_agent(
Expand Down Expand Up @@ -2220,7 +2346,7 @@ def _parse_prune_int(raw, default):
# Store for reuse by _check_compression_model_feasibility (auxiliary
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
_merge_custom_provider_extra_body(agent, _custom_providers)
_merge_custom_provider_extra_body(agent, _custom_providers, agent_cfg=_agent_cfg)

# Check custom_providers per-model context_length
if _config_context_length is None and _custom_providers:
Expand Down
156 changes: 156 additions & 0 deletions tests/agent/test_builtin_provider_extra_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Built-in / first-class provider ``providers.<name>.extra_body`` resolution.

Addresses hermes-sweeper review on #21554:
- Resolve once at agent setup into ``request_overrides`` (not per transport call)
- Canonicalize aliases (``dashscope`` β†’ ``alibaba``) so either config key works
- Preserve named custom-endpoint behavior (URL-bearing providers entries)
"""

from __future__ import annotations

from types import SimpleNamespace

from agent.agent_init import (
_builtin_provider_extra_body_for_agent,
_merge_custom_provider_extra_body,
_provider_lookup_keys,
)


def test_provider_lookup_keys_prefer_session_then_canonical_then_aliases():
keys = _provider_lookup_keys("dashscope")
assert keys[0] == "dashscope"
assert "alibaba" in keys
# Other documented aliases should be present after canonical
assert "alibaba-cloud" in keys or "qwen-dashscope" in keys


def test_provider_lookup_keys_skip_custom():
assert _provider_lookup_keys("custom") == []
assert _provider_lookup_keys("custom:foo") == []


def test_builtin_extra_body_via_alias_key():
"""Config under providers.dashscope applies when session provider is alibaba."""
got = _builtin_provider_extra_body_for_agent(
provider="alibaba",
providers_cfg={
"dashscope": {"extra_body": {"enable_thinking": False}},
},
)
assert got == {"enable_thinking": False}


def test_builtin_extra_body_via_canonical_key_when_session_is_alias():
got = _builtin_provider_extra_body_for_agent(
provider="dashscope",
providers_cfg={
"alibaba": {"extra_body": {"enable_thinking": False}},
},
)
assert got == {"enable_thinking": False}


def test_exact_session_key_wins_over_canonical():
"""If both alias and canonical keys exist, session string match wins."""
got = _builtin_provider_extra_body_for_agent(
provider="dashscope",
providers_cfg={
"dashscope": {"extra_body": {"source": "alias"}},
"alibaba": {"extra_body": {"source": "canonical"}},
},
)
assert got == {"source": "alias"}


def test_url_bearing_providers_entry_skipped_by_builtin_path():
"""Named custom endpoints keep the custom path; builtin must not steal them."""
got = _builtin_provider_extra_body_for_agent(
provider="alibaba",
providers_cfg={
"dashscope": {
"api": "https://example.test/v1",
"extra_body": {"enable_thinking": False},
},
},
)
assert got is None


def test_merge_builtin_into_request_overrides():
agent = SimpleNamespace(
provider="alibaba",
model="qwen-plus",
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
request_overrides={},
)
_merge_custom_provider_extra_body(
agent,
custom_providers=[],
agent_cfg={
"providers": {
"dashscope": {"extra_body": {"enable_thinking": False}},
}
},
)
assert agent.request_overrides == {
"extra_body": {"enable_thinking": False},
}


def test_merge_caller_extra_body_wins_over_builtin():
agent = SimpleNamespace(
provider="alibaba",
model="qwen-plus",
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
request_overrides={
"extra_body": {"enable_thinking": True, "caller_only": 1},
},
)
_merge_custom_provider_extra_body(
agent,
custom_providers=[],
agent_cfg={
"providers": {
"alibaba": {
"extra_body": {
"enable_thinking": False,
"from_config": True,
}
}
}
},
)
assert agent.request_overrides["extra_body"] == {
"enable_thinking": True, # caller wins
"from_config": True,
"caller_only": 1,
}


def test_custom_endpoint_path_still_preferred_over_builtin():
"""When provider is custom, builtin lookup must not run."""
agent = SimpleNamespace(
provider="custom",
model="google/gemma-4-31b-it",
base_url="https://example.test/v1",
request_overrides={},
)
_merge_custom_provider_extra_body(
agent,
custom_providers=[
{
"name": "gemma",
"base_url": "https://example.test/v1",
"model": "google/gemma-4-31b-it",
"extra_body": {"from_custom": True},
}
],
agent_cfg={
"providers": {
# Would match if wrongly applied to custom sessions
"alibaba": {"extra_body": {"from_builtin": True}},
}
},
)
assert agent.request_overrides == {"extra_body": {"from_custom": True}}
31 changes: 31 additions & 0 deletions website/docs/integrations/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,37 @@ extra_body:
enable_thinking: false
```

### Built-in provider overrides (`providers.<name>.extra_body`)

Named custom endpoints above require a URL. First-class / built-in providers
(the profiles under `plugins/model-providers/`, e.g. `alibaba` / `dashscope`,
`openai`, `openrouter`) can also pin request body fields without declaring a
URL β€” put a **partial** entry under the keyed `providers:` schema:

```yaml
providers:
# Either the canonical profile name …
alibaba:
extra_body:
enable_thinking: false
# … or any registered alias works (dashscope β†’ alibaba):
# dashscope:
# extra_body:
# enable_thinking: false
```

Hermes resolves this **once at agent setup** into `request_overrides.extra_body`
(same merge path the chat-completions transport already uses). Lookup order:

1. Exact session provider string
2. Canonical profile name
3. Other registered aliases

Per-call `request_overrides.extra_body` still wins on key conflict. Entries
that include `api` / `base_url` / `url` remain **named custom endpoints** and
are handled by the custom-provider path above β€” they are not double-applied
here.

The `hermes model` β†’ Custom Endpoint wizard now prompts for the API mode explicitly and persists your answer to `config.yaml` (as `transport` on the provider entry). URL-based auto-detection (e.g. `/anthropic` paths β†’ `anthropic_messages`) still happens as a fallback when the field is left blank.

**Native vision for custom-provider models.** If your custom endpoint serves a vision-capable model that isn't in models.dev, set `model.supports_vision: true` so Hermes routes attached images natively (as `image_url` parts) instead of pre-processing them through `vision_analyze`. Single knob β€” no need to also set `agent.image_input_mode: native`.
Expand Down
Loading