Skip to content

Commit dfd8f0e

Browse files
Copilotlpcox
andauthored
feat(api-proxy): write models.json snapshot after startup model fetch (#2339)
* Initial plan * 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 * fix: use AWF_API_PROXY_LOG_DIR env var for models.json log dir * 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> --------- 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 7060515 commit dfd8f0e

2 files changed

Lines changed: 205 additions & 3 deletions

File tree

containers/api-proxy/server.js

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* 4. Respects domain whitelisting enforced by Squid
1111
*/
1212

13+
const fs = require('fs');
14+
const path = require('path');
1315
const http = require('http');
1416
const https = require('https');
1517
const tls = require('tls');
@@ -1424,6 +1426,74 @@ async function fetchStartupModels(overrides = {}) {
14241426
modelFetchComplete = true;
14251427
}
14261428

1429+
// Default log directory for models.json (matches the volume mount in docker-compose)
1430+
const MODELS_LOG_DIR = process.env.AWF_API_PROXY_LOG_DIR || '/var/log/api-proxy';
1431+
1432+
/**
1433+
* Build the models.json payload from current cached state.
1434+
*
1435+
* @returns {object} The models JSON object with timestamp, providers, and model_aliases
1436+
*/
1437+
function buildModelsJson() {
1438+
const opencodeConfigured = !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN);
1439+
return {
1440+
timestamp: new Date().toISOString(),
1441+
providers: {
1442+
openai: {
1443+
configured: !!OPENAI_API_KEY,
1444+
models: cachedModels.openai !== undefined ? cachedModels.openai : null,
1445+
target: OPENAI_API_KEY ? OPENAI_API_TARGET : null,
1446+
},
1447+
anthropic: {
1448+
configured: !!ANTHROPIC_API_KEY,
1449+
models: cachedModels.anthropic !== undefined ? cachedModels.anthropic : null,
1450+
target: ANTHROPIC_API_KEY ? ANTHROPIC_API_TARGET : null,
1451+
},
1452+
copilot: {
1453+
configured: !!COPILOT_AUTH_TOKEN,
1454+
models: cachedModels.copilot !== undefined ? cachedModels.copilot : null,
1455+
target: COPILOT_AUTH_TOKEN ? COPILOT_API_TARGET : null,
1456+
},
1457+
gemini: {
1458+
configured: !!GEMINI_API_KEY,
1459+
models: cachedModels.gemini !== undefined ? cachedModels.gemini : null,
1460+
target: GEMINI_API_KEY ? GEMINI_API_TARGET : null,
1461+
},
1462+
opencode: {
1463+
configured: opencodeConfigured,
1464+
models: null,
1465+
target: null,
1466+
},
1467+
},
1468+
model_aliases: MODEL_ALIASES ? MODEL_ALIASES.models : null,
1469+
};
1470+
}
1471+
1472+
/**
1473+
* Write the current model availability snapshot to models.json in the log directory.
1474+
*
1475+
* Called after fetchStartupModels() completes.
1476+
* The file is written to the volume-mounted log directory so it is automatically
1477+
* available for artifact upload.
1478+
*
1479+
* @param {string} [logDir] - Directory to write models.json to (default: MODELS_LOG_DIR)
1480+
*/
1481+
function writeModelsJson(logDir = MODELS_LOG_DIR) {
1482+
const filePath = path.join(logDir, 'models.json');
1483+
try {
1484+
fs.mkdirSync(logDir, { recursive: true });
1485+
fs.writeFileSync(filePath, JSON.stringify(buildModelsJson(), null, 2) + '\n', 'utf8');
1486+
logRequest('info', 'models_json_written', { path: filePath });
1487+
} catch (err) {
1488+
logRequest('warn', 'models_json_write_failed', {
1489+
message: 'Failed to write models.json',
1490+
logDir,
1491+
path: filePath,
1492+
error: err instanceof Error ? (err.stack || err.message) : String(err),
1493+
});
1494+
}
1495+
}
1496+
14271497
/**
14281498
* Build the reflection response describing all proxy endpoints and their available models.
14291499
*
@@ -1550,9 +1620,12 @@ if (require.main === module) {
15501620
logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) });
15511621
keyValidationComplete = true;
15521622
});
1553-
fetchStartupModels().catch((err) => {
1623+
fetchStartupModels().then(() => {
1624+
writeModelsJson();
1625+
}).catch((err) => {
15541626
logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) });
15551627
modelFetchComplete = true;
1628+
writeModelsJson();
15561629
});
15571630
}
15581631
}
@@ -1855,4 +1928,4 @@ if (require.main === module) {
18551928
}
18561929

18571930
// Export for testing
1858-
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 };
1931+
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 };

containers/api-proxy/server.test.js

Lines changed: 130 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, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES } = 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, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson } = require('./server');
1010

1111
describe('normalizeApiTarget', () => {
1212
it('should strip https:// prefix', () => {
@@ -1787,3 +1787,132 @@ describe('makeModelBodyTransform', () => {
17871787
});
17881788
});
17891789

1790+
// ── buildModelsJson ────────────────────────────────────────────────────────
1791+
1792+
describe('buildModelsJson', () => {
1793+
afterEach(() => {
1794+
resetModelCacheState();
1795+
});
1796+
1797+
it('should return an object with timestamp, providers, and model_aliases fields', () => {
1798+
const result = buildModelsJson();
1799+
expect(typeof result.timestamp).toBe('string');
1800+
expect(typeof result.providers).toBe('object');
1801+
expect(result).toHaveProperty('model_aliases');
1802+
});
1803+
1804+
it('should include all five providers', () => {
1805+
const result = buildModelsJson();
1806+
const providerKeys = Object.keys(result.providers);
1807+
expect(providerKeys).toHaveLength(5);
1808+
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']));
1809+
});
1810+
1811+
it('should set models to null for uncached providers', () => {
1812+
const result = buildModelsJson();
1813+
// Without populating cachedModels, all models fields should be null
1814+
for (const provider of ['openai', 'anthropic', 'copilot', 'gemini', 'opencode']) {
1815+
expect(result.providers[provider].models).toBeNull();
1816+
}
1817+
});
1818+
1819+
it('should include cached models when available', () => {
1820+
cachedModels.openai = ['gpt-4o', 'gpt-4o-mini'];
1821+
cachedModels.copilot = ['claude-sonnet-4'];
1822+
const result = buildModelsJson();
1823+
expect(result.providers.openai.models).toEqual(['gpt-4o', 'gpt-4o-mini']);
1824+
expect(result.providers.copilot.models).toEqual(['claude-sonnet-4']);
1825+
expect(result.providers.anthropic.models).toBeNull();
1826+
});
1827+
1828+
it('should include null models for providers that returned null (fetch failed)', () => {
1829+
cachedModels.openai = null;
1830+
const result = buildModelsJson();
1831+
expect(result.providers.openai.models).toBeNull();
1832+
});
1833+
1834+
it('should set model_aliases to null when MODEL_ALIASES is not configured', () => {
1835+
// MODEL_ALIASES is a module-level constant fixed at import time.
1836+
// This assertion is only meaningful when AWF_MODEL_ALIASES is unset.
1837+
if (MODEL_ALIASES) {
1838+
expect(MODEL_ALIASES).not.toBeNull(); // trivially passes — env var is set, skip
1839+
return;
1840+
}
1841+
const result = buildModelsJson();
1842+
expect(result.model_aliases).toBeNull();
1843+
});
1844+
1845+
it('should produce a valid ISO 8601 timestamp', () => {
1846+
const result = buildModelsJson();
1847+
const ts = new Date(result.timestamp);
1848+
expect(ts.toString()).not.toBe('Invalid Date');
1849+
});
1850+
1851+
it('should include opencode provider with correct static fields', () => {
1852+
// opencode.configured mirrors whether any base provider is configured at
1853+
// module load time — just verify the expected shape is always present.
1854+
const result = buildModelsJson();
1855+
expect(typeof result.providers.opencode.configured).toBe('boolean');
1856+
expect(result.providers.opencode.models).toBeNull();
1857+
expect(result.providers.opencode.target).toBeNull();
1858+
});
1859+
});
1860+
1861+
// ── writeModelsJson ────────────────────────────────────────────────────────
1862+
1863+
describe('writeModelsJson', () => {
1864+
const os = require('os');
1865+
const fs = require('fs');
1866+
const path = require('path');
1867+
1868+
let tmpDir;
1869+
1870+
beforeEach(() => {
1871+
resetModelCacheState();
1872+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-models-'));
1873+
});
1874+
1875+
afterEach(() => {
1876+
resetModelCacheState();
1877+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
1878+
});
1879+
1880+
it('should write models.json to the specified directory', () => {
1881+
writeModelsJson(tmpDir);
1882+
const filePath = path.join(tmpDir, 'models.json');
1883+
expect(fs.existsSync(filePath)).toBe(true);
1884+
});
1885+
1886+
it('should write valid JSON', () => {
1887+
writeModelsJson(tmpDir);
1888+
const content = fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8');
1889+
expect(() => JSON.parse(content)).not.toThrow();
1890+
});
1891+
1892+
it('should write JSON with the expected schema', () => {
1893+
cachedModels.openai = ['gpt-4o'];
1894+
writeModelsJson(tmpDir);
1895+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8'));
1896+
expect(typeof data.timestamp).toBe('string');
1897+
expect(typeof data.providers).toBe('object');
1898+
const providerKeys = Object.keys(data.providers);
1899+
expect(providerKeys).toHaveLength(5);
1900+
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'opencode']));
1901+
expect(data).toHaveProperty('model_aliases');
1902+
});
1903+
1904+
it('should create the directory if it does not exist', () => {
1905+
const nestedDir = path.join(tmpDir, 'sub', 'dir');
1906+
writeModelsJson(nestedDir);
1907+
expect(fs.existsSync(path.join(nestedDir, 'models.json'))).toBe(true);
1908+
});
1909+
1910+
it('should overwrite an existing models.json on subsequent writes', () => {
1911+
writeModelsJson(tmpDir);
1912+
cachedModels.copilot = ['claude-sonnet-4'];
1913+
writeModelsJson(tmpDir);
1914+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, 'models.json'), 'utf8'));
1915+
expect(data.providers.copilot.models).toEqual(['claude-sonnet-4']);
1916+
});
1917+
});
1918+

0 commit comments

Comments
 (0)