Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
50 changes: 50 additions & 0 deletions containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN
* regardless of which auth mode is active, because the /models endpoint only
* accepts OAuth tokens, not API keys.
*
* Azure OpenAI BYOK: when the target is *.openai.azure.com, the adapter uses
* `api-key:` header (instead of `Authorization: Bearer`) and appends
* `api-version` query parameter if not present.
*/

const {
Expand Down Expand Up @@ -125,6 +129,24 @@ function deriveCopilotApiTarget(env = process.env) {
return 'api.githubcopilot.com';
}

/**
* Returns true when the target hostname is an Azure OpenAI endpoint.
* Azure OpenAI uses a distinct auth header (`api-key:`) and may need
* `api-version` query parameters.
*
* @param {string} target - Normalized hostname
* @returns {boolean}
*/
function isAzureOpenAITarget(target) {
return (
target === 'openai.azure.com' || target.endsWith('.openai.azure.com') ||
target === 'cognitiveservices.azure.com' || target.endsWith('.cognitiveservices.azure.com')
);
}

/** Default Azure OpenAI API version used when none is specified */
const AZURE_DEFAULT_API_VERSION = '2024-10-21';

/**
* Derive the GitHub REST API target hostname (used for GHES/GHEC endpoints).
*
Expand Down Expand Up @@ -241,6 +263,8 @@ function createCopilotAdapter(env, deps = {}) {
const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const rawTarget = deriveCopilotApiTarget(env);
const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH);
const isAzure = isAzureOpenAITarget(rawTarget);
const azureApiVersion = env.COPILOT_AZURE_API_VERSION || AZURE_DEFAULT_API_VERSION;

const bodyTransform = composeBodyTransforms(
deps.bodyTransform || null,
Expand Down Expand Up @@ -373,13 +397,37 @@ function createCopilotAdapter(env, deps = {}) {
};
}

// Azure OpenAI uses `api-key:` header instead of `Authorization: Bearer`
if (isAzure) {
return { 'api-key': authToken };
}

return {
'Authorization': `Bearer ${authToken}`,
'Copilot-Integration-Id': integrationId,
};
},

getBodyTransform() { return bodyTransform; },

/**
* For Azure OpenAI targets, inject `api-version` query param if absent.
* @param {string} url
* @returns {string}
*/
transformRequestUrl(url) {
if (!isAzure) return url;
try {
const parsed = new URL(url, 'http://localhost');
if (!parsed.searchParams.has('api-version')) {
parsed.searchParams.set('api-version', azureApiVersion);
}
return parsed.pathname + parsed.search;
} catch {
return url;
}
},

...adapterMethods,

/** Response returned for all requests when no Copilot credentials are configured. */
Expand Down Expand Up @@ -420,7 +468,9 @@ module.exports = {
deriveGitHubApiTarget,
deriveGitHubApiBasePath,
isGithubCopilotCatalogTarget,
isAzureOpenAITarget,
COPILOT_PLACEHOLDER_TOKEN,
COPILOT_DUMMY_BYOK_KEY,
AZURE_DEFAULT_API_VERSION,
},
};
124 changes: 124 additions & 0 deletions containers/api-proxy/server.copilot-azure.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Tests for Copilot Azure OpenAI BYOK routing.
*
* Covers: isAzureOpenAITarget detection, api-key header injection,
* and api-version query parameter injection via transformRequestUrl.
*/

const {
createCopilotAdapter,
_testing: {
isAzureOpenAITarget,
AZURE_DEFAULT_API_VERSION,
},
} = require('./providers/copilot');

