From 27cbf46395931fea139158909f741b818f6fbd11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:18:23 +0000 Subject: [PATCH 1/2] Initial plan From a449538e8d0ba42c641dfebf8955f2a696357723 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:24:24 +0000 Subject: [PATCH 2/2] refactor: extract adapter-factory.js from proxy-utils.js (concern #5) --- containers/api-proxy/Dockerfile | 2 +- containers/api-proxy/adapter-factory.js | 157 ++++++++++++++++++ .../api-proxy/providers/ADDING-A-PROVIDER.md | 2 +- containers/api-proxy/providers/anthropic.js | 3 +- containers/api-proxy/providers/copilot.js | 2 +- containers/api-proxy/providers/gemini.js | 3 +- containers/api-proxy/providers/openai.js | 8 +- containers/api-proxy/proxy-utils.js | 142 +--------------- containers/api-proxy/server.routing.test.js | 2 +- 9 files changed, 169 insertions(+), 152 deletions(-) create mode 100644 containers/api-proxy/adapter-factory.js diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 04afcfb55..16582e5ce 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -18,7 +18,7 @@ RUN npm ci --omit=dev COPY server.js logging.js metrics.js rate-limiter.js \ token-tracker.js token-persistence.js token-parsers.js \ token-tracker-http.js token-tracker-ws.js \ - model-resolver.js model-utils.js model-body-rewriter.js proxy-utils.js anthropic-transforms.js \ + model-resolver.js model-utils.js model-body-rewriter.js proxy-utils.js adapter-factory.js anthropic-transforms.js \ model-config.js key-validation.js server-factory.js startup.js \ proxy-request.js model-discovery.js management.js oidc-token-provider.js \ oidc-token-provider-base.js \ diff --git a/containers/api-proxy/adapter-factory.js b/containers/api-proxy/adapter-factory.js new file mode 100644 index 000000000..13459dfae --- /dev/null +++ b/containers/api-proxy/adapter-factory.js @@ -0,0 +1,157 @@ +/** + * Adapter factory — credential-injection infrastructure for provider adapters. + * + * Exports the two factory functions used by every provider adapter to read + * API keys from the environment and build the common structural adapter methods + * (getTargetHost, getBasePath, getValidationProbe, getModelsFetchConfig, + * getReflectionInfo, participatesInValidation). + * + * Isolated from proxy-utils.js so that the security-critical credential path + * can be reviewed independently of the general-purpose proxy utilities. + */ + +'use strict'; + +const { normalizeApiTarget, normalizeBasePath } = require('./proxy-utils'); + +/** + * + * Every non-Copilot adapter repeats the same three-line pattern to read + * an API key, normalize a target hostname, and normalize a base path. + * This helper centralizes that logic so each adapter only specifies env + * var names and a default target. + * + * @param {Record} env - Environment variables + * @param {object} opts + * @param {string} opts.keyEnvVar - e.g. 'OPENAI_API_KEY' + * @param {string} opts.targetEnvVar - e.g. 'OPENAI_API_TARGET' + * @param {string} opts.basePathEnvVar - e.g. 'OPENAI_API_BASE_PATH' + * @param {string} opts.defaultTarget - e.g. 'api.openai.com' + * @returns {{ apiKey: string|undefined, rawTarget: string, basePath: string }} + */ +function createBaseAdapterConfig(env, { keyEnvVar, targetEnvVar, basePathEnvVar, defaultTarget }) { + const apiKey = (env[keyEnvVar] || '').trim() || undefined; + const rawTarget = normalizeApiTarget(env[targetEnvVar]) || defaultTarget; + const basePath = normalizeBasePath(env[basePathEnvVar]); + return { apiKey, rawTarget, basePath }; +} + +/** + * Build common structural adapter methods with optional provider overrides. + * + * @param {object} opts + * @param {string|undefined} [opts.apiKey] + * @param {string} opts.rawTarget + * @param {string} [opts.basePath] + * @param {string} opts.provider + * @param {number} opts.port + * @param {string|null} opts.modelsPath + * @param {string} [opts.defaultTarget] + * @param {string} [opts.validationPath] + * @param {'GET'|'POST'} [opts.validationMethod] + * @param {Record|(() => Record)} [opts.validationHeaders] + * @param {string} [opts.validationBody] + * @param {() => ({ skip: true, reason: string }|null)} [opts.validationSkip] + * @param {() => boolean} [opts.skipModelsFetch] + * @param {Record|(() => Record)} [opts.modelsFetchHeaders] + * @param {string|null} [opts.modelsCacheKey] + * @param {boolean} [opts.participatesInValidation] + * @param {boolean} [opts.reflectionConfigured] + * @param {string|null} [opts.reflectionModelsPath] + * @param {Record|(() => Record)} [opts.reflectionExtra] + * @param {() => ({ url: string, opts: object }|{ skip: true, reason: string }|null)} [opts.getValidationProbe] + * @param {() => ({ url: string, opts: object, cacheKey: string }|null)} [opts.getModelsFetchConfig] + * @param {() => object} [opts.getReflectionInfo] + * @returns {{ + * getTargetHost: (req?: import('http').IncomingMessage) => string, + * getBasePath: (req?: import('http').IncomingMessage) => string, + * participatesInValidation: boolean, + * getValidationProbe: () => ({ url: string, opts: object }|{ skip: true, reason: string }|null), + * getModelsFetchConfig: () => ({ url: string, opts: object, cacheKey: string }|null), + * getReflectionInfo: () => object + * }} + */ +function createAdapterMethods(opts) { + const { + apiKey, + rawTarget, + basePath = '', + provider, + port, + modelsPath, + defaultTarget, + validationPath = modelsPath || '', + validationMethod = 'GET', + validationHeaders = {}, + validationBody, + validationSkip, + skipModelsFetch, + modelsFetchHeaders = validationHeaders, + modelsCacheKey = provider, + participatesInValidation = !!apiKey, + reflectionConfigured = !!apiKey, + reflectionModelsPath = modelsPath, + reflectionExtra = {}, + getValidationProbe, + getModelsFetchConfig, + getReflectionInfo, + } = opts; + + const resolveValue = (value) => (typeof value === 'function' ? value() : value); + + const builtValidationProbe = getValidationProbe || (() => { + const skip = validationSkip ? validationSkip() : null; + if (skip) return skip; + if (!apiKey) return null; + if (defaultTarget && rawTarget !== defaultTarget) { + return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; + } + return { + url: `https://${rawTarget}${validationPath}`, + opts: { + method: validationMethod, + headers: resolveValue(validationHeaders), + ...(validationBody !== undefined ? { body: validationBody } : {}), + }, + }; + }); + + const builtModelsFetchConfig = getModelsFetchConfig || (() => { + if (skipModelsFetch && skipModelsFetch()) return null; + if (!apiKey || !modelsPath || !modelsCacheKey) return null; + // Startup model fetch follows provider behavior of honoring explicit basePath + // prefixes for OpenAI-compatible gateways, while validation probes use the + // canonical default-target endpoint path. + const modelsPrefix = basePath === '/' ? '' : basePath; + const path = modelsPrefix ? `${modelsPrefix}/models` : modelsPath; + return { + url: `https://${rawTarget}${path}`, + opts: { method: 'GET', headers: resolveValue(modelsFetchHeaders) }, + cacheKey: modelsCacheKey, + }; + }); + + const builtReflectionInfo = getReflectionInfo || (() => ({ + provider, + port, + base_url: `http://api-proxy:${port}`, + configured: reflectionConfigured, + models_cache_key: modelsCacheKey, + models_url: reflectionModelsPath ? `http://api-proxy:${port}${reflectionModelsPath}` : null, + ...resolveValue(reflectionExtra), + })); + + return { + getTargetHost() { return rawTarget; }, + getBasePath() { return basePath; }, + participatesInValidation, + getValidationProbe: builtValidationProbe, + getModelsFetchConfig: builtModelsFetchConfig, + getReflectionInfo: builtReflectionInfo, + }; +} + +module.exports = { + createBaseAdapterConfig, + createAdapterMethods, +}; diff --git a/containers/api-proxy/providers/ADDING-A-PROVIDER.md b/containers/api-proxy/providers/ADDING-A-PROVIDER.md index 506316eee..65c63268a 100644 --- a/containers/api-proxy/providers/ADDING-A-PROVIDER.md +++ b/containers/api-proxy/providers/ADDING-A-PROVIDER.md @@ -17,7 +17,7 @@ Create `providers/.js`. The adapter is a plain JS object (no class syntax ```js 'use strict'; -const { createBaseAdapterConfig, createAdapterMethods } = require('../proxy-utils'); +const { createBaseAdapterConfig, createAdapterMethods } = require('../adapter-factory'); function createMyProviderAdapter(env, deps = {}) { // Read credentials and config from env at construction time diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js index 141709096..45f256049 100644 --- a/containers/api-proxy/providers/anthropic.js +++ b/containers/api-proxy/providers/anthropic.js @@ -17,9 +17,8 @@ const { makeProviderNotConfiguredResponse, makeUnconfiguredHealthResponse, validateAuthHeaderEnv, - createBaseAdapterConfig, - createAdapterMethods, } = require('../proxy-utils'); +const { createBaseAdapterConfig, createAdapterMethods } = require('../adapter-factory'); const { AnthropicOidcTokenProvider } = require('../anthropic-oidc-token-provider'); let makeAnthropicTransform, loadCustomTransform, EXTENDED_CACHE_BETA; diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index ed4f64b06..6fed8284b 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -21,9 +21,9 @@ const { normalizeBasePath, makeProviderNotConfiguredResponse, makeUnconfiguredHealthResponse, - createAdapterMethods, composeBodyTransforms, } = require('../proxy-utils'); +const { createAdapterMethods } = require('../adapter-factory'); const { sanitizeNullToolCallTypes } = require('../body-transform'); const { parseByokExtraHeaders, diff --git a/containers/api-proxy/providers/gemini.js b/containers/api-proxy/providers/gemini.js index 9d13715b1..022b5d49f 100644 --- a/containers/api-proxy/providers/gemini.js +++ b/containers/api-proxy/providers/gemini.js @@ -13,7 +13,8 @@ * Gemini SDK versions append alongside the header. */ -const { stripGeminiKeyParam, createBaseAdapterConfig, createAdapterMethods, makeUnconfiguredHealthResponse } = require('../proxy-utils'); +const { stripGeminiKeyParam, makeUnconfiguredHealthResponse } = require('../proxy-utils'); +const { createBaseAdapterConfig, createAdapterMethods } = require('../adapter-factory'); /** * Create the Google Gemini provider adapter. diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index c8a437656..b7f05a988 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -10,12 +10,8 @@ * Base path: OPENAI_API_BASE_PATH (default: /v1 for the public endpoint) */ -const { - createBaseAdapterConfig, - createAdapterMethods, - normalizeBasePath, - validateAuthHeaderEnv, -} = require('../proxy-utils'); +const { normalizeBasePath, validateAuthHeaderEnv } = require('../proxy-utils'); +const { createBaseAdapterConfig, createAdapterMethods } = require('../adapter-factory'); const { resolveCloudOidcProviders } = require('./cloud-oidc-init'); function parseByokBaseUrl(baseUrl) { diff --git a/containers/api-proxy/proxy-utils.js b/containers/api-proxy/proxy-utils.js index 6bf2658c0..09158b0cc 100644 --- a/containers/api-proxy/proxy-utils.js +++ b/containers/api-proxy/proxy-utils.js @@ -1,6 +1,9 @@ /** * Shared proxy utilities — pure functions with no provider-specific logic. * Used by both server.js (core) and provider adapters. + * + * Note: the provider adapter factory functions (createBaseAdapterConfig, + * createAdapterMethods) live in adapter-factory.js. */ 'use strict'; @@ -257,143 +260,6 @@ function validateAuthHeaderEnv(envVarName, rawValue, defaultHeader) { return header; } -/** - * - * Every non-Copilot adapter repeats the same three-line pattern to read - * an API key, normalize a target hostname, and normalize a base path. - * This helper centralizes that logic so each adapter only specifies env - * var names and a default target. - * - * @param {Record} env - Environment variables - * @param {object} opts - * @param {string} opts.keyEnvVar - e.g. 'OPENAI_API_KEY' - * @param {string} opts.targetEnvVar - e.g. 'OPENAI_API_TARGET' - * @param {string} opts.basePathEnvVar - e.g. 'OPENAI_API_BASE_PATH' - * @param {string} opts.defaultTarget - e.g. 'api.openai.com' - * @returns {{ apiKey: string|undefined, rawTarget: string, basePath: string }} - */ -function createBaseAdapterConfig(env, { keyEnvVar, targetEnvVar, basePathEnvVar, defaultTarget }) { - const apiKey = (env[keyEnvVar] || '').trim() || undefined; - const rawTarget = normalizeApiTarget(env[targetEnvVar]) || defaultTarget; - const basePath = normalizeBasePath(env[basePathEnvVar]); - return { apiKey, rawTarget, basePath }; -} - -/** - * Build common structural adapter methods with optional provider overrides. - * - * @param {object} opts - * @param {string|undefined} [opts.apiKey] - * @param {string} opts.rawTarget - * @param {string} [opts.basePath] - * @param {string} opts.provider - * @param {number} opts.port - * @param {string|null} opts.modelsPath - * @param {string} [opts.defaultTarget] - * @param {string} [opts.validationPath] - * @param {'GET'|'POST'} [opts.validationMethod] - * @param {Record|(() => Record)} [opts.validationHeaders] - * @param {string} [opts.validationBody] - * @param {() => ({ skip: true, reason: string }|null)} [opts.validationSkip] - * @param {() => boolean} [opts.skipModelsFetch] - * @param {Record|(() => Record)} [opts.modelsFetchHeaders] - * @param {string|null} [opts.modelsCacheKey] - * @param {boolean} [opts.participatesInValidation] - * @param {boolean} [opts.reflectionConfigured] - * @param {string|null} [opts.reflectionModelsPath] - * @param {Record|(() => Record)} [opts.reflectionExtra] - * @param {() => ({ url: string, opts: object }|{ skip: true, reason: string }|null)} [opts.getValidationProbe] - * @param {() => ({ url: string, opts: object, cacheKey: string }|null)} [opts.getModelsFetchConfig] - * @param {() => object} [opts.getReflectionInfo] - * @returns {{ - * getTargetHost: (req?: import('http').IncomingMessage) => string, - * getBasePath: (req?: import('http').IncomingMessage) => string, - * participatesInValidation: boolean, - * getValidationProbe: () => ({ url: string, opts: object }|{ skip: true, reason: string }|null), - * getModelsFetchConfig: () => ({ url: string, opts: object, cacheKey: string }|null), - * getReflectionInfo: () => object - * }} - */ -function createAdapterMethods(opts) { - const { - apiKey, - rawTarget, - basePath = '', - provider, - port, - modelsPath, - defaultTarget, - validationPath = modelsPath || '', - validationMethod = 'GET', - validationHeaders = {}, - validationBody, - validationSkip, - skipModelsFetch, - modelsFetchHeaders = validationHeaders, - modelsCacheKey = provider, - participatesInValidation = !!apiKey, - reflectionConfigured = !!apiKey, - reflectionModelsPath = modelsPath, - reflectionExtra = {}, - getValidationProbe, - getModelsFetchConfig, - getReflectionInfo, - } = opts; - - const resolveValue = (value) => (typeof value === 'function' ? value() : value); - - const builtValidationProbe = getValidationProbe || (() => { - const skip = validationSkip ? validationSkip() : null; - if (skip) return skip; - if (!apiKey) return null; - if (defaultTarget && rawTarget !== defaultTarget) { - return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; - } - return { - url: `https://${rawTarget}${validationPath}`, - opts: { - method: validationMethod, - headers: resolveValue(validationHeaders), - ...(validationBody !== undefined ? { body: validationBody } : {}), - }, - }; - }); - - const builtModelsFetchConfig = getModelsFetchConfig || (() => { - if (skipModelsFetch && skipModelsFetch()) return null; - if (!apiKey || !modelsPath || !modelsCacheKey) return null; - // Startup model fetch follows provider behavior of honoring explicit basePath - // prefixes for OpenAI-compatible gateways, while validation probes use the - // canonical default-target endpoint path. - const modelsPrefix = basePath === '/' ? '' : basePath; - const path = modelsPrefix ? `${modelsPrefix}/models` : modelsPath; - return { - url: `https://${rawTarget}${path}`, - opts: { method: 'GET', headers: resolveValue(modelsFetchHeaders) }, - cacheKey: modelsCacheKey, - }; - }); - - const builtReflectionInfo = getReflectionInfo || (() => ({ - provider, - port, - base_url: `http://api-proxy:${port}`, - configured: reflectionConfigured, - models_cache_key: modelsCacheKey, - models_url: reflectionModelsPath ? `http://api-proxy:${port}${reflectionModelsPath}` : null, - ...resolveValue(reflectionExtra), - })); - - return { - getTargetHost() { return rawTarget; }, - getBasePath() { return basePath; }, - participatesInValidation, - getValidationProbe: builtValidationProbe, - getModelsFetchConfig: builtModelsFetchConfig, - getReflectionInfo: builtReflectionInfo, - }; -} - module.exports = { normalizeApiTarget, normalizeBasePath, @@ -405,6 +271,4 @@ module.exports = { makeUnconfiguredHealthResponse, isValidHeaderName, validateAuthHeaderEnv, - createBaseAdapterConfig, - createAdapterMethods, }; diff --git a/containers/api-proxy/server.routing.test.js b/containers/api-proxy/server.routing.test.js index 323b841f0..bde01571d 100644 --- a/containers/api-proxy/server.routing.test.js +++ b/containers/api-proxy/server.routing.test.js @@ -9,8 +9,8 @@ const { normalizeBasePath, buildUpstreamPath, makeProviderNotConfiguredResponse, - createAdapterMethods, } = require('./proxy-utils'); +const { createAdapterMethods } = require('./adapter-factory'); const { _testing: { deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath } } = require('./providers/copilot-auth'); describe('normalizeApiTarget', () => {