Skip to content

feat(config): add chat.headers hook with outbound header injection#101

Open
waiyong wants to merge 1 commit into
DEVtheOPS:mainfrom
waiyong:feat/chat-headers-outbound
Open

feat(config): add chat.headers hook with outbound header injection#101
waiyong wants to merge 1 commit into
DEVtheOPS:mainfrom
waiyong:feat/chat-headers-outbound

Conversation

@waiyong

@waiyong waiyong commented Jul 10, 2026

Copy link
Copy Markdown

Description

Adds a chat.headers hook that injects user-configured custom headers into opencode's outgoing LLM requests, with optional endpoint scoping so headers are not sent to unintended providers.

The mechanism is provider-agnostic — it copies arbitrary key=value pairs onto the request and matches against arbitrary hostnames. It has no knowledge of any specific gateway or backend. LLM gateways (LiteLLM, vLLM, or any reverse proxy) commonly require custom request headers: routing hints, tenant IDs, feature flags, or tracing toggles. Arize Phoenix's x-enable-phoenix-tracing is one example of such a header; nothing in the code is specific to it.

Two settings, both also available as plugin-tuple options following the option > env > default precedence from #65:

Env var Option Purpose
OPENCODE_OUTBOUND_HEADERS outboundHeaders Comma-separated key=value headers to inject, parsed by the existing parseAttributePairs().
OPENCODE_OUTBOUND_ENDPOINTS outboundEndpoints Comma-separated endpoint URLs, parsed by the existing splitList() / normalizeList(). When set, headers are injected only for requests whose resolved URL matches by hostname. When unset, headers go to every request (backward compatible).

Why endpoint scoping matters: chat.headers fires for every outgoing LLM request. Without OPENCODE_OUTBOUND_ENDPOINTS, a configured header is sent to all providers, including third parties (Anthropic, OpenAI). If a user ever placed a credential-like value in the env var, that would leak. Scoping restricts injection to the hostnames you name.

Implementation notes:

  • PluginConfig.outboundHeaders stays a raw string and is parsed at the use site, mirroring spanAttributes (feat(config): support OPENCODE_SPAN_ATTRIBUTES #67).
  • Both values are threaded through HandlerContext, alongside disabledTraces.
  • The resolved request URL is a non-empty provider.options.baseURL if overridden, otherwise model.api.url — mirroring opencode's own resolveSDK.
  • Endpoint matching is by hostname; an unparseable URL fails closed when an allowlist is configured.
  • The hook body lives in src/handlers/chat-headers.ts for direct unit testing, consistent with the other handlers.

This PR is scoped to custom headers only. W3C traceparent propagation is a natural follow-up but is a separate, opt-in concern (it would leak internal trace IDs to third parties by default), so it will be a second PR.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Chore (dependency updates, etc.)

Checklist

  • I have read the CONTRIBUTING.md document
  • My code follows the style guidelines of this project
  • bun run lint passes with no errors
  • bun run check:jsdoc-coverage passes with no errors
  • bun run typecheck passes with no errors
  • bun test passes with no errors
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly
  • My commits follow the Conventional Commits specification

Related issues

Closes #100

Additional context

Tests (bun test: 322 pass, 0 fail — 20 new):

  • tests/chat-headers.test.tsgetResolvedURL (baseURL override, empty-string override falls through, default model.api.url, both unset); matchesEndpoint (empty allowlist matches all, hostname match ignoring path, non-match, unparseable URL fails closed, unparseable allowlist entries ignored); handleChatHeaders (injects unscoped, injects on matching endpoint, skips on non-matching endpoint, multiple headers, no-op when unconfigured).
  • tests/config.test.tsloadConfig for OPENCODE_OUTBOUND_HEADERS / OPENCODE_OUTBOUND_ENDPOINTS, and the plugin-option overrides.
  • Beyond the unit tests, I invoked the exported OtelPlugin with telemetry enabled and confirmed it registers the chat.headers hook, injects unscoped headers, skips a non-matching endpoint, and injects for a matching baseURL override.

Open question for review: endpoint matching is by hostname, which tolerates port and path differences but means http://gateway.internal matches an allowlist entry of https://gateway.internal. Happy to switch to origin (scheme + host + port) if you prefer strict matching.

Adds a chat.headers hook that injects user-configured custom headers
(OPENCODE_OUTBOUND_HEADERS) into outgoing LLM requests, scoped to
specific endpoints (OPENCODE_OUTBOUND_ENDPOINTS) so headers are not
sent to third-party providers.

The resolved URL mirrors opencode's own resolution: a non-empty
provider.options.baseURL override wins, otherwise model.api.url.
Matching is by hostname and fails closed on unparseable URLs. When
OPENCODE_OUTBOUND_ENDPOINTS is unset, headers are injected into every
request, preserving the obvious default.

Both settings are also available as plugin-tuple options
(outboundHeaders, outboundEndpoints), following the established
option > env > default precedence. outboundHeaders is stored raw in
PluginConfig and parsed at the use site, mirroring spanAttributes.

The handler lives in src/handlers/chat-headers.ts for direct unit
testing, consistent with the other handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Inject custom headers into outgoing LLM requests (OPENCODE_OUTBOUND_HEADERS)

1 participant