Skip to content

feat(agent): allow user-supplied default_headers per provider in config.yaml - #18322

Closed
coldxiangyu163 wants to merge 1 commit into
NousResearch:mainfrom
coldxiangyu163:feat/configurable-provider-default-headers
Closed

feat(agent): allow user-supplied default_headers per provider in config.yaml#18322
coldxiangyu163 wants to merge 1 commit into
NousResearch:mainfrom
coldxiangyu163:feat/configurable-provider-default-headers

Conversation

@coldxiangyu163

Copy link
Copy Markdown

What does this PR do?

Adds a default_headers config knob so users can override or augment the HTTP headers Hermes sends to OpenAI-compatible providers — without editing source.

Today run_agent.py (AIAgent.__init__ and _apply_client_headers_for_base_url) hardcodes a host-specific if/elif chain that only sets default_headers for six known hosts (openrouter, routermint, copilot, kimi, qwen, codex). For any other base_url it falls through and the OpenAI Python SDK's default User-Agent: OpenAI/Python <version> leaks unchanged.

That User-Agent is on the WAF block list of several third-party OpenAI-compatible relays — typically Chinese "new-api" channels. Such relays return HTTP 403 Your request was blocked. for every Hermes call, even though direct curl with any other UA succeeds against the very same endpoint. There is currently no in-tree escape hatch — _VALID_CUSTOM_PROVIDER_FIELDS does not include any header field, no env var injects one, and there is no --header CLI flag.

This PR makes that escape hatch official.

Related Issue

Fixes #

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • hermes_cli/config.py
    • Add default_headers to _VALID_CUSTOM_PROVIDER_FIELDS and to the _KNOWN_KEYS set inside _normalize_custom_provider_entry.
    • New shared validator _sanitize_default_headers(raw, source) — accepts a mapping of header name to scalar value, drops malformed entries with a logger.warning, returns None for empty/invalid input.
  • run_agent.py
    • New AIAgent._resolve_user_default_headers() reads model.default_headers (applies to whichever provider is active) and providers.<provider_name>.default_headers (per-provider override). Per-provider entries override top-level on conflicts.
    • The result is merged into client_kwargs["default_headers"] at init time (after the existing host-specific chain and the OpenRouter Claude beta logic, so user keys win without erasing host-specific keys like HTTP-Referer).
    • _apply_client_headers_for_base_url is refactored to assemble host_headers first, merge user headers on top, and only pop("default_headers") when the merged result is empty. This keeps the headers intact across /model switches and credential refreshes.
  • cli-config.yaml.example
    • Documents the new field under the existing providers: block, with the WAF-bypass motivation and a working example.
  • tests/hermes_cli/test_default_headers.py
    • 21 new tests covering sanitizer edge cases (non-dict, non-string keys, non-scalar values, empty dict, scalar coercion), schema flow-through via _normalize_custom_provider_entry, and _resolve_user_default_headers precedence (top-level vs per-provider, case-insensitive provider matching, malformed config tolerance, load_config failure swallowing).

