diff --git a/.github/workflows/smoke-opencode.md b/.github/workflows/smoke-opencode.md new file mode 100644 index 00000000..d77d1e54 --- /dev/null +++ b/.github/workflows/smoke-opencode.md @@ -0,0 +1,94 @@ +--- +description: Smoke test workflow that validates OpenCode engine functionality by testing AWF firewall capabilities +on: + roles: all + schedule: every 12h + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] + reaction: "rocket" +permissions: + contents: read + issues: read + pull-requests: read + discussions: read +name: Smoke OpenCode +engine: opencode +strict: true +imports: + - shared/gh.md + - shared/reporting.md +network: + allowed: + - defaults + - github +tools: + cache-memory: true + github: + toolsets: [repos, pull_requests] + edit: + bash: + - "*" +safe-outputs: + threat-detection: + enabled: false + add-comment: + hide-older-comments: true + max: 2 + create-issue: + expires: 2h + close-older-issues: true + add-labels: + allowed: [smoke-opencode] + hide-comment: + messages: + footer: "> 🌐 *Transmitted by [{workflow_name}]({run_url})*" + run-started: "🌐 [{workflow_name}]({run_url}) is initializing on this {event_type}..." + run-success: "✅ [{workflow_name}]({run_url}) completed successfully. All systems nominal. 🚀" + run-failure: "❌ [{workflow_name}]({run_url}) {status}. Investigation required..." +timeout-minutes: 15 +post-steps: + - name: Validate safe outputs were invoked + run: | + OUTPUTS_FILE="${GH_AW_SAFE_OUTPUTS:-${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl}" + if [ ! -s "$OUTPUTS_FILE" ]; then + echo "::error::No safe outputs were invoked. Smoke tests require the agent to call safe output tools." + exit 1 + fi + echo "Safe output entries found: $(wc -l < "$OUTPUTS_FILE")" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + if ! grep -q '"add_comment"' "$OUTPUTS_FILE"; then + echo "::error::Agent did not call add_comment on a pull_request trigger." + exit 1 + fi + echo "add_comment verified for PR trigger" + fi + echo "Safe output validation passed" +--- + +> **Note:** This workflow has not yet been compiled to a `.lock.yml` file and is **not active** in GitHub Actions. +> Compilation is pending `opencode` engine support in gh-aw. Once the engine is supported, run +> `gh-aw compile .github/workflows/smoke-opencode.md` followed by +> `npx tsx scripts/ci/postprocess-smoke-workflows.ts` to generate the lock file. + +# Smoke Test: OpenCode Engine Validation + +**IMPORTANT: Keep all outputs extremely short and concise. Use single-line responses where possible. No verbose explanations.** + +## Test Requirements + +1. **GitHub MCP Testing**: Review the last 2 merged pull requests in `__GH_AW_GITHUB_REPOSITORY__` +2. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-opencode-${{ github.run_id }}.txt` with content "Smoke test passed for OpenCode at $(date)" (create the directory if it doesn't exist) +3. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back) +4. **Build AWF**: Run `npm ci && npm run build` to verify the agent can successfully build the AWF project. If the command fails, mark this test as ❌ and report the failure. +5. **Add Comment**: Use the `add_comment` tool to post a brief summary comment on the current pull request + +## Output + +**REQUIRED**: Call `add_comment` to post a brief comment (max 5-10 lines) on the current pull request (this is validated by the post-step check) containing: +- PR titles only (no descriptions) +- ✅ or ❌ for each test result +- Overall status: PASS or FAIL + +If all tests pass: +- Use the `add_labels` safe-output tool to add the label `smoke-opencode` to the pull request diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 27a8b0c1..e4a2edc0 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -288,6 +288,33 @@ if (!proxyAgent) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } +/** + * Resolves the OpenCode routing configuration based on available credentials. + * Priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > copilotToken (COPILOT_GITHUB_TOKEN / COPILOT_API_KEY) + * + * @param {string|undefined} openaiKey + * @param {string|undefined} anthropicKey + * @param {string|undefined} copilotToken + * @param {string} openaiTarget + * @param {string} anthropicTarget + * @param {string} copilotTarget + * @param {string} [openaiBasePath] + * @param {string} [anthropicBasePath] + * @returns {{ target: string, headers: Record, basePath: string|undefined, needsAnthropicVersion: boolean } | null} + */ +function resolveOpenCodeRoute(openaiKey, anthropicKey, copilotToken, openaiTarget, anthropicTarget, copilotTarget, openaiBasePath, anthropicBasePath) { + if (openaiKey) { + return { target: openaiTarget, headers: { 'Authorization': `Bearer ${openaiKey}` }, basePath: openaiBasePath, needsAnthropicVersion: false }; + } + if (anthropicKey) { + return { target: anthropicTarget, headers: { 'x-api-key': anthropicKey }, basePath: anthropicBasePath, needsAnthropicVersion: true }; + } + if (copilotToken) { + return { target: copilotTarget, headers: { 'Authorization': `Bearer ${copilotToken}` }, basePath: undefined, needsAnthropicVersion: false }; + } + return null; +} + /** * Check rate limit and send 429 if exceeded. * Returns true if request was rate-limited (caller should return early). @@ -1027,11 +1054,22 @@ if (require.main === module) { }); } - // OpenCode API proxy (port 10004) — routes to Anthropic (default BYOK provider) - // OpenCode gets a separate port from Claude (10001) for per-engine rate limiting, - // metrics isolation, and future provider routing (OpenCode is BYOK and may route - // to different providers in the future based on model prefix). - if (ANTHROPIC_API_KEY) { + // OpenCode API proxy (port 10004) — dynamic provider routing + // 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. + // + // Credential priority (first available wins): + // 1. OPENAI_API_KEY → OpenAI/Copilot-compatible route (OPENAI_API_TARGET) + // 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 + const opencodeStartupRoute = resolveOpenCodeRoute( + OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, + OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, + OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH + ); + if (opencodeStartupRoute) { const opencodeServer = http.createServer((req, res) => { if (req.url === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -1046,26 +1084,59 @@ if (require.main === module) { method: logMethod, url: logUrl, }); - logRequest('info', 'opencode_proxy_header_injection', { - message: '[OpenCode Proxy] Injecting x-api-key header with ANTHROPIC_API_KEY', + + const parsedContentLength = Number(req.headers['content-length']); + const contentLength = Number.isFinite(parsedContentLength) && parsedContentLength > 0 ? parsedContentLength : 0; + if (checkRateLimit(req, res, 'opencode', contentLength)) { + return; + } + + const route = resolveOpenCodeRoute( + OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, + OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, + OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH + ); + if (!route) { + logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot route request' }); + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'OpenCode proxy has no credentials configured' })); + return; + } + + logRequest('info', 'opencode_proxy_routing_target', { + message: `[OpenCode Proxy] Routing to ${route.target}`, + target: route.target, }); - const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; - if (!req.headers['anthropic-version']) { - anthropicHeaders['anthropic-version'] = '2023-06-01'; + + const headers = Object.assign({}, route.headers); + if (route.needsAnthropicVersion && !req.headers['anthropic-version']) { + headers['anthropic-version'] = '2023-06-01'; } - proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders); + proxyRequest(req, res, route.target, headers, 'opencode', route.basePath); }); opencodeServer.on('upgrade', (req, socket, head) => { - const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; - if (!req.headers['anthropic-version']) { - anthropicHeaders['anthropic-version'] = '2023-06-01'; + const route = resolveOpenCodeRoute( + OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, + OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, + OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH + ); + if (!route) { + logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot upgrade WebSocket' }); + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const headers = Object.assign({}, route.headers); + if (route.needsAnthropicVersion && !req.headers['anthropic-version']) { + headers['anthropic-version'] = '2023-06-01'; } - proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'opencode'); + proxyWebSocket(req, socket, head, route.target, headers, 'opencode', route.basePath); }); opencodeServer.listen(10004, '0.0.0.0', () => { - console.log(`[API Proxy] OpenCode proxy listening on port 10004 (-> Anthropic at ${ANTHROPIC_API_TARGET})`); + logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` }); }); } @@ -1084,4 +1155,4 @@ if (require.main === module) { } // Export for testing -module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken }; +module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 9e7ea6c9..c1ba1a46 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -5,7 +5,7 @@ const http = require('http'); const tls = require('tls'); const { EventEmitter } = require('events'); -const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -781,3 +781,103 @@ describe('resolveCopilotAuthToken', () => { }); }); +describe('resolveOpenCodeRoute', () => { + const OPENAI_TARGET = 'api.openai.com'; + const ANTHROPIC_TARGET = 'api.anthropic.com'; + const COPILOT_TARGET = 'api.githubcopilot.com'; + const OPENAI_BASE = '/v1'; + const ANTHROPIC_BASE = ''; + + it('should route to OpenAI when OPENAI_API_KEY is set (highest priority)', () => { + const route = resolveOpenCodeRoute( + 'sk-openai-key', 'sk-anthropic-key', 'gho_copilot-token', + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.target).toBe(OPENAI_TARGET); + expect(route.headers['Authorization']).toBe('Bearer sk-openai-key'); + expect(route.basePath).toBe(OPENAI_BASE); + expect(route.needsAnthropicVersion).toBe(false); + }); + + it('should route to Anthropic when only ANTHROPIC_API_KEY is set', () => { + const route = resolveOpenCodeRoute( + undefined, 'sk-anthropic-key', undefined, + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.target).toBe(ANTHROPIC_TARGET); + expect(route.headers['x-api-key']).toBe('sk-anthropic-key'); + expect(route.basePath).toBe(ANTHROPIC_BASE); + expect(route.needsAnthropicVersion).toBe(true); + }); + + it('should prefer OpenAI over Anthropic when both are set', () => { + const route = resolveOpenCodeRoute( + 'sk-openai-key', 'sk-anthropic-key', undefined, + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.target).toBe(OPENAI_TARGET); + expect(route.headers['Authorization']).toBe('Bearer sk-openai-key'); + expect(route.needsAnthropicVersion).toBe(false); + }); + + it('should route to Copilot when only copilotToken is set', () => { + const route = resolveOpenCodeRoute( + undefined, undefined, 'gho_copilot-token', + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.target).toBe(COPILOT_TARGET); + expect(route.headers['Authorization']).toBe('Bearer gho_copilot-token'); + expect(route.basePath).toBeUndefined(); + expect(route.needsAnthropicVersion).toBe(false); + }); + + it('should prefer Anthropic over Copilot when both are set', () => { + const route = resolveOpenCodeRoute( + undefined, 'sk-anthropic-key', 'gho_copilot-token', + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.target).toBe(ANTHROPIC_TARGET); + expect(route.headers['x-api-key']).toBe('sk-anthropic-key'); + expect(route.needsAnthropicVersion).toBe(true); + }); + + it('should return null when no credentials are available', () => { + const route = resolveOpenCodeRoute( + undefined, undefined, undefined, + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).toBeNull(); + }); + + it('should not set Authorization header for Anthropic route', () => { + const route = resolveOpenCodeRoute( + undefined, 'sk-anthropic-key', undefined, + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.headers['Authorization']).toBeUndefined(); + }); + + it('should not set x-api-key header for OpenAI route', () => { + const route = resolveOpenCodeRoute( + 'sk-openai-key', undefined, undefined, + OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET, + OPENAI_BASE, ANTHROPIC_BASE + ); + expect(route).not.toBeNull(); + expect(route.headers['x-api-key']).toBeUndefined(); + }); +}); + diff --git a/src/types.ts b/src/types.ts index 3098af27..47d914a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,8 +40,8 @@ export const API_PROXY_PORTS = { GEMINI: 10003, /** - * OpenCode API proxy port (routes to Anthropic by default) - * OpenCode is BYOK — defaults to Anthropic as the primary provider + * OpenCode API proxy port (defaults to Copilot/OpenAI routing; falls back to Anthropic) + * OpenCode is BYOK — credential priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > COPILOT_GITHUB_TOKEN/COPILOT_API_KEY * @see containers/api-proxy/server.js */ OPENCODE: 10004, @@ -611,7 +611,7 @@ export interface WrapperConfig { * - http://api-proxy:10000 - OpenAI API proxy (for Codex) {@link API_PROXY_PORTS.OPENAI} * - http://api-proxy:10001 - Anthropic API proxy (for Claude) {@link API_PROXY_PORTS.ANTHROPIC} * - http://api-proxy:10002 - GitHub Copilot API proxy {@link API_PROXY_PORTS.COPILOT} - * - http://api-proxy:10004 - OpenCode API proxy (routes to Anthropic) {@link API_PROXY_PORTS.OPENCODE} + * - http://api-proxy:10004 - OpenCode API proxy (defaults to Copilot/OpenAI routing) {@link API_PROXY_PORTS.OPENCODE} * * When the corresponding API key is provided, the following environment * variables are set in the agent container: