Skip to content

Commit 063f128

Browse files
CopilotlpcoxCopilot
authored
fix(api-proxy): address review comments on OpenCode port 10004 routing (#1984)
* Initial plan * fix: update OpenCode proxy to default to Copilot/OpenAI routing Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: address code review - correct comment to use COPILOT_AUTH_TOKEN Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: address review comments from PR #1979 - types.ts: correct credential priority comment to reference user-configurable vars COPILOT_GITHUB_TOKEN/COPILOT_API_KEY instead of internal COPILOT_AUTH_TOKEN - smoke-opencode.md: add note that lock file compilation is pending until opencode engine support lands in gh-aw - server.js: add rate limiting to OpenCode (port 10004) HTTP handler using content-length-aware checkRateLimit() call - server.js: extract resolveOpenCodeRoute() helper for testability and refactor handler to use it - server.test.js: add 8 unit tests covering all OpenCode routing priority scenarios and header injection Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/a0621fda-d1a3-449b-a3da-a9d0331c4c76 * fix: handle null route with 503 response in OpenCode proxy handlers Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/a0621fda-d1a3-449b-a3da-a9d0331c4c76 * refactor: eliminate code duplication in OpenCode proxy routing - Add needsAnthropicVersion flag to resolveOpenCodeRoute return value to centralize anthropic-version header logic - Use resolveOpenCodeRoute() for startup guard instead of repeating the credential availability check - Update tests to assert needsAnthropicVersion for all scenarios Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/a0621fda-d1a3-449b-a3da-a9d0331c4c76 * Update containers/api-proxy/server.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update containers/api-proxy/server.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 81a5805 commit 063f128

4 files changed

Lines changed: 286 additions & 21 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
description: Smoke test workflow that validates OpenCode engine functionality by testing AWF firewall capabilities
3+
on:
4+
roles: all
5+
schedule: every 12h
6+
workflow_dispatch:
7+
pull_request:
8+
types: [opened, synchronize, reopened]
9+
reaction: "rocket"
10+
permissions:
11+
contents: read
12+
issues: read
13+
pull-requests: read
14+
discussions: read
15+
name: Smoke OpenCode
16+
engine: opencode
17+
strict: true
18+
imports:
19+
- shared/gh.md
20+
- shared/reporting.md
21+
network:
22+
allowed:
23+
- defaults
24+
- github
25+
tools:
26+
cache-memory: true
27+
github:
28+
toolsets: [repos, pull_requests]
29+
edit:
30+
bash:
31+
- "*"
32+
safe-outputs:
33+
threat-detection:
34+
enabled: false
35+
add-comment:
36+
hide-older-comments: true
37+
max: 2
38+
create-issue:
39+
expires: 2h
40+
close-older-issues: true
41+
add-labels:
42+
allowed: [smoke-opencode]
43+
hide-comment:
44+
messages:
45+
footer: "> 🌐 *Transmitted by [{workflow_name}]({run_url})*"
46+
run-started: "🌐 [{workflow_name}]({run_url}) is initializing on this {event_type}..."
47+
run-success: "✅ [{workflow_name}]({run_url}) completed successfully. All systems nominal. 🚀"
48+
run-failure: "❌ [{workflow_name}]({run_url}) {status}. Investigation required..."
49+
timeout-minutes: 15
50+
post-steps:
51+
- name: Validate safe outputs were invoked
52+
run: |
53+
OUTPUTS_FILE="${GH_AW_SAFE_OUTPUTS:-${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl}"
54+
if [ ! -s "$OUTPUTS_FILE" ]; then
55+
echo "::error::No safe outputs were invoked. Smoke tests require the agent to call safe output tools."
56+
exit 1
57+
fi
58+
echo "Safe output entries found: $(wc -l < "$OUTPUTS_FILE")"
59+
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
60+
if ! grep -q '"add_comment"' "$OUTPUTS_FILE"; then
61+
echo "::error::Agent did not call add_comment on a pull_request trigger."
62+
exit 1
63+
fi
64+
echo "add_comment verified for PR trigger"
65+
fi
66+
echo "Safe output validation passed"
67+
---
68+
69+
> **Note:** This workflow has not yet been compiled to a `.lock.yml` file and is **not active** in GitHub Actions.
70+
> Compilation is pending `opencode` engine support in gh-aw. Once the engine is supported, run
71+
> `gh-aw compile .github/workflows/smoke-opencode.md` followed by
72+
> `npx tsx scripts/ci/postprocess-smoke-workflows.ts` to generate the lock file.
73+
74+
# Smoke Test: OpenCode Engine Validation
75+
76+
**IMPORTANT: Keep all outputs extremely short and concise. Use single-line responses where possible. No verbose explanations.**
77+
78+
## Test Requirements
79+
80+
1. **GitHub MCP Testing**: Review the last 2 merged pull requests in `__GH_AW_GITHUB_REPOSITORY__`
81+
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)
82+
3. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back)
83+
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.
84+
5. **Add Comment**: Use the `add_comment` tool to post a brief summary comment on the current pull request
85+
86+
## Output
87+
88+
**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:
89+
- PR titles only (no descriptions)
90+
- ✅ or ❌ for each test result
91+
- Overall status: PASS or FAIL
92+
93+
If all tests pass:
94+
- Use the `add_labels` safe-output tool to add the label `smoke-opencode` to the pull request

