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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
157 changes: 157 additions & 0 deletions containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
@@ -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<string, string|undefined>} 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<string,string>|(() => Record<string,string>)} [opts.validationHeaders]
* @param {string} [opts.validationBody]
* @param {() => ({ skip: true, reason: string }|null)} [opts.validationSkip]
* @param {() => boolean} [opts.skipModelsFetch]
* @param {Record<string,string>|(() => Record<string,string>)} [opts.modelsFetchHeaders]
* @param {string|null} [opts.modelsCacheKey]
* @param {boolean} [opts.participatesInValidation]
* @param {boolean} [opts.reflectionConfigured]
* @param {string|null} [opts.reflectionModelsPath]
* @param {Record<string, unknown>|(() => Record<string, unknown>)} [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,
};
2 changes: 1 addition & 1 deletion containers/api-proxy/providers/ADDING-A-PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Create `providers/<name>.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
Expand Down
3 changes: 1 addition & 2 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion containers/api-proxy/providers/gemini.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 2 additions & 6 deletions containers/api-proxy/providers/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
142 changes: 3 additions & 139 deletions containers/api-proxy/proxy-utils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string|undefined>} 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<string,string>|(() => Record<string,string>)} [opts.validationHeaders]
* @param {string} [opts.validationBody]
* @param {() => ({ skip: true, reason: string }|null)} [opts.validationSkip]
* @param {() => boolean} [opts.skipModelsFetch]
* @param {Record<string,string>|(() => Record<string,string>)} [opts.modelsFetchHeaders]
* @param {string|null} [opts.modelsCacheKey]
* @param {boolean} [opts.participatesInValidation]
* @param {boolean} [opts.reflectionConfigured]
* @param {string|null} [opts.reflectionModelsPath]
* @param {Record<string, unknown>|(() => Record<string, unknown>)} [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,
Expand All @@ -405,6 +271,4 @@ module.exports = {
makeUnconfiguredHealthResponse,
isValidHeaderName,
validateAuthHeaderEnv,
createBaseAdapterConfig,
createAdapterMethods,
};
2 changes: 1 addition & 1 deletion containers/api-proxy/server.routing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading