Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,15 @@
headers['x-request-id'] = requestId;
Object.assign(headers, injectHeaders);

// Default X-Initiator to "agent" for billing purposes on Copilot-bound requests.
// In agentic workflows, the vast majority of requests are agent-initiated.
// If the client already set it (e.g. standard Copilot CLI), respect that value.
// Check is on targetHost rather than provider name so that OpenCode requests
// routed to the Copilot backend also receive the header.
if (targetHost.endsWith('githubcopilot.com') && !headers['x-initiator']) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
headers['x-initiator'] = 'agent';
}

if (body.length !== inboundBytes) {
headers['content-length'] = String(body.length);
delete headers['transfer-encoding'];
Expand Down
108 changes: 107 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const { deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath,
const { resolveOpenCodeRoute } = require('./providers/opencode');

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

describe('normalizeApiTarget', () => {
it('should strip https:// prefix', () => {
Expand Down Expand Up @@ -2773,3 +2773,109 @@ describe('provider adapter alwaysBind', () => {
expect(body.error).toMatch(/no candidate provider credentials/);
});
});

// ── proxyRequest X-Initiator billing header injection ─────────────────────────
//
// When forwarding to the Copilot API, the proxy must inject "X-Initiator: agent"
// when the header is absent so that autonomous turns are billed correctly instead
// of defaulting to the (more expensive) "user" rate.
//
describe('proxyRequest X-Initiator injection', () => {
/** Minimal mock for http.IncomingMessage backed by EventEmitter. */
function makeReq(headers = {}) {
const req = new EventEmitter();
req.url = '/v1/chat/completions';
req.method = 'POST';
req.headers = { 'content-type': 'application/json', ...headers };
return req;
}

/** Minimal mock for http.ServerResponse. */
function makeRes() {
return {
headersSent: false,
setHeader: jest.fn(),
writeHead: jest.fn(),
end: jest.fn(),
};
}

afterEach(() => {
jest.restoreAllMocks();
});

/**
* Mock https.request to capture the outgoing options (including headers)
* without making a real network connection.
*/
function mockHttpsRequest() {
let capturedOptions;
jest.spyOn(https, 'request').mockImplementation((options) => {
capturedOptions = options;
const proxyReq = new EventEmitter();
proxyReq.end = jest.fn();
proxyReq.write = jest.fn();
proxyReq.destroy = jest.fn();
return proxyReq;
});
return { getCaptured: () => capturedOptions };
}

it('injects x-initiator: agent when absent on direct copilot requests', () => {
const { getCaptured } = mockHttpsRequest();
const req = makeReq();
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'copilot');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBe('agent');
});

it('injects x-initiator: agent when absent on enterprise githubcopilot.com target', () => {
const { getCaptured } = mockHttpsRequest();
const req = makeReq();
proxyRequest(req, makeRes(), 'api.enterprise.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'copilot');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBe('agent');
});

it('injects x-initiator: agent when OpenCode routes to Copilot backend', () => {
// OpenCode delegates to Copilot; provider name is "opencode" but target host is Copilot.
const { getCaptured } = mockHttpsRequest();
const req = makeReq();
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'opencode');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBe('agent');
});

it('preserves a client-supplied x-initiator value on copilot requests', () => {
const { getCaptured } = mockHttpsRequest();
const req = makeReq({ 'x-initiator': 'user' });
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'copilot');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBe('user');
});

it('preserves a client-supplied x-initiator value on OpenCode→Copilot requests', () => {
const { getCaptured } = mockHttpsRequest();
const req = makeReq({ 'x-initiator': 'user' });
proxyRequest(req, makeRes(), 'api.githubcopilot.com', { 'Authorization': 'Bearer token' }, 'opencode');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBe('user');
});

it('does not inject x-initiator for non-copilot provider targets', () => {
const { getCaptured } = mockHttpsRequest();
const req = makeReq();
proxyRequest(req, makeRes(), 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBeUndefined();
});

it('does not inject x-initiator when OpenCode routes to non-Copilot backend', () => {
// OpenCode routing to OpenAI — no Copilot billing header should be injected.
const { getCaptured } = mockHttpsRequest();
const req = makeReq();
proxyRequest(req, makeRes(), 'api.openai.com', { 'Authorization': 'Bearer sk-test' }, 'opencode');
req.emit('end');
expect(getCaptured().headers['x-initiator']).toBeUndefined();
});
});
Loading