From 21a8eac0c6bb0a61ff6168ddafecb540737a3394 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:03:47 +0000 Subject: [PATCH 1/4] Initial plan From 2e54d9bbb2d2ff233e4a899fa2ebbabd8fbabb6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:07:50 +0000 Subject: [PATCH 2/4] feat: api-proxy writes models.json after startup model fetch - Add buildModelsJson() to build JSON snapshot of model availability from cachedModels, configured API targets, and model aliases - Add writeModelsJson() to write the snapshot to /var/log/api-proxy/models.json (volume-mounted for artifact upload); creates the directory if missing - Call writeModelsJson() after fetchStartupModels() at startup (both on success and on error, so a partial snapshot is always written) - Add fs/path requires at the top of server.js - Export buildModelsJson and writeModelsJson for testing - Add 13 new tests covering schema, provider fields, directory creation, and overwrite behaviour Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/5a2d7cba-a13e-44d1-8320-50d5be105e48 --- containers/api-proxy/server.js | 72 +++++++++++++++- containers/api-proxy/server.test.js | 122 +++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 2819d672b..ba4007997 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -10,6 +10,8 @@ * 4. Respects domain whitelisting enforced by Squid */ +const fs = require('fs'); +const path = require('path'); const http = require('http'); const https = require('https'); const tls = require('tls'); @@ -1424,6 +1426,69 @@ async function fetchStartupModels(overrides = {}) { modelFetchComplete = true; } +// Default log directory for models.json (matches the volume mount in docker-compose) +const MODELS_LOG_DIR = process.env.AWF_TOKEN_LOG_DIR || '/var/log/api-proxy'; + +/** + * Build the models.json payload from current cached state. + * + * @returns {object} The models JSON object with timestamp, providers, and model_aliases + */ +function buildModelsJson() { + const opencodeConfigured = !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN); + return { + timestamp: new Date().toISOString(), + providers: { + openai: { + configured: !!OPENAI_API_KEY, + models: cachedModels.openai !== undefined ? cachedModels.openai : null, + target: OPENAI_API_KEY ? OPENAI_API_TARGET : null, + }, + anthropic: { + configured: !!ANTHROPIC_API_KEY, + models: cachedModels.anthropic !== undefined ? cachedModels.anthropic : null, + target: ANTHROPIC_API_KEY ? ANTHROPIC_API_TARGET : null, + }, + copilot: { + configured: !!COPILOT_AUTH_TOKEN, + models: cachedModels.copilot !== undefined ? cachedModels.copilot : null, + target: COPILOT_AUTH_TOKEN ? COPILOT_API_TARGET : null, + }, + gemini: { + configured: !!GEMINI_API_KEY, + models: cachedModels.gemini !== undefined ? cachedModels.gemini : null, + target: GEMINI_API_KEY ? GEMINI_API_TARGET : null, + }, + opencode: { + configured: opencodeConfigured, + models: null, + target: null, + }, + }, + model_aliases: MODEL_ALIASES ? MODEL_ALIASES.models : null, + }; +} + +/** + * Write the current model availability snapshot to models.json in the log directory. + * + * Called after fetchStartupModels() completes and whenever models are refreshed. + * The file is written to the volume-mounted log directory so it is automatically + * available for artifact upload. + * + * @param {string} [logDir] - Directory to write models.json to (default: MODELS_LOG_DIR) + */ +function writeModelsJson(logDir = MODELS_LOG_DIR) { + try { + fs.mkdirSync(logDir, { recursive: true }); + const filePath = path.join(logDir, 'models.json'); + fs.writeFileSync(filePath, JSON.stringify(buildModelsJson(), null, 2) + '\n', 'utf8'); + logRequest('info', 'models_json_written', { path: filePath }); + } catch (err) { + logRequest('warn', 'models_json_write_failed', { message: 'Failed to write models.json', error: String(err) }); + } +} + /** * Build the reflection response describing all proxy endpoints and their available models. * @@ -1550,9 +1615,12 @@ if (require.main === module) { logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) }); keyValidationComplete = true; }); - fetchStartupModels().catch((err) => { + fetchStartupModels().then(() => { + writeModelsJson(); + }).catch((err) => { logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) }); modelFetchComplete = true; + writeModelsJson(); }); } } @@ -1855,4 +1923,4 @@ if (require.main === module) { } // Export for testing -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, makeModelBodyTransform, MODEL_ALIASES }; +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, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index f5c36244f..9c8eb7068 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -6,7 +6,7 @@ const http = require('http'); const https = require('https'); const tls = require('tls'); const { EventEmitter } = require('events'); -const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -1787,3 +1787,123 @@ describe('makeModelBodyTransform', () => { }); }); +// ── buildModelsJson ──────────────────────────────────────────────────────── + +describe('buildModelsJson', () => { + afterEach(() => { + resetModelCacheState(); + }); + + it('should return an object with timestamp, providers, and model_aliases fields', () => { + const result = buildModelsJson(); + expect(typeof result.timestamp).toBe('string'); + expect(typeof result.providers).toBe('object'); + expect(result).toHaveProperty('model_aliases'); + }); + + it('should include all five providers', () => { + const result = buildModelsJson(); + expect(Object.keys(result.providers)).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']); + }); + + it('should set models to null for uncached providers', () => { + const result = buildModelsJson(); + // Without populating cachedModels, all models fields should be null + for (const provider of ['openai', 'anthropic', 'copilot', 'gemini', 'opencode']) { + expect(result.providers[provider].models).toBeNull(); + } + }); + + it('should include cached models when available', () => { + cachedModels.openai = ['gpt-4o', 'gpt-4o-mini']; + cachedModels.copilot = ['claude-sonnet-4']; + const result = buildModelsJson(); + expect(result.providers.openai.models).toEqual(['gpt-4o', 'gpt-4o-mini']); + expect(result.providers.copilot.models).toEqual(['claude-sonnet-4']); + expect(result.providers.anthropic.models).toBeNull(); + }); + + it('should include null models for providers that returned null (fetch failed)', () => { + cachedModels.openai = null; + const result = buildModelsJson(); + expect(result.providers.openai.models).toBeNull(); + }); + + it('should set model_aliases to null when MODEL_ALIASES is not configured', () => { + if (MODEL_ALIASES) return; // skip if env var happens to be set + const result = buildModelsJson(); + expect(result.model_aliases).toBeNull(); + }); + + it('should produce a valid ISO 8601 timestamp', () => { + const result = buildModelsJson(); + const ts = new Date(result.timestamp); + expect(ts.toString()).not.toBe('Invalid Date'); + }); + + it('should set opencode configured to true when openai key is available', () => { + // opencode.configured mirrors whether any base provider is configured; + // the module-level constant is fixed at import time — just verify shape. + const result = buildModelsJson(); + expect(typeof result.providers.opencode.configured).toBe('boolean'); + expect(result.providers.opencode.models).toBeNull(); + expect(result.providers.opencode.target).toBeNull(); + }); +}); + +// ── writeModelsJson ──────────────────────────────────────────────────────── + +describe('writeModelsJson', () => { + const os = require('os'); + const fs = require('fs'); + const path = require('path'); + + let tmpDir; + + beforeEach(() => { + resetModelCacheState(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-models-')); + }); + + afterEach(() => { + resetModelCacheState(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('should write models.json to the specified directory', () => { + writeModelsJson(tmpDir); + const filePath = path.join(tmpDir, 'models.json'); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it('should write valid JSON', () => { + writeModelsJson(tmpDir); + const content = fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8'); + expect(() => JSON.parse(content)).not.toThrow(); + }); + + it('should write JSON with the expected schema', () => { + cachedModels.openai = ['gpt-4o']; + writeModelsJson(tmpDir); + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8')); + expect(typeof data.timestamp).toBe('string'); + expect(typeof data.providers).toBe('object'); + expect(Object.keys(data.providers)).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']); + expect(data).toHaveProperty('model_aliases'); + }); + + it('should create the directory if it does not exist', () => { + const nestedDir = path.join(tmpDir, 'sub', 'dir'); + writeModelsJson(nestedDir); + expect(fs.existsSync(path.join(nestedDir, 'models.json'))).toBe(true); + }); + + it('should overwrite an existing models.json on subsequent writes', () => { + writeModelsJson(tmpDir); + cachedModels.copilot = ['claude-sonnet-4']; + writeModelsJson(tmpDir); + const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8')); + expect(data.providers.copilot.models).toEqual(['claude-sonnet-4']); + }); +}); + From 79c65382774ba6699df480afc988d39ec05286fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:10:32 +0000 Subject: [PATCH 3/4] fix: use AWF_API_PROXY_LOG_DIR env var for models.json log dir --- containers/api-proxy/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index ba4007997..7e01cfdeb 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -1427,7 +1427,7 @@ async function fetchStartupModels(overrides = {}) { } // Default log directory for models.json (matches the volume mount in docker-compose) -const MODELS_LOG_DIR = process.env.AWF_TOKEN_LOG_DIR || '/var/log/api-proxy'; +const MODELS_LOG_DIR = process.env.AWF_API_PROXY_LOG_DIR || '/var/log/api-proxy'; /** * Build the models.json payload from current cached state. From 2b4742a55c8bb5bf504998351ea91459288a4cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:21:08 +0000 Subject: [PATCH 4/4] fix: address review feedback on models.json snapshot - Move filePath before try block in writeModelsJson so it is available in the catch handler; include logDir, path, and err.stack in the warning log - Fix docstring: remove the incorrect 'whenever models are refreshed' claim - Make provider-key order assertions non-brittle using arrayContaining (buildModelsJson and writeModelsJson tests; both callers of Object.keys) - Fix environment-dependent model_aliases test: replace bare early-return with a trivially-passing expectation so it is always counted by Jest - Rename opencode test to reflect what it actually asserts (static field shape, not a key-availability check) Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/c1f5b1ba-e0d8-43b5-9063-203dbb3a3f51 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/api-proxy/server.js | 11 ++++++++--- containers/api-proxy/server.test.js | 21 +++++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 7e01cfdeb..181aa0d23 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -1472,20 +1472,25 @@ function buildModelsJson() { /** * Write the current model availability snapshot to models.json in the log directory. * - * Called after fetchStartupModels() completes and whenever models are refreshed. + * Called after fetchStartupModels() completes. * The file is written to the volume-mounted log directory so it is automatically * available for artifact upload. * * @param {string} [logDir] - Directory to write models.json to (default: MODELS_LOG_DIR) */ function writeModelsJson(logDir = MODELS_LOG_DIR) { + const filePath = path.join(logDir, 'models.json'); try { fs.mkdirSync(logDir, { recursive: true }); - const filePath = path.join(logDir, 'models.json'); fs.writeFileSync(filePath, JSON.stringify(buildModelsJson(), null, 2) + '\n', 'utf8'); logRequest('info', 'models_json_written', { path: filePath }); } catch (err) { - logRequest('warn', 'models_json_write_failed', { message: 'Failed to write models.json', error: String(err) }); + logRequest('warn', 'models_json_write_failed', { + message: 'Failed to write models.json', + logDir, + path: filePath, + error: err instanceof Error ? (err.stack || err.message) : String(err), + }); } } diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 9c8eb7068..1d254e9d3 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -1803,7 +1803,9 @@ describe('buildModelsJson', () => { it('should include all five providers', () => { const result = buildModelsJson(); - expect(Object.keys(result.providers)).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']); + const providerKeys = Object.keys(result.providers); + expect(providerKeys).toHaveLength(5); + expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'opencode'])); }); it('should set models to null for uncached providers', () => { @@ -1830,7 +1832,12 @@ describe('buildModelsJson', () => { }); it('should set model_aliases to null when MODEL_ALIASES is not configured', () => { - if (MODEL_ALIASES) return; // skip if env var happens to be set + // MODEL_ALIASES is a module-level constant fixed at import time. + // This assertion is only meaningful when AWF_MODEL_ALIASES is unset. + if (MODEL_ALIASES) { + expect(MODEL_ALIASES).not.toBeNull(); // trivially passes — env var is set, skip + return; + } const result = buildModelsJson(); expect(result.model_aliases).toBeNull(); }); @@ -1841,9 +1848,9 @@ describe('buildModelsJson', () => { expect(ts.toString()).not.toBe('Invalid Date'); }); - it('should set opencode configured to true when openai key is available', () => { - // opencode.configured mirrors whether any base provider is configured; - // the module-level constant is fixed at import time — just verify shape. + it('should include opencode provider with correct static fields', () => { + // opencode.configured mirrors whether any base provider is configured at + // module load time — just verify the expected shape is always present. const result = buildModelsJson(); expect(typeof result.providers.opencode.configured).toBe('boolean'); expect(result.providers.opencode.models).toBeNull(); @@ -1888,7 +1895,9 @@ describe('writeModelsJson', () => { const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8')); expect(typeof data.timestamp).toBe('string'); expect(typeof data.providers).toBe('object'); - expect(Object.keys(data.providers)).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']); + const providerKeys = Object.keys(data.providers); + expect(providerKeys).toHaveLength(5); + expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'opencode'])); expect(data).toHaveProperty('model_aliases'); });