diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index a5055b85..2fbae776 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -20,7 +20,7 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \ token-tracker-http.js token-tracker-ws.js token-tracker-shared.js \ model-resolver.js model-fallback.js model-utils.js model-body-rewriter.js proxy-utils.js oidc-adapter-utils.js adapter-factory.js anthropic-transforms.js \ model-config.js key-validation.js server-factory.js startup.js \ - proxy-request.js proxy-guards.js proxy-error-handler.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \ + proxy-request.js request-headers.js upstream-http.js proxy-guards.js proxy-error-handler.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \ oidc-token-provider-base.js \ github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \ anthropic-oidc-token-provider.js \ diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index 8673ce80..914a5bac 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -11,13 +11,14 @@ const https = require('https'); const { HTTPS_PROXY, proxyAgent } = require('./http-client'); const { createBodyHandler, sleep, _setSleepForTests, _resetSleepForTests } = require('./body-handler'); const { generateRequestId, sanitizeForLog, logRequest } = require('./logging'); +const { isValidRequestId, buildRequestHeaders } = require('./request-headers'); +const { createSendUpstreamRequest } = require('./upstream-http'); const metrics = require('./metrics'); const rateLimiter = require('./rate-limiter'); const { buildUpstreamPath, shouldStripHeader } = require('./proxy-utils'); const { injectSteeringMessage } = require('./body-transform'); const { handleRequestError } = require('./proxy-error-handler'); const { - maybeStripLearnedHeaderValues, resetDeprecatedHeaderValuesForTests, parseDeprecatedHeaderFromBody, learnAndStripDeprecatedHeaderValue, @@ -121,12 +122,6 @@ try { /** Shared RateLimiter instance. */ const limiter = rateLimiter.create(); -/** - * Backoff delays (ms) between successive model-not-supported retries. - * Index 0 → delay before the 1st retry, index 1 → delay before the 2nd retry. - */ -const MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS = [1000, 2000]; - function getUrlPathForSpan(requestUrl) { if (typeof requestUrl !== 'string' || !requestUrl) return '/'; try { @@ -138,16 +133,6 @@ function getUrlPathForSpan(requestUrl) { // ── Utility ─────────────────────────────────────────────────────────────────── -/** - * Return true if id is a safe, non-empty request-ID string. - * Limits length and character set to prevent log injection. - * @param {unknown} id - * @returns {boolean} - */ -function isValidRequestId(id) { - return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); -} - const { collectRequestBody, transformRequestBody } = createBodyHandler({ handleRequestError, otel }); const checkRateLimit = createRateLimitChecker({ @@ -190,59 +175,6 @@ const proxyWebSocket = createProxyWebSocket({ // ── Proxy helpers ───────────────────────────────────────────────────────────── -/** - * Build the headers object for the upstream request. - * Strips headers matched by `shouldStripHeader()`, merges injected auth - * headers, sets the request-id, and adjusts content-length when the body was - * transformed. - * - * @param {Buffer} body - Final (possibly transformed) request body - * @param {number} inboundBytes - Original body size before transforms - * @param {import('http').IncomingMessage} req - * @param {{ injectHeaders: object, provider: string, targetHost: string, requestId: string }} opts - * @returns {object} Headers object for the upstream request - */ -function buildRequestHeaders(body, inboundBytes, req, { injectHeaders, provider, targetHost, requestId }) { - const headers = {}; - for (const [name, value] of Object.entries(req.headers)) { - if (!shouldStripHeader(name)) headers[name] = value; - } - headers['x-request-id'] = requestId; - Object.assign(headers, injectHeaders); - - if (provider === 'anthropic' || provider === 'copilot') { - maybeStripLearnedHeaderValues(headers, requestId, provider); - } - - const isCopilotHost = - targetHost === 'githubcopilot.com' || - targetHost.endsWith('.githubcopilot.com'); - if (isCopilotHost && !headers['x-initiator']) { - headers['x-initiator'] = 'agent'; - } - - if (body.length !== inboundBytes) { - headers['content-length'] = String(body.length); - delete headers['transfer-encoding']; - } - - 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, - has_anthropic_version: !!headers['anthropic-version'], - }); - } - - return headers; -} - const { handleUpstreamResponse } = createUpstreamResponseHandlers({ metrics, logRequest, @@ -257,66 +189,15 @@ const { handleUpstreamResponse } = createUpstreamResponseHandlers({ learnAndStripDeprecatedHeaderValue, }); -/** - * Create and dispatch the upstream HTTPS request. - * Sets up the proxyReq error handler, writes the body, and delegates response - * handling to handleUpstreamResponse (including the one-shot retry path). - * - * @param {object} requestHeaders - Headers for the upstream request - * @param {{ body: Buffer, targetHost: string, upstreamPath: string, req: object, - * res: object, provider: string, requestId: string, startTime: number, - * span: object, requestBytes: number, hasRetried?: boolean, - * modelNotSupportedRetryCount?: number }} ctx - */ -function sendUpstreamRequest(requestHeaders, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, - hasRetried = false, - modelNotSupportedRetryCount = 0, -}) { - const options = { - hostname: targetHost, port: 443, path: upstreamPath, - method: req.method, headers: requestHeaders, - agent: proxyAgent, - }; - - const proxyReq = https.request(options, (proxyRes) => { - handleUpstreamResponse(proxyRes, requestHeaders, { - body, res, provider, requestId, req, targetHost, startTime, span, requestBytes, - hasRetried, - modelNotSupportedRetryCount, - onRetry: (retryHeaders) => sendUpstreamRequest(retryHeaders, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, - hasRetried: true, - modelNotSupportedRetryCount, - }), - onModelNotSupportedRetry: () => { - const delayMs = MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[modelNotSupportedRetryCount] ?? 2000; - sleep(delayMs).then(() => { - sendUpstreamRequest(requestHeaders, { - body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, - hasRetried, - modelNotSupportedRetryCount: modelNotSupportedRetryCount + 1, - }); - }); - }, - }); - }); - - proxyReq.on('error', (err) => { - otel.endSpanError(span, err, 502); - handleRequestError(err, { - res, requestId, provider, req, targetHost, startTime, - statusCode: 502, clientMessage: 'Proxy error', - extraMetrics: (duration) => { - metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' }); - metrics.observe('request_duration_ms', duration, { provider }); - }, - }); - }); - - if (body.length > 0) proxyReq.write(body); - proxyReq.end(); -} +const sendUpstreamRequest = createSendUpstreamRequest({ + https, + proxyAgent, + handleUpstreamResponse, + sleep, + otel, + handleRequestError, + metrics, +}); // ── Core proxy: HTTP ────────────────────────────────────────────────────────── diff --git a/containers/api-proxy/request-headers.js b/containers/api-proxy/request-headers.js new file mode 100644 index 00000000..69bf41d1 --- /dev/null +++ b/containers/api-proxy/request-headers.js @@ -0,0 +1,73 @@ +'use strict'; + +const { logRequest } = require('./logging'); +const { shouldStripHeader } = require('./proxy-utils'); +const { maybeStripLearnedHeaderValues } = require('./deprecated-header-tracker'); + +/** + * Return true if id is a safe, non-empty request-ID string. + * Limits length and character set to prevent log injection. + * @param {unknown} id + * @returns {boolean} + */ +function isValidRequestId(id) { + return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); +} + +/** + * Build the headers object for the upstream request. + * Strips headers matched by `shouldStripHeader()`, merges injected auth + * headers, sets the request-id, and adjusts content-length when the body was + * transformed. + * + * @param {Buffer} body - Final (possibly transformed) request body + * @param {number} inboundBytes - Original body size before transforms + * @param {import('http').IncomingMessage} req + * @param {{ injectHeaders: object, provider: string, targetHost: string, requestId: string }} opts + * @returns {object} Headers object for the upstream request + */ +function buildRequestHeaders(body, inboundBytes, req, { injectHeaders, provider, targetHost, requestId }) { + const headers = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (!shouldStripHeader(name)) headers[name] = value; + } + headers['x-request-id'] = requestId; + Object.assign(headers, injectHeaders); + + if (provider === 'anthropic' || provider === 'copilot') { + maybeStripLearnedHeaderValues(headers, requestId, provider); + } + + const isCopilotHost = + targetHost === 'githubcopilot.com' || + targetHost.endsWith('.githubcopilot.com'); + if (isCopilotHost && !headers['x-initiator']) { + headers['x-initiator'] = 'agent'; + } + + if (body.length !== inboundBytes) { + headers['content-length'] = String(body.length); + delete headers['transfer-encoding']; + } + + 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, + has_anthropic_version: !!headers['anthropic-version'], + }); + } + + return headers; +} + +module.exports = { + isValidRequestId, + buildRequestHeaders, +}; diff --git a/containers/api-proxy/request-headers.test.js b/containers/api-proxy/request-headers.test.js new file mode 100644 index 00000000..934bdcec --- /dev/null +++ b/containers/api-proxy/request-headers.test.js @@ -0,0 +1,53 @@ +const { isValidRequestId, buildRequestHeaders } = require('./request-headers'); + +describe('request-headers', () => { + test('isValidRequestId enforces expected constraints', () => { + expect(isValidRequestId('req-123.ABC')).toBe(true); + expect(isValidRequestId('')).toBe(false); + expect(isValidRequestId('bad value')).toBe(false); + expect(isValidRequestId('a'.repeat(129))).toBe(false); + }); + + test('buildRequestHeaders strips sensitive inbound headers and injects auth/request id', () => { + const req = { + headers: { + host: 'example.com', + authorization: '******', + 'x-forwarded-for': '1.2.3.4', + 'x-custom': 'keep-me', + }, + }; + const headers = buildRequestHeaders(Buffer.from('{}'), 2, req, { + injectHeaders: { authorization: '******' }, + provider: 'openai', + targetHost: 'api.openai.com', + requestId: 'req-1', + }); + + expect(headers.host).toBeUndefined(); + expect(headers['x-forwarded-for']).toBeUndefined(); + expect(headers.authorization).toBe('******'); + expect(headers['x-custom']).toBe('keep-me'); + expect(headers['x-request-id']).toBe('req-1'); + }); + + test('buildRequestHeaders applies copilot initiator and content length rewrite', () => { + const req = { + headers: { + 'x-custom': 'keep-me', + 'transfer-encoding': 'chunked', + }, + }; + const body = Buffer.from('rewritten'); + const headers = buildRequestHeaders(body, 1, req, { + injectHeaders: { authorization: '******' }, + provider: 'copilot', + targetHost: 'api.githubcopilot.com', + requestId: 'req-2', + }); + + expect(headers['x-initiator']).toBe('agent'); + expect(headers['content-length']).toBe(String(body.length)); + expect(headers['transfer-encoding']).toBeUndefined(); + }); +}); diff --git a/containers/api-proxy/upstream-http.js b/containers/api-proxy/upstream-http.js new file mode 100644 index 00000000..60bed981 --- /dev/null +++ b/containers/api-proxy/upstream-http.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Backoff delays (ms) between successive model-not-supported retries. + * Index 0 → delay before the 1st retry, index 1 → delay before the 2nd retry. + */ +const MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS = [1000, 2000]; + +/** + * Create and dispatch the upstream HTTPS request. + * Sets up the proxyReq error handler, writes the body, and delegates response + * handling to handleUpstreamResponse (including the one-shot retry path). + * + * @param {{ https: import('https'), proxyAgent: import('http').Agent, handleUpstreamResponse: Function, sleep: Function, otel: object, handleRequestError: Function, metrics: object }} deps + * @returns {(requestHeaders: object, ctx: object) => void} + */ +function createSendUpstreamRequest({ + https, + proxyAgent, + handleUpstreamResponse, + sleep, + otel, + handleRequestError, + metrics, +}) { + return function sendUpstreamRequest(requestHeaders, { + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + hasRetried = false, + modelNotSupportedRetryCount = 0, + }) { + const options = { + hostname: targetHost, port: 443, path: upstreamPath, + method: req.method, headers: requestHeaders, + agent: proxyAgent, + }; + + const proxyReq = https.request(options, (proxyRes) => { + handleUpstreamResponse(proxyRes, requestHeaders, { + body, res, provider, requestId, req, targetHost, startTime, span, requestBytes, + hasRetried, + modelNotSupportedRetryCount, + onRetry: (retryHeaders) => sendUpstreamRequest(retryHeaders, { + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + hasRetried: true, + modelNotSupportedRetryCount, + }), + onModelNotSupportedRetry: () => { + const delayMs = MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[modelNotSupportedRetryCount] ?? 2000; + sleep(delayMs).then(() => { + sendUpstreamRequest(requestHeaders, { + body, targetHost, upstreamPath, req, res, provider, requestId, startTime, span, requestBytes, + hasRetried, + modelNotSupportedRetryCount: modelNotSupportedRetryCount + 1, + }); + }); + }, + }); + }); + + proxyReq.on('error', (err) => { + otel.endSpanError(span, err, 502); + handleRequestError(err, { + res, requestId, provider, req, targetHost, startTime, + statusCode: 502, clientMessage: 'Proxy error', + extraMetrics: (duration) => { + metrics.increment('requests_total', { provider, method: req.method, status_class: '5xx' }); + metrics.observe('request_duration_ms', duration, { provider }); + }, + }); + }); + + if (body.length > 0) proxyReq.write(body); + proxyReq.end(); + }; +} + +module.exports = { + MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS, + createSendUpstreamRequest, +}; diff --git a/containers/api-proxy/upstream-http.test.js b/containers/api-proxy/upstream-http.test.js new file mode 100644 index 00000000..0de39046 --- /dev/null +++ b/containers/api-proxy/upstream-http.test.js @@ -0,0 +1,83 @@ +const { createSendUpstreamRequest, MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS } = require('./upstream-http'); + +describe('upstream-http', () => { + function createContext(overrides = {}) { + return { + body: Buffer.from('{"ok":true}'), + targetHost: 'api.example.com', + upstreamPath: '/v1/chat/completions', + req: { method: 'POST' }, + res: {}, + provider: 'copilot', + requestId: 'req-1', + startTime: Date.now(), + span: {}, + requestBytes: 11, + ...overrides, + }; + } + + test('dispatches upstream HTTPS requests with proxy agent and request body', () => { + const proxyReq = { on: jest.fn(), write: jest.fn(), end: jest.fn() }; + const httpsRequest = jest.fn((_options, cb) => { + cb({ statusCode: 200, headers: {} }); + return proxyReq; + }); + const handleUpstreamResponse = jest.fn(); + const proxyAgent = { keepAlive: true }; + + const sendUpstreamRequest = createSendUpstreamRequest({ + https: { request: httpsRequest }, + proxyAgent, + handleUpstreamResponse, + sleep: jest.fn(() => Promise.resolve()), + otel: { endSpanError: jest.fn() }, + handleRequestError: jest.fn(), + metrics: { increment: jest.fn(), observe: jest.fn() }, + }); + + sendUpstreamRequest({ authorization: '******' }, createContext()); + + expect(httpsRequest).toHaveBeenCalledWith(expect.objectContaining({ + hostname: 'api.example.com', + port: 443, + path: '/v1/chat/completions', + method: 'POST', + headers: { authorization: '******' }, + agent: proxyAgent, + }), expect.any(Function)); + expect(proxyReq.write).toHaveBeenCalledWith(Buffer.from('{"ok":true}')); + expect(proxyReq.end).toHaveBeenCalled(); + expect(handleUpstreamResponse).toHaveBeenCalled(); + }); + + test('applies model-not-supported backoff before recursive retry', async () => { + const proxyReq = { on: jest.fn(), write: jest.fn(), end: jest.fn() }; + const responseCallbacks = []; + const httpsRequest = jest.fn((_options, cb) => { + responseCallbacks.push(cb); + return proxyReq; + }); + const handleUpstreamResponse = jest.fn(); + const sleep = jest.fn(() => Promise.resolve()); + + const sendUpstreamRequest = createSendUpstreamRequest({ + https: { request: httpsRequest }, + proxyAgent: {}, + handleUpstreamResponse, + sleep, + otel: { endSpanError: jest.fn() }, + handleRequestError: jest.fn(), + metrics: { increment: jest.fn(), observe: jest.fn() }, + }); + + sendUpstreamRequest({ authorization: '******' }, createContext()); + responseCallbacks[0]({ statusCode: 400, headers: {} }); + const firstCallCtx = handleUpstreamResponse.mock.calls[0][2]; + firstCallCtx.onModelNotSupportedRetry(); + await Promise.resolve(); + + expect(sleep).toHaveBeenCalledWith(MODEL_NOT_SUPPORTED_RETRY_DELAYS_MS[0]); + expect(httpsRequest).toHaveBeenCalledTimes(2); + }); +});