Skip to content

Commit a813abc

Browse files
authored
[awf] Support Azure Copilot BYOK env routing in api-proxy and resolve gpt-5.4 via gpt-5 family aliases (#3241)
* Initial plan * fix: support azure byok envs in api-proxy --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent b0e16b4 commit a813abc

6 files changed

Lines changed: 146 additions & 4 deletions

File tree

containers/api-proxy/model-resolver.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,20 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider,
133133
const newChain = [...chain, key];
134134

135135
// ── Find alias entry (case-insensitive) ───────────────────────────────────
136-
const aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);
136+
let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);
137+
138+
if (!aliasEntry) {
139+
// Family fallback: treat gpt-5.<minor> as gpt-5 when only the family alias
140+
// exists. This keeps versioned IDs like gpt-5.4 compatible with configs that
141+
// define "gpt-5" alias patterns.
142+
const familyAlias = key.match(/^(gpt-5)\.\d+(?:[._-].*)?$/)?.[1];
143+
if (familyAlias) {
144+
aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === familyAlias);
145+
if (aliasEntry) {
146+
log.push(`[model-resolver] fallback alias: "${requestedModel}" → "${aliasEntry[0]}"`);
147+
}
148+
}
149+
}
137150

138151
if (!aliasEntry) {
139152
// No alias defined — check if the model directly matches an available model for

containers/api-proxy/model-resolver.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ describe('resolveModel', () => {
221221
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
222222
});
223223

224+
it('should resolve gpt-5 minor-version aliases via gpt-5 family fallback', () => {
225+
const result = resolveModel(
226+
'gpt-5.4',
227+
{ 'gpt-5': ['copilot/gpt-5*'] },
228+
{ copilot: ['gpt-5.3', 'gpt-5.4'] },
229+
'copilot'
230+
);
231+
expect(result).not.toBeNull();
232+
expect(result.resolvedModel).toBe('gpt-5.4');
233+
});
234+
224235
it('should not match provider patterns for a different provider', () => {
225236
// "gpt-5-codex" only has copilot/... and openai/... patterns
226237
// When resolving for anthropic, there's nothing to match

containers/api-proxy/oidc-token-provider.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,36 @@ describe('OpenAI adapter with OIDC', () => {
287287
expect(adapter.getReflectionInfo().auth_type).toBe('static-key');
288288
});
289289

290+
it('should configure OpenAI adapter from Copilot Azure BYOK env vars', () => {
291+
const adapter = createOpenAIAdapter({
292+
COPILOT_PROVIDER_TYPE: 'azure',
293+
COPILOT_PROVIDER_BASE_URL: 'https://my-resource.openai.azure.com/openai/deployments/gpt-5',
294+
COPILOT_PROVIDER_API_KEY: 'azure-byok-key',
295+
});
296+
297+
expect(adapter.isEnabled()).toBe(true);
298+
expect(adapter.getTargetHost()).toBe('my-resource.openai.azure.com');
299+
expect(adapter.getBasePath()).toBe('/openai/deployments/gpt-5');
300+
expect(adapter.getAuthHeaders()).toEqual({ Authorization: 'Bearer azure-byok-key' });
301+
expect(adapter.getReflectionInfo().auth_type).toBe('static-key');
302+
});
303+
304+
it('should prefer explicit OPENAI_* config over Copilot Azure BYOK env vars', () => {
305+
const adapter = createOpenAIAdapter({
306+
OPENAI_API_KEY: 'sk-openai',
307+
OPENAI_API_TARGET: 'gateway.example.com',
308+
OPENAI_API_BASE_PATH: '/v2',
309+
COPILOT_PROVIDER_TYPE: 'azure',
310+
COPILOT_PROVIDER_BASE_URL: 'https://my-resource.openai.azure.com/openai/deployments/gpt-5',
311+
COPILOT_PROVIDER_API_KEY: 'azure-byok-key',
312+
});
313+
314+
expect(adapter.isEnabled()).toBe(true);
315+
expect(adapter.getTargetHost()).toBe('gateway.example.com');
316+
expect(adapter.getBasePath()).toBe('/v2');
317+
expect(adapter.getAuthHeaders()).toEqual({ Authorization: 'Bearer sk-openai' });
318+
});
319+
290320
it('should not create OIDC provider when required vars are missing', () => {
291321
const adapter = createOpenAIAdapter({
292322
AWF_AUTH_TYPE: 'github-oidc',

containers/api-proxy/providers/openai.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,32 @@
1010
* Base path: OPENAI_API_BASE_PATH (default: /v1 for the public endpoint)
1111
*/
1212

13-
const { createBaseAdapterConfig, createAdapterMethods } = require('../proxy-utils');
13+
const {
14+
createBaseAdapterConfig,
15+
createAdapterMethods,
16+
normalizeBasePath,
17+
} = require('../proxy-utils');
1418
const { OidcTokenProvider } = require('../oidc-token-provider');
1519

20+
function parseByokBaseUrl(baseUrl) {
21+
if (!baseUrl) return { target: undefined, basePath: '' };
22+
const trimmed = baseUrl.trim();
23+
if (!trimmed) return { target: undefined, basePath: '' };
24+
25+
const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
26+
? trimmed
27+
: `https://${trimmed}`;
28+
try {
29+
const parsed = new URL(candidate);
30+
return {
31+
target: parsed.hostname || undefined,
32+
basePath: normalizeBasePath(parsed.pathname),
33+
};
34+
} catch {
35+
return { target: undefined, basePath: '' };
36+
}
37+
}
38+
1639
/**
1740
* Create the OpenAI provider adapter.
1841
*
@@ -21,12 +44,21 @@ const { OidcTokenProvider } = require('../oidc-token-provider');
2144
* @returns {import('./index').ProviderAdapter}
2245
*/
2346
function createOpenAIAdapter(env, deps = {}) {
24-
const { apiKey, rawTarget, basePath: explicitBasePath } = createBaseAdapterConfig(env, {
47+
const { apiKey: openaiApiKey, rawTarget: openaiTarget, basePath: openaiBasePath } = createBaseAdapterConfig(env, {
2548
keyEnvVar: 'OPENAI_API_KEY',
2649
targetEnvVar: 'OPENAI_API_TARGET',
2750
basePathEnvVar: 'OPENAI_API_BASE_PATH',
2851
defaultTarget: 'api.openai.com',
2952
});
53+
const providerType = (env.COPILOT_PROVIDER_TYPE || '').trim().toLowerCase();
54+
const copilotAzureByokEnabled = providerType === 'azure';
55+
const copilotByokApiKey = (env.COPILOT_PROVIDER_API_KEY || '').trim() || undefined;
56+
const { target: copilotByokTarget, basePath: copilotByokBasePath } = parseByokBaseUrl(env.COPILOT_PROVIDER_BASE_URL);
57+
58+
const apiKey = openaiApiKey || (copilotAzureByokEnabled ? copilotByokApiKey : undefined);
59+
const explicitOpenAITarget = env.OPENAI_API_TARGET ? openaiTarget : undefined;
60+
const rawTarget = explicitOpenAITarget || (copilotAzureByokEnabled ? copilotByokTarget : undefined) || 'api.openai.com';
61+
const explicitBasePath = openaiBasePath || (copilotAzureByokEnabled ? copilotByokBasePath : '');
3062

3163
// For the default OpenAI endpoint, unversioned clients (e.g. Codex CLI sending
3264
// /responses) need a /v1 prefix to reach the correct versioned API surface.
@@ -175,7 +207,7 @@ function createOpenAIAdapter(env, deps = {}) {
175207
}
176208
return {
177209
statusCode: 404,
178-
body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY or OIDC auth)' },
210+
body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY/COPILOT_PROVIDER_API_KEY or OIDC auth)' },
179211
};
180212
},
181213
};

src/services/api-proxy-service.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,44 @@ describe('API proxy sidecar', () => {
900900
expect(env.COPILOT_PROVIDER_BASE_URL).toBeUndefined();
901901
});
902902

903+
it('should pass COPILOT_PROVIDER_TYPE/BASE_URL/API_KEY from additionalEnv to api-proxy', () => {
904+
const configWithProxy = {
905+
...mockConfig,
906+
enableApiProxy: true,
907+
additionalEnv: {
908+
COPILOT_PROVIDER_TYPE: 'azure',
909+
COPILOT_PROVIDER_BASE_URL: 'https://example-resource.openai.azure.com/openai/deployments/test',
910+
COPILOT_PROVIDER_API_KEY: 'azure-byok-key',
911+
},
912+
};
913+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
914+
const proxy = result.services['api-proxy'];
915+
const env = proxy.environment as Record<string, string>;
916+
expect(env.COPILOT_PROVIDER_TYPE).toBe('azure');
917+
expect(env.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/test');
918+
expect(env.COPILOT_PROVIDER_API_KEY).toBe('azure-byok-key');
919+
});
920+
921+
it('should pass COPILOT_PROVIDER_TYPE/BASE_URL/API_KEY from envFile to api-proxy', () => {
922+
const envFilePath = path.join(mockConfig.workDir, '.env.azure-byok');
923+
fs.writeFileSync(envFilePath, [
924+
'COPILOT_PROVIDER_TYPE=azure',
925+
'COPILOT_PROVIDER_BASE_URL=https://example-resource.openai.azure.com/openai/deployments/test',
926+
'COPILOT_PROVIDER_API_KEY=azure-byok-key',
927+
].join('\n'));
928+
const configWithProxy = {
929+
...mockConfig,
930+
enableApiProxy: true,
931+
envFile: envFilePath,
932+
};
933+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
934+
const proxy = result.services['api-proxy'];
935+
const env = proxy.environment as Record<string, string>;
936+
expect(env.COPILOT_PROVIDER_TYPE).toBe('azure');
937+
expect(env.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/test');
938+
expect(env.COPILOT_PROVIDER_API_KEY).toBe('azure-byok-key');
939+
});
940+
903941
it.each(['gpt-5', 'openai/o3-mini', 'gpt-5.4-mini', 'GPT-5', 'O3'])('should set COPILOT_PROVIDER_WIRE_API=responses in GitHub token mode when COPILOT_MODEL is %s', (copilotModel) => {
904942
const configWithProxy = {
905943
...mockConfig,

src/services/api-proxy-service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ function getCopilotModel(config: WrapperConfig): string | undefined {
4747
return normalizedModel || undefined;
4848
}
4949

50+
function getConfigEnvValue(config: WrapperConfig, key: string): string | undefined {
51+
const envFileValue = config.envFile
52+
? readEnvFile(config.envFile)[key]
53+
: undefined;
54+
const value =
55+
config.additionalEnv?.[key] ??
56+
envFileValue ??
57+
(config.envAll ? process.env[key] : undefined);
58+
const normalizedValue = value?.trim();
59+
return normalizedValue || undefined;
60+
}
61+
5062
function requiresResponsesWireApi(copilotModel: string): boolean {
5163
return RESPONSES_WIRE_API_MODEL_PATTERN.test(copilotModel);
5264
}
@@ -59,6 +71,9 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui
5971
const { config, networkConfig, apiProxyLogsPath, imageConfig } = params;
6072
const { useGHCR, registry, parsedTag, projectRoot } = imageConfig;
6173
const normalizedAuthType = (process.env.AWF_AUTH_TYPE || '').trim().toLowerCase();
74+
const copilotProviderType = getConfigEnvValue(config, 'COPILOT_PROVIDER_TYPE');
75+
const copilotProviderBaseUrl = getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
76+
const copilotProviderApiKey = getConfigEnvValue(config, 'COPILOT_PROVIDER_API_KEY');
6277

6378
if (!networkConfig.proxyIp) {
6479
throw new Error('buildApiProxyService: networkConfig.proxyIp is required');
@@ -91,6 +106,9 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui
91106
// container at all (belt-and-suspenders for gh-aw#25137).
92107
...(config.copilotApiTarget && { COPILOT_API_TARGET: stripScheme(config.copilotApiTarget) }),
93108
...(config.copilotApiBasePath && { COPILOT_API_BASE_PATH: config.copilotApiBasePath }),
109+
...(copilotProviderType && { COPILOT_PROVIDER_TYPE: copilotProviderType }),
110+
...(copilotProviderBaseUrl && { COPILOT_PROVIDER_BASE_URL: copilotProviderBaseUrl }),
111+
...(copilotProviderApiKey && { COPILOT_PROVIDER_API_KEY: copilotProviderApiKey }),
94112
...(config.openaiApiTarget && { OPENAI_API_TARGET: stripScheme(config.openaiApiTarget) }),
95113
...(config.openaiApiBasePath && { OPENAI_API_BASE_PATH: config.openaiApiBasePath }),
96114
...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: stripScheme(config.anthropicApiTarget) }),

0 commit comments

Comments
 (0)