Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
94 changes: 94 additions & 0 deletions .github/workflows/smoke-opencode.md
Original file line number Diff line number Diff line change
@@ -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
102 changes: 86 additions & 16 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>, 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).
Expand Down Expand Up @@ -1027,11 +1054,21 @@ 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_AUTH_TOKEN → Copilot route (COPILOT_API_TARGET)
Comment thread
lpcox marked this conversation as resolved.
Outdated
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' });
Expand All @@ -1046,26 +1083,59 @@ if (require.main === module) {
method: logMethod,
url: logUrl,
});

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_header_injection', {
Comment thread
lpcox marked this conversation as resolved.
Outdated
message: '[OpenCode Proxy] Injecting x-api-key header with ANTHROPIC_API_KEY',
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})` });
});
}

Expand All @@ -1084,4 +1154,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 };
102 changes: 101 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});

6 changes: 3 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading