Skip to content

fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)#41096

Merged
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/custom-default-headers-40403
Jun 7, 2026
Merged

fix: honor model.default_headers for custom OpenAI-compatible providers, main + auxiliary clients (#40033)#41096
kshitijk4poor merged 2 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/custom-default-headers-40403

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Summary

Salvages #40403 (sanidhyasin) onto current main and completes the fix across both client paths, fixing #40033.

A custom OpenAI-compatible provider can fail with an opaque HTTP 502: Upstream access forbidden (or a 4xx) even though the identical API key, base URL, model, and JSON body succeed with curl. The cause is an upstream gateway/WAF rejecting the OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ..., X-Stainless-*). There was no supported way to override them.

This adds a model.default_headers config key whose entries are merged onto OpenAI-wire clients with user values taking precedence over provider/SDK defaults, so a custom endpoint can swap in a plain User-Agent.

model:
  provider: custom
  default: <model>
  base_url: http://<host>/v1
  default_headers:
    User-Agent: "curl/8.7.1"

Why two commits

  1. fix(agent): honor model.default_headers … (sanidhyasin, cherry-picked, authorship preserved) — applies the override to the main agent OpenAI client at construction (agent/agent_init.py) and across credential swaps / client rebuilds (run_agent.py::_apply_client_headers_for_base_url). No-op for native Anthropic/Bedrock.

  2. fix(aux): honor model.default_headers on auxiliary client too (follow-up) — the original fix only covered the main client. The auxiliary client (title generation, context compression, vision routing) builds its own OpenAI clients, so for a custom WAF'd endpoint the main turn would succeed while auxiliary calls to the same endpoint still failed. This adds a shared agent.auxiliary_client._apply_user_default_headers() and applies it at every OpenAI-wire construction site — config-level model.provider: custom (_try_custom_endpoint), named custom providers (custom_providers/providers entries, incl. the anthropic-SDK-missing OpenAI-wire fallback), the api-key-provider, async-conversion, and main resolve fallback branches. To prevent the two clients ever drifting on precedence/value handling, the main-path method now delegates the config read + merge to this single shared helper.

E2E verified

With a real isolated HERMES_HOME + config.yaml, the override reaches the OpenAI client on all three paths:

  • main agent client ✓
  • config-level provider: custom auxiliary client ✓
  • named custom_providers auxiliary client ✓

Tests

  • tests/run_agent/test_provider_attribution_headers.py — 4 tests from fix(agent): honor model.default_headers for custom OpenAI-compatible providers (#40033) #40403 (main path): custom override, user-wins-over-provider-defaults, unconfigured no-op, Anthropic skip.
  • tests/agent/test_auxiliary_user_default_headers.py — 8 new tests: helper merge semantics (user-wins, no-op, None-handling), config-level custom aux, named-custom aux.
  • Full aux + attribution header suites green (295 passed). ruff clean.

Design notes

  • User-always-wins on the OpenAI wire (over kimi/copilot/nvidia provider headers too) is intentional and consistent between the main and auxiliary paths — model.default_headers is a documented global escape hatch. A reviewer flagged this; it's a low-risk, by-design tradeoff, not a footgun in practice (one wouldn't set a global curl UA while routing aux to kimi.com).
  • ${ENV_VAR} interpolation in header values is intentionally out of scope (separate concern, one-PR-one-concern) — can be a follow-up.

Credit

Original issue #40033 reported by @danfye.

Closes #40403
Closes #40033

@kshitijk4poor kshitijk4poor force-pushed the salvage/custom-default-headers-40403 branch from 5e5c11c to 0dca2bd Compare June 7, 2026 08:40
@liuhao1024

Copy link
Copy Markdown
Contributor

Positive verification — reviewed the full diff and test suite.

The implementation is clean and consistent:

  1. Centralized merge helper (_apply_user_default_headers) — single source of truth in auxiliary_client.py, called from every OpenAI client construction path (main agent, auxiliary, async, custom, fallback). Each call site follows the same pattern: merge → guard against None → assign.

  2. No-op when unconfigured — the function returns the original dict unchanged when model.default_headers is absent or empty, so zero behavioral change for existing users.

  3. Anthropic/Bedrock skip_apply_user_default_headers in run_agent.py early-returns for non-OpenAI modes, which is correct since those paths don't use the OpenAI client.

  4. Test coverage — 8 test functions covering: merge+win, no-config no-op, None headers with config, None headers without config, None values skipped, custom provider override, no-config leaves defaults, Anthropic mode skip.

One minor observation (non-blocking): _apply_user_default_headers is called ~8 times across auxiliary_client.py at different client construction sites. Each call re-reads load_config(). For a long-running session this is a negligible cost, but if config caching is ever added, these call sites would automatically benefit.

No issues found — this is ready to merge.

@kshitijk4poor kshitijk4poor force-pushed the salvage/custom-default-headers-40403 branch from 0dca2bd to cb0feda Compare June 7, 2026 08:53
sanidhyasin and others added 2 commits June 7, 2026 14:25
…providers (NousResearch#40033)

Custom OpenAI-compatible endpoints sitting behind a gateway/WAF can reject
the OpenAI Python SDK's default identifying headers (User-Agent: OpenAI/Python,
X-Stainless-*) and return an opaque 502/4xx even though the same request body
succeeds under curl. There was no supported way to override those headers.

Add a model.default_headers config key whose values are merged onto the
OpenAI client's default_headers, taking precedence over provider- and
SDK-supplied defaults. Applied at client construction and on every credential
swap / client rebuild so the override survives reconnects. No-op for native
Anthropic / Bedrock modes and when unconfigured.
…search#40033)

The salvaged main-agent fix (sanidhyasin) applies model.default_headers
to the primary OpenAI client, but the auxiliary client (title generation,
context compression, vision routing) builds its own clients and did not
read the override. For a `provider: custom` endpoint behind a gateway/WAF
that rejects the OpenAI SDK's identifying headers, the main turn would
succeed while auxiliary calls to the same endpoint still failed with the
opaque 502/4xx from NousResearch#40033.

Add agent.auxiliary_client._apply_user_default_headers() (user values win
over provider/SDK defaults; no-op when unconfigured) and apply it at every
OpenAI-wire client construction site:
- _try_custom_endpoint() — config-level `model.provider: custom`
- the named custom-provider branch (custom_providers/providers entries),
  including the anthropic-SDK-missing OpenAI-wire fallback
- the api-key-provider, async-conversion, and main resolve_provider_client
  fallback branches

To prevent the two clients ever drifting on precedence/value handling,
AIAgent._apply_user_default_headers (run_agent.py) now delegates the config
read + merge to this shared helper (run_agent already imports from
auxiliary_client). Native Anthropic/Bedrock branches are untouched (they
don't use the OpenAI wire).

8 new tests (helper semantics + config-level custom + named custom);
full aux + attribution header suites green (295).
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 loop, run_agent.py, prompt builder P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom OpenAI-compatible provider can fail when upstream blocks OpenAI Python SDK default headers

4 participants