From 1dd8baa01e12cf4b695037c0c91d4afddc46310e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:01:29 +0000 Subject: [PATCH 1/6] feat: support base path prefix for OpenAI and Anthropic API targets Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/api-proxy/server.js | 41 +++++++++++++++++++++++---- containers/api-proxy/server.test.js | 44 ++++++++++++++++++++++++++++- src/cli.ts | 10 +++++++ src/docker-manager.ts | 8 ++++++ src/types.ts | 32 +++++++++++++++++++++ 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index daa914a8d..cc09f1ecc 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -50,6 +50,33 @@ const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN; const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com'; const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.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; +} + +// 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); + // 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() { @@ -96,6 +123,10 @@ logRequest('info', 'startup', { anthropic: ANTHROPIC_API_TARGET, copilot: COPILOT_API_TARGET, }, + api_base_paths: { + openai: OPENAI_API_BASE_PATH || '(none)', + anthropic: ANTHROPIC_API_BASE_PATH || '(none)', + }, providers: { openai: !!OPENAI_API_KEY, anthropic: !!ANTHROPIC_API_KEY, @@ -164,7 +195,7 @@ function isValidRequestId(id) { return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id); } -function proxyRequest(req, res, targetHost, injectHeaders, provider) { +function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = '') { const clientRequestId = req.headers['x-request-id']; const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId(); const startTime = Date.now(); @@ -281,7 +312,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) { const options = { hostname: targetHost, port: 443, - path: targetUrl.pathname + targetUrl.search, + path: basePath + targetUrl.pathname + targetUrl.search, method: req.method, headers, agent: proxyAgent, // Route through Squid @@ -420,7 +451,7 @@ if (require.main === module) { proxyRequest(req, res, OPENAI_API_TARGET, { 'Authorization': `Bearer ${OPENAI_API_KEY}`, - }, 'openai'); + }, 'openai', OPENAI_API_BASE_PATH); }); server.listen(HEALTH_PORT, '0.0.0.0', () => { @@ -457,7 +488,7 @@ if (require.main === module) { if (!req.headers['anthropic-version']) { anthropicHeaders['anthropic-version'] = '2023-06-01'; } - proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic'); + proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH); }); server.listen(10001, '0.0.0.0', () => { @@ -536,4 +567,4 @@ if (require.main === module) { } // Export for testing -module.exports = { deriveCopilotApiTarget }; +module.exports = { deriveCopilotApiTarget, normalizeBasePath }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 58c865f12..5446c84de 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -2,7 +2,7 @@ * Tests for api-proxy server.js */ -const { deriveCopilotApiTarget } = require('./server'); +const { deriveCopilotApiTarget, normalizeBasePath } = require('./server'); describe('deriveCopilotApiTarget', () => { let originalEnv; @@ -122,3 +122,45 @@ describe('deriveCopilotApiTarget', () => { }); }); }); + +describe('normalizeBasePath', () => { + it('should return empty string for undefined', () => { + expect(normalizeBasePath(undefined)).toBe(''); + }); + + it('should return empty string for null', () => { + expect(normalizeBasePath(null)).toBe(''); + }); + + it('should return empty string for empty string', () => { + expect(normalizeBasePath('')).toBe(''); + }); + + it('should return empty string for whitespace-only string', () => { + expect(normalizeBasePath(' ')).toBe(''); + }); + + it('should preserve a well-formed path', () => { + expect(normalizeBasePath('/serving-endpoints')).toBe('/serving-endpoints'); + }); + + it('should add leading slash when missing', () => { + expect(normalizeBasePath('serving-endpoints')).toBe('/serving-endpoints'); + }); + + it('should strip trailing slash', () => { + expect(normalizeBasePath('/serving-endpoints/')).toBe('/serving-endpoints'); + }); + + it('should handle multi-segment paths', () => { + expect(normalizeBasePath('/openai/deployments/gpt-4')).toBe('/openai/deployments/gpt-4'); + }); + + it('should normalize a path missing the leading slash and with trailing slash', () => { + expect(normalizeBasePath('openai/deployments/gpt-4/')).toBe('/openai/deployments/gpt-4'); + }); + + it('should preserve a root-only path', () => { + expect(normalizeBasePath('/')).toBe('/'); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 87c23eaf2..68b9696f9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1270,10 +1270,18 @@ program '--openai-api-target ', 'Target hostname for OpenAI API requests (default: api.openai.com)', ) + .option( + '--openai-api-base-path ', + 'Base path prefix for OpenAI API requests (e.g. /serving-endpoints for Databricks)', + ) .option( '--anthropic-api-target ', 'Target hostname for Anthropic API requests (default: api.anthropic.com)', ) + .option( + '--anthropic-api-base-path ', + 'Base path prefix for Anthropic API requests (e.g. /anthropic)', + ) .option( '--rate-limit-rpm ', 'Max requests per minute per provider (requires --enable-api-proxy)', @@ -1623,7 +1631,9 @@ program copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN, copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET, openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET, + openaiApiBasePath: options.openaiApiBasePath || process.env.OPENAI_API_BASE_PATH, anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET, + anthropicApiBasePath: options.anthropicApiBasePath || process.env.ANTHROPIC_API_BASE_PATH, }; // Parse and validate --agent-timeout diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 0efb4fd2c..df51e138d 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1201,7 +1201,9 @@ export function generateDockerCompose( // Configurable API targets (for GHES/GHEC / custom endpoints) ...(config.copilotApiTarget && { COPILOT_API_TARGET: config.copilotApiTarget }), ...(config.openaiApiTarget && { OPENAI_API_TARGET: config.openaiApiTarget }), + ...(config.openaiApiBasePath && { OPENAI_API_BASE_PATH: config.openaiApiBasePath }), ...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: config.anthropicApiTarget }), + ...(config.anthropicApiBasePath && { ANTHROPIC_API_BASE_PATH: config.anthropicApiBasePath }), // Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), // Route through Squid to respect domain whitelisting @@ -1266,6 +1268,9 @@ export function generateDockerCompose( if (config.openaiApiTarget) { logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`); } + if (config.openaiApiBasePath) { + logger.debug(`OpenAI API base path set to: ${config.openaiApiBasePath}`); + } } if (config.anthropicApiKey) { environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`; @@ -1273,6 +1278,9 @@ export function generateDockerCompose( if (config.anthropicApiTarget) { logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`); } + if (config.anthropicApiBasePath) { + logger.debug(`Anthropic API base path set to: ${config.anthropicApiBasePath}`); + } // Set placeholder token for Claude Code CLI compatibility // Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy diff --git a/src/types.ts b/src/types.ts index b72d5a870..cb3143da1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -559,6 +559,23 @@ export interface WrapperConfig { */ openaiApiTarget?: string; + /** + * Base path prefix for OpenAI API requests (used by API proxy sidecar) + * + * When set, this path is prepended to every upstream request path so that + * endpoints which require a URL prefix (e.g. Databricks serving endpoints, + * Azure OpenAI deployments) work correctly. + * + * Can be set via: + * - CLI flag: `--openai-api-base-path ` + * - Environment variable: `OPENAI_API_BASE_PATH` + * + * @default '' + * @example '/serving-endpoints' + * @example '/openai/deployments/gpt-4' + */ + openaiApiBasePath?: string; + /** * Target hostname for Anthropic API requests (used by API proxy sidecar) * @@ -581,6 +598,21 @@ export interface WrapperConfig { */ anthropicApiTarget?: string; + /** + * Base path prefix for Anthropic API requests (used by API proxy sidecar) + * + * When set, this path is prepended to every upstream request path so that + * endpoints which require a URL prefix work correctly. + * + * Can be set via: + * - CLI flag: `--anthropic-api-base-path ` + * - Environment variable: `ANTHROPIC_API_BASE_PATH` + * + * @default '' + * @example '/anthropic' + */ + anthropicApiBasePath?: string; + /** * Enable Data Loss Prevention (DLP) scanning * From d5dcd1e692a50e8ff44fbde3a6c7442d0aa33083 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:41:05 -0700 Subject: [PATCH 2/6] [WIP] Fix the failing GitHub Actions workflow for test coverage report (#1370) * Initial plan * fix: add tests for api-base-path feature to fix coverage regression --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/docker-manager.test.ts | 54 +++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 0369660b3..81f679bf5 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -120,6 +120,12 @@ describe('docker-manager', () => { delete process.env.SUDO_UID; expect(getSafeHostUid()).toBe('1001'); }); + + it('should return 1000 when SUDO_UID is not a valid number', () => { + process.getuid = () => 0; // Running as root + process.env.SUDO_UID = 'not-a-number'; + expect(getSafeHostUid()).toBe('1000'); + }); }); describe('getSafeHostGid', () => { @@ -170,6 +176,12 @@ describe('docker-manager', () => { delete process.env.SUDO_GID; expect(getSafeHostGid()).toBe('1001'); }); + + it('should return 1000 when SUDO_GID is not a valid number', () => { + process.getgid = () => 0; // Running as root + process.env.SUDO_GID = 'not-a-number'; + expect(getSafeHostGid()).toBe('1000'); + }); }); describe('getRealUserHome', () => { @@ -795,15 +807,17 @@ describe('docker-manager', () => { expect(environment.AWF_CHROOT_ENABLED).toBe('true'); }); - it('should pass GOROOT, CARGO_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when env vars are set', () => { + it('should pass GOROOT, CARGO_HOME, RUSTUP_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when env vars are set', () => { const originalGoroot = process.env.GOROOT; const originalCargoHome = process.env.CARGO_HOME; + const originalRustupHome = process.env.RUSTUP_HOME; const originalJavaHome = process.env.JAVA_HOME; const originalDotnetRoot = process.env.DOTNET_ROOT; const originalBunInstall = process.env.BUN_INSTALL; process.env.GOROOT = '/usr/local/go'; process.env.CARGO_HOME = '/home/user/.cargo'; + process.env.RUSTUP_HOME = '/home/user/.rustup'; process.env.JAVA_HOME = '/usr/lib/jvm/java-17'; process.env.DOTNET_ROOT = '/usr/lib/dotnet'; process.env.BUN_INSTALL = '/home/user/.bun'; @@ -815,6 +829,7 @@ describe('docker-manager', () => { expect(environment.AWF_GOROOT).toBe('/usr/local/go'); expect(environment.AWF_CARGO_HOME).toBe('/home/user/.cargo'); + expect(environment.AWF_RUSTUP_HOME).toBe('/home/user/.rustup'); expect(environment.AWF_JAVA_HOME).toBe('/usr/lib/jvm/java-17'); expect(environment.AWF_DOTNET_ROOT).toBe('/usr/lib/dotnet'); expect(environment.AWF_BUN_INSTALL).toBe('/home/user/.bun'); @@ -830,6 +845,11 @@ describe('docker-manager', () => { } else { delete process.env.CARGO_HOME; } + if (originalRustupHome !== undefined) { + process.env.RUSTUP_HOME = originalRustupHome; + } else { + delete process.env.RUSTUP_HOME; + } if (originalJavaHome !== undefined) { process.env.JAVA_HOME = originalJavaHome; } else { @@ -2048,6 +2068,22 @@ describe('docker-manager', () => { expect(env.OPENAI_API_TARGET).toBeUndefined(); }); + it('should set OPENAI_API_BASE_PATH in api-proxy when openaiApiBasePath is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiBasePath: '/serving-endpoints' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.OPENAI_API_BASE_PATH).toBe('/serving-endpoints'); + }); + + it('should not set OPENAI_API_BASE_PATH in api-proxy when openaiApiBasePath is not provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.OPENAI_API_BASE_PATH).toBeUndefined(); + }); + it('should set ANTHROPIC_API_TARGET in api-proxy when anthropicApiTarget is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key', anthropicApiTarget: 'custom.anthropic-router.internal' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); @@ -2064,6 +2100,22 @@ describe('docker-manager', () => { expect(env.ANTHROPIC_API_TARGET).toBeUndefined(); }); + it('should set ANTHROPIC_API_BASE_PATH in api-proxy when anthropicApiBasePath is provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key', anthropicApiBasePath: '/anthropic' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.ANTHROPIC_API_BASE_PATH).toBe('/anthropic'); + }); + + it('should not set ANTHROPIC_API_BASE_PATH in api-proxy when anthropicApiBasePath is not provided', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.ANTHROPIC_API_BASE_PATH).toBeUndefined(); + }); + it('should set COPILOT_API_TARGET in api-proxy when copilotApiTarget is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotGithubToken: 'ghu_test_token', copilotApiTarget: 'api.copilot.internal' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); From 13650058445c2e83d6980524a14d4f56d33c07c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:47:13 +0000 Subject: [PATCH 3/6] test: add robust tests for API target path preservation --- containers/api-proxy/server.js | 27 +++++++- containers/api-proxy/server.test.js | 104 +++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index cc09f1ecc..c294cca4c 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -73,6 +73,27 @@ function normalizeBasePath(rawPath) { return path; } +/** + * Build the full upstream path by joining basePath, reqUrl's pathname, and query string. + * + * Examples: + * buildUpstreamPath('/v1/chat/completions', 'api.openai.com', '') + * → '/v1/chat/completions' + * 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 '/') + * @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) { + const targetUrl = new URL(reqUrl, `https://${targetHost}`); + return basePath + targetUrl.pathname + targetUrl.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); @@ -234,7 +255,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = } // Build target URL - const targetUrl = new URL(req.url, `https://${targetHost}`); + const upstreamPath = buildUpstreamPath(req.url, targetHost, basePath); // Handle client-side errors (e.g. aborted connections) req.on('error', (err) => { @@ -312,7 +333,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = const options = { hostname: targetHost, port: 443, - path: basePath + targetUrl.pathname + targetUrl.search, + path: upstreamPath, method: req.method, headers, agent: proxyAgent, // Route through Squid @@ -567,4 +588,4 @@ if (require.main === module) { } // Export for testing -module.exports = { deriveCopilotApiTarget, normalizeBasePath }; +module.exports = { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 5446c84de..a10d33d56 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -2,7 +2,7 @@ * Tests for api-proxy server.js */ -const { deriveCopilotApiTarget, normalizeBasePath } = require('./server'); +const { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath } = require('./server'); describe('deriveCopilotApiTarget', () => { let originalEnv; @@ -164,3 +164,105 @@ describe('normalizeBasePath', () => { expect(normalizeBasePath('/')).toBe('/'); }); }); + +describe('buildUpstreamPath', () => { + const HOST = 'api.example.com'; + + describe('no base path (empty string)', () => { + it('should return the request path unchanged when basePath is empty', () => { + expect(buildUpstreamPath('/v1/chat/completions', HOST, '')).toBe('/v1/chat/completions'); + }); + + it('should preserve query string when basePath is empty', () => { + expect(buildUpstreamPath('/v1/chat/completions?stream=true', HOST, '')).toBe('/v1/chat/completions?stream=true'); + }); + + it('should preserve multiple query params when basePath is empty', () => { + expect(buildUpstreamPath('/v1/models?limit=10&order=asc', HOST, '')).toBe('/v1/models?limit=10&order=asc'); + }); + + it('should handle root path with no base path', () => { + expect(buildUpstreamPath('/', HOST, '')).toBe('/'); + }); + }); + + describe('Databricks serving-endpoints (single-segment base path)', () => { + it('should prepend /serving-endpoints to chat completions path', () => { + expect(buildUpstreamPath('/v1/chat/completions', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/chat/completions'); + }); + + it('should prepend /serving-endpoints and preserve query string', () => { + expect(buildUpstreamPath('/v1/chat/completions?stream=true', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/chat/completions?stream=true'); + }); + + it('should prepend /serving-endpoints to models path', () => { + expect(buildUpstreamPath('/v1/models', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/models'); + }); + + it('should prepend /serving-endpoints to embeddings path', () => { + expect(buildUpstreamPath('/v1/embeddings', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/embeddings'); + }); + }); + + describe('Azure OpenAI deployments (multi-segment base path)', () => { + it('should prepend Azure deployment path to chat completions', () => { + expect(buildUpstreamPath('/chat/completions', HOST, '/openai/deployments/gpt-4')) + .toBe('/openai/deployments/gpt-4/chat/completions'); + }); + + it('should prepend Azure deployment path and preserve api-version query param', () => { + expect(buildUpstreamPath('/chat/completions?api-version=2024-02-01', HOST, '/openai/deployments/gpt-4')) + .toBe('/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01'); + }); + + it('should handle a deeply nested Azure deployment name', () => { + expect(buildUpstreamPath('/chat/completions', HOST, '/openai/deployments/my-custom-gpt-4-deployment')) + .toBe('/openai/deployments/my-custom-gpt-4-deployment/chat/completions'); + }); + }); + + describe('Anthropic custom target with base path', () => { + it('should prepend /anthropic to messages endpoint', () => { + expect(buildUpstreamPath('/v1/messages', 'proxy.corporate.com', '/anthropic')) + .toBe('/anthropic/v1/messages'); + }); + + it('should preserve Anthropic query params', () => { + expect(buildUpstreamPath('/v1/messages?beta=true', 'proxy.corporate.com', '/anthropic')) + .toBe('/anthropic/v1/messages?beta=true'); + }); + }); + + describe('path preservation for real-world API endpoints', () => { + it('should preserve /v1/chat/completions exactly (OpenAI standard path)', () => { + expect(buildUpstreamPath('/v1/chat/completions', 'api.openai.com', '')) + .toBe('/v1/chat/completions'); + }); + + it('should preserve /v1/messages exactly (Anthropic standard path)', () => { + expect(buildUpstreamPath('/v1/messages', 'api.anthropic.com', '')) + .toBe('/v1/messages'); + }); + + it('should handle URL-encoded characters in path', () => { + // %2F is preserved by the URL parser (an encoded slash stays encoded) + expect(buildUpstreamPath('/v1/models/gpt-4%2Fturbo', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/models/gpt-4%2Fturbo'); + }); + + it('should handle hash fragment being ignored (not forwarded in HTTP requests)', () => { + // Hash fragments are never sent to the server; URL parser drops them + expect(buildUpstreamPath('/v1/chat/completions#fragment', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/chat/completions'); + }); + + it('should preserve empty query string marker', () => { + expect(buildUpstreamPath('/v1/chat/completions?', HOST, '/serving-endpoints')) + .toBe('/serving-endpoints/v1/chat/completions'); + }); + }); +}); From becb3389b2ec50b52a8bffbda818f0aec7638619 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 19 Mar 2026 12:01:32 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- containers/api-proxy/server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index c294cca4c..d03ff9004 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -91,7 +91,8 @@ function normalizeBasePath(rawPath) { */ function buildUpstreamPath(reqUrl, targetHost, basePath) { const targetUrl = new URL(reqUrl, `https://${targetHost}`); - return basePath + targetUrl.pathname + targetUrl.search; + const prefix = basePath === '/' ? '' : basePath; + return prefix + targetUrl.pathname + targetUrl.search; } // Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks) From 1a00f4adac447a3dbea87ec7029127474e655c6c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 19 Mar 2026 12:03:14 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- containers/api-proxy/server.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index a10d33d56..36b645c6d 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -260,7 +260,7 @@ describe('buildUpstreamPath', () => { .toBe('/serving-endpoints/v1/chat/completions'); }); - it('should preserve empty query string marker', () => { + it('should drop empty query string marker', () => { expect(buildUpstreamPath('/v1/chat/completions?', HOST, '/serving-endpoints')) .toBe('/serving-endpoints/v1/chat/completions'); }); From e7e1c73b203f32277637be6f66fdf7fcfe7a50c8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:03:57 -0700 Subject: [PATCH 6/6] fix: resolve high severity flatted prototype pollution vulnerability (#1372) * Initial plan * fix: update flatted to 3.4.2 to resolve high severity prototype pollution vulnerability Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3c215795d..4939dda08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5171,9 +5171,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" },