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
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
26 changes: 22 additions & 4 deletions containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1639,15 +1639,33 @@ 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
Copy link

Copilot AI May 1, 2026

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.
});

it('should report opencode as configured when AWF_ENABLE_OPENCODE=true and a credential is present', () => {
let isolatedReflect;
jest.isolateModules(() => {
process.env.AWF_ENABLE_OPENCODE = 'true';
process.env.OPENAI_API_KEY = 'sk-test-isolated';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
({ reflectEndpoints: isolatedReflect } = require('./server'));
} finally {
delete process.env.AWF_ENABLE_OPENCODE;
delete process.env.OPENAI_API_KEY;
}
});
const result = isolatedReflect();
const opencode = result.endpoints.find((e) => e.provider === 'opencode');
expect(opencode.configured).toBe(true);
});
});

// ── healthResponse ─────────────────────────────────────────────────────────
Expand Down
19 changes: 18 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateEnableOpenCodeFlag, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli';
import { redactSecrets } from './redact-secrets';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -1545,6 +1545,23 @@
});
});

describe('validateEnableOpenCodeFlag', () => {
it('should pass when both --enable-opencode and --enable-api-proxy are set', () => {
expect(validateEnableOpenCodeFlag(true, true)).toEqual({ valid: true });
});
it('should pass when --enable-opencode is false', () => {
expect(validateEnableOpenCodeFlag(false, false)).toEqual({ valid: true });
});
it('should pass when --enable-opencode is false and --enable-api-proxy is true', () => {
expect(validateEnableOpenCodeFlag(true, false)).toEqual({ valid: true });
});
it('should fail when --enable-opencode is true without --enable-api-proxy', () => {
const r = validateEnableOpenCodeFlag(false, true);
expect(r.valid).toBe(false);
expect(r.error).toContain('--enable-api-proxy');
});
});

describe('hasRateLimitOptions', () => {
it('should return false when no rate limit options set', () => {
expect(hasRateLimitOptions({})).toBe(false);
Expand Down
24 changes: 24 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,16 @@ export function validateRateLimitFlags(enableApiProxy: boolean, options: {
return { valid: true };
}

/**
* Validates that --enable-opencode is not used without --enable-api-proxy.
*/
export function validateEnableOpenCodeFlag(enableApiProxy: boolean, enableOpenCode: boolean): FlagValidationResult {
if (enableOpenCode && !enableApiProxy) {
return { valid: false, error: '--enable-opencode requires --enable-api-proxy' };
}
return { valid: true };
}

/**
* Result of validating flag combinations
*/
Expand Down Expand Up @@ -1513,6 +1523,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 +1982,7 @@ program
enableDlp: options.enableDlp,
allowedUrls,
enableApiProxy: options.enableApiProxy,
enableOpenCode: options.enableOpencode,
modelAliases,
Comment on lines 1984 to 1986
Copy link

Copilot AI May 1, 2026

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 Expand Up @@ -2017,6 +2034,13 @@ program
process.exit(1);
}

// Error if --enable-opencode is used without --enable-api-proxy
const enableOpenCodeValidation = validateEnableOpenCodeFlag(config.enableApiProxy ?? false, config.enableOpenCode ?? false);
if (!enableOpenCodeValidation.valid) {
logger.error(enableOpenCodeValidation.error!);
process.exit(1);
}

// Warn if --env-all is used
if (config.envAll) {
logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container');
Expand Down
12 changes: 12 additions & 0 deletions src/config-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ describe('config-file', () => {
expect(errors).toContain('config.apiProxy.enabled must be a boolean');
});

it('accepts boolean apiProxy.enableOpenCode', () => {
expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: true } })).toEqual([]);
expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: false } })).toEqual([]);
});

it('rejects non-boolean apiProxy.enableOpenCode', () => {
const errors = validateAwfFileConfig({ apiProxy: { enableOpenCode: 'yes' } });
expect(errors).toContain('config.apiProxy.enableOpenCode must be a boolean');
});

it('rejects non-object apiProxy.targets', () => {
const errors = validateAwfFileConfig({ apiProxy: { targets: 'invalid' } });
expect(errors).toContain('config.apiProxy.targets must be an object');
Expand Down Expand Up @@ -497,6 +507,7 @@ describe('config-file', () => {
it('maps all API proxy target fields', () => {
const result = mapAwfFileConfigToCliOptions({
apiProxy: {
enableOpenCode: true,
targets: {
openai: { host: 'api.openai.com', basePath: '/v1' },
copilot: { host: 'api.githubcopilot.com' },
Expand All @@ -505,6 +516,7 @@ describe('config-file', () => {
},
});

expect(result.enableOpencode).toBe(true);
expect(result.openaiApiTarget).toBe('api.openai.com');
expect(result.openaiApiBasePath).toBe('/v1');
expect(result.copilotApiTarget).toBe('api.githubcopilot.com');
Expand Down
7 changes: 6 additions & 1 deletion src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface AwfFileConfig {
};
apiProxy?: {
enabled?: boolean;
enableOpenCode?: boolean;
targets?: {
openai?: { host?: string; basePath?: string };
anthropic?: { host?: string; basePath?: string };
Expand Down Expand Up @@ -150,10 +151,13 @@ export function validateAwfFileConfig(config: unknown): string[] {
if (!isRecord(config.apiProxy)) {
errors.push('config.apiProxy must be an object');
} else {
validateKnownKeys(config.apiProxy, ['enabled', 'targets', 'models'], 'config.apiProxy', errors);
validateKnownKeys(config.apiProxy, ['enabled', 'enableOpenCode', 'targets', 'models'], 'config.apiProxy', errors);
if (config.apiProxy.enabled !== undefined && typeof config.apiProxy.enabled !== 'boolean') {
errors.push('config.apiProxy.enabled must be a boolean');
}
if (config.apiProxy.enableOpenCode !== undefined && typeof config.apiProxy.enableOpenCode !== 'boolean') {
errors.push('config.apiProxy.enableOpenCode must be a boolean');
}
if (config.apiProxy.targets !== undefined) {
if (!isRecord(config.apiProxy.targets)) {
errors.push('config.apiProxy.targets must be an object');
Expand Down Expand Up @@ -355,6 +359,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
upstreamProxy: config.network?.upstreamProxy,

enableApiProxy: config.apiProxy?.enabled,
enableOpencode: config.apiProxy?.enableOpenCode,
openaiApiTarget: config.apiProxy?.targets?.openai?.host,
openaiApiBasePath: config.apiProxy?.targets?.openai?.basePath,
anthropicApiTarget: config.apiProxy?.targets?.anthropic?.host,
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