Skip to content

Commit cd122c5

Browse files
Copilotlpcox
andauthored
inject X-Initiator: agent default on Copilot requests to fix billing
Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/f4644feb-041a-4451-8134-d0ef07fc3425 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 8a3e067 commit cd122c5

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

containers/api-proxy/server.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,13 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
320320
headers['x-request-id'] = requestId;
321321
Object.assign(headers, injectHeaders);
322322

323+
// Default X-Initiator to "agent" for billing purposes on Copilot requests.
324+
// In agentic workflows, the vast majority of requests are agent-initiated.
325+
// If the client already set it (e.g. standard Copilot CLI), respect that value.
326+
if (provider === 'copilot' && !headers['x-initiator']) {
327+
headers['x-initiator'] = 'agent';
328+
}
329+
323330
if (body.length !== inboundBytes) {
324331
headers['content-length'] = String(body.length);
325332
delete headers['transfer-encoding'];

containers/api-proxy/server.test.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const { deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath,
1515
const { resolveOpenCodeRoute } = require('./providers/opencode');
1616

1717
// Core proxy functions that remain in server.js
18-
const { proxyWebSocket, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson, createProviderServer } = require('./server');
18+
const { proxyRequest, proxyWebSocket, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson, createProviderServer } = require('./server');
1919

2020
describe('normalizeApiTarget', () => {
2121
it('should strip https:// prefix', () => {
@@ -2773,3 +2773,75 @@ describe('provider adapter alwaysBind', () => {
27732773
expect(body.error).toMatch(/no candidate provider credentials/);
27742774
});
27752775
});
2776+
2777+
// ── proxyRequest X-Initiator billing header injection ─────────────────────────
2778+
//
2779+
// When forwarding to the Copilot API, the proxy must inject "X-Initiator: agent"
2780+
// when the header is absent so that autonomous turns are billed correctly instead
2781+
// of defaulting to the (more expensive) "user" rate.
2782+
//
2783+
describe('proxyRequest X-Initiator injection', () => {
2784+
/** Minimal mock for http.IncomingMessage backed by EventEmitter. */
2785+
function makeReq(headers = {}) {
2786+
const req = new EventEmitter();
2787+
req.url = '/v1/chat/completions';
2788+
req.method = 'POST';
2789+
req.headers = { 'content-type': 'application/json', ...headers };
2790+
return req;
2791+
}
2792+
2793+
/** Minimal mock for http.ServerResponse. */
2794+
function makeRes() {
2795+
return {
2796+
headersSent: false,
2797+
setHeader: jest.fn(),
2798+
writeHead: jest.fn(),
2799+
end: jest.fn(),
2800+
};
2801+
}
2802+
2803+
afterEach(() => {
2804+
jest.restoreAllMocks();
2805+
});
2806+
2807+
/**
2808+
* Mock https.request to capture the outgoing options (including headers)
2809+
* without making a real network connection.
2810+
*/
2811+
function mockHttpsRequest() {
2812+
let capturedOptions;
2813+
jest.spyOn(https, 'request').mockImplementation((options) => {
2814+
capturedOptions = options;
2815+
const proxyReq = new EventEmitter();
2816+
proxyReq.end = jest.fn();
2817+
proxyReq.write = jest.fn();
2818+
proxyReq.destroy = jest.fn();
2819+
return proxyReq;
2820+
});
2821+
return { getCaptured: () => capturedOptions };
2822+
}
2823+
2824+
it('injects x-initiator: agent when absent on copilot requests', () => {
2825+
const { getCaptured } = mockHttpsRequest();
2826+
const req = makeReq();
2827+
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'copilot');
2828+
req.emit('end');
2829+
expect(getCaptured().headers['x-initiator']).toBe('agent');
2830+
});
2831+
2832+
it('preserves a client-supplied x-initiator value on copilot requests', () => {
2833+
const { getCaptured } = mockHttpsRequest();
2834+
const req = makeReq({ 'x-initiator': 'user' });
2835+
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'copilot');
2836+
req.emit('end');
2837+
expect(getCaptured().headers['x-initiator']).toBe('user');
2838+
});
2839+
2840+
it('does not inject x-initiator for non-copilot providers', () => {
2841+
const { getCaptured } = mockHttpsRequest();
2842+
const req = makeReq();
2843+
proxyRequest(req, makeRes(), 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic');
2844+
req.emit('end');
2845+
expect(getCaptured().headers['x-initiator']).toBeUndefined();
2846+
});
2847+
});

0 commit comments

Comments
 (0)