Skip to content

Commit 2d8498d

Browse files
Copilotlpcox
andauthored
api-proxy: expose models_fetch_complete in /health, fix port tables, add readiness polling docs (#2305)
* Initial plan * feat: expose models_fetch_complete in /health, fix port tables, add polling recipe Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/0abd7d5b-2744-4269-8039-558fb5331a53 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: hermetic test overrides and complete JSON example in health check docs Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/25b45b00-16c8-494f-b84e-c811d44f6870 Co-authored-by: lpcox <15877973+lpcox@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>
1 parent 07079b7 commit 2d8498d

4 files changed

Lines changed: 81 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts
2626
- Enabled via `--enable-api-proxy`; not started otherwise
2727
- Injects real API credentials (OpenAI, Anthropic, Copilot) that the agent never sees
2828
- Agent calls the sidecar with no auth (e.g., `http://172.30.0.30:10001` for Anthropic); sidecar injects the real key and forwards via Squid
29-
- Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10004 (OpenCode) — these are discrete ports, not a contiguous range
29+
- Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10003 (Gemini), 10004 (OpenCode) — these are discrete ports, not a contiguous range
3030

3131
### Documentation Files
3232

@@ -152,7 +152,7 @@ The codebase follows a modular architecture with clear separation of concerns:
152152
- `SYS_CHROOT` and `SYS_ADMIN` dropped via `capsh` before user code runs; `NET_ADMIN` never granted to agent (only to the iptables-init init container)
153153

154154
**API Proxy Sidecar** (`containers/api-proxy/`) — *optional, requires `--enable-api-proxy`*
155-
- Node.js HTTP proxy at `172.30.0.30`; listens on ports 10000, 10001, 10002, 10004
155+
- Node.js HTTP proxy at `172.30.0.30`; listens on ports 10000, 10001, 10002, 10003, 10004
156156
- Agent sends unauthenticated requests; sidecar injects the real API key before forwarding
157157
- All upstream traffic goes through Squid (`HTTP_PROXY` env set inside sidecar)
158158
- Agent container's `depends_on` adds `api-proxy: service_healthy` when enabled

containers/api-proxy/server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,7 @@ function healthResponse() {
14271427
complete: keyValidationComplete,
14281428
results: keyValidationResults,
14291429
},
1430+
models_fetch_complete: modelFetchComplete,
14301431
metrics_summary: metrics.getSummary(),
14311432
rate_limits: limiter.getAllStatus(),
14321433
};
@@ -1782,4 +1783,4 @@ if (require.main === module) {
17821783
}
17831784

17841785
// Export for testing
1785-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys, probeProvider, httpProbe, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, cachedModels, resetModelCacheState };
1786+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys, probeProvider, httpProbe, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState };

containers/api-proxy/server.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const http = require('http');
66
const https = require('https');
77
const tls = require('tls');
88
const { EventEmitter } = require('events');
9-
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, cachedModels, resetModelCacheState } = require('./server');
9+
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState } = require('./server');
1010

1111
describe('normalizeApiTarget', () => {
1212
it('should strip https:// prefix', () => {
@@ -1649,3 +1649,38 @@ describe('reflectEndpoints', () => {
16491649
expect(opencode.models_url).toBeNull();
16501650
});
16511651
});
1652+
1653+
// ── healthResponse ─────────────────────────────────────────────────────────
1654+
1655+
describe('healthResponse', () => {
1656+
afterEach(() => {
1657+
resetModelCacheState();
1658+
});
1659+
1660+
it('should include models_fetch_complete: false before model fetch runs', () => {
1661+
const result = healthResponse();
1662+
expect(result.models_fetch_complete).toBe(false);
1663+
});
1664+
1665+
it('should include models_fetch_complete: true after model fetch completes', async () => {
1666+
// Pass explicit undefined overrides so no real network calls are made
1667+
await fetchStartupModels({
1668+
openaiKey: undefined,
1669+
anthropicKey: undefined,
1670+
copilotGithubToken: undefined,
1671+
copilotAuthToken: undefined,
1672+
geminiKey: undefined,
1673+
});
1674+
const result = healthResponse();
1675+
expect(result.models_fetch_complete).toBe(true);
1676+
});
1677+
1678+
it('should include required top-level fields', () => {
1679+
const result = healthResponse();
1680+
expect(result.status).toBe('healthy');
1681+
expect(result.service).toBe('awf-api-proxy');
1682+
expect(typeof result.providers).toBe('object');
1683+
expect(typeof result.key_validation).toBe('object');
1684+
expect(typeof result.models_fetch_complete).toBe('boolean');
1685+
});
1686+
});

docs/api-proxy-sidecar.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ The sidecar container:
310310
- **Image**: `ghcr.io/github/gh-aw-firewall/api-proxy:latest`
311311
- **Base**: `node:22-alpine`
312312
- **Network**: `awf-net` at `172.30.0.30`
313-
- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini)
313+
- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini), 10004 (OpenCode)
314314
- **Proxy**: Routes via Squid at `http://172.30.0.10:3128`
315315

316316
### Health check
@@ -321,6 +321,46 @@ Docker healthcheck on the `/health` endpoint (port 10000):
321321
- **Retries**: 5
322322
- **Start period**: 2s
323323

324+
The `/health` endpoint returns a JSON object that includes a `models_fetch_complete` field, indicating whether the startup model-discovery pass has finished:
325+
326+
```json
327+
{
328+
"status": "healthy",
329+
"service": "awf-api-proxy",
330+
"squid_proxy": "http://172.30.0.10:3128",
331+
"providers": { "openai": true, "anthropic": false, "gemini": false, "copilot": false },
332+
"key_validation": { "complete": true, "results": { "openai": "valid" } },
333+
"models_fetch_complete": true,
334+
"metrics_summary": { "total_requests": 0, "success_rate": 100, "avg_latency_ms": 0 },
335+
"rate_limits": {}
336+
}
337+
```
338+
339+
Use `models_fetch_complete` as a readiness gate before submitting the first inference request, ensuring model lists are warm. See the [Readiness polling](#readiness-polling) recipe below.
340+
341+
### Readiness polling
342+
343+
Poll `/health` (or `/reflect`) until `models_fetch_complete: true` before launching the agent command, so model lists are fully cached:
344+
345+
```bash
346+
# Wait up to 30 seconds for model discovery to complete
347+
for i in $(seq 1 30); do
348+
result=$(curl -sf http://172.30.0.30:10000/health 2>/dev/null)
349+
if [ "$(echo "$result" | jq -r '.models_fetch_complete')" = "true" ]; then
350+
echo "Model discovery complete"
351+
break
352+
fi
353+
echo "Waiting for model discovery... ($i/30)"
354+
sleep 1
355+
done
356+
```
357+
358+
Or use `/reflect` directly if you also need the model lists:
359+
360+
```bash
361+
curl -sf http://172.30.0.30:10000/reflect | jq '.models_fetch_complete, .endpoints[].models'
362+
```
363+
324364
### Reflection endpoint
325365

326366
The management port (10000) also exposes a `GET /reflect` endpoint for dynamic provider and model discovery. This allows agent harnesses to query which providers are configured and which models are available at runtime.

0 commit comments

Comments
 (0)