describe('isAzureOpenAITarget', () => {
it('detects *.openai.azure.com', () => {
expect(isAzureOpenAITarget('my-resource.openai.azure.com')).toBe(true);
});

it('detects *.cognitiveservices.azure.com', () => {
expect(isAzureOpenAITarget('my-resource.cognitiveservices.azure.com')).toBe(true);
});

it('does not match standard Copilot target', () => {
expect(isAzureOpenAITarget('api.githubcopilot.com')).toBe(false);
});

it('does not match partial hostname match', () => {
expect(isAzureOpenAITarget('evil.openai.azure.com.attacker.com')).toBe(false);
expect(isAzureOpenAITarget('openai.azure.com')).toBe(true);
});

it('does not match GitHub catalog targets', () => {
expect(isAzureOpenAITarget('models.inference.ai.azure.com')).toBe(false);
});
});

describe('Azure OpenAI BYOK adapter', () => {
const azureEnv = {
COPILOT_API_KEY: 'my-azure-api-key',
COPILOT_API_TARGET: 'https://my-resource.openai.azure.com',
COPILOT_API_BASE_PATH: '/openai/deployments/gpt-4o',
};

describe('getAuthHeaders', () => {
it('uses api-key header for Azure targets', () => {
const adapter = createCopilotAdapter(azureEnv);
const req = { url: '/chat/completions', method: 'POST', headers: {} };
const headers = adapter.getAuthHeaders(req);
expect(headers).toEqual({ 'api-key': 'my-azure-api-key' });
});

it('does not include Copilot-Integration-Id for Azure targets', () => {
const adapter = createCopilotAdapter(azureEnv);
const req = { url: '/chat/completions', method: 'POST', headers: {} };
const headers = adapter.getAuthHeaders(req);
expect(headers['Copilot-Integration-Id']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});

it('still uses Bearer auth for non-Azure targets', () => {
const adapter = createCopilotAdapter({
COPILOT_API_KEY: 'my-key',
COPILOT_API_TARGET: 'https://api.githubcopilot.com',
});
const req = { url: '/chat/completions', method: 'POST', headers: {} };
const headers = adapter.getAuthHeaders(req);
expect(headers['Authorization']).toBe('Bearer my-key');
});
});

describe('transformRequestUrl', () => {
it('appends api-version when absent for Azure targets', () => {
const adapter = createCopilotAdapter(azureEnv);
const result = adapter.transformRequestUrl('/chat/completions');
expect(result).toBe(`/chat/completions?api-version=${AZURE_DEFAULT_API_VERSION}`);
});

it('preserves existing api-version parameter', () => {
const adapter = createCopilotAdapter(azureEnv);
const result = adapter.transformRequestUrl('/chat/completions?api-version=2025-01-01');
expect(result).toBe('/chat/completions?api-version=2025-01-01');
});

it('preserves other query parameters', () => {
const adapter = createCopilotAdapter(azureEnv);
const result = adapter.transformRequestUrl('/chat/completions?stream=true');
expect(result).toContain('stream=true');
expect(result).toContain(`api-version=${AZURE_DEFAULT_API_VERSION}`);
});

it('respects COPILOT_AZURE_API_VERSION override', () => {
const adapter = createCopilotAdapter({
...azureEnv,
COPILOT_AZURE_API_VERSION: '2025-03-01',
});
const result = adapter.transformRequestUrl('/chat/completions');
expect(result).toBe('/chat/completions?api-version=2025-03-01');
});

it('is a no-op for non-Azure targets', () => {
const adapter = createCopilotAdapter({
COPILOT_API_KEY: 'my-key',
COPILOT_API_TARGET: 'https://api.githubcopilot.com',
});
const result = adapter.transformRequestUrl('/v1/chat/completions');
expect(result).toBe('/v1/chat/completions');
});
});

describe('cognitiveservices.azure.com target', () => {
it('also uses api-key header', () => {
const adapter = createCopilotAdapter({
COPILOT_API_KEY: 'cog-key',
COPILOT_API_TARGET: 'https://my-resource.cognitiveservices.azure.com',
COPILOT_API_BASE_PATH: '/openai/deployments/gpt-4o',
});
const req = { url: '/chat/completions', method: 'POST', headers: {} };
const headers = adapter.getAuthHeaders(req);
expect(headers).toEqual({ 'api-key': 'cog-key' });
});
});
});
Loading
Loading