How to Test

  1. New tests pass

    pytest tests/hermes_cli/test_default_headers.py -v

    21 passed locally.

  2. Existing config / provider tests still pass (sanity check that schema additions didn't regress validation)

    pytest tests/hermes_cli/test_config.py \
           tests/hermes_cli/test_config_validation.py \
           tests/hermes_cli/test_custom_provider_context_length.py \
           tests/hermes_cli/test_custom_provider_model_switch.py \
           tests/hermes_cli/test_user_providers_model_switch.py \
           tests/hermes_cli/test_model_switch_custom_providers.py

    188 passed locally.

  3. End-to-end against a real WAF-blocked relay

    In ~/.hermes/config.yaml:

    model:
      default: gpt-5.5
      provider: custom
      base_url: https://<your-relay>/v1
      api_key: sk-...
      default_headers:
        User-Agent: claude-code/0.1.0

    Before the PR: hermes chat -q "say pong"HTTP 403: Your request was blocked.
    After the PR: hermes chat -q "say pong"pong. Verified locally on a "new-api" channel that blocks OpenAI/Python UAs.

Tested on

macOS 15 (darwin 25.2), Python 3.14, against a third-party new-api relay.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature
  • I've run the relevant test selectors (see above) and they all pass
  • I've added tests for my changes
  • I've tested on my platform: macOS 15

Documentation & Housekeeping

  • I've updated cli-config.yaml.example with the new config key
  • N/A — README / docs/ / CONTRIBUTING.md / AGENTS.md not affected
  • N/A — no cross-platform impact (pure Python, stdlib dict operations, no FS / process / terminal touch)
  • N/A — no tool descriptions changed

Notes for reviewers

  • Backward compatibility: configs without default_headers produce identical behavior to today (the new merge step is a no-op when _resolve_user_default_headers() returns {}). Existing tests confirm this.
  • Header precedence: built-in host headers form the base, user keys are layered on top. This means a user can override e.g. OpenRouter's User-Agent if they really want to, but won't accidentally erase HTTP-Referer / X-OpenRouter-Title by setting a single key. Documented in cli-config.yaml.example and the docstring on _resolve_user_default_headers.
  • Per-provider wins on conflict (between top-level model.default_headers and providers.<name>.default_headers) is the natural specificity rule; tested explicitly in test_per_provider_overrides_top_level_on_conflict.
  • Sanitizer is permissive: malformed entries are dropped with a warning rather than raising, matching the rest of the _normalize_custom_provider_entry flow which also .warning()s on unknown keys instead of failing.
  • AWS Bedrock: not affected — the Bedrock path uses boto3 rather than the OpenAI SDK, mirroring the existing scope note next to request_timeout_seconds in cli-config.yaml.example.

…ig.yaml

Hermes hardcodes a host-specific if/elif chain for HTTP default_headers
(openrouter, routermint, copilot, kimi, qwen, codex). Any other base_url
falls through and the OpenAI Python SDK's default User-Agent
(``OpenAI/Python <version>``) leaks unchanged.

That UA is on the WAF block list of several third-party OpenAI-compatible
relays — typically Chinese "new-api" channels. Such relays return HTTP 403
"Your request was blocked." for every Hermes call even though direct curl
with any other UA succeeds, leaving users with no in-tree escape hatch
short of a source patch.

This adds a ``default_headers`` mapping that users can declare in
``config.yaml`` either at the top of the ``model:`` block (applies to
whichever provider is active) or under a ``providers.<name>:`` entry
(per-provider override). Per-provider entries override top-level on
conflicts. Hermes' built-in host defaults are merged underneath so user
keys win without erasing things like OpenRouter's ``HTTP-Referer``
attribution headers.

Implementation:

- ``hermes_cli/config.py``: add ``default_headers`` to the custom-provider
  schema and a shared ``_sanitize_default_headers`` validator that drops
  malformed entries with a warning instead of raising.
- ``run_agent.py``: ``AIAgent._resolve_user_default_headers`` reads from
  ``model.default_headers`` and ``providers.<provider>.default_headers``;
  the result is merged into ``client_kwargs["default_headers"]`` both at
  init time and inside ``_apply_client_headers_for_base_url`` (so client
  rebuilds during ``/model`` switches and credential refresh keep the
  headers).
- ``cli-config.yaml.example``: documents the new field next to the
  existing ``providers:`` timeout examples, including the WAF-bypass
  motivation.
- ``tests/hermes_cli/test_default_headers.py``: 21 new tests covering
  sanitizer edge cases, schema flow-through, and resolver precedence.

Verified locally on macOS 15 (darwin 25.2): with
``model.default_headers.User-Agent: claude-code/0.1.0`` the
previously-403 relay now returns 200 end-to-end.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles labels May 1, 2026
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Closing as superseded by #41096, which adds model.default_headers support for custom OpenAI-compatible providers across both the main and auxiliary client paths (overriding the OpenAI SDK's User-Agent/X-Stainless-* headers that some gateways/WAFs reject — #40033). Thanks for the contribution! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants