Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ the corresponding CLI flag.
- `apiProxy.maxPermissionDenied` → `--max-permission-denied <number>`
- `apiProxy.requestedModel` → *(config-only; maps to `AWF_REQUESTED_MODEL` for pre-startup validation)*
- `apiProxy.modelFallback` → *(config-only; model fallback strategy)*
- `apiProxy.modelRouter.providerType` → *(config-only; maps to `COPILOT_PROVIDER_TYPE`)*
- `apiProxy.modelRouter.baseUrl` → *(config-only; maps to `COPILOT_PROVIDER_BASE_URL`)*
- `apiProxy.models` → *(config-only; model alias rewriting)*
- `apiProxy.logging.debugTokens` → *(config-only; maps to `AWF_DEBUG_TOKENS`)*
- `apiProxy.logging.tokenLogDir` → *(config-only; maps to `AWF_TOKEN_LOG_DIR`)*
Expand Down
1,171 changes: 593 additions & 578 deletions docs/awf-config.schema.json

Large diffs are not rendered by default.

1,171 changes: 593 additions & 578 deletions src/awf-config-schema.json

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/commands/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const ENV_KEYS = [
'ANTHROPIC_API_KEY',
'COPILOT_GITHUB_TOKEN',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_TYPE',
'COPILOT_PROVIDER_BASE_URL',
'GEMINI_API_KEY',
'GITHUB_TOKEN',
'GH_TOKEN',
Expand Down Expand Up @@ -190,6 +192,18 @@ describe('buildConfig', () => {
expect(config.copilotProviderApiKey).toBe('sk-byok-provider');
});

it('should read COPILOT_PROVIDER_TYPE from process.env', () => {
process.env.COPILOT_PROVIDER_TYPE = 'azure';
const config = buildConfig(makeInputs());
expect(config.copilotProviderType).toBe('azure');
});

it('should read COPILOT_PROVIDER_BASE_URL from process.env', () => {
process.env.COPILOT_PROVIDER_BASE_URL = 'https://router.example.com/v1';
const config = buildConfig(makeInputs());
expect(config.copilotProviderBaseUrl).toBe('https://router.example.com/v1');
});

it('should prefer GITHUB_TOKEN over GH_TOKEN', () => {
process.env.GITHUB_TOKEN = 'github-token';
process.env.GH_TOKEN = 'gh-token';
Expand Down Expand Up @@ -296,5 +310,19 @@ describe('buildConfig', () => {
}));
expect(config.copilotByokExtraHeaders).toEqual({ 'x-session-id': 'run-42' });
});

it('should prefer config options over COPILOT_PROVIDER_TYPE/BASE_URL env vars', () => {
process.env.COPILOT_PROVIDER_TYPE = 'env-type';
process.env.COPILOT_PROVIDER_BASE_URL = 'https://env-router.example.com/v1';
const config = buildConfig(makeInputs({
options: {
...makeInputs().options,
copilotProviderType: 'azure',
copilotProviderBaseUrl: 'https://config-router.example.com/v1',
},
}));
expect(config.copilotProviderType).toBe('azure');
expect(config.copilotProviderBaseUrl).toBe('https://config-router.example.com/v1');
});
});
});
4 changes: 4 additions & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
copilotProviderApiKey: process.env.COPILOT_PROVIDER_API_KEY,
copilotProviderType:
(options.copilotProviderType as string | undefined) || process.env.COPILOT_PROVIDER_TYPE,
copilotProviderBaseUrl:
(options.copilotProviderBaseUrl as string | undefined) || process.env.COPILOT_PROVIDER_BASE_URL,
geminiApiKey: process.env.GEMINI_API_KEY,
copilotApiTarget: resolvedCopilotApiTarget,
copilotApiBasePath: resolvedCopilotApiBasePath,
Expand Down
13 changes: 13 additions & 0 deletions src/config-file-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,19 @@ describe('mapAwfFileConfigToCliOptions', () => {
});
});

