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
62 changes: 62 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,32 @@ 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')
);
}

function isAzureOpenAIV1Path(pathname) {
return pathname === '/openai/v1' || pathname.startsWith('/openai/v1/');
}

function shouldInjectAzureApiVersion(basePath, requestPathname) {
return !isAzureOpenAIV1Path(basePath) && !isAzureOpenAIV1Path(requestPathname);
}

/** 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 +271,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 +405,40 @@ function createCopilotAdapter(env, deps = {}) {
};
}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tested and api-key header does NOT work for Entra access tokens - but Authorization: Bearer ... appears to work for both Foundry API keys and Entra tokens.

Copy link
Copy Markdown
Collaborator

@zarenner zarenner May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed for both API forms:

  • https://[resource].openai.azure.com/openai/v1/chat/completions (with model in body set to the deployment name)
  • https://[resource].openai.azure.com/openai/deployments/[model_deployment_name]/chat/completions?api-version=2024-06-01

}

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 (!shouldInjectAzureApiVersion(basePath || '', parsed.pathname)) {
return url;
}
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 +479,10 @@ module.exports = {
deriveGitHubApiTarget,
deriveGitHubApiBasePath,
isGithubCopilotCatalogTarget,
isAzureOpenAITarget,
shouldInjectAzureApiVersion,
COPILOT_PLACEHOLDER_TOKEN,
COPILOT_DUMMY_BYOK_KEY,
AZURE_DEFAULT_API_VERSION,
},
};
157 changes: 157 additions & 0 deletions containers/api-proxy/server.copilot-azure.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* 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,
shouldInjectAzureApiVersion,
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('shouldInjectAzureApiVersion', () => {
it('returns true for Azure deployment-style base paths', () => {
expect(shouldInjectAzureApiVersion('/openai/deployments/gpt-4o', '/chat/completions')).toBe(true);
});

it('returns false for Azure v1 base path', () => {
expect(shouldInjectAzureApiVersion('/openai/v1', '/chat/completions')).toBe(false);
});

it('returns false when request path is Azure v1 formatted', () => {
expect(shouldInjectAzureApiVersion('', '/openai/v1/chat/completions')).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', () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot api-version should only be appended for /openai/deployments/.../chat/completions?api-version=2024-06-01. It MUST NOT be appended for /openai/v1/chat/completions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot address review feedback

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('does not append api-version for Azure OpenAI v1 base path', () => {
const adapter = createCopilotAdapter({
...azureEnv,
COPILOT_API_BASE_PATH: '/openai/v1',
});
const result = adapter.transformRequestUrl('/chat/completions?stream=true');
expect(result).toBe('/chat/completions?stream=true');
});

it('does not append api-version for Azure OpenAI v1 request path', () => {
const adapter = createCopilotAdapter({
...azureEnv,
COPILOT_API_BASE_PATH: '',
});
const result = adapter.transformRequestUrl('/openai/v1/chat/completions?stream=true');
expect(result).toBe('/openai/v1/chat/completions?stream=true');
});

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' });
});
});
});
35 changes: 35 additions & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ the corresponding CLI flag.
- `apiProxy.targets.openai.authHeader` → `--openai-api-auth-header`
- `apiProxy.targets.anthropic.basePath` → `--anthropic-api-base-path`
- `apiProxy.targets.anthropic.authHeader` → `--anthropic-api-auth-header`
- `apiProxy.targets.copilot.basePath` → `COPILOT_API_BASE_PATH` *(config-only; used for Azure OpenAI deployment path)*
- `apiProxy.targets.copilot.azureApiVersion` → `COPILOT_AZURE_API_VERSION` *(config-only; default `2024-10-21`)*
- `apiProxy.targets.gemini.basePath` → `--gemini-api-base-path`
- `apiProxy.targets.antigravity.basePath` → `--gemini-api-base-path`
- When both `apiProxy.targets.antigravity` and `apiProxy.targets.gemini` are set, `antigravity` takes precedence per field.
Expand Down Expand Up @@ -520,6 +522,39 @@ When `security.difcProxy.host` is set, `GITHUB_TOKEN` and `GH_TOKEN` MUST
be excluded from the agent environment. These tokens SHALL be held
exclusively by the external DIFC proxy.

### 9.7 Azure OpenAI BYOK Routing

When the Copilot target host is an Azure OpenAI endpoint (`*.openai.azure.com`
or `*.cognitiveservices.azure.com`), the API proxy adapter applies Azure-specific
routing:

1. **Auth header**: Uses `api-key: <token>` instead of `Authorization: Bearer <token>`
2. **API version**: Appends `?api-version=<version>` to upstream requests when absent
3. **Deployment path**: The `basePath` specifies the Azure deployment path
(e.g., `/openai/deployments/gpt-4o`)

**Configuration example:**

```json
{
"apiProxy": {
"targets": {
"copilot": {
"host": "my-resource.openai.azure.com",
"basePath": "/openai/deployments/gpt-4o",
"azureApiVersion": "2024-10-21"
}
}
}
}
```

**Environment variable equivalents:**
- `COPILOT_API_TARGET=https://my-resource.openai.azure.com`
- `COPILOT_API_BASE_PATH=/openai/deployments/gpt-4o`
- `COPILOT_API_KEY=<azure-api-key>` (security-sensitive; use env var, not config)
- `COPILOT_AZURE_API_VERSION=2024-10-21` (optional; defaults to `2024-10-21`)

## 10. Effective Token Budget Enforcement

*This section is normative.*
Expand Down
23 changes: 21 additions & 2 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@
"description": "Anthropic API target override."
},
"copilot": {
"$ref": "#/$defs/providerHostOnlyTarget",
"description": "GitHub Copilot API target override (basePath not supported)."
"$ref": "#/$defs/copilotProviderTarget",
"description": "GitHub Copilot API target override. Supports Azure OpenAI BYOK via basePath and azureApiVersion."
},
"gemini": {
"$ref": "#/$defs/providerTarget",
Expand Down Expand Up @@ -550,6 +550,25 @@
}
}
},
"copilotProviderTarget": {
"type": "object",
"description": "Copilot API provider target override with Azure OpenAI support.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the Copilot API host (e.g., 'my-resource.openai.azure.com' for Azure BYOK)."
},
"basePath": {
"type": "string",
"description": "Override the API base path (e.g., '/openai/deployments/gpt-4o' for Azure deployments)."
},
"azureApiVersion": {
"type": "string",
"description": "Azure OpenAI API version. Only used when host is an Azure endpoint. Default: '2024-10-21'."
}
}
},
"providerHostOnlyTarget": {
"type": "object",
"description": "API provider target override (host only; basePath not supported).",
Expand Down
23 changes: 21 additions & 2 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@
"description": "Anthropic API target override."
},
"copilot": {
"$ref": "#/$defs/providerHostOnlyTarget",
"description": "GitHub Copilot API target override (basePath not supported)."
"$ref": "#/$defs/copilotProviderTarget",
"description": "GitHub Copilot API target override. Supports Azure OpenAI BYOK via basePath and azureApiVersion."
},
"gemini": {
"$ref": "#/$defs/providerTarget",
Expand Down Expand Up @@ -550,6 +550,25 @@
}
}
},
"copilotProviderTarget": {
"type": "object",
"description": "Copilot API provider target override with Azure OpenAI support.",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"description": "Override the Copilot API host (e.g., 'my-resource.openai.azure.com' for Azure BYOK)."
},
"basePath": {
"type": "string",
"description": "Override the API base path (e.g., '/openai/deployments/gpt-4o' for Azure deployments)."
},
"azureApiVersion": {
"type": "string",
"description": "Azure OpenAI API version. Only used when host is an Azure endpoint. Default: '2024-10-21'."
}
}
},
"providerHostOnlyTarget": {
"type": "object",
"description": "API provider target override (host only; basePath not supported).",
Expand Down
Loading
Loading