diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 2819d672b..181aa0d23 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,74 @@ 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_API_PROXY_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. + * 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 }); + 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', + logDir, + path: filePath, + error: err instanceof Error ? (err.stack || err.message) : String(err), + }); + } +} + /** * Build the reflection response describing all proxy endpoints and their available models. * @@ -1550,9 +1620,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 +1928,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..1d254e9d3 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,132 @@ 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(); + 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', () => { + 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', () => { + // 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(); + }); + + 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 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(); + 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'); + 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'); + }); + + 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']); + }); +}); +