feat(config): add chat.headers hook with outbound header injection#101
feat(config): add chat.headers hook with outbound header injection#101waiyong wants to merge 1 commit into
Conversation
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>
|
Thanks for the contribution. Could you clarify how you intend this feature to relate specifically to OpenTelemetry or the telemetry exported by this plugin? As currently implemented, this is a general-purpose mechanism for injecting arbitrary headers into outgoing LLM requests. That seems somewhat unusual for an OpenTelemetry exporter and appears outside the project's current scope. The optional endpoint filtering and default injection into every provider also raise concerns about unintentionally forwarding sensitive values. If the intended use is telemetry-specific, such as propagating W3C |
Description
Adds a
chat.headershook 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=valuepairs 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'sx-enable-phoenix-tracingis 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 > defaultprecedence from #65:OPENCODE_OUTBOUND_HEADERSoutboundHeaderskey=valueheaders to inject, parsed by the existingparseAttributePairs().OPENCODE_OUTBOUND_ENDPOINTSoutboundEndpointssplitList()/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.headersfires for every outgoing LLM request. WithoutOPENCODE_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.outboundHeadersstays a raw string and is parsed at the use site, mirroringspanAttributes(feat(config): support OPENCODE_SPAN_ATTRIBUTES #67).HandlerContext, alongsidedisabledTraces.provider.options.baseURLif overridden, otherwisemodel.api.url— mirroring opencode's ownresolveSDK.src/handlers/chat-headers.tsfor direct unit testing, consistent with the other handlers.This PR is scoped to custom headers only. W3C
traceparentpropagation 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
Checklist
bun run lintpasses with no errorsbun run check:jsdoc-coveragepasses with no errorsbun run typecheckpasses with no errorsbun testpasses with no errorsRelated issues
Closes #100
Additional context
Tests (
bun test: 322 pass, 0 fail — 20 new):tests/chat-headers.test.ts—getResolvedURL(baseURL override, empty-string override falls through, defaultmodel.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.ts—loadConfigforOPENCODE_OUTBOUND_HEADERS/OPENCODE_OUTBOUND_ENDPOINTS, and the plugin-option overrides.OtelPluginwith telemetry enabled and confirmed it registers thechat.headershook, injects unscoped headers, skips a non-matching endpoint, and injects for a matchingbaseURLoverride.Open question for review: endpoint matching is by hostname, which tolerates port and path differences but means
http://gateway.internalmatches an allowlist entry ofhttps://gateway.internal. Happy to switch toorigin(scheme + host + port) if you prefer strict matching.