fix: passthrough X-Initiator header in Copilot channel for billing control#1222
Conversation
…ntrol When using opencode with AxonHub's Copilot channel, every tool call is billed as a separate premium request because the X-Initiator header (which tells GitHub Copilot whether a request is user- or agent-initiated) is dropped by AxonHub's header merge pipeline. - Block X-Initiator from auto-merge to prevent leaking to non-Copilot providers - Explicitly forward X-Initiator in Copilot outbound transformer for both Chat Completions and Responses API (Codex) paths Closes looplj#820
There was a problem hiding this comment.
Code Review
This pull request introduces the X-Initiator header for Copilot billing control, blocking it from automatic forwarding while implementing manual forwarding in the outbound transformer. Feedback was provided to address code duplication by extracting the header forwarding logic into a reusable helper function.
| if llmReq.RawRequest != nil && llmReq.RawRequest.Headers != nil { | ||
| if initiator := llmReq.RawRequest.Headers.Get(InitiatorHeader); initiator != "" { | ||
| headers.Set(InitiatorHeader, initiator) | ||
| } | ||
| } |
There was a problem hiding this comment.
This logic for forwarding a header is duplicated in transformResponsesRequest at lines 588-592. To improve maintainability and avoid code duplication, consider extracting this into a helper function. This would also be useful if other headers need to be forwarded in the future.
For example, you could create a function like this:
// forwardHeaderIfExists forwards a header from the inbound raw request to the destination headers if it exists.
func forwardHeaderIfExists(dest http.Header, llmReq *llm.Request, headerName string) {
if llmReq != nil && llmReq.RawRequest != nil && llmReq.RawRequest.Headers != nil {
if value := llmReq.RawRequest.Headers.Get(headerName); value != "" {
dest.Set(headerName, value)
}
}
}Then you could call it here as forwardHeaderIfExists(headers, llmReq, InitiatorHeader) and similarly in transformResponsesRequest.
Summary
X-Initiatorfrom the global header auto-merge pipeline (blockedHeaders) to prevent it from leaking to non-Copilot upstream providersX-Initiatorin the Copilot outbound transformer for both Chat Completions and Responses API (Codex) request pathsProblem
When using opencode (or oh-my-opencode) with AxonHub's Copilot channel, every tool call is billed as a separate premium request, causing quota to deplete dozens of times faster than expected.
opencode already sends
X-Initiator: agentfor agent-initiated requests (tool calls, compaction, subagent flows) andX-Initiator: userfor user-initiated turns. GitHub Copilot's server uses this header to skip billing for agent requests. However, AxonHub'sMergeInboundRequestpipeline silently forwards ALL non-blocked inbound headers to ALL upstream providers — soX-Initiatoreither gets forwarded everywhere (undesirable) or needs to be explicitly controlled per channel.Solution
X-InitiatortoblockedHeadersinllm/httpclient/utils.go— this prevents the header from being auto-forwarded to any upstream provider via the merge pipelinellm/transformer/openai/copilot/outbound.go, explicitly readX-Initiatorfrom the inbound request headers and set it on the outgoing Copilot request — following the same pattern used by the Codex transformer forOriginator,Session_id, andUser-AgentThis ensures only the Copilot channel forwards
X-Initiatorto GitHub's API, while all other channels remain unaffected.Changes
llm/httpclient/utils.goX-InitiatortoblockedHeadersllm/transformer/openai/copilot/outbound.goInitiatorHeaderconstant; forward header inTransformRequest()andtransformResponsesRequest()Testing
The header forwarding follows the exact same pattern as
codex/outbound.go(lines 94-98, 157-174) which readsSession_id,Originator,User-AgentfromllmReq.RawRequest.Headersand sets them on the outgoing request. The pattern is battle-tested across the codebase.Closes #820