Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 7 additions & 2 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function resolveCopilotAuthToken(env = process.env) {
const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env);
const COPILOT_INTEGRATION_ID = process.env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;
const ENABLE_OPENCODE = process.env.AWF_ENABLE_OPENCODE === 'true';

/**
* Normalizes an API target value to a bare hostname.
Expand Down Expand Up @@ -1434,7 +1435,7 @@ async function fetchStartupModels(overrides = {}) {
* @returns {{ endpoints: Array<object>, models_fetch_complete: boolean, model_aliases: Record<string, string[]>|null }}
*/
function reflectEndpoints() {
const opencodeConfigured = !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN);
const opencodeConfigured = ENABLE_OPENCODE && !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN);
return {
endpoints: [
{
Expand Down Expand Up @@ -1540,7 +1541,7 @@ if (require.main === module) {
if (ANTHROPIC_API_KEY) expectedListeners++;
if (COPILOT_AUTH_TOKEN) expectedListeners++;
if (GEMINI_API_KEY) expectedListeners++;
if (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN) expectedListeners++; // OpenCode (10004)
if (ENABLE_OPENCODE && (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN)) expectedListeners++; // OpenCode (10004)
let readyListeners = 0;
function onListenerReady() {
readyListeners++;
Expand Down Expand Up @@ -1754,6 +1755,8 @@ if (require.main === module) {
}

// OpenCode API proxy (port 10004) — dynamic provider routing
// Only started when AWF_ENABLE_OPENCODE=true, so it doesn't activate
// unconditionally whenever any credential is present (e.g. Copilot-only runs).
// Defaults to Copilot/OpenAI routing (OPENAI_API_KEY), with Anthropic as a BYOK fallback.
// OpenCode gets a separate port from Claude (10001) and Codex (10000) for per-engine
// rate limiting and metrics isolation.
Expand All @@ -1763,6 +1766,7 @@ if (require.main === module) {
// 2. ANTHROPIC_API_KEY → Anthropic BYOK route (ANTHROPIC_API_TARGET)
// 3. COPILOT_GITHUB_TOKEN/API_KEY → Copilot route (COPILOT_API_TARGET),
// resolved internally to COPILOT_AUTH_TOKEN
if (ENABLE_OPENCODE) {
const opencodeStartupRoute = resolveOpenCodeRoute(
OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN,
OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET,
Expand Down Expand Up @@ -1839,6 +1843,7 @@ if (require.main === module) {
onListenerReady();
});
}
} // end if (ENABLE_OPENCODE)

// Graceful shutdown
process.on('SIGTERM', async () => {
Expand Down
8 changes: 4 additions & 4 deletions containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1639,12 +1639,12 @@ describe('reflectEndpoints', () => {
expect(urlMap.opencode).toBeNull();
});

it('should report opencode as configured when openai key is present', () => {
// The module-level OPENAI_API_KEY is whatever process.env had at import time.
// We reflect the real configured state — just verify the shape is correct.
it('should report opencode as not configured when AWF_ENABLE_OPENCODE is not set', () => {
// ENABLE_OPENCODE is false at module load time (AWF_ENABLE_OPENCODE not set in test env),
// so opencode.configured must always be false regardless of other credentials.
const result = reflectEndpoints();
const opencode = result.endpoints.find((e) => e.provider === 'opencode');
expect(typeof opencode.configured).toBe('boolean');
expect(opencode.configured).toBe(false);
expect(opencode.models).toBeNull();
expect(opencode.models_url).toBeNull();
Comment on lines +1642 to 1649

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

This change adds coverage for the disabled-by-default case, but there’s no corresponding test asserting that setting AWF_ENABLE_OPENCODE=true at module load time flips opencode.configured to true when a credential is present. Add a test that sets the env var before importing the module (e.g., via jest.resetModules()/jest.isolateModules()), so regressions in the enable flag wiring are caught.

Copilot uses AI. Check for mistakes.
});
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,12 @@ program
'--gemini-api-base-path <path>',
'Base path prefix for Gemini API requests',
)
.option(
'--enable-opencode',
'Enable OpenCode API proxy listener on port 10004 (requires --enable-api-proxy).\n' +
' Only start this when the workflow uses the OpenCode engine.',
false
)
.option(
'--rate-limit-rpm <n>',
'Max requests per minute per provider (requires --enable-api-proxy)',
Expand Down Expand Up @@ -1966,6 +1972,7 @@ program
enableDlp: options.enableDlp,
allowedUrls,
enableApiProxy: options.enableApiProxy,
enableOpenCode: options.enableOpencode,
modelAliases,
Comment on lines 1984 to 1986

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

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

The CLI help text says --enable-opencode requires --enable-api-proxy, but there’s no validation enforcing that relationship. If a user sets --enable-opencode without --enable-api-proxy, the flag silently has no effect (no api-proxy sidecar -> no port 10004). Add a validation/error similar to validateRateLimitFlags so misuse fails fast with a clear message.

Copilot uses AI. Check for mistakes.
openaiApiKey: process.env.OPENAI_API_KEY,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
Expand Down
24 changes: 24 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2783,6 +2783,30 @@ describe('docker-manager', () => {
expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined();
});

it('should set AWF_ENABLE_OPENCODE=true in api-proxy when enableOpenCode is true', () => {
const configWithOpenCode = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: true };
const result = generateDockerCompose(configWithOpenCode, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.AWF_ENABLE_OPENCODE).toBe('true');
});

it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is false', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: false };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.AWF_ENABLE_OPENCODE).toBeUndefined();
});

it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is undefined', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.AWF_ENABLE_OPENCODE).toBeUndefined();
});

it('should set OPENAI_API_TARGET in api-proxy when openaiApiTarget is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'custom.openai-router.internal' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
Expand Down
2 changes: 2 additions & 0 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,8 @@ export function generateDockerCompose(
...(config.modelAliases && {
AWF_MODEL_ALIASES: JSON.stringify({ models: config.modelAliases }),
}),
// Enable OpenCode listener only when explicitly requested
...(config.enableOpenCode && { AWF_ENABLE_OPENCODE: 'true' }),
},
healthcheck: {
test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`],
Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,19 @@ export interface WrapperConfig {
*/
geminiApiKey?: string;

/**
* Enable the OpenCode API proxy listener on port 10004
*
* When true, the api-proxy sidecar starts the OpenCode listener (port 10004) that
* dynamically routes requests to whichever LLM credential is available.
* When false (the default), the listener is not started even if other API keys
* are present, preventing unnecessary port exposure in workflows that do not use
* the OpenCode engine.
*
* @default false
*/
enableOpenCode?: boolean;

/**
* Target hostname for GitHub Copilot API requests (used by API proxy sidecar)
*
Expand Down
Loading