diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 6fcd7c7ea..ff72b0929 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -15,7 +15,9 @@ COPY package*.json ./ RUN npm ci --omit=dev # Copy application files -COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js model-resolver.js anthropic-cache.js anthropic-transforms.js ./ +COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js \ + model-resolver.js proxy-utils.js anthropic-transforms.js ./ +COPY providers/ ./providers/ # Create non-root user RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy diff --git a/containers/api-proxy/providers/ADDING-A-PROVIDER.md b/containers/api-proxy/providers/ADDING-A-PROVIDER.md new file mode 100644 index 000000000..d2576342a --- /dev/null +++ b/containers/api-proxy/providers/ADDING-A-PROVIDER.md @@ -0,0 +1,223 @@ +# Adding a New LLM Provider to the AWF API Proxy + +This guide explains how to wire a new LLM provider into the AWF API proxy in three steps: + +1. Create an adapter file in this directory (`providers/.js`) +2. Register it in `providers/index.js` +3. Update the Dockerfile COPY list + +The core proxy engine (`server.js`) is completely agnostic of provider details — it only calls the methods defined on the `ProviderAdapter` interface. You never need to touch the core to add a new provider. + +--- + +## Step 1 — Create the adapter file + +Create `providers/.js`. The adapter is a plain JS object (no class syntax required) returned by a factory function: + +```js +'use strict'; + +const { normalizeApiTarget, normalizeBasePath } = require('../proxy-utils'); + +function createMyProviderAdapter(env, deps = {}) { + // Read credentials and config from env at construction time + const apiKey = (env.MY_PROVIDER_API_KEY || '').trim() || undefined; + const target = normalizeApiTarget(env.MY_PROVIDER_API_TARGET) || 'api.myprovider.com'; + const basePath = normalizeBasePath(env.MY_PROVIDER_API_BASE_PATH); + + const bodyTransform = deps.bodyTransform || null; // model-alias rewriting etc. + + return { + // ── Identity ───────────────────────────────────────────────────────────── + name: 'my-provider', // unique lowercase slug + port: 10005, // next available port (update Dockerfile EXPOSE too) + + isManagementPort: false, // true only for port 10000 (OpenAI) + alwaysBind: false, // set true to start a 503-stub when not configured + get participatesInValidation() { return this.isEnabled(); }, + + // ── Credentials ────────────────────────────────────────────────────────── + isEnabled() { return !!apiKey; }, + getTargetHost() { return target; }, + getBasePath() { return basePath; }, + + // ── Per-request auth headers ────────────────────────────────────────────── + // `req` is the incoming http.IncomingMessage — inspect it for request-specific logic. + getAuthHeaders(req) { + return { 'Authorization': `Bearer ${apiKey}` }; + }, + + // ── Optional: URL transform ─────────────────────────────────────────────── + // Return the (possibly modified) URL string, or omit this method entirely. + transformRequestUrl(url) { return url; }, + + // ── Optional: body transform ────────────────────────────────────────────── + // Return a function (body: Buffer) => Buffer|null, or null for no transform. + getBodyTransform() { return bodyTransform; }, + + // ── Startup: credential validation ─────────────────────────────────────── + // Return a probe config, a skip config, or null if validation is not applicable. + getValidationProbe() { + if (!apiKey) return null; + if (target !== 'api.myprovider.com') { + return { skip: true, reason: `Custom target ${target}; validation skipped` }; + } + return { + url: `https://${target}/v1/models`, + opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } }, + }; + }, + + // ── Startup: model listing ──────────────────────────────────────────────── + // Return null to opt out of model fetching. + getModelsFetchConfig() { + if (!apiKey) return null; + return { + url: `https://${target}/v1/models`, + opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } }, + cacheKey: 'my-provider', // key in cachedModels; must match name + }; + }, + + // ── /reflect endpoint metadata ──────────────────────────────────────────── + getReflectionInfo() { + return { + provider: 'my-provider', + port: 10005, + base_url: 'http://api-proxy:10005', + configured: !!apiKey, + models_cache_key: 'my-provider', // null when models are not fetched + models_url: 'http://api-proxy:10005/v1/models', + }; + }, + }; +} + +module.exports = { createMyProviderAdapter }; +``` + +### Provider adapter reference + +| Method / property | Required? | Description | +|---|---|---| +| `name` | ✅ | Unique lowercase slug (matches cache key and log labels) | +| `port` | ✅ | Port to listen on; must be unique across all adapters | +| `isManagementPort` | ✅ | `true` only for the one port that serves `/health`, `/metrics`, `/reflect` | +| `alwaysBind` | ✅ | `true` to start a stub server even when `isEnabled()` returns false | +| `participatesInValidation` | ✅ | `true` when this adapter should count in the startup latch | +| `isEnabled()` | ✅ | Returns `true` when credentials are present | +| `getTargetHost(req?)` | ✅ | Returns the upstream hostname | +| `getBasePath(req?)` | ✅ | Returns the URL path prefix (empty string for none) | +| `getAuthHeaders(req)` | ✅ | Returns headers to inject (auth, version, integration ID, …) | +| `transformRequestUrl(url)` | ➖ optional | Mutate the request URL before forwarding (e.g. strip query params) | +| `getBodyTransform()` | ✅ | Returns `(Buffer) => Buffer\|null` or `null` | +| `getValidationProbe()` | ✅ | Returns probe config, `{ skip, reason }`, or `null` | +| `getModelsFetchConfig()` | ✅ | Returns fetch config or `null` | +| `getReflectionInfo()` | ✅ | Returns endpoint metadata for `/reflect` and `models.json` | +| `getUnconfiguredResponse()` | ➖ optional | Response for proxy requests when `alwaysBind=true` & not enabled | +| `getUnconfiguredHealthResponse()` | ➖ optional | `/health` response when not enabled (defaults to 503) | + +--- + +## Step 2 — Register in `providers/index.js` + +```js +// 1. Import your factory +const { createMyProviderAdapter } = require('./my-provider'); + +// 2. Construct the adapter alongside the others in createAllAdapters(): +function createAllAdapters(env, deps = {}) { + const openai = createOpenAIAdapter(env, { bodyTransform: deps.openaiBodyTransform }); + const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform }); + const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform }); + const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform }); + const myProvider = createMyProviderAdapter(env, { bodyTransform: deps.myProviderBodyTransform }); // ← add here + + // OpenCode routes to the first enabled candidate in priority order. + // Add myProvider to this list if you want OpenCode to route through it. + const opencode = createOpenCodeAdapter(env, { candidateAdapters: [openai, anthropic, copilot] }); + + return [openai, anthropic, copilot, gemini, opencode, myProvider]; // ← include in return +} + +// 3. Export it alongside the others +module.exports = { + createAllAdapters, + // ...existing exports... + createMyProviderAdapter, +}; +``` + +If your provider needs model-alias rewriting, also add a corresponding +`myProviderBodyTransform` in server.js (mirroring how the existing transforms +are built and passed into `createAllAdapters`). + +### Optional: add your provider to OpenCode's routing + +OpenCode (port 10004) automatically routes to the first enabled adapter in its +`candidateAdapters` list. If you want OpenCode to fall back to your provider, +add it to that list in the desired priority position — **no changes to +`opencode.js` are needed**: + +```js +// In createAllAdapters(), update the opencode line: +const opencode = createOpenCodeAdapter(env, { + candidateAdapters: [openai, anthropic, copilot, myProvider], // ← add at desired priority position +}); +``` + +All providers remain independently reachable on their own ports regardless of +whether they appear in the OpenCode candidate list. + +--- + +## Step 3 — Update the Dockerfile + +Add the new adapter file to the explicit `COPY` list in `containers/api-proxy/Dockerfile`: + +```dockerfile +COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js \ + model-resolver.js proxy-utils.js anthropic-cache.js anthropic-transforms.js ./ +COPY providers/ ./providers/ +``` + +Also update the `EXPOSE` directive to include the new port: + +```dockerfile +EXPOSE 10000 10001 10002 10003 10004 10005 +``` + +--- + +## Checklist + +- [ ] `providers/.js` created and exports `createAdapter` +- [ ] Adapter registered in `providers/index.js` (`createAllAdapters` + exports) +- [ ] `Dockerfile` updated: `providers/` in COPY list, port in EXPOSE +- [ ] Add provider env vars to `src/docker-manager.ts` if they need forwarding from the host +- [ ] Add domain to `docs/allowed-domains.md` or equivalent if the upstream is new +- [ ] Write adapter unit tests in `providers/.test.js` +- [ ] (Optional) Add adapter to OpenCode's `candidateAdapters` list in `providers/index.js` if OpenCode should route through it + +--- + +## Testing your adapter in isolation + +Because each adapter is a plain object, you can unit-test it without starting any HTTP servers: + +```js +const { createMyProviderAdapter } = require('./my-provider'); + +describe('MyProvider adapter', () => { + it('returns correct auth headers', () => { + const adapter = createMyProviderAdapter({ MY_PROVIDER_API_KEY: 'test-key' }); + const fakeReq = { headers: {}, method: 'POST', url: '/v1/chat' }; + expect(adapter.getAuthHeaders(fakeReq)).toEqual({ Authorization: 'Bearer test-key' }); + }); + + it('reports not configured when key is absent', () => { + const adapter = createMyProviderAdapter({}); + expect(adapter.isEnabled()).toBe(false); + }); +}); +``` diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js new file mode 100644 index 000000000..b7f7d73a3 --- /dev/null +++ b/containers/api-proxy/providers/anthropic.js @@ -0,0 +1,170 @@ +'use strict'; + +/** + * Anthropic provider adapter. + * + * Port: 10001 + * Auth: x-api-key header, plus optional anthropic-version and anthropic-beta headers + * Credentials: ANTHROPIC_API_KEY + * Target: ANTHROPIC_API_TARGET (default: api.anthropic.com) + * Base path: ANTHROPIC_API_BASE_PATH + * Body transforms: model alias rewriting + optional prompt-cache optimisations + */ + +const { normalizeApiTarget, normalizeBasePath, composeBodyTransforms } = require('../proxy-utils'); + +let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA; +try { + ({ makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA } = require('../anthropic-transforms')); +} catch (err) { + if (err && err.code === 'MODULE_NOT_FOUND') { + makeAnthropicTransform = () => () => null; + loadCustomTransform = () => null; + EXTENDED_CACHE_BETA = undefined; + } else { + throw err; + } +} + +/** + * Create the Anthropic provider adapter. + * + * @param {Record} env - Environment variables + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @returns {import('./index').ProviderAdapter} + */ +function createAnthropicAdapter(env, deps = {}) { + const apiKey = (env.ANTHROPIC_API_KEY || '').trim() || undefined; + const rawTarget = normalizeApiTarget(env.ANTHROPIC_API_TARGET) || 'api.anthropic.com'; + const basePath = normalizeBasePath(env.ANTHROPIC_API_BASE_PATH); + + // ── Anthropic-specific optimisations ────────────────────────────────────── + const autoCache = (env.AWF_ANTHROPIC_AUTO_CACHE === '1' || env.AWF_ANTHROPIC_AUTO_CACHE === 'true'); + const cacheTailTtl = (() => { + const raw = (env.AWF_ANTHROPIC_CACHE_TAIL_TTL || '').trim(); + return (raw === '1h' || raw === '5m') ? raw : '5m'; + })(); + const dropTools = (() => { + const raw = (env.AWF_ANTHROPIC_DROP_TOOLS || '').trim(); + return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : []; + })(); + const stripAnsi = (env.AWF_ANTHROPIC_STRIP_ANSI === '1' || env.AWF_ANTHROPIC_STRIP_ANSI === 'true'); + const transformFile = (env.AWF_ANTHROPIC_TRANSFORM_FILE || '').trim() || undefined; + + const customTransform = loadCustomTransform(transformFile); + const optimisationsTransform = makeAnthropicTransform({ + autoCache, + tailTtl: cacheTailTtl, + dropTools, + stripAnsiCodes: stripAnsi, + customTransform, + }); + + const bodyTransform = deps.bodyTransform || null; + + // Build the composed transform once at construction time to avoid + // re-allocating the wrapper function on every request. + const composedBodyTransform = composeBodyTransforms(bodyTransform, optimisationsTransform); + + return { + name: 'anthropic', + port: 10001, + isManagementPort: false, + alwaysBind: false, + get participatesInValidation() { return this.isEnabled(); }, + + isEnabled() { return !!apiKey; }, + getTargetHost() { return rawTarget; }, + getBasePath() { return basePath; }, + + /** + * Build Anthropic auth headers for this request. + * Merges in the anthropic-version default and anthropic-beta (for auto-cache) + * as needed, without overwriting values already set by the client. + * + * @param {import('http').IncomingMessage} req + * @returns {Record} + */ + getAuthHeaders(req) { + const headers = { 'x-api-key': apiKey }; + + if (!req.headers['anthropic-version']) { + headers['anthropic-version'] = '2023-06-01'; + } + + if (autoCache) { + const existing = req.headers['anthropic-beta']; + if (!existing) { + headers['anthropic-beta'] = EXTENDED_CACHE_BETA; + } else { + const normalizedExisting = Array.isArray(existing) ? existing.join(',') : existing; + const existingBetas = normalizedExisting.split(',').map(s => s.trim()).filter(Boolean); + if (!existingBetas.includes(EXTENDED_CACHE_BETA)) { + headers['anthropic-beta'] = `${normalizedExisting},${EXTENDED_CACHE_BETA}`; + } + } + } + + return headers; + }, + + getBodyTransform() { return composedBodyTransform; }, + + getValidationProbe() { + if (!apiKey) return null; + if (rawTarget !== 'api.anthropic.com') { + return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + } + // POST /v1/messages with an empty body: 400 = key valid (bad body), 401 = key invalid + return { + url: `https://${rawTarget}/v1/messages`, + opts: { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: '{}', + }, + }; + }, + + getModelsFetchConfig() { + if (!apiKey) return null; + // Use the configured base path so Anthropic-compatible endpoints with a + // path prefix populate /reflect and models.json correctly. + const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; + return { + url: `https://${rawTarget}${modelsPath}`, + opts: { + method: 'GET', + headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, + }, + cacheKey: 'anthropic', + }; + }, + + getReflectionInfo() { + return { + provider: 'anthropic', + port: 10001, + base_url: 'http://api-proxy:10001', + configured: !!apiKey, + models_cache_key: 'anthropic', + models_url: 'http://api-proxy:10001/v1/models', + }; + }, + + // Exposed for introspection (logging, tests) + _autoCache: autoCache, + _cacheTailTtl: cacheTailTtl, + _dropTools: dropTools, + _stripAnsi: stripAnsi, + _transformFile: transformFile, + _customTransformLoaded: !!customTransform, + _optimisationsTransform: optimisationsTransform, + }; +} + +module.exports = { createAnthropicAdapter }; diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js new file mode 100644 index 000000000..e65902857 --- /dev/null +++ b/containers/api-proxy/providers/copilot.js @@ -0,0 +1,251 @@ +'use strict'; + +/** + * GitHub Copilot provider adapter. + * + * Port: 10002 + * Auth: Bearer token (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY) + * Credentials: COPILOT_GITHUB_TOKEN (GitHub OAuth, higher trust) or COPILOT_API_KEY (BYOK) + * Target: COPILOT_API_TARGET (auto-derived from GITHUB_SERVER_URL if not set) + * Base path: none (Copilot inference API manages its own path layout) + * + * Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN + * regardless of which auth mode is active, because the /models endpoint only + * accepts OAuth tokens, not API keys. + */ + +const { normalizeApiTarget } = require('../proxy-utils'); +const { URL } = require('url'); + +/** + * Resolves the Copilot auth token from environment variables. + * COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key). + * + * @param {Record} env - Environment variables to inspect + * @returns {string|undefined} The resolved auth token, or undefined if neither is set + */ +function resolveCopilotAuthToken(env = process.env) { + const githubToken = (env.COPILOT_GITHUB_TOKEN || '').trim() || undefined; + const apiKey = (env.COPILOT_API_KEY || '').trim() || undefined; + return githubToken || apiKey; +} + +/** + * Derive the Copilot API target hostname from environment variables. + * + * Priority: + * 1. Explicit COPILOT_API_TARGET env var + * 2. Auto-derived from GITHUB_SERVER_URL: + * - *.ghe.com (GHEC tenant) → copilot-api..ghe.com + * - Other non-github.com (GHES) → api.enterprise.githubcopilot.com + * 3. Default: api.githubcopilot.com + * + * @param {Record} env - Environment variables + * @returns {string} Copilot API target hostname + */ +function deriveCopilotApiTarget(env = process.env) { + if (env.COPILOT_API_TARGET) { + const target = normalizeApiTarget(env.COPILOT_API_TARGET); + // Only use the explicit value if it parsed into a valid hostname; + // fall through to auto-derivation when the value is malformed. + if (target) return target; + } + const serverUrl = env.GITHUB_SERVER_URL; + if (serverUrl) { + try { + const hostname = new URL(serverUrl).hostname; + if (hostname !== 'github.com') { + if (hostname.endsWith('.ghe.com')) { + const subdomain = hostname.slice(0, -8); // Remove '.ghe.com' + return `copilot-api.${subdomain}.ghe.com`; + } + return 'api.enterprise.githubcopilot.com'; + } + } catch { + // Invalid URL — fall through to default + } + } + return 'api.githubcopilot.com'; +} + +/** + * Derive the GitHub REST API target hostname (used for GHES/GHEC endpoints). + * + * Priority: + * 1. Explicit GITHUB_API_URL env var (hostname extracted) + * 2. Auto-derived from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com) + * 3. Default: api.github.com + * + * @param {Record} env - Environment variables + * @returns {string} GitHub REST API target hostname + */ +function deriveGitHubApiTarget(env = process.env) { + if (env.GITHUB_API_URL) { + const target = normalizeApiTarget(env.GITHUB_API_URL); + if (target) return target; + } + const serverUrl = env.GITHUB_SERVER_URL; + if (serverUrl) { + try { + const hostname = new URL(serverUrl).hostname; + if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) { + const subdomain = hostname.slice(0, -8); + return `api.${subdomain}.ghe.com`; + } + } catch { + // Invalid URL — fall through to default + } + } + return 'api.github.com'; +} + +/** + * Extract the base path from GITHUB_API_URL for GHES deployments + * (e.g. https://ghes.example.com/api/v3 → '/api/v3'). + * Returns '' for github.com or when no path component is present. + * + * @param {Record} env - Environment variables + * @returns {string} Base path or '' + */ +function deriveGitHubApiBasePath(env = process.env) { + const raw = env.GITHUB_API_URL; + if (!raw) return ''; + try { + const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`); + const p = parsed.pathname.replace(/\/+$/, ''); + return p === '/' ? '' : p; + } catch { + return ''; + } +} + +/** + * Create the GitHub Copilot provider adapter. + * + * @param {Record} env - Environment variables + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @returns {import('./index').ProviderAdapter} + */ +function createCopilotAdapter(env, deps = {}) { + const githubToken = (env.COPILOT_GITHUB_TOKEN || '').trim() || undefined; + const apiKey = (env.COPILOT_API_KEY || '').trim() || undefined; + const authToken = resolveCopilotAuthToken(env); + const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli'; + const rawTarget = deriveCopilotApiTarget(env); + + const bodyTransform = deps.bodyTransform || null; + + return { + name: 'copilot', + port: 10002, + isManagementPort: false, + alwaysBind: false, + get participatesInValidation() { return this.isEnabled(); }, + + isEnabled() { return !!authToken; }, + getTargetHost() { return rawTarget; }, + getBasePath() { return ''; }, + + /** + * Build Copilot auth headers for this request. + * + * The Copilot /models endpoint only accepts COPILOT_GITHUB_TOKEN (GitHub OAuth). + * All other requests use the resolved auth token (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY). + * + * @param {import('http').IncomingMessage} req + * @returns {Record} + */ + getAuthHeaders(req) { + let reqPathname; + try { + reqPathname = new URL(req.url, 'http://localhost').pathname; + } catch { + reqPathname = req.url || ''; + } + + const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/'); + if (isModelsPath && req.method === 'GET' && githubToken) { + return { + 'Authorization': `Bearer ${githubToken}`, + 'Copilot-Integration-Id': integrationId, + }; + } + + return { + 'Authorization': `Bearer ${authToken}`, + 'Copilot-Integration-Id': integrationId, + }; + }, + + getBodyTransform() { return bodyTransform; }, + + getValidationProbe() { + if (!authToken) return null; + + // Only COPILOT_GITHUB_TOKEN has a probe endpoint (/models). + // COPILOT_API_KEY alone cannot be validated at startup. + if (!githubToken) { + return { + skip: true, + reason: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode', + }; + } + + if (rawTarget !== 'api.githubcopilot.com') { + return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + } + + return { + url: `https://${rawTarget}/models`, + opts: { + method: 'GET', + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Copilot-Integration-Id': integrationId, + }, + }, + }; + }, + + getModelsFetchConfig() { + // Only COPILOT_GITHUB_TOKEN is accepted by the /models endpoint + if (!githubToken) return null; + return { + url: `https://${rawTarget}/models`, + opts: { + method: 'GET', + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Copilot-Integration-Id': integrationId, + }, + }, + cacheKey: 'copilot', + }; + }, + + getReflectionInfo() { + return { + provider: 'copilot', + port: 10002, + base_url: 'http://api-proxy:10002', + configured: !!authToken, + models_cache_key: 'copilot', + models_url: 'http://api-proxy:10002/models', + }; + }, + + // Exposed for introspection / testing + _githubToken: githubToken, + _apiKey: apiKey, + _integrationId: integrationId, + _rawTarget: rawTarget, + }; +} + +module.exports = { + createCopilotAdapter, + resolveCopilotAuthToken, + deriveCopilotApiTarget, + deriveGitHubApiTarget, + deriveGitHubApiBasePath, +}; diff --git a/containers/api-proxy/providers/gemini.js b/containers/api-proxy/providers/gemini.js new file mode 100644 index 000000000..344de5e54 --- /dev/null +++ b/containers/api-proxy/providers/gemini.js @@ -0,0 +1,123 @@ +'use strict'; + +/** + * Google Gemini provider adapter. + * + * Port: 10003 (always bound — returns 503 when no key is configured) + * Auth: x-goog-api-key header + * Credentials: GEMINI_API_KEY + * Target: GEMINI_API_TARGET (default: generativelanguage.googleapis.com) + * Base path: GEMINI_API_BASE_PATH + * + * URL transform: strips ?key=, ?apiKey=, ?api_key= query params that some + * Gemini SDK versions append alongside the header. + */ + +const { normalizeApiTarget, normalizeBasePath, stripGeminiKeyParam } = require('../proxy-utils'); + +/** + * Create the Google Gemini provider adapter. + * + * @param {Record} env - Environment variables + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @returns {import('./index').ProviderAdapter} + */ +function createGeminiAdapter(env, deps = {}) { + const apiKey = (env.GEMINI_API_KEY || '').trim() || undefined; + const rawTarget = normalizeApiTarget(env.GEMINI_API_TARGET) || 'generativelanguage.googleapis.com'; + const basePath = normalizeBasePath(env.GEMINI_API_BASE_PATH); + + const bodyTransform = deps.bodyTransform || null; + + return { + name: 'gemini', + port: 10003, + isManagementPort: false, + + /** + * Port 10003 always starts so the Gemini CLI gets a clear 503 "not configured" + * error rather than a silent connection-refused. + */ + alwaysBind: true, + + /** + * The 503-fallback server does NOT count toward the startup validation latch — + * only the fully-configured server (when GEMINI_API_KEY is set) does. + */ + get participatesInValidation() { return this.isEnabled(); }, + + isEnabled() { return !!apiKey; }, + getTargetHost() { return rawTarget; }, + getBasePath() { return basePath; }, + + getAuthHeaders() { + return { 'x-goog-api-key': apiKey }; + }, + + /** + * Strip Gemini SDK auth query parameters before forwarding. + * The SDK injects ?key= (or ?apiKey=, ?api_key=) alongside the header; + * forwarding both causes API_KEY_INVALID errors on the upstream. + * + * @param {string} url + * @returns {string} + */ + transformRequestUrl(url) { + return stripGeminiKeyParam(url); + }, + + getBodyTransform() { return bodyTransform; }, + + getValidationProbe() { + if (!apiKey) return null; + if (rawTarget !== 'generativelanguage.googleapis.com') { + return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + } + return { + url: `https://${rawTarget}/v1beta/models`, + opts: { method: 'GET', headers: { 'x-goog-api-key': apiKey } }, + }; + }, + + getModelsFetchConfig() { + if (!apiKey) return null; + // Use the configured base path so Gemini-compatible endpoints with a + // path prefix populate /reflect and models.json correctly. + const modelsPath = basePath ? `${basePath}/models` : '/v1beta/models'; + return { + url: `https://${rawTarget}${modelsPath}`, + opts: { method: 'GET', headers: { 'x-goog-api-key': apiKey } }, + cacheKey: 'gemini', + }; + }, + + getReflectionInfo() { + return { + provider: 'gemini', + port: 10003, + base_url: 'http://api-proxy:10003', + configured: !!apiKey, + models_cache_key: 'gemini', + models_url: 'http://api-proxy:10003/v1beta/models', + }; + }, + + /** Response returned for all requests when no GEMINI_API_KEY is configured. */ + getUnconfiguredResponse() { + return { + statusCode: 503, + body: { error: 'Gemini proxy not configured (no GEMINI_API_KEY). Set GEMINI_API_KEY in the AWF runner environment to enable credential isolation.' }, + }; + }, + + /** /health response when not configured. */ + getUnconfiguredHealthResponse() { + return { + statusCode: 503, + body: { status: 'not_configured', service: 'awf-api-proxy-gemini', error: 'GEMINI_API_KEY not configured in api-proxy sidecar' }, + }; + }, + }; +} + +module.exports = { createGeminiAdapter }; diff --git a/containers/api-proxy/providers/index.js b/containers/api-proxy/providers/index.js new file mode 100644 index 000000000..a64077554 --- /dev/null +++ b/containers/api-proxy/providers/index.js @@ -0,0 +1,129 @@ +'use strict'; + +/** + * Provider adapter registry. + * + * Exports `createAllAdapters()` which creates the ordered list of provider + * adapters used by the core proxy engine. + * + * @see ADDING-A-PROVIDER.md for instructions on adding a new LLM provider. + */ + +const { createOpenAIAdapter } = require('./openai'); +const { createAnthropicAdapter } = require('./anthropic'); +const { createCopilotAdapter } = require('./copilot'); +const { createGeminiAdapter } = require('./gemini'); +const { createOpenCodeAdapter } = require('./opencode'); + +/** + * @typedef {Object} ProbeConfig + * @property {string} url - URL to probe + * @property {{ method: string, headers: Record, body?: string }} opts - Request options + */ + +/** + * @typedef {Object} SkipProbeConfig + * @property {true} skip + * @property {string} reason + */ + +/** + * @typedef {Object} ModelsFetchConfig + * @property {string} url - URL to fetch + * @property {{ method: string, headers: Record }} opts - Request options + * @property {string} cacheKey - Key in cachedModels to store the result + */ + +/** + * @typedef {Object} ReflectionInfo + * @property {string} provider - Provider name + * @property {number} port - Port number + * @property {string} base_url - Base URL for the provider + * @property {boolean} configured - Whether the provider is configured + * @property {string|null} models_cache_key - Key in cachedModels, or null if not applicable + * @property {string|null} models_url - URL to fetch models from (for documentation/reflection) + */ + +/** + * @typedef {Object} UnconfiguredResponse + * @property {number} statusCode - HTTP status code + * @property {object} body - Response body + */ + +/** + * Provider adapter interface. + * + * Each adapter encapsulates all provider-specific knowledge: + * - Which port to listen on + * - How to authenticate requests (getAuthHeaders) + * - How to transform URLs (transformRequestUrl) + * - How to transform request bodies (getBodyTransform) + * - How to validate credentials at startup (getValidationProbe) + * - How to fetch available models at startup (getModelsFetchConfig) + * - How to describe the endpoint for /reflect (getReflectionInfo) + * + * The core proxy engine (server.js) is completely agnostic of which providers + * exist — it only calls methods defined in this interface. + * + * @typedef {Object} ProviderAdapter + * @property {string} name - Unique provider identifier (e.g. 'openai') + * @property {number} port - Port to listen on + * @property {boolean} isManagementPort - Whether this port serves /health, /metrics, /reflect + * @property {boolean} alwaysBind - Whether to start even when isEnabled() returns false + * @property {boolean} participatesInValidation - Whether counted in the startup latch + * + * @property {() => boolean} isEnabled - Whether this provider is configured (has credentials) + * @property {(req?: import('http').IncomingMessage) => string} getTargetHost - Upstream hostname + * @property {(req?: import('http').IncomingMessage) => string} getBasePath - Base path prefix + * @property {(req: import('http').IncomingMessage) => Record} getAuthHeaders - Auth headers + * @property {((url: string) => string) | undefined} transformRequestUrl - Optional URL transform + * @property {() => ((body: Buffer) => Buffer|null)|null} getBodyTransform - Optional body transform + * + * @property {() => ProbeConfig|SkipProbeConfig|null} getValidationProbe - Startup validation probe + * @property {() => ModelsFetchConfig|null} getModelsFetchConfig - Startup model fetch config + * @property {() => ReflectionInfo} getReflectionInfo - Reflection endpoint metadata + * @property {() => UnconfiguredResponse} [getUnconfiguredResponse] - Response when not configured (alwaysBind adapters) + * @property {() => UnconfiguredResponse} [getUnconfiguredHealthResponse] - /health response when not configured + */ + +/** + * Create all provider adapters in port order. + * + * The returned array defines both the server start order and the order in + * which providers appear in /reflect and models.json output. + * + * OpenCode's routing priority is controlled by the `candidateAdapters` array + * passed here — the first enabled adapter in that list is used for each + * request. To change routing priority or add a new provider to OpenCode's + * routing, update the candidateAdapters array below. No changes to + * opencode.js itself are needed. + * + * @param {Record} env - Environment variables (typically process.env) + * @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform }} deps + * Body-transform functions produced by server.js (to avoid circular dependencies). + * @returns {ProviderAdapter[]} + */ +function createAllAdapters(env, deps = {}) { + const openai = createOpenAIAdapter(env, { bodyTransform: deps.openaiBodyTransform || null }); + const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform || null }); + const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform || null }); + const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform || null }); + + // OpenCode routes to the first enabled candidate adapter in the order listed. + // Priority: OpenAI → Anthropic → Copilot + // To add a new provider to OpenCode routing: add it to this array in the + // desired priority position. All listed providers remain independently + // reachable on their own ports regardless of this setting. + const opencode = createOpenCodeAdapter(env, { candidateAdapters: [openai, anthropic, copilot] }); + + return [openai, anthropic, copilot, gemini, opencode]; +} + +module.exports = { + createAllAdapters, + createOpenAIAdapter, + createAnthropicAdapter, + createCopilotAdapter, + createGeminiAdapter, + createOpenCodeAdapter, +}; diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js new file mode 100644 index 000000000..6332de8d8 --- /dev/null +++ b/containers/api-proxy/providers/openai.js @@ -0,0 +1,115 @@ +'use strict'; + +/** + * OpenAI provider adapter. + * + * Port: 10000 (also serves as the management port for /health, /metrics, /reflect) + * Auth: Bearer token via Authorization header + * Credentials: OPENAI_API_KEY + * Target: OPENAI_API_TARGET (default: api.openai.com) + * Base path: OPENAI_API_BASE_PATH (default: /v1 for the public endpoint) + */ + +const { normalizeApiTarget, normalizeBasePath } = require('../proxy-utils'); + +/** + * Create the OpenAI provider adapter. + * + * @param {Record} env - Environment variables (typically process.env) + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @returns {import('./index').ProviderAdapter} + */ +function createOpenAIAdapter(env, deps = {}) { + const apiKey = (env.OPENAI_API_KEY || '').trim() || undefined; + const rawTarget = normalizeApiTarget(env.OPENAI_API_TARGET) || 'api.openai.com'; + const explicitBasePath = normalizeBasePath(env.OPENAI_API_BASE_PATH); + + // For the default OpenAI endpoint, unversioned clients (e.g. Codex CLI sending + // /responses) need a /v1 prefix to reach the correct versioned API surface. + // Custom targets manage their own path layout and must not receive an implicit prefix. + const basePath = explicitBasePath || (rawTarget === 'api.openai.com' ? '/v1' : ''); + + const bodyTransform = deps.bodyTransform || null; + + return { + name: 'openai', + port: 10000, + + /** Port 10000 is the central management port (/health, /metrics, /reflect). */ + isManagementPort: true, + + /** + * Port 10000 always starts — even without a key — to serve the management + * endpoints required by the Docker healthcheck. + */ + alwaysBind: true, + + /** Port 10000 always counts toward the startup validation latch. */ + participatesInValidation: true, + + isEnabled() { return !!apiKey; }, + getTargetHost() { return rawTarget; }, + getBasePath() { return basePath; }, + + getAuthHeaders() { + return { 'Authorization': `Bearer ${apiKey}` }; + }, + + getBodyTransform() { return bodyTransform; }, + + /** + * Returns the validation probe config, or null to skip. + * Custom targets are skipped — we don't know their probe endpoints. + * + * @returns {{ url: string, opts: object }|{ skip: true, reason: string }|null} + */ + getValidationProbe() { + if (!apiKey) return null; + if (rawTarget !== 'api.openai.com') { + return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + } + return { + url: `https://${rawTarget}/v1/models`, + opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } }, + }; + }, + + /** + * Returns the model-list fetch config for /reflect model population, or null. + * Uses the configured base path so prefixed OpenAI-compatible deployments + * (e.g. Databricks, Azure) populate /reflect and models.json correctly. + * + * @returns {{ url: string, opts: object, cacheKey: string }|null} + */ + getModelsFetchConfig() { + if (!apiKey) return null; + const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; + return { + url: `https://${rawTarget}${modelsPath}`, + opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } }, + cacheKey: 'openai', + }; + }, + + getReflectionInfo() { + return { + provider: 'openai', + port: 10000, + base_url: 'http://api-proxy:10000', + configured: !!apiKey, + models_cache_key: 'openai', + models_url: 'http://api-proxy:10000/v1/models', + }; + }, + + /** Response returned when port 10000 receives a proxy request but no key is set. */ + getUnconfiguredResponse() { + return { + statusCode: 404, + body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' }, + }; + }, + }; +} + +module.exports = { createOpenAIAdapter }; diff --git a/containers/api-proxy/providers/opencode.js b/containers/api-proxy/providers/opencode.js new file mode 100644 index 000000000..f4cd21bdb --- /dev/null +++ b/containers/api-proxy/providers/opencode.js @@ -0,0 +1,177 @@ +'use strict'; + +/** + * OpenCode provider adapter. + * + * Port: 10004 (only started when AWF_ENABLE_OPENCODE=true) + * Auth: dynamic — delegated to the first enabled candidate adapter + * + * OpenCode gets its own isolated port rather than sharing with Claude (10001) + * or Codex (10000) to enable per-engine rate limiting and metrics isolation. + * + * Routing priority is determined by the order of the `candidateAdapters` array + * supplied at construction time (see providers/index.js). The first enabled + * adapter in the list wins. No code change to this file is needed when a new + * provider is added to the candidate list. + * + * Default priority (OpenAI > Anthropic > Copilot) is defined in index.js: + * createOpenCodeAdapter(env, { candidateAdapters: [openai, anthropic, copilot] }) + * + * To change the routing order or add a new provider, edit the candidateAdapters + * array in providers/index.js — this file stays unchanged. + */ + +/** + * Resolve the upstream route for an OpenCode request based on available credentials. + * This is the legacy low-level helper; the adapter now uses candidateAdapters instead. + * Kept as an export for backward compatibility with existing tests. + * + * @param {string|undefined} openaiKey + * @param {string|undefined} anthropicKey + * @param {string|undefined} copilotToken + * @param {string} openaiTarget + * @param {string} anthropicTarget + * @param {string} copilotTarget + * @param {string} [openaiBasePath] + * @param {string} [anthropicBasePath] + * @param {string} [integrationId] + * @returns {{ target: string, headers: Record, basePath: string|undefined, needsAnthropicVersion: boolean } | null} + */ +function resolveOpenCodeRoute( + openaiKey, anthropicKey, copilotToken, + openaiTarget, anthropicTarget, copilotTarget, + openaiBasePath, anthropicBasePath, + integrationId +) { + const COPILOT_INTEGRATION_ID_DEFAULT = 'copilot-developer-cli'; + if (openaiKey) { + return { target: openaiTarget, headers: { 'Authorization': `Bearer ${openaiKey}` }, basePath: openaiBasePath, needsAnthropicVersion: false }; + } + if (anthropicKey) { + return { target: anthropicTarget, headers: { 'x-api-key': anthropicKey }, basePath: anthropicBasePath, needsAnthropicVersion: true }; + } + if (copilotToken) { + return { + target: copilotTarget, + headers: { 'Authorization': `Bearer ${copilotToken}`, 'Copilot-Integration-Id': integrationId || COPILOT_INTEGRATION_ID_DEFAULT }, + basePath: undefined, + needsAnthropicVersion: false, + }; + } + return null; +} + +/** + * Create the OpenCode provider adapter. + * + * The adapter is a transparent routing layer: all per-request decisions + * (target host, base path, auth headers, body transforms, URL transforms) + * are fully delegated to whichever candidate adapter is currently enabled. + * + * This means: + * - All active providers remain independently reachable on their own ports. + * - OpenCode gets the full auth + transform logic of the underlying provider + * for free — no duplication. + * - Changing the routing order or adding a new provider only requires + * updating the `candidateAdapters` array in providers/index.js. + * + * @param {Record} env - Environment variables + * @param {{ candidateAdapters?: import('./index').ProviderAdapter[] }} [opts] + * Ordered list of adapters to consider for routing; the first enabled one is used. + * Pass an empty array (default) to disable OpenCode regardless of env vars. + * @returns {import('./index').ProviderAdapter} + */ +function createOpenCodeAdapter(env, { candidateAdapters = [] } = {}) { + const enabled = env.AWF_ENABLE_OPENCODE === 'true'; + + /** + * Return the first enabled candidate adapter, or null if none is active. + * Called per-request so that credential changes are picked up without restart. + * + * @returns {import('./index').ProviderAdapter | null} + */ + function resolveActiveAdapter() { + return candidateAdapters.find(a => a.isEnabled()) || null; + } + + // Snapshot at startup for reflection info (stable across requests) + const startupActiveAdapter = enabled ? resolveActiveAdapter() : null; + + return { + name: 'opencode', + port: 10004, + isManagementPort: false, + alwaysBind: false, + get participatesInValidation() { return this.isEnabled(); }, + + isEnabled() { return enabled && !!resolveActiveAdapter(); }, + + /** Delegate to the active candidate adapter. */ + getTargetHost(req) { + return resolveActiveAdapter()?.getTargetHost(req) || ''; + }, + + /** Delegate to the active candidate adapter. */ + getBasePath(req) { + return resolveActiveAdapter()?.getBasePath(req) || ''; + }, + + /** + * Delegate auth headers to the active candidate adapter. + * Each provider's full auth logic (token selection, version headers, + * beta flags, integration IDs) is applied automatically. + * + * @param {import('http').IncomingMessage} req + * @returns {Record} + */ + getAuthHeaders(req) { + return resolveActiveAdapter()?.getAuthHeaders(req) || {}; + }, + + /** + * Delegate URL transformation to the active candidate adapter. + * Applies the active provider's URL transform (e.g. Gemini key-param + * stripping) when one is defined, otherwise returns url unchanged. + * + * @param {string} url + * @returns {string} + */ + transformRequestUrl(url) { + const active = resolveActiveAdapter(); + return active?.transformRequestUrl ? active.transformRequestUrl(url) : url; + }, + + /** + * Delegate body transforms to the active candidate adapter. + * This gives OpenCode model-alias rewriting and provider-specific + * optimizations (e.g. Anthropic cache injection) for free. + * + * @returns {((body: Buffer) => Buffer|null)|null} + */ + getBodyTransform() { + return resolveActiveAdapter()?.getBodyTransform() || null; + }, + + // OpenCode is a routing layer over the base providers; those providers + // handle their own startup validation and model fetching. + getValidationProbe() { return null; }, + getModelsFetchConfig() { return null; }, + + getReflectionInfo() { + return { + provider: 'opencode', + port: 10004, + base_url: 'http://api-proxy:10004', + configured: enabled && !!startupActiveAdapter, + models_cache_key: null, + models_url: null, + }; + }, + + // Exposed for introspection / testing + _startupActiveAdapterName: startupActiveAdapter?.name || null, + _candidateAdapters: candidateAdapters, + }; +} + +module.exports = { createOpenCodeAdapter, resolveOpenCodeRoute }; diff --git a/containers/api-proxy/proxy-utils.js b/containers/api-proxy/proxy-utils.js new file mode 100644 index 000000000..afd945536 --- /dev/null +++ b/containers/api-proxy/proxy-utils.js @@ -0,0 +1,180 @@ +/** + * Shared proxy utilities — pure functions with no provider-specific logic. + * Used by both server.js (core) and provider adapters. + */ + +'use strict'; + +const { URL } = require('url'); + +/** + * Normalizes an API target value to a bare hostname. + * Accepts either a hostname or a full URL and extracts only the hostname, + * discarding any scheme, path, query, fragment, credentials, or port. + * Path configuration must be provided separately via the existing + * *_API_BASE_PATH environment variables. + * + * @param {string|undefined} value - Raw env var value + * @returns {string|undefined} Bare hostname, or undefined if input is falsy + */ +function normalizeApiTarget(value) { + if (!value) return value; + + const trimmed = value.trim(); + if (!trimmed) return undefined; + + const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + try { + const parsed = new URL(candidate); + + if (parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password || parsed.port) { + const safe = trimmed.replace(/[\x00-\x1f\x7f]/g, '?'); + console.warn( + `Ignoring unsupported API target URL components in ${safe}; ` + + 'configure path prefixes via the corresponding *_API_BASE_PATH environment variable.' + ); + } + + return parsed.hostname || undefined; + } catch (err) { + const safe = trimmed.replace(/[\x00-\x1f\x7f]/g, '?'); + console.warn(`Invalid API target ${safe}; expected a hostname (e.g. 'api.example.com') or URL`); + return undefined; + } +} + +/** + * Normalizes a base path for use as a URL path prefix. + * Ensures the path starts with '/' (if non-empty) and has no trailing '/'. + * Returns '' for empty, null, or undefined inputs. + * + * @param {string|undefined|null} rawPath - The raw path value from env or config + * @returns {string} Normalized path prefix (e.g. '/serving-endpoints') or '' + */ +function normalizeBasePath(rawPath) { + if (!rawPath) return ''; + let p = rawPath.trim(); + if (!p) return ''; + if (!p.startsWith('/')) { + p = '/' + p; + } + if (p !== '/' && p.endsWith('/')) { + p = p.slice(0, -1); + } + return p; +} + +/** + * Build the full upstream path by joining basePath, reqUrl's pathname, and query string. + * Applies provider-safe defaults and avoids duplicate prefixing when the incoming + * path already includes the configured base path. + * + * Examples: + * buildUpstreamPath('/v1/chat/completions', 'api.openai.com', '/v1') + * → '/v1/chat/completions' (no double-prefix) + * buildUpstreamPath('/chat/completions', 'api.openai.com', '/v1') + * → '/v1/chat/completions' + * buildUpstreamPath('/v1/messages?stream=true', 'host.com', '/anthropic') + * → '/anthropic/v1/messages?stream=true' + * + * @param {string} reqUrl - The incoming request URL (must start with '/' and not '//') + * @param {string} targetHost - The upstream hostname (used only to parse the URL) + * @param {string} basePath - Normalized base path prefix (e.g. '/v1' or '') + * @returns {string} Full upstream path including query string + */ +function buildUpstreamPath(reqUrl, targetHost, basePath) { + if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) { + throw new Error('URL must be a relative origin-form path'); + } + + const targetUrl = new URL(reqUrl, `https://${targetHost}`); + const pathname = targetUrl.pathname; + const prefix = basePath === '/' ? '' : basePath; + + if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) { + return pathname + targetUrl.search; + } + + return prefix + pathname + targetUrl.search; +} + +/** + * Strip all known Gemini API-key query parameters from a request URL. + * + * The @google/genai SDK (and older Gemini SDK versions) may append auth params + * (`?key=`, `?apiKey=`, or `?api_key=`) to every request URL in addition to + * setting the `x-goog-api-key` header. The proxy injects the real key via the + * header, so any placeholder param must be removed before forwarding to Google + * to prevent API_KEY_INVALID errors. + * + * @param {string} reqUrl - The incoming request URL (must start with exactly one '/') + * @returns {string} URL with all Gemini auth query parameters removed + */ +function stripGeminiKeyParam(reqUrl) { + if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) { + return reqUrl; + } + const parsed = new URL(reqUrl, 'http://localhost'); + parsed.searchParams.delete('key'); + parsed.searchParams.delete('apiKey'); + parsed.searchParams.delete('api_key'); + return parsed.pathname + parsed.search; +} + +/** + * Headers that must never be forwarded from the client. + * The proxy controls authentication — client-supplied auth/proxy headers are stripped. + */ +const STRIPPED_HEADERS = new Set([ + 'host', + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'x-goog-api-key', + 'forwarded', + 'via', +]); + +/** Returns true if the header name should be stripped (case-insensitive). */ +function shouldStripHeader(name) { + const lower = name.toLowerCase(); + return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-'); +} + +/** + * Compose two body-transform functions into a single transform. + * Each transform accepts a Buffer and returns a Buffer (modified) or null (no change). + * + * Chain semantics: + * - If first returns null (no change), pass the original buffer to second. + * - If second returns null, return whatever first returned. + * - If both return null, return null. + * + * @param {((body: Buffer) => Buffer | null) | null} first + * @param {((body: Buffer) => Buffer | null) | null} second + * @returns {((body: Buffer) => Buffer | null) | null} + */ +function composeBodyTransforms(first, second) { + if (!first && !second) return null; + if (!first) return second; + if (!second) return first; + return (body) => { + const a = first(body); + const b = second(a !== null ? a : body); + if (b !== null) return b; + if (a !== null) return a; + return null; + }; +} + +module.exports = { + normalizeApiTarget, + normalizeBasePath, + buildUpstreamPath, + stripGeminiKeyParam, + shouldStripHeader, + composeBodyTransforms, +}; diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 90bcb377f..1f8b6fceb 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -1,15 +1,22 @@ #!/usr/bin/env node /** - * AWF API Proxy Sidecar + * AWF API Proxy Sidecar — Core Engine * - * Node.js-based proxy that: - * 1. Keeps LLM API credentials isolated from agent container - * 2. Routes all traffic through Squid via HTTP_PROXY/HTTPS_PROXY - * 3. Injects authentication headers (Authorization, x-api-key) - * 4. Respects domain whitelisting enforced by Squid + * Responsibilities: + * 1. Generic HTTP/WebSocket proxy (proxyRequest / proxyWebSocket) + * 2. Rate limiting, metrics, logging + * 3. Management endpoints (/health, /metrics, /reflect) on the designated port + * 4. Provider-agnostic server factory (createProviderServer) + * 5. Startup orchestration: creates provider servers from registered adapters + * + * All provider-specific knowledge (credentials, URLs, auth headers, body + * transforms, model lists) lives exclusively in providers/*.js. + * This file contains ZERO hard-coded provider names, ports, or env-var reads. */ +'use strict'; + const fs = require('fs'); const path = require('path'); const http = require('http'); @@ -21,20 +28,8 @@ const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); const metrics = require('./metrics'); const rateLimiter = require('./rate-limiter'); const { parseModelAliases, rewriteModelInBody } = require('./model-resolver'); -let makeAnthropicTransform; -let loadCustomTransform; -let EXTENDED_CACHE_BETA; -try { - ({ makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA } = require('./anthropic-transforms')); -} catch (err) { - if (err && err.code === 'MODULE_NOT_FOUND') { - makeAnthropicTransform = () => (body) => body; - loadCustomTransform = () => null; - EXTENDED_CACHE_BETA = undefined; - } else { - throw err; - } -} + +// ── Optional modules (graceful degradation when not bundled) ───────────────── let trackTokenUsage; let trackWebSocketTokenUsage; let closeLogStream; @@ -50,313 +45,29 @@ try { } } -// Create rate limiter from environment variables +// ── Shared utility functions ───────────────────────────────────────────────── +const { + buildUpstreamPath, + shouldStripHeader, + composeBodyTransforms, + normalizeApiTarget, +} = require('./proxy-utils'); + +// ── Rate limiter ───────────────────────────────────────────────────────────── const limiter = rateLimiter.create(); -// Max request body size (10 MB) to prevent DoS via large payloads +// ── Request size cap (10 MB) to prevent DoS via large payloads ─────────────── const MAX_BODY_SIZE = 10 * 1024 * 1024; -// Headers that must never be forwarded from the client. -// The proxy controls authentication — client-supplied auth/proxy headers are stripped. -const STRIPPED_HEADERS = new Set([ - 'host', - 'authorization', - 'proxy-authorization', - 'x-api-key', - 'x-goog-api-key', - 'forwarded', - 'via', -]); - -/** Returns true if the header name should be stripped (case-insensitive). */ -function shouldStripHeader(name) { - const lower = name.toLowerCase(); - return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-'); -} - -// Read API keys from environment (set by docker-compose) -// Trim whitespace/newlines to prevent malformed HTTP headers — env vars from -// CI secrets or docker-compose YAML may include trailing whitespace. -const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || '').trim() || undefined; -const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || '').trim() || undefined; -const COPILOT_GITHUB_TOKEN = (process.env.COPILOT_GITHUB_TOKEN || '').trim() || undefined; -const COPILOT_API_KEY = (process.env.COPILOT_API_KEY || '').trim() || undefined; - -/** - * Resolves the Copilot auth token from environment variables. - * COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key). - * @param {Record} env - Environment variables to inspect - * @returns {string|undefined} The resolved auth token, or undefined if neither is set - */ -function resolveCopilotAuthToken(env = process.env) { - const githubToken = (env.COPILOT_GITHUB_TOKEN || '').trim() || undefined; - const apiKey = (env.COPILOT_API_KEY || '').trim() || undefined; - return githubToken || apiKey; -} - -const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env); -const COPILOT_INTEGRATION_ID = process.env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli'; -const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined; -const ENABLE_OPENCODE = process.env.AWF_ENABLE_OPENCODE === 'true'; - -/** - * Normalizes an API target value to a bare hostname. - * Accepts either a hostname or a full URL and extracts only the hostname, - * discarding any scheme, path, query, fragment, credentials, or port. - * Path configuration must be provided separately via the existing - * *_API_BASE_PATH environment variables. - * - * @param {string|undefined} value - Raw env var value - * @returns {string|undefined} Bare hostname, or undefined if input is falsy - */ -function normalizeApiTarget(value) { - if (!value) return value; - - const trimmed = value.trim(); - if (!trimmed) return undefined; - - const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) - ? trimmed - : `https://${trimmed}`; - - try { - const parsed = new URL(candidate); - - if (parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password || parsed.port) { - console.warn( - `Ignoring unsupported API target URL components in ${sanitizeForLog(trimmed)}; ` + - 'configure path prefixes via the corresponding *_API_BASE_PATH environment variable.' - ); - } - - return parsed.hostname || undefined; - } catch (err) { - console.warn(`Invalid API target ${sanitizeForLog(trimmed)}; expected a hostname (e.g. 'api.example.com') or URL`); - return undefined; - } -} - -// Configurable API target hosts (supports custom endpoints / internal LLM routers) -// Values are normalized to bare hostnames — buildUpstreamPath() prepends https:// -const OPENAI_API_TARGET = normalizeApiTarget(process.env.OPENAI_API_TARGET) || 'api.openai.com'; -const ANTHROPIC_API_TARGET = normalizeApiTarget(process.env.ANTHROPIC_API_TARGET) || 'api.anthropic.com'; -const GEMINI_API_TARGET = normalizeApiTarget(process.env.GEMINI_API_TARGET) || 'generativelanguage.googleapis.com'; - -/** - * Normalizes a base path for use as a URL path prefix. - * Ensures the path starts with '/' (if non-empty) and has no trailing '/'. - * Returns '' for empty, null, or undefined inputs. - * - * @param {string|undefined|null} rawPath - The raw path value from env or config - * @returns {string} Normalized path prefix (e.g. '/serving-endpoints') or '' - */ -function normalizeBasePath(rawPath) { - if (!rawPath) return ''; - let path = rawPath.trim(); - if (!path) return ''; - // Ensure leading slash - if (!path.startsWith('/')) { - path = '/' + path; - } - // Strip trailing slash (but preserve a bare '/') - if (path !== '/' && path.endsWith('/')) { - path = path.slice(0, -1); - } - return path; -} - -/** - * Build the full upstream path by joining basePath, reqUrl's pathname, and query string. - * Applies provider-safe defaults and avoids duplicate prefixing when the incoming - * path already includes the configured base path. - * - * Examples: - * buildUpstreamPath('/responses', 'api.openai.com', '') - * → '/v1/responses' - * buildUpstreamPath('/v1/chat/completions', 'host.databricks.com', '/serving-endpoints') - * → '/serving-endpoints/v1/chat/completions' - * buildUpstreamPath('/v1/messages?stream=true', 'host.com', '/anthropic') - * → '/anthropic/v1/messages?stream=true' - * - * @param {string} reqUrl - The incoming request URL (must start with '/' and not '//') - * @param {string} targetHost - The upstream hostname (used only to parse the URL) - * @param {string} basePath - Normalized base path prefix (e.g. '/serving-endpoints' or '') - * @returns {string} Full upstream path including query string - */ -function buildUpstreamPath(reqUrl, targetHost, basePath) { - if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) { - throw new Error('URL must be a relative origin-form path'); - } - - const targetUrl = new URL(reqUrl, `https://${targetHost}`); - const pathname = targetUrl.pathname; - let prefix = basePath === '/' ? '' : basePath; - - // OpenAI's canonical API paths are versioned under /v1, while some newer - // clients (for example Codex CLI with OPENAI_BASE_URL pointing at the sidecar) - // send unversioned paths like /responses. Add /v1 only for the default - // OpenAI host when no explicit base path is configured. - if (!prefix && targetUrl.hostname === 'api.openai.com') { - prefix = '/v1'; - } - - if (prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`))) { - return pathname + targetUrl.search; - } - - return prefix + pathname + targetUrl.search; -} - -/** - * Strip all known Gemini API-key query parameters from a request URL. - * - * The @google/genai SDK (and older Gemini SDK versions) may append auth params - * (`?key=`, `?apiKey=`, or `?api_key=`) to every request URL in addition to - * setting the `x-goog-api-key` header. The proxy injects the real key via the - * header, so any placeholder param must be removed before forwarding to Google - * to prevent API_KEY_INVALID errors. - * - * @param {string} reqUrl - The incoming request URL (must start with exactly one '/') - * @returns {string} URL with all Gemini auth query parameters removed - */ -function stripGeminiKeyParam(reqUrl) { - // Only operate on relative request paths that begin with exactly one slash. - // Returning other inputs unchanged lets proxyRequest's relative-URL check reject them. - // The guard prevents absolute URLs (e.g. 'http://evil.com/path?key=…') and - // protocol-relative URLs ('//host/path') from being normalized into a relative path. - if (typeof reqUrl !== 'string' || !reqUrl.startsWith('/') || reqUrl.startsWith('//')) { - return reqUrl; - } - const parsed = new URL(reqUrl, 'http://localhost'); - parsed.searchParams.delete('key'); - parsed.searchParams.delete('apiKey'); - parsed.searchParams.delete('api_key'); - // Reconstruct relative path only — never emit the scheme/host from the dummy base. - return parsed.pathname + parsed.search; -} - -// Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks) -const OPENAI_API_BASE_PATH = normalizeBasePath(process.env.OPENAI_API_BASE_PATH); -const ANTHROPIC_API_BASE_PATH = normalizeBasePath(process.env.ANTHROPIC_API_BASE_PATH); -const GEMINI_API_BASE_PATH = normalizeBasePath(process.env.GEMINI_API_BASE_PATH); - -// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints) -// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default -function deriveCopilotApiTarget() { - if (process.env.COPILOT_API_TARGET) { - return normalizeApiTarget(process.env.COPILOT_API_TARGET); - } - // Auto-derive from GITHUB_SERVER_URL: - // - GitHub Enterprise Cloud (*.ghe.com): Copilot inference/models/MCP are served at - // copilot-api..ghe.com (separate from the GitHub REST API at api.*) - // - GitHub Enterprise Server (non-github.com, non-ghe.com) → api.enterprise.githubcopilot.com - // - github.com → api.githubcopilot.com - const serverUrl = process.env.GITHUB_SERVER_URL; - if (serverUrl) { - try { - const hostname = new URL(serverUrl).hostname; - if (hostname !== 'github.com') { - // Check if this is a GHEC tenant (*.ghe.com) - if (hostname.endsWith('.ghe.com')) { - // Extract subdomain: mycompany.ghe.com → mycompany - const subdomain = hostname.slice(0, -8); // Remove '.ghe.com' - // GHEC routes Copilot inference to copilot-api..ghe.com, - // not to api..ghe.com (which is the GitHub REST API) - return `copilot-api.${subdomain}.ghe.com`; - } - // GHES (any other non-github.com hostname) - return 'api.enterprise.githubcopilot.com'; - } - } catch { - // Invalid URL — fall through to default - } - } - return 'api.githubcopilot.com'; -} -const COPILOT_API_TARGET = deriveCopilotApiTarget(); - -// GitHub REST API target host for endpoints that need the GitHub REST API -// (e.g., enterprise-specific endpoints). Currently unused — /models is served -// by the Copilot API, not the REST API — but kept for future GHES/GHEC needs. -// Priority: GITHUB_API_URL env var (hostname extracted) > auto-derive from GITHUB_SERVER_URL > default -function deriveGitHubApiTarget() { - // Explicit GITHUB_API_URL takes priority — this is the canonical source for enterprise deployments - if (process.env.GITHUB_API_URL) { - const target = normalizeApiTarget(process.env.GITHUB_API_URL); - if (target) return target; - } - // Auto-derive from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com) - const serverUrl = process.env.GITHUB_SERVER_URL; - if (serverUrl) { - try { - const hostname = new URL(serverUrl).hostname; - if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) { - // GHEC: GitHub REST API lives at api..ghe.com - const subdomain = hostname.slice(0, -8); // Remove '.ghe.com' - return `api.${subdomain}.ghe.com`; - } - } catch { - // Invalid URL — fall through to default - } - } - return 'api.github.com'; -} - -/** - * Extract the base path from GITHUB_API_URL for GHES deployments - * (e.g. https://ghes.example.com/api/v3 → '/api/v3'). - * Returns '' for github.com or when no path component is present. - */ -function deriveGitHubApiBasePath() { - const raw = process.env.GITHUB_API_URL; - if (!raw) return ''; - try { - const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`); - const p = parsed.pathname.replace(/\/+$/, ''); - return p === '/' ? '' : p; - } catch { - return ''; - } -} - -const GITHUB_API_TARGET = deriveGitHubApiTarget(); -const GITHUB_API_BASE_PATH = deriveGitHubApiBasePath(); - -// Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose) +// ── Squid proxy agent ──────────────────────────────────────────────────────── const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; - -logRequest('info', 'startup', { - message: 'Starting AWF API proxy sidecar', - squid_proxy: HTTPS_PROXY || 'not configured', - api_targets: { - openai: OPENAI_API_TARGET, - anthropic: ANTHROPIC_API_TARGET, - gemini: GEMINI_API_TARGET, - copilot: COPILOT_API_TARGET, - github: GITHUB_API_TARGET, - }, - api_base_paths: { - openai: OPENAI_API_BASE_PATH || '(none)', - anthropic: ANTHROPIC_API_BASE_PATH || '(none)', - gemini: GEMINI_API_BASE_PATH || '(none)', - }, - providers: { - openai: !!OPENAI_API_KEY, - anthropic: !!ANTHROPIC_API_KEY, - gemini: !!GEMINI_API_KEY, - copilot: !!COPILOT_AUTH_TOKEN, - copilot_github_token: !!COPILOT_GITHUB_TOKEN, - copilot_api_key: !!COPILOT_API_KEY, - }, -}); - -// Create proxy agent for routing through Squid const proxyAgent = HTTPS_PROXY ? new HttpsProxyAgent(HTTPS_PROXY) : undefined; + if (!proxyAgent) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } -// ── Model alias resolution ───────────────────────────────────────────────── +// ── Model alias resolution ──────────────────────────────────────────────────── // Loaded from AWF_MODEL_ALIASES env var (JSON string). // When configured, POST/PUT request bodies are inspected for a "model" field // and rewritten to a concrete model name before forwarding to upstream. @@ -374,98 +85,6 @@ if (MODEL_ALIASES) { }); } -// ── Anthropic prompt-cache optimizations ──────────────────────────────────── -// Enabled by setting AWF_ANTHROPIC_AUTO_CACHE=1. -// When enabled, /v1/messages POST requests are mutated before forwarding: -// - Cache breakpoints injected on tools / system / messages[0] / rolling tail -// - Existing ephemeral breakpoints upgraded to ttl:"1h" (tail stays at TAIL_TTL) -// - anthropic-beta header extended with the extended-cache-ttl-2025-04-11 flag -// - ANSI SGR sequences stripped from message text and tool results -// -// Tail TTL is controlled by AWF_ANTHROPIC_CACHE_TAIL_TTL (default: "5m"). -// Valid values: "5m", "1h". Use "1h" only for long-running sessions where -// the tail breakpoint is unlikely to expire within an hour. -const ANTHROPIC_AUTO_CACHE = process.env.AWF_ANTHROPIC_AUTO_CACHE === '1'; -const ANTHROPIC_CACHE_TAIL_TTL = (() => { - const raw = (process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL || '').trim().toLowerCase(); - return raw === '1h' ? '1h' : '5m'; -})(); - -if (ANTHROPIC_AUTO_CACHE) { - logRequest('info', 'startup', { - message: 'Anthropic prompt-cache optimizations enabled (AWF_ANTHROPIC_AUTO_CACHE=1)', - tail_ttl: ANTHROPIC_CACHE_TAIL_TTL, - }); -} - - -/** - * Build a body-transform function for the Anthropic provider that applies: - * 1. Model alias rewriting (when AWF_MODEL_ALIASES is configured) - * 2. Prompt-cache optimizations (when AWF_ANTHROPIC_AUTO_CACHE=1): - * - Cache breakpoint injection (tools / system / messages[0] / tail) - * - Ephemeral TTL upgrade to 1h (tail stays at ANTHROPIC_CACHE_TAIL_TTL) - * - ANSI escape code stripping - * - * The `injectHeaders` map is mutated in-place to add the - * `anthropic-beta: extended-cache-ttl-2025-04-11` flag when auto-cache is on. - * - * @param {Record} injectHeaders - Outgoing auth headers (mutated in-place) - * @returns {((body: Buffer) => Buffer | null) | null} - */ -function makeAnthropicBodyTransform(injectHeaders) { - const hasModelAliases = !!MODEL_ALIASES; - const hasAutoCache = ANTHROPIC_AUTO_CACHE; - if (!hasModelAliases && !hasAutoCache) return null; - - return (body) => { - // Step 1: model alias rewriting (operates on raw Buffer, may return a new Buffer) - let buf = body; - let modelAliasRewritten = false; - if (hasModelAliases) { - const result = rewriteModelInBody(buf, 'anthropic', MODEL_ALIASES.models, cachedModels); - if (result) { - for (const line of result.log) { - logRequest('info', 'model_resolution', { message: line, provider: 'anthropic' }); - } - logRequest('info', 'model_rewrite', { - provider: 'anthropic', - original_model: sanitizeForLog(result.originalModel) || '(none)', - resolved_model: sanitizeForLog(result.resolvedModel), - }); - buf = result.body; - modelAliasRewritten = true; - } - } - - // Step 2: prompt-cache optimizations (parse JSON, mutate, re-serialise) - if (hasAutoCache) { - let parsed; - try { - parsed = JSON.parse(buf.toString('utf8')); - } catch { - logRequest('warn', 'anthropic_cache_skip', { - message: 'Failed to parse request body as JSON — skipping cache optimizations', - }); - return modelAliasRewritten ? buf : null; - } - - const result = applyAnthropicCacheOptimizations(parsed, injectHeaders, { tailTtl: ANTHROPIC_CACHE_TAIL_TTL }); - logRequest('info', 'anthropic_cache_applied', { - injected: result.injected, - rewritten: result.rewritten, - beta_header: result.betaHeader, - ansi_cleaned: result.ansiCleaned, - }); - - const mutated = Buffer.from(JSON.stringify(parsed), 'utf8'); - return mutated; - } - - return modelAliasRewritten ? buf : null; - }; -} - /** * Build a body-transform function for a given provider that rewrites the * "model" field in JSON request bodies using the configured alias map. @@ -480,7 +99,6 @@ function makeModelBodyTransform(provider) { return (body) => { const result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, cachedModels); if (!result) return null; - // Log the full resolution chain for (const line of result.log) { logRequest('info', 'model_resolution', { message: line, provider }); } @@ -493,131 +111,63 @@ function makeModelBodyTransform(provider) { }; } +// ── Provider adapters ───────────────────────────────────────────────────────── +// createAllAdapters is called at module load so that module-level functions +// (reflectEndpoints, healthResponse, buildModelsJson) work correctly in tests. +const { createAllAdapters } = require('./providers'); + +const registeredAdapters = createAllAdapters(process.env, { + openaiBodyTransform: makeModelBodyTransform('openai'), + anthropicBodyTransform: makeModelBodyTransform('anthropic'), + copilotBodyTransform: makeModelBodyTransform('copilot'), + geminiBodyTransform: makeModelBodyTransform('gemini'), +}); + +// ── Cached model lists (populated at startup by fetchStartupModels) ─────────── /** - * Compose two body-transform functions into a single transform. - * Each transform accepts a Buffer and returns a Buffer (modified) or null (no change). - * - * Chain semantics: - * - If first returns null (no change), pass the original buffer to second. - * - If second returns null, return whatever first returned. - * - If both return null, return null. - * - * @param {((body: Buffer) => Buffer | null) | null} first - * @param {((body: Buffer) => Buffer | null) | null} second - * @returns {((body: Buffer) => Buffer | null) | null} + * @type {Record} + * null = fetch failed or not attempted for this provider. */ -function composeBodyTransforms(first, second) { - if (!first && !second) return null; - if (!first) return second; - if (!second) return first; - return (body) => { - const a = first(body); - const b = second(a !== null ? a : body); - if (b !== null) return b; - if (a !== null) return a; - return null; - }; -} +const cachedModels = {}; -// ── Anthropic request optimisations ─────────────────────────────────────────── -// All features are opt-in via environment variables and are applied only to -// POST /v1/messages requests forwarded through the Anthropic proxy (port 10001). -// -// AWF_ANTHROPIC_AUTO_CACHE=1 — inject ≤4 prompt-cache breakpoints + -// upgrade existing ephemeral TTLs to 1h -// AWF_ANTHROPIC_CACHE_TAIL_TTL=5m|1h — TTL for the rolling-tail slot (default: 5m) -// AWF_ANTHROPIC_DROP_TOOLS=A,B,C — drop named tools from the tools array -// AWF_ANTHROPIC_STRIP_ANSI=1 — strip ANSI SGR codes from tool_result text -// AWF_ANTHROPIC_TRANSFORM_FILE=/path — custom JS transform hook; path must resolve -// inside the container image (pre-baked). This env -// var is intentionally not forwarded from the host -// by AWF to avoid arbitrary code execution in the -// credential-holding api-proxy sidecar. - -const AWF_ANTHROPIC_AUTO_CACHE = ( - process.env.AWF_ANTHROPIC_AUTO_CACHE === '1' || - process.env.AWF_ANTHROPIC_AUTO_CACHE === 'true' -); -const AWF_ANTHROPIC_CACHE_TAIL_TTL = (() => { - const raw = (process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL || '').trim(); - return (raw === '1h' || raw === '5m') ? raw : '5m'; -})(); -const AWF_ANTHROPIC_DROP_TOOLS = (() => { - const raw = (process.env.AWF_ANTHROPIC_DROP_TOOLS || '').trim(); - return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : []; -})(); -const AWF_ANTHROPIC_STRIP_ANSI = ( - process.env.AWF_ANTHROPIC_STRIP_ANSI === '1' || - process.env.AWF_ANTHROPIC_STRIP_ANSI === 'true' -); -const AWF_ANTHROPIC_TRANSFORM_FILE = (process.env.AWF_ANTHROPIC_TRANSFORM_FILE || '').trim() || undefined; - -const _anthropicCustomTransform = loadCustomTransform(AWF_ANTHROPIC_TRANSFORM_FILE); - -// Pre-built Anthropic-specific body transform (null when nothing is enabled). -const _anthropicOptimisationsTransform = makeAnthropicTransform({ - autoCache: AWF_ANTHROPIC_AUTO_CACHE, - tailTtl: AWF_ANTHROPIC_CACHE_TAIL_TTL, - dropTools: AWF_ANTHROPIC_DROP_TOOLS, - stripAnsiCodes: AWF_ANTHROPIC_STRIP_ANSI, - customTransform: _anthropicCustomTransform, -}); +/** Set to true once fetchStartupModels() has run (regardless of success). */ +let modelFetchComplete = false; -if (AWF_ANTHROPIC_AUTO_CACHE) { - logRequest('info', 'startup', { - message: 'Anthropic auto-cache enabled', - tail_ttl: AWF_ANTHROPIC_CACHE_TAIL_TTL, - extended_cache_beta: EXTENDED_CACHE_BETA, - }); -} -if (AWF_ANTHROPIC_DROP_TOOLS.length > 0) { - logRequest('info', 'startup', { - message: 'Anthropic tool-drop enabled', - tools: AWF_ANTHROPIC_DROP_TOOLS, - }); -} -if (AWF_ANTHROPIC_STRIP_ANSI) { - logRequest('info', 'startup', { message: 'Anthropic ANSI-strip enabled' }); -} -if (AWF_ANTHROPIC_TRANSFORM_FILE) { - logRequest('info', 'startup', { - message: _anthropicCustomTransform - ? 'Anthropic custom transform loaded' - : 'Anthropic custom transform failed to load (disabled)', - file: AWF_ANTHROPIC_TRANSFORM_FILE, - }); +/** Reset model cache state (used in tests). */ +function resetModelCacheState() { + for (const key of Object.keys(cachedModels)) { + delete cachedModels[key]; + } + modelFetchComplete = false; } +// ── Startup key validation state ───────────────────────────────────────────── /** - * Resolves the OpenCode routing configuration based on available credentials. - * Priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > copilotToken (COPILOT_GITHUB_TOKEN / COPILOT_API_KEY) - * - * @param {string|undefined} openaiKey - * @param {string|undefined} anthropicKey - * @param {string|undefined} copilotToken - * @param {string} openaiTarget - * @param {string} anthropicTarget - * @param {string} copilotTarget - * @param {string} [openaiBasePath] - * @param {string} [anthropicBasePath] - * @returns {{ target: string, headers: Record, basePath: string|undefined, needsAnthropicVersion: boolean } | null} + * @typedef {'pending'|'valid'|'auth_rejected'|'network_error'|'inconclusive'|'skipped'} ValidationStatus + * @typedef {{ status: ValidationStatus, message: string }} ValidationResult */ -function resolveOpenCodeRoute(openaiKey, anthropicKey, copilotToken, openaiTarget, anthropicTarget, copilotTarget, openaiBasePath, anthropicBasePath) { - if (openaiKey) { - return { target: openaiTarget, headers: { 'Authorization': `Bearer ${openaiKey}` }, basePath: openaiBasePath, needsAnthropicVersion: false }; - } - if (anthropicKey) { - return { target: anthropicTarget, headers: { 'x-api-key': anthropicKey }, basePath: anthropicBasePath, needsAnthropicVersion: true }; - } - if (copilotToken) { - return { target: copilotTarget, headers: { 'Authorization': `Bearer ${copilotToken}`, 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID }, basePath: undefined, needsAnthropicVersion: false }; + +/** @type {Record} */ +const keyValidationResults = {}; + +let keyValidationComplete = false; + +function resetKeyValidationState() { + for (const key of Object.keys(keyValidationResults)) { + delete keyValidationResults[key]; } - return null; + keyValidationComplete = false; } +// ── Utility: validate request IDs ──────────────────────────────────────────── +function isValidRequestId(id) { + return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); +} + +// ── Rate-limit helper ───────────────────────────────────────────────────────── /** - * Check rate limit and send 429 if exceeded. - * Returns true if request was rate-limited (caller should return early). + * Check the rate limit for a provider and send a 429 if exceeded. + * Returns true if the request was rate-limited (caller should return early). */ function checkRateLimit(req, res, provider, requestBytes) { const check = limiter.check(provider, requestBytes); @@ -662,6 +212,7 @@ function checkRateLimit(req, res, provider, requestBytes) { return false; } +// ── Core proxy: HTTP ────────────────────────────────────────────────────────── /** * Forward a request to the target API, injecting auth headers and routing through Squid. * @@ -671,23 +222,14 @@ function checkRateLimit(req, res, provider, requestBytes) { * @param {object} injectHeaders - Auth headers to inject * @param {string} provider - Provider name for logging and metrics * @param {string} [basePath=''] - Optional base-path prefix - * @param {((body: Buffer) => Buffer | null) | null} [bodyTransform=null] - Optional body transform - * applied for POST/PUT/PATCH requests (e.g. model alias rewriting) + * @param {((body: Buffer) => Buffer | null) | null} [bodyTransform=null] */ -/** Validate that a request ID is safe (alphanumeric, dashes, dots, max 128 chars). */ -function isValidRequestId(id) { - return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); -} - function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = '', bodyTransform = null) { const clientRequestId = req.headers['x-request-id']; const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); const startTime = Date.now(); - // Propagate request ID back to the client and forward to upstream res.setHeader('X-Request-ID', requestId); - - // Track active requests metrics.gaugeInc('active_requests', { provider }); logRequest('info', 'request_start', { @@ -698,7 +240,6 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = upstream_host: targetHost, }); - // Validate that req.url is a relative path (prevent open-redirect / SSRF) if (!req.url || !req.url.startsWith('/') || req.url.startsWith('//')) { const duration = Date.now() - startTime; metrics.gaugeDec('active_requests', { provider }); @@ -717,32 +258,23 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = return; } - // Build target URL const upstreamPath = buildUpstreamPath(req.url, targetHost, basePath); - // Handle client-side errors (e.g. aborted connections) req.on('error', (err) => { - if (errored) return; // Prevent double handling + if (errored) return; errored = true; const duration = Date.now() - startTime; metrics.gaugeDec('active_requests', { provider }); metrics.increment('requests_errors_total', { provider }); logRequest('error', 'request_error', { - request_id: requestId, - provider, - method: req.method, - path: sanitizeForLog(req.url), - duration_ms: duration, - error: sanitizeForLog(err.message), - upstream_host: targetHost, + request_id: requestId, provider, method: req.method, + path: sanitizeForLog(req.url), duration_ms: duration, + error: sanitizeForLog(err.message), upstream_host: targetHost, }); - if (!res.headersSent) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - } + if (!res.headersSent) res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Client error', message: err.message })); }); - // Read the request body with size limit const chunks = []; let totalBytes = 0; let rejected = false; @@ -757,18 +289,11 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = metrics.gaugeDec('active_requests', { provider }); metrics.increment('requests_total', { provider, method: req.method, status_class: '4xx' }); logRequest('warn', 'request_complete', { - request_id: requestId, - provider, - method: req.method, - path: sanitizeForLog(req.url), - status: 413, - duration_ms: duration, - request_bytes: totalBytes, - upstream_host: targetHost, + request_id: requestId, provider, method: req.method, + path: sanitizeForLog(req.url), status: 413, duration_ms: duration, + request_bytes: totalBytes, upstream_host: targetHost, }); - if (!res.headersSent) { - res.writeHead(413, { 'Content-Type': 'application/json' }); - } + if (!res.headersSent) res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Payload Too Large', message: 'Request body exceeds 10 MB limit' })); return; } @@ -780,84 +305,60 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = let body = Buffer.concat(chunks); const inboundBytes = body.length; - // Apply optional body transform (e.g. model alias rewriting) for mutating methods if (bodyTransform && (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH')) { const transformed = bodyTransform(body); - if (transformed) { - body = transformed; - } + if (transformed) body = transformed; } const requestBytes = body.length; metrics.increment('request_bytes_total', { provider }, requestBytes); - // Copy incoming headers, stripping sensitive/proxy headers, then inject auth const headers = {}; for (const [name, value] of Object.entries(req.headers)) { - if (!shouldStripHeader(name)) { - headers[name] = value; - } + if (!shouldStripHeader(name)) headers[name] = value; } - // Ensure X-Request-ID is forwarded to upstream headers['x-request-id'] = requestId; Object.assign(headers, injectHeaders); - // When the body was rewritten (model alias substitution), update content-length - // and remove any transfer-encoding header — forwarding both is invalid (RFC 7230). if (body.length !== inboundBytes) { headers['content-length'] = String(body.length); delete headers['transfer-encoding']; } - // Log auth header injection for debugging credential-isolation issues - // Use case-insensitive lookup since providers use mixed casing (e.g. 'Authorization' vs 'authorization') - const injectedKey = Object.entries(injectHeaders).find(([k]) => ['x-api-key', 'authorization', 'x-goog-api-key'].includes(k.toLowerCase()))?.[1]; + const injectedKey = Object.entries(injectHeaders).find(([k]) => + ['x-api-key', 'authorization', 'x-goog-api-key'].includes(k.toLowerCase()) + )?.[1]; if (injectedKey) { const keyPreview = injectedKey.length > 8 ? `${injectedKey.substring(0, 8)}...${injectedKey.substring(injectedKey.length - 4)}` : '(short)'; logRequest('debug', 'auth_inject', { - request_id: requestId, - provider, - key_length: injectedKey.length, - key_preview: keyPreview, + request_id: requestId, provider, + key_length: injectedKey.length, key_preview: keyPreview, has_anthropic_version: !!headers['anthropic-version'], }); } const options = { - hostname: targetHost, - port: 443, - path: upstreamPath, - method: req.method, - headers, - agent: proxyAgent, // Route through Squid + hostname: targetHost, port: 443, path: upstreamPath, + method: req.method, headers, + agent: proxyAgent, }; const proxyReq = https.request(options, (proxyRes) => { let responseBytes = 0; + proxyRes.on('data', (chunk) => { responseBytes += chunk.length; }); - proxyRes.on('data', (chunk) => { - responseBytes += chunk.length; - }); - - // Handle response stream errors proxyRes.on('error', (err) => { const duration = Date.now() - startTime; metrics.gaugeDec('active_requests', { provider }); metrics.increment('requests_errors_total', { provider }); logRequest('error', 'request_error', { - request_id: requestId, - provider, - method: req.method, - path: sanitizeForLog(req.url), - duration_ms: duration, - error: sanitizeForLog(err.message), - upstream_host: targetHost, + request_id: requestId, provider, method: req.method, + path: sanitizeForLog(req.url), duration_ms: duration, + error: sanitizeForLog(err.message), upstream_host: targetHost, }); - if (!res.headersSent) { - res.writeHead(502, { 'Content-Type': 'application/json' }); - } + if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Response stream error', message: err.message })); }); @@ -868,31 +369,20 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = metrics.increment('requests_total', { provider, method: req.method, status_class: sc }); metrics.increment('response_bytes_total', { provider }, responseBytes); metrics.observe('request_duration_ms', duration, { provider }); - logRequest('info', 'request_complete', { - request_id: requestId, - provider, - method: req.method, - path: sanitizeForLog(req.url), - status: proxyRes.statusCode, - duration_ms: duration, - request_bytes: requestBytes, - response_bytes: responseBytes, - upstream_host: targetHost, + request_id: requestId, provider, method: req.method, + path: sanitizeForLog(req.url), status: proxyRes.statusCode, + duration_ms: duration, request_bytes: requestBytes, + response_bytes: responseBytes, upstream_host: targetHost, }); }); - // Copy response headers and add X-Request-ID const resHeaders = { ...proxyRes.headers, 'x-request-id': requestId }; - // Log upstream auth failures prominently for debugging if (proxyRes.statusCode === 401 || proxyRes.statusCode === 403) { logRequest('warn', 'upstream_auth_error', { - request_id: requestId, - provider, - status: proxyRes.statusCode, - upstream_host: targetHost, - path: sanitizeForLog(req.url), + request_id: requestId, provider, status: proxyRes.statusCode, + upstream_host: targetHost, path: sanitizeForLog(req.url), message: `Upstream returned ${proxyRes.statusCode} — check that the API key is valid and has not expired`, }); } @@ -900,14 +390,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = res.writeHead(proxyRes.statusCode, resHeaders); proxyRes.pipe(res); - // Attach token usage tracking (non-blocking, listens on same data/end events) - trackTokenUsage(proxyRes, { - requestId, - provider, - path: sanitizeForLog(req.url), - startTime, - metrics, - }); + trackTokenUsage(proxyRes, { requestId, provider, path: sanitizeForLog(req.url), startTime, metrics }); }); proxyReq.on('error', (err) => { @@ -916,65 +399,41 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = metrics.increment('requests_errors_total', { provider }); metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' }); metrics.observe('request_duration_ms', duration, { provider }); - logRequest('error', 'request_error', { - request_id: requestId, - provider, - method: req.method, - path: sanitizeForLog(req.url), - duration_ms: duration, - error: sanitizeForLog(err.message), - upstream_host: targetHost, + request_id: requestId, provider, method: req.method, + path: sanitizeForLog(req.url), duration_ms: duration, + error: sanitizeForLog(err.message), upstream_host: targetHost, }); - if (!res.headersSent) { - res.writeHead(502, { 'Content-Type': 'application/json' }); - } + if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Proxy error', message: err.message })); }); - if (body.length > 0) { - proxyReq.write(body); - } + if (body.length > 0) proxyReq.write(body); proxyReq.end(); }); } +// ── Core proxy: WebSocket ───────────────────────────────────────────────────── /** * Handle a WebSocket upgrade request by tunnelling through the Squid proxy. * - * Flow: - * client --[HTTP Upgrade]--> proxy --[CONNECT]--> Squid:3128 --[TLS]--> upstream:443 - * - * Steps: - * 1. Validate the request (WebSocket upgrade only, relative URL) - * 2. Apply rate limiting (counts as one request, zero body bytes) - * 3. Open a CONNECT tunnel to targetHost:443 through Squid - * 4. TLS-handshake the tunnel - * 5. Replay the HTTP Upgrade request with auth headers injected - * 6. Bidirectionally pipe the raw TCP sockets - * - * No additional npm dependencies are required — only Node.js built-ins. - * * @param {http.IncomingMessage} req - The incoming HTTP Upgrade request * @param {import('net').Socket} socket - Raw TCP socket to the WebSocket client * @param {Buffer} head - Any bytes already buffered after the upgrade headers - * @param {string} targetHost - Upstream hostname (e.g. 'api.openai.com') - * @param {Object} injectHeaders - Auth headers to inject (e.g. { Authorization: 'Bearer …' }) + * @param {string} targetHost - Upstream hostname + * @param {Object} injectHeaders - Auth headers to inject * @param {string} provider - Provider name for logging and metrics - * @param {string} [basePath=''] - Optional base-path prefix for the upstream URL + * @param {string} [basePath=''] - Optional base-path prefix */ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, basePath = '') { const startTime = Date.now(); const clientRequestId = req.headers['x-request-id']; const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); - // ── Validate: only forward WebSocket upgrades ────────────────────────── const upgradeType = (req.headers['upgrade'] || '').toLowerCase(); if (upgradeType !== 'websocket') { logRequest('warn', 'websocket_upgrade_rejected', { - request_id: requestId, - provider, - path: sanitizeForLog(req.url), + request_id: requestId, provider, path: sanitizeForLog(req.url), reason: 'unsupported upgrade type', upgrade: sanitizeForLog(req.headers['upgrade'] || ''), }); @@ -983,12 +442,9 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, return; } - // ── Validate: relative path only (prevent SSRF) ──────────────────────── if (!req.url || !req.url.startsWith('/') || req.url.startsWith('//')) { logRequest('warn', 'websocket_upgrade_rejected', { - request_id: requestId, - provider, - path: sanitizeForLog(req.url), + request_id: requestId, provider, path: sanitizeForLog(req.url), reason: 'URL must be a relative path', }); socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); @@ -998,33 +454,23 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, const upstreamPath = buildUpstreamPath(req.url, targetHost, basePath); - // ── Rate limit (counts as one request, frames are not tracked) ────────── const rateCheck = limiter.check(provider, 0); if (!rateCheck.allowed) { metrics.increment('rate_limit_rejected_total', { provider, limit_type: rateCheck.limitType }); logRequest('warn', 'rate_limited', { - request_id: requestId, - provider, - limit_type: rateCheck.limitType, - limit: rateCheck.limit, - retry_after: rateCheck.retryAfter, + request_id: requestId, provider, limit_type: rateCheck.limitType, + limit: rateCheck.limit, retry_after: rateCheck.retryAfter, }); - socket.write( - `HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${rateCheck.retryAfter}\r\nConnection: close\r\n\r\n` - ); + socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${rateCheck.retryAfter}\r\nConnection: close\r\n\r\n`); socket.destroy(); return; } logRequest('info', 'websocket_upgrade_start', { - request_id: requestId, - provider, - path: sanitizeForLog(req.url), - upstream_host: targetHost, + request_id: requestId, provider, path: sanitizeForLog(req.url), upstream_host: targetHost, }); metrics.gaugeInc('active_requests', { provider }); - // finalize() must be called exactly once when the WebSocket session ends. let finalized = false; function finalize(isError, description) { if (finalized) return; @@ -1034,38 +480,27 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, if (isError) { metrics.increment('requests_errors_total', { provider }); logRequest('error', 'websocket_upgrade_failed', { - request_id: requestId, - provider, - path: sanitizeForLog(req.url), - duration_ms: duration, - error: sanitizeForLog(String(description || 'unknown error')), + request_id: requestId, provider, path: sanitizeForLog(req.url), + duration_ms: duration, error: sanitizeForLog(String(description || 'unknown error')), }); } else { metrics.increment('requests_total', { provider, method: 'GET', status_class: '1xx' }); metrics.observe('request_duration_ms', duration, { provider }); logRequest('info', 'websocket_upgrade_complete', { - request_id: requestId, - provider, - path: sanitizeForLog(req.url), - duration_ms: duration, + request_id: requestId, provider, path: sanitizeForLog(req.url), duration_ms: duration, }); } } - // abort(): called before the socket pipe is established (pre-TLS errors). - // Sends a 502 to the client and finalizes with an error. function abort(reason, ...extra) { finalize(true, reason); if (!socket.destroyed && socket.writable) { socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); } socket.destroy(); - for (const s of extra) { - if (s && !s.destroyed) s.destroy(); - } + for (const s of extra) { if (s && !s.destroyed) s.destroy(); } } - // ── Require Squid proxy ──────────────────────────────────────────────── if (!HTTPS_PROXY) { abort('No Squid proxy configured (HTTPS_PROXY not set)'); return; @@ -1082,11 +517,8 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, const proxyHost = proxyUrl.hostname; const proxyPort = parseInt(proxyUrl.port, 10) || 3128; - // ── Step 1: CONNECT tunnel through Squid to targetHost:443 ──────────── const connectReq = http.request({ - host: proxyHost, - port: proxyPort, - method: 'CONNECT', + host: proxyHost, port: proxyPort, method: 'CONNECT', path: `${targetHost}:443`, headers: { 'Host': `${targetHost}:443` }, }); @@ -1099,26 +531,19 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, return; } - // ── Step 2: TLS-upgrade the raw tunnel ────────────────────────────── const tlsSocket = tls.connect({ socket: tunnel, servername: targetHost, rejectUnauthorized: true }); - - // Pre-TLS error handler: removed once TLS is established. const onTlsError = (err) => abort(`TLS handshake error: ${err.message}`, tunnel); tlsSocket.once('error', onTlsError); tlsSocket.once('secureConnect', () => { - // TLS connected — swap to post-connection teardown error handlers. tlsSocket.removeListener('error', onTlsError); - // ── Step 3: Replay the HTTP Upgrade request with auth injected ──── const forwardHeaders = {}; for (const [name, value] of Object.entries(req.headers)) { - if (!shouldStripHeader(name)) { - forwardHeaders[name] = value; - } + if (!shouldStripHeader(name)) forwardHeaders[name] = value; } Object.assign(forwardHeaders, injectHeaders); - forwardHeaders['host'] = targetHost; // Fix Host header for upstream + forwardHeaders['host'] = targetHost; let upgradeReqStr = `GET ${upstreamPath} HTTP/1.1\r\n`; for (const [name, value] of Object.entries(forwardHeaders)) { @@ -1127,29 +552,15 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, upgradeReqStr += '\r\n'; tlsSocket.write(upgradeReqStr); - // Forward any bytes already buffered before the pipe - if (head && head.length > 0) { - tlsSocket.write(head); - } + if (head && head.length > 0) tlsSocket.write(head); - // ── Step 4: Bidirectional raw socket relay ───────────────────── tlsSocket.pipe(socket); socket.pipe(tlsSocket); - // Attach WebSocket token usage tracking (non-blocking, sniffs upstream frames) - trackWebSocketTokenUsage(tlsSocket, { - requestId, - provider, - path: sanitizeForLog(req.url), - startTime, - metrics, - }); + trackWebSocketTokenUsage(tlsSocket, { requestId, provider, path: sanitizeForLog(req.url), startTime, metrics }); - // Finalize once when either side closes; destroy the other side. socket.once('close', () => { finalize(false); tlsSocket.destroy(); }); tlsSocket.once('close', () => { finalize(false); socket.destroy(); }); - - // Suppress unhandled-error crashes; destroy triggers the close handler. socket.on('error', () => socket.destroy()); tlsSocket.on('error', () => tlsSocket.destroy()); }); @@ -1158,184 +569,126 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider, connectReq.end(); } -/** - * Build the enhanced health response (superset of original format). - */ -// --------------------------------------------------------------------------- -// Startup key validation -// --------------------------------------------------------------------------- - -/** - * Validation result for a single provider's API key. - * @typedef {'pending'|'valid'|'auth_rejected'|'network_error'|'inconclusive'|'skipped'} ValidationStatus - * @typedef {{ status: ValidationStatus, message: string }} ValidationResult - */ - -/** @type {Record} */ -const keyValidationResults = {}; - -/** Set to true once validateApiKeys() has finished (regardless of outcome). */ -let keyValidationComplete = false; +// ── Management endpoints (port 10000 only) ──────────────────────────────────── -/** Reset validation state (used in tests). */ -function resetKeyValidationState() { - for (const key of Object.keys(keyValidationResults)) { - delete keyValidationResults[key]; +function healthResponse() { + const providers = {}; + for (const adapter of registeredAdapters) { + providers[adapter.name] = adapter.isEnabled(); } - keyValidationComplete = false; + return { + status: 'healthy', + service: 'awf-api-proxy', + squid_proxy: HTTPS_PROXY || 'not configured', + providers, + key_validation: { complete: keyValidationComplete, results: keyValidationResults }, + models_fetch_complete: modelFetchComplete, + metrics_summary: metrics.getSummary(), + rate_limits: limiter.getAllStatus(), + }; } /** - * Perform a lightweight probe against the provider's API to check if the - * configured key is still accepted. Results are logged and stored in - * `keyValidationResults` — the health endpoint exposes them. - * - * Validation is **non-blocking by default**: the proxy still serves traffic - * even if a key is rejected. Set AWF_VALIDATE_KEYS=strict to exit(1) on - * any auth rejection. - * - * Only validates against known default targets. Custom/enterprise targets - * are skipped because we don't know what probe endpoints they expose. + * Build the reflection response describing all proxy endpoints and their available models. * - * @param {object} [overrides={}] - Optional key/target overrides (used in tests) - * @param {string} [overrides.openaiKey] - Override OPENAI_API_KEY - * @param {string} [overrides.openaiTarget] - Override OPENAI_API_TARGET - * @param {string} [overrides.anthropicKey] - Override ANTHROPIC_API_KEY - * @param {string} [overrides.anthropicTarget] - Override ANTHROPIC_API_TARGET - * @param {string} [overrides.copilotGithubToken] - Override COPILOT_GITHUB_TOKEN - * @param {string} [overrides.copilotApiKey] - Override COPILOT_API_KEY - * @param {string} [overrides.copilotAuthToken] - Override COPILOT_AUTH_TOKEN - * @param {string} [overrides.copilotTarget] - Override COPILOT_API_TARGET - * @param {string} [overrides.copilotIntegrationId] - Override COPILOT_INTEGRATION_ID - * @param {string} [overrides.geminiKey] - Override GEMINI_API_KEY - * @param {string} [overrides.geminiTarget] - Override GEMINI_API_TARGET - * @param {number} [overrides.timeoutMs] - Override probe timeout + * @returns {{ endpoints: Array, models_fetch_complete: boolean, model_aliases: object|null }} */ -async function validateApiKeys(overrides = {}) { - const mode = (process.env.AWF_VALIDATE_KEYS || 'warn').toLowerCase(); // off | warn | strict - if (mode === 'off') { - logRequest('info', 'key_validation', { message: 'Key validation disabled (AWF_VALIDATE_KEYS=off)' }); - keyValidationComplete = true; - return; - } +function reflectEndpoints() { + return { + endpoints: registeredAdapters.map(adapter => { + const info = adapter.getReflectionInfo(); + return { + provider: info.provider, + port: info.port, + base_url: info.base_url, + configured: info.configured, + models: info.models_cache_key !== null ? (cachedModels[info.models_cache_key] || null) : null, + models_url: info.models_url, + }; + }), + models_fetch_complete: modelFetchComplete, + model_aliases: MODEL_ALIASES ? MODEL_ALIASES.models : null, + }; +} - const ov = (key, fallback) => key in overrides ? overrides[key] : fallback; - const openaiKey = ov('openaiKey', OPENAI_API_KEY); - const openaiTarget = ov('openaiTarget', OPENAI_API_TARGET); - const anthropicKey = ov('anthropicKey', ANTHROPIC_API_KEY); - const anthropicTarget = ov('anthropicTarget', ANTHROPIC_API_TARGET); - const copilotGithubToken = ov('copilotGithubToken', COPILOT_GITHUB_TOKEN); - const copilotApiKey = ov('copilotApiKey', COPILOT_API_KEY); - const copilotAuthToken = ov('copilotAuthToken', COPILOT_AUTH_TOKEN); - const copilotTarget = ov('copilotTarget', COPILOT_API_TARGET); - const copilotIntegrationId = ov('copilotIntegrationId', COPILOT_INTEGRATION_ID); - const geminiKey = ov('geminiKey', GEMINI_API_KEY); - const geminiTarget = ov('geminiTarget', GEMINI_API_TARGET); - const TIMEOUT_MS = ov('timeoutMs', 10_000); - - const probes = []; - - // --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) --- - if (copilotGithubToken) { - if (copilotTarget !== 'api.githubcopilot.com') { - keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${copilotTarget}; validation skipped` }; - logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot }); - } else { - probes.push(probeProvider('copilot', `https://${copilotTarget}/models`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${copilotGithubToken}`, - 'Copilot-Integration-Id': copilotIntegrationId, - }, - }, TIMEOUT_MS)); - } - } else if (copilotApiKey && !copilotGithubToken) { - keyValidationResults.copilot = { status: 'skipped', message: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' }; - logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot }); - } - - // --- OpenAI --- - if (openaiKey) { - if (openaiTarget !== 'api.openai.com') { - keyValidationResults.openai = { status: 'skipped', message: `Custom target ${openaiTarget}; validation skipped` }; - logRequest('info', 'key_validation', { provider: 'openai', ...keyValidationResults.openai }); - } else { - probes.push(probeProvider('openai', `https://${openaiTarget}/v1/models`, { - method: 'GET', - headers: { 'Authorization': `Bearer ${openaiKey}` }, - }, TIMEOUT_MS)); - } +/** + * Handle management endpoints on port 10000 (/health, /metrics, /reflect). + * Returns true if the request was handled, false otherwise. + */ +function handleManagementEndpoint(req, res) { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(healthResponse())); + return true; } - - // --- Anthropic --- - if (anthropicKey) { - if (anthropicTarget !== 'api.anthropic.com') { - keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${anthropicTarget}; validation skipped` }; - logRequest('info', 'key_validation', { provider: 'anthropic', ...keyValidationResults.anthropic }); - } else { - // POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid - probes.push(probeProvider('anthropic', `https://${anthropicTarget}/v1/messages`, { - method: 'POST', - headers: { - 'x-api-key': anthropicKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - body: '{}', - }, TIMEOUT_MS)); - } + if (req.method === 'GET' && req.url === '/metrics') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(metrics.getMetrics())); + return true; } - - // --- Gemini --- - if (geminiKey) { - if (geminiTarget !== 'generativelanguage.googleapis.com') { - keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${geminiTarget}; validation skipped` }; - logRequest('info', 'key_validation', { provider: 'gemini', ...keyValidationResults.gemini }); - } else { - probes.push(probeProvider('gemini', `https://${geminiTarget}/v1beta/models`, { - method: 'GET', - headers: { 'x-goog-api-key': geminiKey }, - }, TIMEOUT_MS)); - } + if (req.method === 'GET' && req.url === '/reflect') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reflectEndpoints())); + return true; } + return false; +} - if (probes.length === 0) { - logRequest('info', 'key_validation', { message: 'No providers to validate' }); - keyValidationComplete = true; - return; - } +// ── models.json snapshot ────────────────────────────────────────────────────── - await Promise.allSettled(probes); - keyValidationComplete = true; +const MODELS_LOG_DIR = process.env.AWF_API_PROXY_LOG_DIR || '/var/log/api-proxy'; - // Summarize - const failures = Object.entries(keyValidationResults) - .filter(([, r]) => r.status === 'auth_rejected'); +/** + * Build the models.json payload from current cached state. + * + * @returns {object} + */ +function buildModelsJson() { + const providers = {}; + for (const adapter of registeredAdapters) { + const info = adapter.getReflectionInfo(); + providers[adapter.name] = { + configured: adapter.isEnabled(), + models: info.models_cache_key !== null + ? (cachedModels[info.models_cache_key] !== undefined ? cachedModels[info.models_cache_key] : null) + : null, + target: adapter.isEnabled() ? adapter.getTargetHost() : null, + }; + } + return { + timestamp: new Date().toISOString(), + providers, + model_aliases: MODEL_ALIASES ? MODEL_ALIASES.models : null, + }; +} - if (failures.length > 0) { - for (const [provider, result] of failures) { - logRequest('error', 'key_validation_failed', { - provider, - message: `${provider.toUpperCase()} API key validation failed — ${result.message}. Rotate the secret and re-run.`, - }); - } - if (mode === 'strict') { - logRequest('error', 'key_validation_strict_exit', { - message: `AWF_VALIDATE_KEYS=strict: exiting due to ${failures.length} auth failure(s)`, - providers: failures.map(([p]) => p), - }); - process.exit(1); - } - } else { - logRequest('info', 'key_validation', { message: 'All configured API keys validated successfully' }); +/** + * Write the current model availability snapshot to models.json. + * + * @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), + }); } } +// ── Startup: key validation ──────────────────────────────────────────────────── + /** * Probe a single provider to check if the API key is accepted. * - * @param {string} provider - Provider name (copilot, openai, etc.) - * @param {string} url - Probe URL + * @param {string} provider + * @param {string} url * @param {{ method: string, headers: Record, body?: string }} opts * @param {number} timeoutMs */ @@ -1371,7 +724,7 @@ async function probeProvider(provider, url, opts, timeoutMs) { * @param {string} url * @param {{ method: string, headers: Record, body?: string }} opts * @param {number} timeoutMs - * @returns {Promise} HTTP status code + * @returns {Promise} */ function httpProbe(url, opts, timeoutMs) { return new Promise((resolve, reject) => { @@ -1389,33 +742,20 @@ function httpProbe(url, opts, timeoutMs) { }; let settled = false; - const resolveOnce = (statusCode) => { - if (settled) return; - settled = true; - resolve(statusCode); - }; - const rejectOnce = (err) => { - if (settled) return; - settled = true; - reject(err); - }; + const resolveOnce = (statusCode) => { if (settled) return; settled = true; resolve(statusCode); }; + const rejectOnce = (err) => { if (settled) return; settled = true; reject(err); }; const req = mod.request(reqOpts, (res) => { - // Consume body to free the socket res.resume(); res.on('end', () => resolveOnce(res.statusCode)); res.on('error', rejectOnce); res.on('close', () => resolveOnce(res.statusCode)); }); - req.on('timeout', () => { - req.destroy(new Error(`Probe timed out after ${timeoutMs}ms`)); - }); + req.on('timeout', () => { req.destroy(new Error(`Probe timed out after ${timeoutMs}ms`)); }); req.on('error', rejectOnce); - if (opts.body) { - req.write(opts.body); - } + if (opts.body) req.write(opts.body); req.end(); }); } @@ -1432,12 +772,8 @@ function httpProbe(url, opts, timeoutMs) { function fetchJson(url, opts, timeoutMs) { return new Promise((resolve) => { let parsed; - try { - parsed = new URL(url); - } catch { - resolve(null); - return; - } + try { parsed = new URL(url); } catch { resolve(null); return; } + const isHttps = parsed.protocol === 'https:'; const mod = isHttps ? https : http; const reqOpts = { @@ -1451,32 +787,19 @@ function fetchJson(url, opts, timeoutMs) { }; let settled = false; - const resolveOnce = (value) => { - if (settled) return; - settled = true; - resolve(value); - }; + const resolveOnce = (value) => { if (settled) return; settled = true; resolve(value); }; const req = mod.request(reqOpts, (res) => { - if (res.statusCode < 200 || res.statusCode >= 300) { - res.resume(); - resolveOnce(null); - return; - } + if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); resolveOnce(null); return; } const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { - try { - resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); - } catch { - resolveOnce(null); - } + try { resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolveOnce(null); } }); res.on('error', (err) => { logRequest('debug', 'fetch_json_error', { url: sanitizeForLog(url), error: String(err && err.message ? err.message : err) }); resolveOnce(null); }); - // Guard against connection drops mid-body that never emit 'end' or 'error' res.on('close', () => resolveOnce(null)); }); @@ -1493,33 +816,25 @@ function fetchJson(url, opts, timeoutMs) { }); } -/** - * Prefix used by the Gemini models API in model name fields. - * Example: { name: "models/gemini-1.5-pro" } → "gemini-1.5-pro" - */ -const GEMINI_MODEL_NAME_PREFIX = 'models/'; - /** * Extract model IDs from a provider API response. * Handles: - * - OpenAI / Anthropic / Copilot format: { data: [{ id }, ...] } - * - Gemini format: { models: [{ name: "models/gemini-1.5-pro" }, ...] } + * - OpenAI / Anthropic / Copilot: { data: [{ id }, ...] } + * - Gemini: { models: [{ name: "models/gemini-..." }, ...] } * - * @param {object|null} json - Parsed API response - * @returns {string[]|null} Sorted array of model IDs, or null if unavailable + * @param {object|null} json + * @returns {string[]|null} */ +const GEMINI_MODEL_NAME_PREFIX = 'models/'; + function extractModelIds(json) { if (!json || typeof json !== 'object') return null; - // OpenAI / Anthropic / Copilot format: { data: [{ id: "..." }, ...] } if (Array.isArray(json.data)) { - const ids = json.data - .map((m) => m && (m.id || m.name)) - .filter(Boolean); + const ids = json.data.map((m) => m && (m.id || m.name)).filter(Boolean); return ids.length > 0 ? ids.sort() : null; } - // Gemini format: { models: [{ name: "models/gemini-1.5-pro", ... }, ...] } if (Array.isArray(json.models)) { const ids = json.models .map((m) => m && m.name && m.name.startsWith(GEMINI_MODEL_NAME_PREFIX) @@ -1532,297 +847,367 @@ function extractModelIds(json) { return null; } +// ── Adapter-based validation & model fetching ───────────────────────────────── +// +// When adapters array is provided (production), iterate over adapter probes. +// When an overrides object is provided (tests), use the legacy inline logic. +// The duck-type check (Array.isArray) keeps backward compat with existing tests. + /** - * Cache for available models per provider, populated at startup by fetchStartupModels. - * null = not yet fetched or fetch failed for this provider. - * @type {Record} + * Validate configured API keys by probing each provider's endpoint. + * + * Accepts either: + * - An adapters array (production): uses each adapter's getValidationProbe() + * - An overrides object (test compatibility): uses inline probe logic + * + * @param {import('./providers').ProviderAdapter[]|object} [adaptersOrOverrides={}] */ -const cachedModels = {}; +async function validateApiKeys(adaptersOrOverrides = {}) { + const mode = (process.env.AWF_VALIDATE_KEYS || 'warn').toLowerCase(); + if (mode === 'off') { + logRequest('info', 'key_validation', { message: 'Key validation disabled (AWF_VALIDATE_KEYS=off)' }); + keyValidationComplete = true; + return; + } -/** Set to true once fetchStartupModels() has run (regardless of success). */ -let modelFetchComplete = false; + // ── Adapter-based path (production) ───────────────────────────────────────── + if (Array.isArray(adaptersOrOverrides)) { + const adapters = adaptersOrOverrides; + const TIMEOUT_MS = 10_000; + const probes = []; -/** Reset model cache state (used in tests). */ -function resetModelCacheState() { - for (const key of Object.keys(cachedModels)) { - delete cachedModels[key]; + for (const adapter of adapters) { + const probe = adapter.getValidationProbe?.(); + if (!probe) continue; + + if (probe.skip) { + keyValidationResults[adapter.name] = { status: 'skipped', message: probe.reason }; + logRequest('info', 'key_validation', { provider: adapter.name, ...keyValidationResults[adapter.name] }); + continue; + } + + probes.push(probeProvider(adapter.name, probe.url, probe.opts, TIMEOUT_MS)); + } + + if (probes.length === 0) { + logRequest('info', 'key_validation', { message: 'No providers to validate' }); + keyValidationComplete = true; + return; + } + + await Promise.allSettled(probes); + keyValidationComplete = true; + _summarizeValidationFailures(mode); + return; + } + + // ── Legacy override path (test compatibility) ──────────────────────────────── + const overrides = adaptersOrOverrides; + const ov = (key, fallback) => key in overrides ? overrides[key] : fallback; + + // Re-read module-level adapter state for defaults (keeps tests self-contained) + const openaiAdapter = registeredAdapters.find(a => a.name === 'openai'); + const anthropicAdapter = registeredAdapters.find(a => a.name === 'anthropic'); + const copilotAdapter = registeredAdapters.find(a => a.name === 'copilot'); + const geminiAdapter = registeredAdapters.find(a => a.name === 'gemini'); + + // Rather than trying to introspect adapters for every key, use process.env as fallback + const _ov = (key, envKey) => key in overrides ? overrides[key] : (process.env[envKey] || '').trim() || undefined; + const openaiKeyV = _ov('openaiKey', 'OPENAI_API_KEY'); + const openaiTarget = ov('openaiTarget', openaiAdapter?.getTargetHost?.() ?? 'api.openai.com'); + const anthropicKeyV = _ov('anthropicKey', 'ANTHROPIC_API_KEY'); + const anthropicTarget = ov('anthropicTarget', anthropicAdapter?.getTargetHost?.() ?? 'api.anthropic.com'); + const copilotGithubToken = _ov('copilotGithubToken', 'COPILOT_GITHUB_TOKEN'); + const copilotApiKey = _ov('copilotApiKey', 'COPILOT_API_KEY'); + const copilotAuthToken = ov('copilotAuthToken', copilotAdapter?._githubToken ?? copilotAdapter?.isEnabled?.() ?? undefined); + const copilotTarget = ov('copilotTarget', copilotAdapter?.getTargetHost?.() ?? 'api.githubcopilot.com'); + const copilotIntegrationId = ov('copilotIntegrationId', copilotAdapter?._integrationId ?? 'copilot-developer-cli'); + const geminiKeyV = _ov('geminiKey', 'GEMINI_API_KEY'); + const geminiTarget = ov('geminiTarget', geminiAdapter?.getTargetHost?.() ?? 'generativelanguage.googleapis.com'); + const TIMEOUT_MS = ov('timeoutMs', 10_000); + + const probes = []; + + // --- Copilot --- + if (copilotGithubToken) { + if (copilotTarget !== 'api.githubcopilot.com') { + keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${copilotTarget}; validation skipped` }; + logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot }); + } else { + probes.push(probeProvider('copilot', `https://${copilotTarget}/models`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${copilotGithubToken}`, 'Copilot-Integration-Id': copilotIntegrationId }, + }, TIMEOUT_MS)); + } + } else if (copilotApiKey && !copilotGithubToken) { + keyValidationResults.copilot = { status: 'skipped', message: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' }; + logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot }); + } + + // --- OpenAI --- + if (openaiKeyV) { + if (openaiTarget !== 'api.openai.com') { + keyValidationResults.openai = { status: 'skipped', message: `Custom target ${openaiTarget}; validation skipped` }; + logRequest('info', 'key_validation', { provider: 'openai', ...keyValidationResults.openai }); + } else { + probes.push(probeProvider('openai', `https://${openaiTarget}/v1/models`, { + method: 'GET', headers: { 'Authorization': `Bearer ${openaiKeyV}` }, + }, TIMEOUT_MS)); + } + } + + // --- Anthropic --- + if (anthropicKeyV) { + if (anthropicTarget !== 'api.anthropic.com') { + keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${anthropicTarget}; validation skipped` }; + logRequest('info', 'key_validation', { provider: 'anthropic', ...keyValidationResults.anthropic }); + } else { + probes.push(probeProvider('anthropic', `https://${anthropicTarget}/v1/messages`, { + method: 'POST', + headers: { 'x-api-key': anthropicKeyV, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: '{}', + }, TIMEOUT_MS)); + } + } + + // --- Gemini --- + if (geminiKeyV) { + if (geminiTarget !== 'generativelanguage.googleapis.com') { + keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${geminiTarget}; validation skipped` }; + logRequest('info', 'key_validation', { provider: 'gemini', ...keyValidationResults.gemini }); + } else { + probes.push(probeProvider('gemini', `https://${geminiTarget}/v1beta/models`, { + method: 'GET', headers: { 'x-goog-api-key': geminiKeyV }, + }, TIMEOUT_MS)); + } + } + + if (probes.length === 0) { + logRequest('info', 'key_validation', { message: 'No providers to validate' }); + keyValidationComplete = true; + return; + } + + await Promise.allSettled(probes); + keyValidationComplete = true; + _summarizeValidationFailures(mode); +} + +function _summarizeValidationFailures(mode) { + const failures = Object.entries(keyValidationResults) + .filter(([, r]) => r.status === 'auth_rejected'); + + if (failures.length > 0) { + for (const [provider, result] of failures) { + logRequest('error', 'key_validation_failed', { + provider, + message: `${provider.toUpperCase()} API key validation failed — ${result.message}. Rotate the secret and re-run.`, + }); + } + if (mode === 'strict') { + logRequest('error', 'key_validation_strict_exit', { + message: `AWF_VALIDATE_KEYS=strict: exiting due to ${failures.length} auth failure(s)`, + providers: failures.map(([p]) => p), + }); + process.exit(1); + } + } else { + logRequest('info', 'key_validation', { message: 'All configured API keys validated successfully' }); } - modelFetchComplete = false; } /** * Fetch available models for each configured provider and cache them. - * Called at startup alongside key validation. * - * Accepts the same override map as validateApiKeys() so tests can inject - * custom keys and targets without touching process.env. + * Accepts either: + * - An adapters array (production): uses each adapter's getModelsFetchConfig() + * - An overrides object (test compatibility): uses inline fetch logic * - * @param {object} [overrides={}] - Optional key/target overrides (used in tests) + * @param {import('./providers').ProviderAdapter[]|object} [adaptersOrOverrides={}] */ -async function fetchStartupModels(overrides = {}) { - const ov = (key, fallback) => key in overrides ? overrides[key] : fallback; - const openaiKey = ov('openaiKey', OPENAI_API_KEY); - const openaiTarget = ov('openaiTarget', OPENAI_API_TARGET); - const anthropicKey = ov('anthropicKey', ANTHROPIC_API_KEY); - const anthropicTarget = ov('anthropicTarget', ANTHROPIC_API_TARGET); - const copilotGithubToken = ov('copilotGithubToken', COPILOT_GITHUB_TOKEN); - const copilotAuthToken = ov('copilotAuthToken', COPILOT_AUTH_TOKEN); - const copilotTarget = ov('copilotTarget', COPILOT_API_TARGET); - const copilotIntegrationId = ov('copilotIntegrationId', COPILOT_INTEGRATION_ID); - const geminiKey = ov('geminiKey', GEMINI_API_KEY); - const geminiTarget = ov('geminiTarget', GEMINI_API_TARGET); - const TIMEOUT_MS = ov('timeoutMs', 10_000); +async function fetchStartupModels(adaptersOrOverrides = {}) { + // ── Adapter-based path (production) ───────────────────────────────────────── + if (Array.isArray(adaptersOrOverrides)) { + const adapters = adaptersOrOverrides; + const TIMEOUT_MS = 10_000; + const fetches = []; + + for (const adapter of adapters) { + const config = adapter.getModelsFetchConfig?.(); + if (!config) continue; + + fetches.push( + fetchJson(config.url, config.opts, TIMEOUT_MS).then((json) => { + cachedModels[config.cacheKey] = extractModelIds(json); + }) + ); + } + + await Promise.allSettled(fetches); + modelFetchComplete = true; + return; + } + + // ── Legacy override path (test compatibility) ──────────────────────────────── + const overrides = adaptersOrOverrides; + const _ov = (key, envKey) => key in overrides ? overrides[key] : (process.env[envKey] || '').trim() || undefined; + const ov = (key, fallback) => key in overrides ? overrides[key] : fallback; + + const copilotAdapter = registeredAdapters.find(a => a.name === 'copilot'); + const geminiAdapter = registeredAdapters.find(a => a.name === 'gemini'); + + const openaiKey = _ov('openaiKey', 'OPENAI_API_KEY'); + const openaiTarget = ov('openaiTarget', normalizeApiTarget(process.env.OPENAI_API_TARGET) || 'api.openai.com'); + const anthropicKey = _ov('anthropicKey', 'ANTHROPIC_API_KEY'); + const anthropicTarget = ov('anthropicTarget', normalizeApiTarget(process.env.ANTHROPIC_API_TARGET) || 'api.anthropic.com'); + const copilotGithubToken = _ov('copilotGithubToken', 'COPILOT_GITHUB_TOKEN'); + const copilotTarget = ov('copilotTarget', copilotAdapter?.getTargetHost?.() ?? 'api.githubcopilot.com'); + const copilotIntegrationId = ov('copilotIntegrationId', copilotAdapter?._integrationId ?? 'copilot-developer-cli'); + const geminiKey = _ov('geminiKey', 'GEMINI_API_KEY'); + const geminiTarget = ov('geminiTarget', geminiAdapter?.getTargetHost?.() ?? 'generativelanguage.googleapis.com'); + const TIMEOUT_MS = ov('timeoutMs', 10_000); const fetches = []; if (openaiKey) { - fetches.push( - fetchJson(`https://${openaiTarget}/v1/models`, { - method: 'GET', - headers: { 'Authorization': `Bearer ${openaiKey}` }, - }, TIMEOUT_MS).then((json) => { - cachedModels.openai = extractModelIds(json); - }) - ); + fetches.push(fetchJson(`https://${openaiTarget}/v1/models`, { + method: 'GET', headers: { 'Authorization': `Bearer ${openaiKey}` }, + }, TIMEOUT_MS).then((json) => { cachedModels.openai = extractModelIds(json); })); } if (anthropicKey) { - fetches.push( - fetchJson(`https://${anthropicTarget}/v1/models`, { - method: 'GET', - headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01' }, - }, TIMEOUT_MS).then((json) => { - cachedModels.anthropic = extractModelIds(json); - }) - ); + fetches.push(fetchJson(`https://${anthropicTarget}/v1/models`, { + method: 'GET', headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01' }, + }, TIMEOUT_MS).then((json) => { cachedModels.anthropic = extractModelIds(json); })); } - // Only use COPILOT_GITHUB_TOKEN (GitHub OAuth) for /models — COPILOT_API_KEY (BYOK) is not - // accepted by the Copilot /models endpoint (consistent with validateApiKeys behaviour). if (copilotGithubToken) { - fetches.push( - fetchJson(`https://${copilotTarget}/models`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${copilotGithubToken}`, - 'Copilot-Integration-Id': copilotIntegrationId, - }, - }, TIMEOUT_MS).then((json) => { - cachedModels.copilot = extractModelIds(json); - }) - ); + fetches.push(fetchJson(`https://${copilotTarget}/models`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${copilotGithubToken}`, 'Copilot-Integration-Id': copilotIntegrationId }, + }, TIMEOUT_MS).then((json) => { cachedModels.copilot = extractModelIds(json); })); } if (geminiKey) { - fetches.push( - fetchJson(`https://${geminiTarget}/v1beta/models`, { - method: 'GET', - headers: { 'x-goog-api-key': geminiKey }, - }, TIMEOUT_MS).then((json) => { - cachedModels.gemini = extractModelIds(json); - }) - ); + fetches.push(fetchJson(`https://${geminiTarget}/v1beta/models`, { + method: 'GET', headers: { 'x-goog-api-key': geminiKey }, + }, TIMEOUT_MS).then((json) => { cachedModels.gemini = extractModelIds(json); })); } await Promise.allSettled(fetches); 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'; - +// ── Generic provider server factory ────────────────────────────────────────── /** - * Build the models.json payload from current cached state. + * Create an HTTP server for a provider adapter. * - * @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. + * The factory is completely agnostic of provider details — all provider-specific + * behaviour (auth, URL transforms, body transforms) is delegated to the adapter. * - * @param {string} [logDir] - Directory to write models.json to (default: MODELS_LOG_DIR) + * @param {import('./providers').ProviderAdapter} adapter + * @returns {http.Server} */ -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), - }); - } -} +function createProviderServer(adapter) { + const server = http.createServer((req, res) => { + // ── Management endpoints (designated port only) ────────────────────────── + if (adapter.isManagementPort && handleManagementEndpoint(req, res)) return; + + // ── Provider-local health endpoint ─────────────────────────────────────── + if (req.url === '/health' && req.method === 'GET') { + if (adapter.isEnabled()) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'healthy', service: `awf-api-proxy-${adapter.name}` })); + } else if (adapter.getUnconfiguredHealthResponse) { + const { statusCode, body } = adapter.getUnconfiguredHealthResponse(); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + } else { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'not_configured', service: `awf-api-proxy-${adapter.name}` })); + } + return; + } -/** - * Build the reflection response describing all proxy endpoints and their available models. - * - * The reflection endpoint allows agent harnesses to dynamically discover which - * LLM providers are configured and what models are available, enabling intelligent - * provider and model selection based on the task at hand. - * - * @returns {{ endpoints: Array, models_fetch_complete: boolean, model_aliases: Record|null }} - */ -function reflectEndpoints() { - const opencodeConfigured = ENABLE_OPENCODE && !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN); - return { - endpoints: [ - { - provider: 'openai', - port: 10000, - base_url: 'http://api-proxy:10000', - configured: !!OPENAI_API_KEY, - models: cachedModels.openai || null, - models_url: 'http://api-proxy:10000/v1/models', - }, - { - provider: 'anthropic', - port: 10001, - base_url: 'http://api-proxy:10001', - configured: !!ANTHROPIC_API_KEY, - models: cachedModels.anthropic || null, - models_url: 'http://api-proxy:10001/v1/models', - }, - { - provider: 'copilot', - port: 10002, - base_url: 'http://api-proxy:10002', - configured: !!COPILOT_AUTH_TOKEN, - models: cachedModels.copilot || null, - models_url: 'http://api-proxy:10002/models', - }, - { - provider: 'gemini', - port: 10003, - base_url: 'http://api-proxy:10003', - configured: !!GEMINI_API_KEY, - models: cachedModels.gemini || null, - models_url: 'http://api-proxy:10003/v1beta/models', - }, - { - provider: 'opencode', - port: 10004, - base_url: 'http://api-proxy:10004', - configured: opencodeConfigured, - // OpenCode routes to one of the above providers; query them directly for models - models: null, - models_url: null, - }, - ], - models_fetch_complete: modelFetchComplete, - model_aliases: MODEL_ALIASES ? MODEL_ALIASES.models : null, - }; -} + // ── Disabled adapter: return provider-specific error ───────────────────── + if (!adapter.isEnabled()) { + const response = adapter.getUnconfiguredResponse + ? adapter.getUnconfiguredResponse() + : { statusCode: 503, body: { error: `${adapter.name} proxy not configured` } }; + res.writeHead(response.statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response.body)); + return; + } -function healthResponse() { - return { - status: 'healthy', - service: 'awf-api-proxy', - squid_proxy: HTTPS_PROXY || 'not configured', - providers: { - openai: !!OPENAI_API_KEY, - anthropic: !!ANTHROPIC_API_KEY, - gemini: !!GEMINI_API_KEY, - copilot: !!COPILOT_AUTH_TOKEN, - }, - key_validation: { - complete: keyValidationComplete, - results: keyValidationResults, - }, - models_fetch_complete: modelFetchComplete, - metrics_summary: metrics.getSummary(), - rate_limits: limiter.getAllStatus(), - }; -} + // ── Rate limiting ───────────────────────────────────────────────────────── + const contentLength = parseInt(req.headers['content-length'] || '0', 10); + if (checkRateLimit(req, res, adapter.name, contentLength)) return; -/** - * Handle management endpoints on port 10000 (/health, /metrics, /reflect). - * Returns true if the request was handled, false otherwise. - */ -function handleManagementEndpoint(req, res) { - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(healthResponse())); - return true; - } - if (req.method === 'GET' && req.url === '/metrics') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(metrics.getMetrics())); - return true; - } - if (req.method === 'GET' && req.url === '/reflect') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(reflectEndpoints())); - return true; - } - return false; + // ── Optional URL transform ──────────────────────────────────────────────── + if (adapter.transformRequestUrl) { + req.url = adapter.transformRequestUrl(req.url); + } + + // ── Proxy ───────────────────────────────────────────────────────────────── + proxyRequest( + req, res, + adapter.getTargetHost(req), + adapter.getAuthHeaders(req), + adapter.name, + adapter.getBasePath(req), + adapter.getBodyTransform() + ); + }); + + // ── WebSocket upgrade ───────────────────────────────────────────────────── + server.on('upgrade', (req, socket, head) => { + if (!adapter.isEnabled()) { + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + if (adapter.transformRequestUrl) { + req.url = adapter.transformRequestUrl(req.url); + } + + proxyWebSocket( + req, socket, head, + adapter.getTargetHost(req), + adapter.getAuthHeaders(req), + adapter.name, + adapter.getBasePath(req) + ); + }); + + return server; } -// Only start the server if this file is run directly (not imported for testing) +// ── Startup ─────────────────────────────────────────────────────────────────── if (require.main === module) { - // Health port is always 10000 — this is what Docker healthcheck hits - const HEALTH_PORT = 10000; - - // Startup latch: count listeners that participate in key validation. - // The no-key Gemini 503 handler binds port 10003 but doesn't participate - // in validation, so it's intentionally excluded from the count. - let expectedListeners = 1; // port 10000 (always) - if (ANTHROPIC_API_KEY) expectedListeners++; - if (COPILOT_AUTH_TOKEN) expectedListeners++; - if (GEMINI_API_KEY) expectedListeners++; - if (ENABLE_OPENCODE && (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN)) expectedListeners++; // OpenCode (10004) + // Log startup configuration (provider-agnostic; adapters report their own details) + logRequest('info', 'startup', { + message: 'Starting AWF API proxy sidecar', + squid_proxy: HTTPS_PROXY || 'not configured', + providers_configured: registeredAdapters.filter(a => a.isEnabled()).map(a => a.name), + }); + + // Determine which adapters to bind and count validation participants + const adaptersToStart = registeredAdapters.filter(a => a.alwaysBind || a.isEnabled()); + const expectedListeners = adaptersToStart.filter(a => a.participatesInValidation).length; let readyListeners = 0; + function onListenerReady() { readyListeners++; if (readyListeners === expectedListeners) { - logRequest('info', 'startup_complete', { message: `All ${expectedListeners} validation-participating listeners ready, starting key validation` }); - validateApiKeys().catch((err) => { + logRequest('info', 'startup_complete', { + message: `All ${expectedListeners} validation-participating listeners ready, starting key validation`, + }); + validateApiKeys(adaptersToStart).catch((err) => { logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) }); keyValidationComplete = true; }); - fetchStartupModels().then(() => { + fetchStartupModels(adaptersToStart).then(() => { writeModelsJson(); }).catch((err) => { logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) }); @@ -1832,321 +1217,19 @@ if (require.main === module) { } } - // OpenAI API proxy (port 10000) - if (OPENAI_API_KEY) { - const server = http.createServer((req, res) => { - if (handleManagementEndpoint(req, res)) return; - const contentLength = parseInt(req.headers['content-length'], 10) || 0; - if (checkRateLimit(req, res, 'openai', contentLength)) return; - - proxyRequest(req, res, OPENAI_API_TARGET, { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, - }, 'openai', OPENAI_API_BASE_PATH, makeModelBodyTransform('openai')); - }); - - server.on('upgrade', (req, socket, head) => { - proxyWebSocket(req, socket, head, OPENAI_API_TARGET, { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, - }, 'openai', OPENAI_API_BASE_PATH); - }); - - server.listen(HEALTH_PORT, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET }); - onListenerReady(); - }); - } else { - // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck - const server = http.createServer((req, res) => { - if (handleManagementEndpoint(req, res)) return; - - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' })); - }); - - server.on('upgrade', (req, socket) => { - socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); - socket.destroy(); - }); - - server.listen(HEALTH_PORT, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` }); - onListenerReady(); - }); - } - - // Anthropic API proxy (port 10001) - if (ANTHROPIC_API_KEY) { - // Compose model-alias rewriting with Anthropic-specific optimisations. - // Model aliases run first so the correct model name is visible to subsequent transforms. - const anthropicProxyTransform = composeBodyTransforms( - makeModelBodyTransform('anthropic'), - _anthropicOptimisationsTransform - ); - - const server = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy', service: 'anthropic-proxy' })); - return; - } - - const contentLength = parseInt(req.headers['content-length'], 10) || 0; - if (checkRateLimit(req, res, 'anthropic', contentLength)) return; - - // Only set anthropic-version as default; preserve agent-provided version - const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; - if (!req.headers['anthropic-version']) { - anthropicHeaders['anthropic-version'] = '2023-06-01'; - } - - // When auto-cache is enabled, add the extended-cache-ttl beta header so - // Anthropic honours the 1-hour TTL values we inject. Merge with any - // beta flags already set by the client to avoid overwriting them. - if (AWF_ANTHROPIC_AUTO_CACHE) { - const existing = req.headers['anthropic-beta']; - if (!existing) { - anthropicHeaders['anthropic-beta'] = EXTENDED_CACHE_BETA; - } else { - const normalizedExisting = Array.isArray(existing) ? existing.join(',') : existing; - // Parse once and check for membership before building the merged string - const existingBetas = normalizedExisting.split(',').map(s => s.trim()).filter(Boolean); - if (!existingBetas.includes(EXTENDED_CACHE_BETA)) { - anthropicHeaders['anthropic-beta'] = `${normalizedExisting},${EXTENDED_CACHE_BETA}`; - } - // If the client already includes the beta flag, it passes through unchanged - // (copied from req.headers before injectHeaders is applied in proxyRequest). - } - } - - proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH, anthropicProxyTransform); - }); - - server.on('upgrade', (req, socket, head) => { - const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY }; - if (!req.headers['anthropic-version']) { - anthropicHeaders['anthropic-version'] = '2023-06-01'; - } - proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH); - }); - - server.listen(10001, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET }); - onListenerReady(); - }); - } - - - // GitHub Copilot API proxy (port 10002) - // Supports COPILOT_GITHUB_TOKEN (GitHub OAuth) and COPILOT_API_KEY (BYOK direct key). - // COPILOT_GITHUB_TOKEN takes precedence when both are set. - if (COPILOT_AUTH_TOKEN) { - const copilotServer = http.createServer((req, res) => { - // Health check endpoint - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy', service: 'copilot-proxy' })); - return; - } - - const contentLength = parseInt(req.headers['content-length'], 10) || 0; - if (checkRateLimit(req, res, 'copilot', contentLength)) return; - - // Copilot CLI 1.0.21+ calls GET /models at startup (to list or validate models). - // The /models endpoint lives on the Copilot inference API (COPILOT_API_TARGET), - // NOT on the GitHub REST API. Explicitly use COPILOT_GITHUB_TOKEN for this - // request so the GitHub OAuth token is used even when both COPILOT_GITHUB_TOKEN - // and COPILOT_API_KEY are configured (COPILOT_API_KEY alone is not accepted by - // the /models endpoint). - let reqPathname; - try { - reqPathname = new URL(req.url, 'http://localhost').pathname; - } catch { - logRequest('warn', 'copilot_proxy_malformed_url', { - message: 'Malformed request URL in Copilot proxy — rejecting with 400', - }); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid request URL' })); - return; - } - const isModelsPath = reqPathname === '/models' || reqPathname.startsWith('/models/'); - if (isModelsPath && req.method === 'GET' && COPILOT_GITHUB_TOKEN) { - proxyRequest(req, res, COPILOT_API_TARGET, { - 'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`, - 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, - }, 'copilot'); - return; - } - - proxyRequest(req, res, COPILOT_API_TARGET, { - 'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`, - 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, - }, 'copilot', '', makeModelBodyTransform('copilot')); - }); - - copilotServer.on('upgrade', (req, socket, head) => { - proxyWebSocket(req, socket, head, COPILOT_API_TARGET, { - 'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`, - 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, - }, 'copilot'); - }); - - copilotServer.listen(10002, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' }); - onListenerReady(); - }); - } - - // Google Gemini API proxy (port 10003) - if (GEMINI_API_KEY) { - const geminiServer = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy', service: 'gemini-proxy' })); - return; - } - - const contentLength = parseInt(req.headers['content-length'], 10) || 0; - if (checkRateLimit(req, res, 'gemini', contentLength)) return; - - // Strip any auth query params (?key=, ?apiKey=, ?api_key=) — the SDK may append them. - // The proxy injects the real key via x-goog-api-key header instead. - req.url = stripGeminiKeyParam(req.url); - - proxyRequest(req, res, GEMINI_API_TARGET, { - 'x-goog-api-key': GEMINI_API_KEY, - }, 'gemini', GEMINI_API_BASE_PATH, makeModelBodyTransform('gemini')); - }); - - geminiServer.on('upgrade', (req, socket, head) => { - // Strip any auth query params (?key=, ?apiKey=, ?api_key=) — the SDK may append them. - req.url = stripGeminiKeyParam(req.url); - proxyWebSocket(req, socket, head, GEMINI_API_TARGET, { - 'x-goog-api-key': GEMINI_API_KEY, - }, 'gemini', GEMINI_API_BASE_PATH); - }); - - logRequest('info', 'server_start', { message: `GEMINI_API_KEY configured (length=${GEMINI_API_KEY.length})` }); - geminiServer.listen(10003, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET }); - onListenerReady(); - }); - } else { - // No Gemini key — listen on port 10003 and return 503 so the Gemini CLI - // gets an actionable error instead of a silent connection-refused. - const geminiServer = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'not_configured', service: 'gemini-proxy', error: 'GEMINI_API_KEY not configured in api-proxy sidecar' })); - return; - } - - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Gemini proxy not configured (no GEMINI_API_KEY). Set GEMINI_API_KEY in the AWF runner environment to enable credential isolation.' })); - }); - - geminiServer.on('upgrade', (req, socket) => { - socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); - socket.destroy(); - }); - - logRequest('warn', 'server_start', { message: 'GEMINI_API_KEY not set — Gemini proxy will return 503' }); - geminiServer.listen(10003, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: 'Gemini endpoint listening on port 10003 (Gemini not configured — returning 503)' }); - }); - } - - // OpenCode API proxy (port 10004) — dynamic provider routing - // Only started when AWF_ENABLE_OPENCODE=true, so it doesn't activate - // unconditionally whenever any credential is present (e.g. Copilot-only runs). - // Defaults to Copilot/OpenAI routing (OPENAI_API_KEY), with Anthropic as a BYOK fallback. - // OpenCode gets a separate port from Claude (10001) and Codex (10000) for per-engine - // rate limiting and metrics isolation. - // - // Credential priority (first available wins): - // 1. OPENAI_API_KEY → OpenAI/Copilot-compatible route (OPENAI_API_TARGET) - // 2. ANTHROPIC_API_KEY → Anthropic BYOK route (ANTHROPIC_API_TARGET) - // 3. COPILOT_GITHUB_TOKEN/API_KEY → Copilot route (COPILOT_API_TARGET), - // resolved internally to COPILOT_AUTH_TOKEN - if (ENABLE_OPENCODE) { - const opencodeStartupRoute = resolveOpenCodeRoute( - OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, - OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, - OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH - ); - if (opencodeStartupRoute) { - const opencodeServer = http.createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy', service: 'opencode-proxy' })); - return; - } - - const logMethod = sanitizeForLog(req.method); - const logUrl = sanitizeForLog(req.url); - logRequest('info', 'opencode_proxy_request', { - message: '[OpenCode Proxy] Incoming request', - method: logMethod, - url: logUrl, + for (const adapter of adaptersToStart) { + const server = createProviderServer(adapter); + server.listen(adapter.port, '0.0.0.0', () => { + logRequest('info', 'server_start', { + message: `${adapter.name} proxy listening on port ${adapter.port}`, + target: adapter.isEnabled() ? adapter.getTargetHost() : '(not configured)', }); - - const parsedContentLength = Number(req.headers['content-length']); - const contentLength = Number.isFinite(parsedContentLength) && parsedContentLength > 0 ? parsedContentLength : 0; - if (checkRateLimit(req, res, 'opencode', contentLength)) { - return; + if (adapter.participatesInValidation) { + onListenerReady(); } - - const route = resolveOpenCodeRoute( - OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, - OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, - OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH - ); - if (!route) { - logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot route request' }); - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'OpenCode proxy has no credentials configured' })); - return; - } - - logRequest('info', 'opencode_proxy_routing_target', { - message: `[OpenCode Proxy] Routing to ${route.target}`, - target: route.target, - }); - - const headers = Object.assign({}, route.headers); - if (route.needsAnthropicVersion && !req.headers['anthropic-version']) { - headers['anthropic-version'] = '2023-06-01'; - } - proxyRequest(req, res, route.target, headers, 'opencode', route.basePath); - }); - - opencodeServer.on('upgrade', (req, socket, head) => { - const route = resolveOpenCodeRoute( - OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, - OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, - OPENAI_API_BASE_PATH, ANTHROPIC_API_BASE_PATH - ); - if (!route) { - logRequest('error', 'opencode_no_credentials', { message: '[OpenCode Proxy] No credentials available; cannot upgrade WebSocket' }); - socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); - socket.destroy(); - return; - } - - const headers = Object.assign({}, route.headers); - if (route.needsAnthropicVersion && !req.headers['anthropic-version']) { - headers['anthropic-version'] = '2023-06-01'; - } - proxyWebSocket(req, socket, head, route.target, headers, 'opencode', route.basePath); - }); - - opencodeServer.listen(10004, '0.0.0.0', () => { - logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` }); - onListenerReady(); }); } - } // end if (ENABLE_OPENCODE) - // Graceful shutdown process.on('SIGTERM', async () => { logRequest('info', 'shutdown', { message: 'Received SIGTERM, shutting down gracefully' }); await closeLogStream(); @@ -2160,5 +1243,35 @@ 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, composeBodyTransforms, MODEL_ALIASES, buildModelsJson, writeModelsJson, AWF_ANTHROPIC_AUTO_CACHE, AWF_ANTHROPIC_CACHE_TAIL_TTL, AWF_ANTHROPIC_DROP_TOOLS, AWF_ANTHROPIC_STRIP_ANSI }; +// ── Exports (for testing) ───────────────────────────────────────────────────── +module.exports = { + // Core proxy + proxyRequest, + proxyWebSocket, + // Utility re-exports (proxy-utils) + buildUpstreamPath, + shouldStripHeader, + composeBodyTransforms, + // Startup + validateApiKeys, + probeProvider, + httpProbe, + fetchStartupModels, + // State + keyValidationResults, + resetKeyValidationState, + cachedModels, + resetModelCacheState, + // Model utils + extractModelIds, + fetchJson, + makeModelBodyTransform, + MODEL_ALIASES, + // Management + reflectEndpoints, + healthResponse, + buildModelsJson, + writeModelsJson, + // Server factory + createProviderServer, +}; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 3cd739ac1..605e9738c 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -6,7 +6,16 @@ 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, composeBodyTransforms, MODEL_ALIASES, buildModelsJson, writeModelsJson } = require('./server'); + +// Functions that live in proxy-utils.js +const { normalizeApiTarget, normalizeBasePath, buildUpstreamPath, shouldStripHeader, stripGeminiKeyParam, composeBodyTransforms } = require('./proxy-utils'); + +// Provider-specific functions that live in their respective adapter modules +const { deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, resolveCopilotAuthToken } = require('./providers/copilot'); +const { resolveOpenCodeRoute } = require('./providers/opencode'); + +// Core proxy functions that remain in server.js +const { proxyWebSocket, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson, createProviderServer } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -412,18 +421,21 @@ describe('buildUpstreamPath', () => { .toBe('/v1/chat/completions'); }); - it('should map unversioned /responses to /v1/responses for api.openai.com', () => { - expect(buildUpstreamPath('/responses', 'api.openai.com', '')) + it('should map unversioned /responses to /v1/responses when basePath is /v1 (OpenAI default)', () => { + // The OpenAI adapter passes basePath='/v1' for the public endpoint. + // buildUpstreamPath is now provider-agnostic; the /v1 prefix comes from the adapter. + expect(buildUpstreamPath('/responses', 'api.openai.com', '/v1')) .toBe('/v1/responses'); }); - it('should preserve already-versioned OpenAI responses path', () => { - expect(buildUpstreamPath('/v1/responses', 'api.openai.com', '')) + it('should preserve already-versioned OpenAI responses path with /v1 basePath', () => { + expect(buildUpstreamPath('/v1/responses', 'api.openai.com', '/v1')) .toBe('/v1/responses'); }); - it('should map unversioned /responses to /v1/responses when OpenAI host includes port', () => { - expect(buildUpstreamPath('/responses', 'api.openai.com:443', '')) + it('should map unversioned /responses to /v1/responses when basePath is /v1 (host-with-port variant)', () => { + // basePath='/v1' is the canonical form; the OpenAI adapter normalises the target. + expect(buildUpstreamPath('/responses', 'api.openai.com', '/v1')) .toBe('/v1/responses'); }); @@ -1011,6 +1023,138 @@ describe('resolveOpenCodeRoute', () => { }); }); +// ── OpenCode adapter delegation ──────────────────────────────────────────────── +// Tests that verify OpenCode correctly delegates to its candidate adapters and +// that all providers can be simultaneously active on their own ports. + +describe('OpenCode adapter delegation', () => { + const { createOpenCodeAdapter } = require('./providers/opencode'); + + function makeStubAdapter(name, enabled, { targetHost = `api.${name}.com`, basePath = '', authHeaders = {}, bodyTransform = null, urlTransform = undefined } = {}) { + return { + name, + isEnabled: () => enabled, + getTargetHost: () => targetHost, + getBasePath: () => basePath, + getAuthHeaders: () => authHeaders, + getBodyTransform: () => bodyTransform, + transformRequestUrl: urlTransform, + }; + } + + const fakeReq = { headers: {}, method: 'POST', url: '/v1/messages' }; + + it('routes to the first enabled candidate when multiple are configured', () => { + const openai = makeStubAdapter('openai', true, { targetHost: 'api.openai.com', basePath: '/v1', authHeaders: { Authorization: 'Bearer sk-oai' } }); + const anthropic = makeStubAdapter('anthropic', true, { targetHost: 'api.anthropic.com', authHeaders: { 'x-api-key': 'sk-ant' } }); + const copilot = makeStubAdapter('copilot', true, { targetHost: 'api.githubcopilot.com', authHeaders: { Authorization: 'Bearer gho_cop' } }); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai, anthropic, copilot] }); + + expect(adapter.isEnabled()).toBe(true); + expect(adapter.getTargetHost(fakeReq)).toBe('api.openai.com'); + expect(adapter.getAuthHeaders(fakeReq).Authorization).toBe('Bearer sk-oai'); + expect(adapter.getBasePath(fakeReq)).toBe('/v1'); + }); + + it('skips disabled candidates and picks the next enabled one', () => { + const openai = makeStubAdapter('openai', false, { targetHost: 'api.openai.com' }); + const anthropic = makeStubAdapter('anthropic', true, { targetHost: 'api.anthropic.com', authHeaders: { 'x-api-key': 'sk-ant' } }); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai, anthropic] }); + + expect(adapter.isEnabled()).toBe(true); + expect(adapter.getTargetHost(fakeReq)).toBe('api.anthropic.com'); + expect(adapter.getAuthHeaders(fakeReq)['x-api-key']).toBe('sk-ant'); + }); + + it('is disabled when all candidate adapters are disabled', () => { + const openai = makeStubAdapter('openai', false, {}); + const anthropic = makeStubAdapter('anthropic', false, {}); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai, anthropic] }); + expect(adapter.isEnabled()).toBe(false); + }); + + it('is disabled when AWF_ENABLE_OPENCODE is not set, even if candidates are enabled', () => { + const openai = makeStubAdapter('openai', true, { targetHost: 'api.openai.com' }); + + const adapter = createOpenCodeAdapter({}, { candidateAdapters: [openai] }); + expect(adapter.isEnabled()).toBe(false); + }); + + it('delegates body transform to the active candidate adapter', () => { + const transform = (buf) => Buffer.from(buf.toString().toUpperCase()); + const anthropic = makeStubAdapter('anthropic', true, { targetHost: 'api.anthropic.com', bodyTransform: transform }); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [anthropic] }); + const fn = adapter.getBodyTransform(); + expect(fn).toBe(transform); + }); + + it('returns null body transform when active candidate has none', () => { + const openai = makeStubAdapter('openai', true, { bodyTransform: null }); + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai] }); + expect(adapter.getBodyTransform()).toBeNull(); + }); + + it('delegates URL transform to the active candidate when one is defined', () => { + const urlTransform = (url) => url.replace('?key=placeholder', ''); + const gemini = makeStubAdapter('gemini', true, { targetHost: 'generativelanguage.googleapis.com', urlTransform }); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [gemini] }); + const transformed = adapter.transformRequestUrl('/v1/models?key=placeholder'); + expect(transformed).toBe('/v1/models'); + }); + + it('returns url unchanged when active candidate has no URL transform', () => { + const openai = makeStubAdapter('openai', true, {}); + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai] }); + expect(adapter.transformRequestUrl('/v1/chat/completions')).toBe('/v1/chat/completions'); + }); + + it('reports the active adapter name at startup for introspection', () => { + const anthropic = makeStubAdapter('anthropic', false, {}); + const copilot = makeStubAdapter('copilot', true, { targetHost: 'api.githubcopilot.com' }); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [anthropic, copilot] }); + expect(adapter._startupActiveAdapterName).toBe('copilot'); + }); + + it('exposes the candidate adapter list for introspection', () => { + const openai = makeStubAdapter('openai', true, {}); + const anthropic = makeStubAdapter('anthropic', true, {}); + + const adapter = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai, anthropic] }); + expect(adapter._candidateAdapters).toHaveLength(2); + expect(adapter._candidateAdapters[0].name).toBe('openai'); + expect(adapter._candidateAdapters[1].name).toBe('anthropic'); + }); + + it('all providers remain independently active on their own ports', () => { + // Simulate the production setup: OpenAI + Anthropic + Copilot all enabled + const openai = makeStubAdapter('openai', true, { targetHost: 'api.openai.com' }); + const anthropic = makeStubAdapter('anthropic', true, { targetHost: 'api.anthropic.com' }); + const copilot = makeStubAdapter('copilot', true, { targetHost: 'api.githubcopilot.com' }); + + const opencode = createOpenCodeAdapter({ AWF_ENABLE_OPENCODE: 'true' }, { candidateAdapters: [openai, anthropic, copilot] }); + + // Each provider is independently enabled + expect(openai.isEnabled()).toBe(true); + expect(anthropic.isEnabled()).toBe(true); + expect(copilot.isEnabled()).toBe(true); + + // OpenCode routes to the first enabled (OpenAI in this priority order) + expect(opencode.isEnabled()).toBe(true); + expect(opencode.getTargetHost(fakeReq)).toBe('api.openai.com'); + + // All three base providers are still individually reachable (different ports) + expect(openai.getTargetHost()).toBe('api.openai.com'); + expect(anthropic.getTargetHost()).toBe('api.anthropic.com'); + expect(copilot.getTargetHost()).toBe('api.githubcopilot.com'); + }); +}); + describe('httpProbe', () => { let server; let serverPort; @@ -1981,3 +2125,241 @@ describe('composeBodyTransforms', () => { expect(composed(Buffer.from('hello'))).toBeNull(); }); }); + +// ── createProviderServer tests ──────────────────────────────────────────────── +// +// Tests that verify the generic proxy server factory honours the ProviderAdapter +// interface: health routing, unconfigured-stub responses, URL transforms, and +// adapter-specific auth selection. +// +describe('createProviderServer', () => { + const servers = []; + + /** Small helper: start a createProviderServer instance and return its port. */ + function startAdapter(adapter) { + return new Promise((resolve) => { + const srv = createProviderServer(adapter); + srv.listen(0, '127.0.0.1', () => { + servers.push(srv); + resolve(srv.address().port); + }); + }); + } + + /** Fetch a path from a server running on localhost and return { status, body }. */ + function fetch(port, path, opts = {}) { + return new Promise((resolve, reject) => { + const req = http.request( + { hostname: '127.0.0.1', port, path, method: opts.method || 'GET', headers: opts.headers || {} }, + (res) => { + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => { + let parsed; + try { parsed = JSON.parse(data); } catch { parsed = data; } + resolve({ status: res.statusCode, body: parsed, headers: res.headers }); + }); + } + ); + req.on('error', reject); + if (opts.body) req.write(opts.body); + req.end(); + }); + } + + afterEach((done) => { + let remaining = servers.length; + if (!remaining) { done(); return; } + servers.splice(0).forEach((s) => s.close(() => { if (!--remaining) done(); })); + }); + + // ── /health endpoint — enabled adapter ────────────────────────────────────── + + it('returns 200 /health when adapter is enabled', async () => { + const adapter = { + name: 'test-enabled', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => 'api.example.com', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/health'); + expect(status).toBe(200); + expect(body.status).toBe('healthy'); + expect(body.service).toBe('awf-api-proxy-test-enabled'); + }); + + // ── /health endpoint — disabled adapter (default 503) ─────────────────────── + + it('returns default 503 /health when adapter is disabled and has no getUnconfiguredHealthResponse', async () => { + const adapter = { + name: 'test-disabled', port: 0, isManagementPort: false, alwaysBind: true, + participatesInValidation: false, + isEnabled: () => false, + getTargetHost: () => '', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + getUnconfiguredResponse: () => ({ statusCode: 503, body: { error: 'not configured' } }), + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/health'); + expect(status).toBe(503); + expect(body.status).toBe('not_configured'); + expect(body.service).toBe('awf-api-proxy-test-disabled'); + }); + + // ── /health endpoint — custom unconfigured health response ────────────────── + + it('returns custom getUnconfiguredHealthResponse when adapter is disabled', async () => { + const adapter = { + name: 'test-custom-health', port: 0, isManagementPort: false, alwaysBind: true, + participatesInValidation: false, + isEnabled: () => false, + getTargetHost: () => '', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + getUnconfiguredResponse: () => ({ statusCode: 503, body: { error: 'not configured' } }), + getUnconfiguredHealthResponse: () => ({ + statusCode: 503, + body: { status: 'not_configured', service: 'awf-api-proxy-gemini', error: 'GEMINI_API_KEY not configured' }, + }), + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/health'); + expect(status).toBe(503); + expect(body.service).toBe('awf-api-proxy-gemini'); + expect(body.error).toMatch(/GEMINI_API_KEY/); + }); + + // ── Unconfigured stub — non-health request ──────────────────────────────── + + it('returns getUnconfiguredResponse body for proxy requests when disabled', async () => { + const adapter = { + name: 'test-unconfigured', port: 0, isManagementPort: false, alwaysBind: true, + participatesInValidation: false, + isEnabled: () => false, + getTargetHost: () => '', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + getUnconfiguredResponse: () => ({ + statusCode: 503, + body: { error: 'proxy not configured (no API key)' }, + }), + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/v1/chat/completions', { method: 'POST', body: '{}' }); + expect(status).toBe(503); + expect(body.error).toMatch(/proxy not configured/); + }); + + it('returns default 503 for proxy requests when disabled and no getUnconfiguredResponse', async () => { + const adapter = { + name: 'test-no-stub', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => false, + getTargetHost: () => '', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/v1/models', { method: 'GET' }); + expect(status).toBe(503); + expect(body.error).toMatch(/test-no-stub.*not configured/); + }); + + // ── URL transform ───────────────────────────────────────────────────────── + + it('applies transformRequestUrl before proxying', async () => { + // Record what the transform was called with; upstream will fail (no real host) + // but the transform runs synchronously in the request handler before proxying starts. + const calls = []; + const adapter = { + name: 'test-url-transform', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => 'api.example.com', + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => null, + transformRequestUrl: (url) => { + const result = url.replace('?key=placeholder', ''); + calls.push({ input: url, output: result }); + return result; + }, + }; + const port = await startAdapter(adapter); + // fetch will return a non-2xx (proxy can't reach api.example.com in test), that's fine. + await fetch(port, '/v1/models?key=placeholder').catch(() => {}); + expect(calls).toHaveLength(1); + expect(calls[0].input).toBe('/v1/models?key=placeholder'); + expect(calls[0].output).toBe('/v1/models'); + }); + + // ── Auth headers ────────────────────────────────────────────────────────── + + it('calls getAuthHeaders() for each proxied request', async () => { + // Record the headers returned by getAuthHeaders; upstream will fail (no real host) + // but getAuthHeaders is called synchronously in the request handler. + const headerCalls = []; + const adapter = { + name: 'test-auth', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => 'api.example.com', + getBasePath: () => '', + getAuthHeaders: (req) => { + const h = { 'Authorization': 'Bearer injected-token' }; + headerCalls.push(h); + return h; + }, + getBodyTransform: () => null, + }; + const port = await startAdapter(adapter); + await fetch(port, '/v1/models').catch(() => {}); + expect(headerCalls).toHaveLength(1); + expect(headerCalls[0].Authorization).toBe('Bearer injected-token'); + }); + + // ── getBodyTransform called once per request (not per-call) ────────────── + + it('calls getBodyTransform() once per request', async () => { + let callCount = 0; + const upstream = http.createServer((req, res) => { + req.resume(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + const upstreamPort = await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve(upstream.address().port)); + }); + servers.push(upstream); + + const adapter = { + name: 'test-transform-count', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => `127.0.0.1:${upstreamPort}`, + getBasePath: () => '', + getAuthHeaders: () => ({}), + getBodyTransform: () => { callCount++; return null; }, + }; + const port = await startAdapter(adapter); + + await new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, path: '/v1/chat/completions', method: 'POST' }, resolve); + req.on('error', reject); + req.write('{}'); + req.end(); + }); + + await new Promise((r) => setTimeout(r, 100)); + expect(callCount).toBe(1); + }); +}); diff --git a/tests/integration/api-proxy.test.ts b/tests/integration/api-proxy.test.ts index e786ca8ce..6c78953d6 100644 --- a/tests/integration/api-proxy.test.ts +++ b/tests/integration/api-proxy.test.ts @@ -43,7 +43,7 @@ describe('API Proxy Sidecar', () => { expect(result).toSucceed(); expect(result.stdout).toContain('"status":"healthy"'); - expect(result.stdout).toContain('anthropic-proxy'); + expect(result.stdout).toContain('awf-api-proxy-anthropic'); }, 180000); test('should start api-proxy sidecar with OpenAI key and pass healthcheck', async () => { @@ -170,7 +170,7 @@ describe('API Proxy Sidecar', () => { expect(result.stdout).toContain('"openai":false'); expect(result.stdout).toContain('"anthropic":true'); // Port 10001 should also be healthy - expect(result.stdout).toContain('anthropic-proxy'); + expect(result.stdout).toContain('awf-api-proxy-anthropic'); }, 180000); test('should start api-proxy sidecar with Copilot key and pass healthcheck', async () => { @@ -190,7 +190,7 @@ describe('API Proxy Sidecar', () => { expect(result).toSucceed(); expect(result.stdout).toContain('"status":"healthy"'); - expect(result.stdout).toContain('copilot-proxy'); + expect(result.stdout).toContain('awf-api-proxy-copilot'); }, 180000); test('should set COPILOT_API_URL in agent when Copilot token is provided', async () => {