Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
});
}
}
Expand Down Expand Up @@ -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 };
131 changes: 130 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Comment on lines +1834 to +1842

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is environment-dependent: it skips when MODEL_ALIASES is set, which can silently reduce coverage in CI or local runs. Consider making it deterministic by setting/clearing AWF_MODEL_ALIASES and re-requiring the module via jest.resetModules() (or refactoring buildModelsJson to accept injected aliases).

Copilot uses AI. Check for mistakes.
});

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']);
});
});

Loading