it('maps modelRouter fields', () => {
const result = mapAwfFileConfigToCliOptions({
apiProxy: {
modelRouter: {
providerType: 'azure',
baseUrl: 'https://example-resource.openai.azure.com/openai/deployments/test',
},
},
});
expect(result.copilotProviderType).toBe('azure');
expect(result.copilotProviderBaseUrl).toBe('https://example-resource.openai.azure.com/openai/deployments/test');
});

it('leaves maxRuns undefined when not set', () => {
const result = mapAwfFileConfigToCliOptions({});
expect(result.maxRuns).toBeUndefined();
Expand Down
11 changes: 11 additions & 0 deletions src/config-file-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ describe('validateAwfFileConfig', () => {
.toContain('config.apiProxy.modelFallback.strategy must be one of: middle_power');
});

it('validates apiProxy.modelRouter fields', () => {
expect(validateAwfFileConfig({
apiProxy: { modelRouter: { providerType: 'azure', baseUrl: 'https://router.example.com/v1' } },
})).toEqual([]);

expect(validateAwfFileConfig({ apiProxy: { modelRouter: { providerType: 123 } } }))
.toContain('config.apiProxy.modelRouter.providerType must be a string');
expect(validateAwfFileConfig({ apiProxy: { modelRouter: { baseUrl: 456 } } }))
.toContain('config.apiProxy.modelRouter.baseUrl must be a string');
});

it('rejects non-object apiProxy.targets', () => {
const errors = validateAwfFileConfig({ apiProxy: { targets: 'invalid' } });
expect(errors).toContain('config.apiProxy.targets must be an object');
Expand Down
6 changes: 6 additions & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ interface AwfFileConfig {
strategy?: 'middle_power';
excludeEngines?: string[];
};
modelRouter?: {
providerType?: string;
baseUrl?: string;
};
targets?: {
openai?: { host?: string; basePath?: string; authHeader?: string };
anthropic?: { host?: string; basePath?: string; authHeader?: string };
Expand Down Expand Up @@ -192,6 +196,8 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
maxPermissionDenied: config.apiProxy?.maxPermissionDenied,
requestedModel: config.apiProxy?.requestedModel,
modelFallback: config.apiProxy?.modelFallback,
copilotProviderType: config.apiProxy?.modelRouter?.providerType,
copilotProviderBaseUrl: config.apiProxy?.modelRouter?.baseUrl,
openaiApiTarget: config.apiProxy?.targets?.openai?.host,
openaiApiBasePath: config.apiProxy?.targets?.openai?.basePath,
openaiApiAuthHeader: config.apiProxy?.targets?.openai?.authHeader,
Expand Down
16 changes: 16 additions & 0 deletions src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ describe('awf-config.schema.json', () => {
expect(validate({ apiProxy: { requestedModel: 123 } })).toBe(false);
});

it('accepts apiProxy.modelRouter with string fields', () => {
expect(validate({
apiProxy: {
modelRouter: {
providerType: 'azure',
baseUrl: 'https://router.example.com/v1',
},
},
})).toBe(true);
});

it('rejects invalid apiProxy.modelRouter field types', () => {
expect(validate({ apiProxy: { modelRouter: { providerType: 123 } } })).toBe(false);
expect(validate({ apiProxy: { modelRouter: { baseUrl: 456 } } })).toBe(false);
});

it('rejects invalid logging.logLevel values', () => {
expect(validate({ logging: { logLevel: 'verbose' } })).toBe(false);
expect(validate({ logging: { logLevel: 'debug' } })).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion src/services/api-proxy-credential-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function buildAgentCredentialEnv(params: ApiProxyCredentialEnvParams): Re
// invariant and surfaces a clear error instead of a silent bypass.
// Reference: https://github.blog/changelog/2026-04-07-copilot-cli-now-supports-byok-and-local-models/
const hasCopilotProviderApiKey = !!config.copilotProviderApiKey;
const hasCopilotProviderBaseUrl = !!getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
const hasCopilotProviderBaseUrl = !!config.copilotProviderBaseUrl || !!getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
if (config.copilotGithubToken || hasCopilotProviderApiKey || hasCopilotProviderBaseUrl) {
agentEnvAdditions.COPILOT_API_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`;
logger.debug(`GitHub Copilot API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`);
Expand Down
4 changes: 2 additions & 2 deletions src/services/api-proxy-service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ interface ApiProxyServiceConfigParams {
* Centralizes the repetitive per-provider target/basePath conditional env generation.
*/
function buildProviderTargetEnv(config: WrapperConfig): Record<string, string> {
const copilotProviderType = getConfigEnvValue(config, 'COPILOT_PROVIDER_TYPE');
const copilotProviderBaseUrl = getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
const copilotProviderType = config.copilotProviderType || getConfigEnvValue(config, 'COPILOT_PROVIDER_TYPE');
const copilotProviderBaseUrl = config.copilotProviderBaseUrl || getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
Comment on lines +27 to +28
const copilotProviderApiKey = config.copilotProviderApiKey;

const env: Record<string, string> = {};
Expand Down
67 changes: 67 additions & 0 deletions src/services/api-proxy-service-env-forwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,73 @@ describe('API proxy sidecar: env var forwarding', () => {
expect(env.COPILOT_PROVIDER_API_KEY).toBe('azure-byok-key');
});

it('should pass COPILOT_PROVIDER_TYPE/BASE_URL from config modelRouter fields', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotProviderType: 'azure',
copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/test',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_TYPE).toBe('azure');
expect(env.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/test');
});
Comment on lines +389 to +401

describe('config-driven modelRouter.baseUrl triggers agent-side BYOK routing', () => {
// When apiProxy.modelRouter.baseUrl is set in AWF config (stored as
// config.copilotProviderBaseUrl), the agent must be routed through the sidecar
// the same way it would be when COPILOT_PROVIDER_BASE_URL is supplied via
// --env / --env-file / --env-all. Without this wiring, the sidecar env is
// configured but COPILOT_OFFLINE / agent COPILOT_PROVIDER_BASE_URL are never
// set, so Copilot CLI would bypass the proxy entirely.

it('should set agent COPILOT_PROVIDER_BASE_URL to sidecar URL', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_BASE_URL).toBe('http://172.30.0.30:10002');
});

it('should set agent COPILOT_OFFLINE=true', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_OFFLINE).toBe('true');
});

it('should forward the real baseUrl to the sidecar', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxyEnv = result.services['api-proxy'].environment as Record<string, string>;
expect(proxyEnv.COPILOT_PROVIDER_BASE_URL).toBe('https://example-resource.openai.azure.com/openai/deployments/my-router');
});

it('should NOT inject a COPILOT_PROVIDER_API_KEY placeholder when no key was supplied', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotProviderBaseUrl: 'https://example-resource.openai.azure.com/openai/deployments/my-router',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined();
});
});

describe('direct-BYOK mode (user-supplied COPILOT_PROVIDER_API_KEY without COPILOT_GITHUB_TOKEN)', () => {
// When the user points Copilot CLI at an arbitrary upstream (Azure Foundry,
// OpenRouter, etc.) via COPILOT_PROVIDER_BASE_URL + COPILOT_PROVIDER_API_KEY,
Expand Down
28 changes: 28 additions & 0 deletions src/types/api-proxy-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ export interface ApiProxyOptions {
*/
copilotProviderApiKey?: string;

/**
* Copilot BYOK provider type hint forwarded to the API proxy sidecar.
*
* When set, the sidecar uses this hint to select provider-specific behavior
* (for example, Azure OpenAI `api-key` header handling).
*
* Can be set via:
* - Config path: `apiProxy.modelRouter.providerType`
* - Environment variable: `COPILOT_PROVIDER_TYPE`
*
* @default undefined
*/
copilotProviderType?: string;

/**
* Copilot BYOK provider base URL forwarded to the API proxy sidecar.
*
* This points the sidecar at a model router or Copilot-compatible upstream
* endpoint (for example, OpenRouter or Azure OpenAI deployment URLs).
*
* Can be set via:
* - Config path: `apiProxy.modelRouter.baseUrl`
* - Environment variable: `COPILOT_PROVIDER_BASE_URL`
*
* @default undefined
*/
copilotProviderBaseUrl?: string;

/**
* Google Gemini API key (used by API proxy sidecar)
*
Expand Down
Loading