containers/api-proxy/server.js

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,33 @@ if (!proxyAgent) {
288288
logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' });
289289
}
290290

291+
/**
292+
* Resolves the OpenCode routing configuration based on available credentials.
293+
* Priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > copilotToken (COPILOT_GITHUB_TOKEN / COPILOT_API_KEY)
294+
*
295+
* @param {string|undefined} openaiKey
296+
* @param {string|undefined} anthropicKey
297+
* @param {string|undefined} copilotToken
298+
* @param {string} openaiTarget
299+
* @param {string} anthropicTarget
300+
* @param {string} copilotTarget
301+
* @param {string} [openaiBasePath]
302+
* @param {string} [anthropicBasePath]
303+
* @returns {{ target: string, headers: Record<string,string>, basePath: string|undefined, needsAnthropicVersion: boolean } | null}
304+
*/
305+
function resolveOpenCodeRoute(openaiKey, anthropicKey, copilotToken, openaiTarget, anthropicTarget, copilotTarget, openaiBasePath, anthropicBasePath) {
306+
if (openaiKey) {
307+
return { target: openaiTarget, headers: { 'Authorization': `Bearer ${openaiKey}` }, basePath: openaiBasePath, needsAnthropicVersion: false };
308+
}
309+
if (anthropicKey) {
310+
return { target: anthropicTarget, headers: { 'x-api-key': anthropicKey }, basePath: anthropicBasePath, needsAnthropicVersion: true };
311+
}
312+
if (copilotToken) {
313+
return { target: copilotTarget, headers: { 'Authorization': `Bearer ${copilotToken}` }, basePath: undefined, needsAnthropicVersion: false };
314+
}
315+
return null;
316+
}
317+
291318
/**
292319
* Check rate limit and send 429 if exceeded.
293320
* Returns true if request was rate-limited (caller should return early).
@@ -1027,11 +1054,22 @@ if (require.main === module) {
10271054
});
10281055
}
10291056

1030-
// OpenCode API proxy (port 10004) — routes to Anthropic (default BYOK provider)
1031-
// OpenCode gets a separate port from Claude (10001) for per-engine rate limiting,
1032-
// metrics isolation, and future provider routing (OpenCode is BYOK and may route
1033-
// to different providers in the future based on model prefix).
1034-
if (ANTHROPIC_API_KEY) {
1057+
// OpenCode API proxy (port 10004) — dynamic provider routing
1058+
// Defaults to Copilot/OpenAI routing (OPENAI_API_KEY), with Anthropic as a BYOK fallback.
1059+
// OpenCode gets a separate port from Claude (10001) and Codex (10000) for per-engine
1060+
// rate limiting and metrics isolation.
1061+
//
1062+
// Credential priority (first available wins):
1063+
// 1. OPENAI_API_KEY → OpenAI/Copilot-compatible route (OPENAI_API_TARGET)
1064+
// 2. ANTHROPIC_API_KEY → Anthropic BYOK route (ANTHROPIC_API_TARGET)
1065+
// 3. COPILOT_GITHUB_TOKEN/API_KEY → Copilot route (COPILOT_API_TARGET),
1066+
// resolved internally to COPILOT_AUTH_TOKEN
1067+
const opencodeStartupRoute = resolveOpenCodeRoute(
1068+
OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN,
1069+
OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET,
1070+
OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH
1071+
);
1072+
if (opencodeStartupRoute) {
10351073
const opencodeServer = http.createServer((req, res) => {
10361074
if (req.url === '/health' && req.method === 'GET') {
10371075
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1046,26 +1084,59 @@ if (require.main === module) {
10461084
method: logMethod,
10471085
url: logUrl,
10481086
});
1049-
logRequest('info', 'opencode_proxy_header_injection', {
1050-
message: '[OpenCode Proxy] Injecting x-api-key header with ANTHROPIC_API_KEY',
1087+
1088+
const parsedContentLength = Number(req.headers['content-length']);
1089+
const contentLength = Number.isFinite(parsedContentLength) && parsedContentLength > 0 ? parsedContentLength : 0;
1090+
if (checkRateLimit(req, res, 'opencode', contentLength)) {
1091+
return;
1092+
}
1093+
1094+
const route = resolveOpenCodeRoute(
1095+
OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN,
1096+
OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET,
1097+
OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH
1098+
);
1099+
if (!route) {
1100+
logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot route request' });
1101+
res.writeHead(503, { 'Content-Type': 'application/json' });
1102+
res.end(JSON.stringify({ error: 'OpenCode proxy has no credentials configured' }));
1103+
return;
1104+
}
1105+
1106+
logRequest('info', 'opencode_proxy_routing_target', {
1107+
message: `[OpenCode Proxy] Routing to ${route.target}`,
1108+
target: route.target,
10511109
});
1052-
const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY };
1053-
if (!req.headers['anthropic-version']) {
1054-
anthropicHeaders['anthropic-version'] = '2023-06-01';
1110+
1111+
const headers = Object.assign({}, route.headers);
1112+
if (route.needsAnthropicVersion && !req.headers['anthropic-version']) {
1113+
headers['anthropic-version'] = '2023-06-01';
10551114
}
1056-
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders);
1115+
proxyRequest(req, res, route.target, headers, 'opencode', route.basePath);
10571116
});
10581117

10591118
opencodeServer.on('upgrade', (req, socket, head) => {
1060-
const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY };
1061-
if (!req.headers['anthropic-version']) {
1062-
anthropicHeaders['anthropic-version'] = '2023-06-01';
1119+
const route = resolveOpenCodeRoute(
1120+
OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN,
1121+
OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET,
1122+
OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH
1123+
);
1124+
if (!route) {
1125+
logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot upgrade WebSocket' });
1126+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
1127+
socket.destroy();
1128+
return;
1129+
}
1130+
1131+
const headers = Object.assign({}, route.headers);
1132+
if (route.needsAnthropicVersion && !req.headers['anthropic-version']) {
1133+
headers['anthropic-version'] = '2023-06-01';
10631134
}
1064-
proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'opencode');
1135+
proxyWebSocket(req, socket, head, route.target, headers, 'opencode', route.basePath);
10651136
});
10661137

10671138
opencodeServer.listen(10004, '0.0.0.0', () => {
1068-
console.log(`[API Proxy] OpenCode proxy listening on port 10004 (-> Anthropic at ${ANTHROPIC_API_TARGET})`);
1139+
logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` });
10691140
});
10701141
}
10711142

@@ -1084,4 +1155,4 @@ if (require.main === module) {
10841155
}
10851156

10861157
// Export for testing
1087-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };
1158+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute };

containers/api-proxy/server.test.js

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
const http = require('http');
66
const tls = require('tls');
77
const { EventEmitter } = require('events');
8-
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');
8+
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute } = require('./server');
99

1010
describe('normalizeApiTarget', () => {
1111
it('should strip https:// prefix', () => {
@@ -781,3 +781,103 @@ describe('resolveCopilotAuthToken', () => {
781781
});
782782
});
783783

784+
describe('resolveOpenCodeRoute', () => {
785+
const OPENAI_TARGET = 'api.openai.com';
786+
const ANTHROPIC_TARGET = 'api.anthropic.com';
787+
const COPILOT_TARGET = 'api.githubcopilot.com';
788+
const OPENAI_BASE = '/v1';
789+
const ANTHROPIC_BASE = '';
790+
791+
it('should route to OpenAI when OPENAI_API_KEY is set (highest priority)', () => {
792+
const route = resolveOpenCodeRoute(
793+
'sk-openai-key', 'sk-anthropic-key', 'gho_copilot-token',
794+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
795+
OPENAI_BASE, ANTHROPIC_BASE
796+
);
797+
expect(route).not.toBeNull();
798+
expect(route.target).toBe(OPENAI_TARGET);
799+
expect(route.headers['Authorization']).toBe('Bearer sk-openai-key');
800+
expect(route.basePath).toBe(OPENAI_BASE);
801+
expect(route.needsAnthropicVersion).toBe(false);
802+
});
803+
804+
it('should route to Anthropic when only ANTHROPIC_API_KEY is set', () => {
805+
const route = resolveOpenCodeRoute(
806+
undefined, 'sk-anthropic-key', undefined,
807+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
808+
OPENAI_BASE, ANTHROPIC_BASE
809+
);
810+
expect(route).not.toBeNull();
811+
expect(route.target).toBe(ANTHROPIC_TARGET);
812+
expect(route.headers['x-api-key']).toBe('sk-anthropic-key');
813+
expect(route.basePath).toBe(ANTHROPIC_BASE);
814+
expect(route.needsAnthropicVersion).toBe(true);
815+
});
816+
817+
it('should prefer OpenAI over Anthropic when both are set', () => {
818+
const route = resolveOpenCodeRoute(
819+
'sk-openai-key', 'sk-anthropic-key', undefined,
820+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
821+
OPENAI_BASE, ANTHROPIC_BASE
822+
);
823+
expect(route).not.toBeNull();
824+
expect(route.target).toBe(OPENAI_TARGET);
825+
expect(route.headers['Authorization']).toBe('Bearer sk-openai-key');
826+
expect(route.needsAnthropicVersion).toBe(false);
827+
});
828+
829+
it('should route to Copilot when only copilotToken is set', () => {
830+
const route = resolveOpenCodeRoute(
831+
undefined, undefined, 'gho_copilot-token',
832+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
833+
OPENAI_BASE, ANTHROPIC_BASE
834+
);
835+
expect(route).not.toBeNull();
836+
expect(route.target).toBe(COPILOT_TARGET);
837+
expect(route.headers['Authorization']).toBe('Bearer gho_copilot-token');
838+
expect(route.basePath).toBeUndefined();
839+
expect(route.needsAnthropicVersion).toBe(false);
840+
});
841+
842+
it('should prefer Anthropic over Copilot when both are set', () => {
843+
const route = resolveOpenCodeRoute(
844+
undefined, 'sk-anthropic-key', 'gho_copilot-token',
845+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
846+
OPENAI_BASE, ANTHROPIC_BASE
847+
);
848+
expect(route).not.toBeNull();
849+
expect(route.target).toBe(ANTHROPIC_TARGET);
850+
expect(route.headers['x-api-key']).toBe('sk-anthropic-key');
851+
expect(route.needsAnthropicVersion).toBe(true);
852+
});
853+
854+
it('should return null when no credentials are available', () => {
855+
const route = resolveOpenCodeRoute(
856+
undefined, undefined, undefined,
857+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
858+
OPENAI_BASE, ANTHROPIC_BASE
859+
);
860+
expect(route).toBeNull();
861+
});
862+
863+
it('should not set Authorization header for Anthropic route', () => {
864+
const route = resolveOpenCodeRoute(
865+
undefined, 'sk-anthropic-key', undefined,
866+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
867+
OPENAI_BASE, ANTHROPIC_BASE
868+
);
869+
expect(route).not.toBeNull();
870+
expect(route.headers['Authorization']).toBeUndefined();
871+
});
872+
873+
it('should not set x-api-key header for OpenAI route', () => {
874+
const route = resolveOpenCodeRoute(
875+
'sk-openai-key', undefined, undefined,
876+
OPENAI_TARGET, ANTHROPIC_TARGET, COPILOT_TARGET,
877+
OPENAI_BASE, ANTHROPIC_BASE
878+
);
879+
expect(route).not.toBeNull();
880+
expect(route.headers['x-api-key']).toBeUndefined();
881+
});
882+
});
883+

src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export const API_PROXY_PORTS = {
4040
GEMINI: 10003,
4141

4242
/**
43-
* OpenCode API proxy port (routes to Anthropic by default)
44-
* OpenCode is BYOK — defaults to Anthropic as the primary provider
43+
* OpenCode API proxy port (defaults to Copilot/OpenAI routing; falls back to Anthropic)
44+
* OpenCode is BYOK — credential priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > COPILOT_GITHUB_TOKEN/COPILOT_API_KEY
4545
* @see containers/api-proxy/server.js
4646
*/
4747
OPENCODE: 10004,
@@ -611,7 +611,7 @@ export interface WrapperConfig {
611611
* - http://api-proxy:10000 - OpenAI API proxy (for Codex) {@link API_PROXY_PORTS.OPENAI}
612612
* - http://api-proxy:10001 - Anthropic API proxy (for Claude) {@link API_PROXY_PORTS.ANTHROPIC}
613613
* - http://api-proxy:10002 - GitHub Copilot API proxy {@link API_PROXY_PORTS.COPILOT}
614-
* - http://api-proxy:10004 - OpenCode API proxy (routes to Anthropic) {@link API_PROXY_PORTS.OPENCODE}
614+
* - http://api-proxy:10004 - OpenCode API proxy (defaults to Copilot/OpenAI routing) {@link API_PROXY_PORTS.OPENCODE}
615615
*
616616
* When the corresponding API key is provided, the following environment
617617
* variables are set in the agent container:

0 commit comments

Comments
 (0)