Skip to content

Commit 9a1d4d3

Browse files
CopilotlpcoxCopilot
authored
feat: support base path prefix for OpenAI and Anthropic API targets (#1369)
* feat: support base path prefix for OpenAI and Anthropic API targets Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * [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> * test: add robust tests for API target path preservation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * 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> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 5ee62d3 commit 9a1d4d3

File tree

7 files changed

+310
-11
lines changed

7 files changed

+310
-11
lines changed

containers/api-proxy/server.js

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,55 @@ const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN;
5050
const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com';
5151
const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.com';
5252

53+
/**
54+
* Normalizes a base path for use as a URL path prefix.
55+
* Ensures the path starts with '/' (if non-empty) and has no trailing '/'.
56+
* Returns '' for empty, null, or undefined inputs.
57+
*
58+
* @param {string|undefined|null} rawPath - The raw path value from env or config
59+
* @returns {string} Normalized path prefix (e.g. '/serving-endpoints') or ''
60+
*/
61+
function normalizeBasePath(rawPath) {
62+
if (!rawPath) return '';
63+
let path = rawPath.trim();
64+
if (!path) return '';
65+
// Ensure leading slash
66+
if (!path.startsWith('/')) {
67+
path = '/' + path;
68+
}
69+
// Strip trailing slash (but preserve a bare '/')
70+
if (path !== '/' && path.endsWith('/')) {
71+
path = path.slice(0, -1);
72+
}
73+
return path;
74+
}
75+
76+
/**
77+
* Build the full upstream path by joining basePath, reqUrl's pathname, and query string.
78+
*
79+
* Examples:
80+
* buildUpstreamPath('/v1/chat/completions', 'api.openai.com', '')
81+
* → '/v1/chat/completions'
82+
* buildUpstreamPath('/v1/chat/completions', 'host.databricks.com', '/serving-endpoints')
83+
* → '/serving-endpoints/v1/chat/completions'
84+
* buildUpstreamPath('/v1/messages?stream=true', 'host.com', '/anthropic')
85+
* → '/anthropic/v1/messages?stream=true'
86+
*
87+
* @param {string} reqUrl - The incoming request URL (must start with '/')
88+
* @param {string} targetHost - The upstream hostname (used only to parse the URL)
89+
* @param {string} basePath - Normalized base path prefix (e.g. '/serving-endpoints' or '')
90+
* @returns {string} Full upstream path including query string
91+
*/
92+
function buildUpstreamPath(reqUrl, targetHost, basePath) {
93+
const targetUrl = new URL(reqUrl, `https://${targetHost}`);
94+
const prefix = basePath === '/' ? '' : basePath;
95+
return prefix + targetUrl.pathname + targetUrl.search;
96+
}
97+
98+
// Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks)
99+
const OPENAI_API_BASE_PATH = normalizeBasePath(process.env.OPENAI_API_BASE_PATH);
100+
const ANTHROPIC_API_BASE_PATH = normalizeBasePath(process.env.ANTHROPIC_API_BASE_PATH);
101+
53102
// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints)
54103
// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default
55104
function deriveCopilotApiTarget() {
@@ -96,6 +145,10 @@ logRequest('info', 'startup', {
96145
anthropic: ANTHROPIC_API_TARGET,
97146
copilot: COPILOT_API_TARGET,
98147
},
148+
api_base_paths: {
149+
openai: OPENAI_API_BASE_PATH || '(none)',
150+
anthropic: ANTHROPIC_API_BASE_PATH || '(none)',
151+
},
99152
providers: {
100153
openai: !!OPENAI_API_KEY,
101154
anthropic: !!ANTHROPIC_API_KEY,
@@ -164,7 +217,7 @@ function isValidRequestId(id) {
164217
return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id);
165218
}
166219

167-
function proxyRequest(req, res, targetHost, injectHeaders, provider) {
220+
function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath = '') {
168221
const clientRequestId = req.headers['x-request-id'];
169222
const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId();
170223
const startTime = Date.now();
@@ -203,7 +256,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) {
203256
}
204257

205258
// Build target URL
206-
const targetUrl = new URL(req.url, `https://${targetHost}`);
259+
const upstreamPath = buildUpstreamPath(req.url, targetHost, basePath);
207260

208261
// Handle client-side errors (e.g. aborted connections)
209262
req.on('error', (err) => {
@@ -281,7 +334,7 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) {
281334
const options = {
282335
hostname: targetHost,
283336
port: 443,
284-
path: targetUrl.pathname + targetUrl.search,
337+
path: upstreamPath,
285338
method: req.method,
286339
headers,
287340
agent: proxyAgent, // Route through Squid
@@ -420,7 +473,7 @@ if (require.main === module) {
420473

421474
proxyRequest(req, res, OPENAI_API_TARGET, {
422475
'Authorization': `Bearer ${OPENAI_API_KEY}`,
423-
}, 'openai');
476+
}, 'openai', OPENAI_API_BASE_PATH);
424477
});
425478

426479
server.listen(HEALTH_PORT, '0.0.0.0', () => {
@@ -457,7 +510,7 @@ if (require.main === module) {
457510
if (!req.headers['anthropic-version']) {
458511
anthropicHeaders['anthropic-version'] = '2023-06-01';
459512
}
460-
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic');
513+
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH);
461514
});
462515

463516
server.listen(10001, '0.0.0.0', () => {
@@ -536,4 +589,4 @@ if (require.main === module) {
536589
}
537590

538591
// Export for testing
539-
module.exports = { deriveCopilotApiTarget };
592+
module.exports = { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath };

containers/api-proxy/server.test.js

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tests for api-proxy server.js
33
*/
44

5-
const { deriveCopilotApiTarget } = require('./server');
5+
const { deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath } = require('./server');
66

77
describe('deriveCopilotApiTarget', () => {
88
let originalEnv;
@@ -122,3 +122,147 @@ describe('deriveCopilotApiTarget', () => {
122122
});
123123
});
124124
});
125+
126+
describe('normalizeBasePath', () => {
127+
it('should return empty string for undefined', () => {
128+
expect(normalizeBasePath(undefined)).toBe('');
129+
});
130+
131+
it('should return empty string for null', () => {
132+
expect(normalizeBasePath(null)).toBe('');
133+
});
134+
135+
it('should return empty string for empty string', () => {
136+
expect(normalizeBasePath('')).toBe('');
137+
});
138+
139+
it('should return empty string for whitespace-only string', () => {
140+
expect(normalizeBasePath(' ')).toBe('');
141+
});
142+
143+
it('should preserve a well-formed path', () => {
144+
expect(normalizeBasePath('/serving-endpoints')).toBe('/serving-endpoints');
145+
});
146+
147+
it('should add leading slash when missing', () => {
148+
expect(normalizeBasePath('serving-endpoints')).toBe('/serving-endpoints');
149+
});
150+
151+
it('should strip trailing slash', () => {
152+
expect(normalizeBasePath('/serving-endpoints/')).toBe('/serving-endpoints');
153+
});
154+
155+
it('should handle multi-segment paths', () => {
156+
expect(normalizeBasePath('/openai/deployments/gpt-4')).toBe('/openai/deployments/gpt-4');
157+
});
158+
159+
it('should normalize a path missing the leading slash and with trailing slash', () => {
160+
expect(normalizeBasePath('openai/deployments/gpt-4/')).toBe('/openai/deployments/gpt-4');
161+
});
162+
163+
it('should preserve a root-only path', () => {
164+
expect(normalizeBasePath('/')).toBe('/');
165+
});
166+
});
167+
168+
describe('buildUpstreamPath', () => {
169+
const HOST = 'api.example.com';
170+
171+
describe('no base path (empty string)', () => {
172+
it('should return the request path unchanged when basePath is empty', () => {
173+
expect(buildUpstreamPath('/v1/chat/completions', HOST, '')).toBe('/v1/chat/completions');
174+
});
175+
176+
it('should preserve query string when basePath is empty', () => {
177+
expect(buildUpstreamPath('/v1/chat/completions?stream=true', HOST, '')).toBe('/v1/chat/completions?stream=true');
178+
});
179+
180+
it('should preserve multiple query params when basePath is empty', () => {
181+
expect(buildUpstreamPath('/v1/models?limit=10&order=asc', HOST, '')).toBe('/v1/models?limit=10&order=asc');
182+
});
183+
184+
it('should handle root path with no base path', () => {
185+
expect(buildUpstreamPath('/', HOST, '')).toBe('/');
186+
});
187+
});
188+
189+
describe('Databricks serving-endpoints (single-segment base path)', () => {
190+
it('should prepend /serving-endpoints to chat completions path', () => {
191+
expect(buildUpstreamPath('/v1/chat/completions', HOST, '/serving-endpoints'))
192+
.toBe('/serving-endpoints/v1/chat/completions');
193+
});
194+
195+
it('should prepend /serving-endpoints and preserve query string', () => {
196+
expect(buildUpstreamPath('/v1/chat/completions?stream=true', HOST, '/serving-endpoints'))
197+
.toBe('/serving-endpoints/v1/chat/completions?stream=true');
198+
});
199+
200+
it('should prepend /serving-endpoints to models path', () => {
201+
expect(buildUpstreamPath('/v1/models', HOST, '/serving-endpoints'))
202+
.toBe('/serving-endpoints/v1/models');
203+
});
204+
205+
it('should prepend /serving-endpoints to embeddings path', () => {
206+
expect(buildUpstreamPath('/v1/embeddings', HOST, '/serving-endpoints'))
207+
.toBe('/serving-endpoints/v1/embeddings');
208+
});
209+
});
210+
211+
describe('Azure OpenAI deployments (multi-segment base path)', () => {
212+
it('should prepend Azure deployment path to chat completions', () => {
213+
expect(buildUpstreamPath('/chat/completions', HOST, '/openai/deployments/gpt-4'))
214+
.toBe('/openai/deployments/gpt-4/chat/completions');
215+
});
216+
217+
it('should prepend Azure deployment path and preserve api-version query param', () => {
218+
expect(buildUpstreamPath('/chat/completions?api-version=2024-02-01', HOST, '/openai/deployments/gpt-4'))
219+
.toBe('/openai/deployments/gpt-4/chat/completions?api-version=2024-02-01');
220+
});
221+
222+
it('should handle a deeply nested Azure deployment name', () => {
223+
expect(buildUpstreamPath('/chat/completions', HOST, '/openai/deployments/my-custom-gpt-4-deployment'))
224+
.toBe('/openai/deployments/my-custom-gpt-4-deployment/chat/completions');
225+
});
226+
});
227+
228+
describe('Anthropic custom target with base path', () => {
229+
it('should prepend /anthropic to messages endpoint', () => {
230+
expect(buildUpstreamPath('/v1/messages', 'proxy.corporate.com', '/anthropic'))
231+
.toBe('/anthropic/v1/messages');
232+
});
233+
234+
it('should preserve Anthropic query params', () => {
235+
expect(buildUpstreamPath('/v1/messages?beta=true', 'proxy.corporate.com', '/anthropic'))
236+
.toBe('/anthropic/v1/messages?beta=true');
237+
});
238+
});
239+
240+
describe('path preservation for real-world API endpoints', () => {
241+
it('should preserve /v1/chat/completions exactly (OpenAI standard path)', () => {
242+
expect(buildUpstreamPath('/v1/chat/completions', 'api.openai.com', ''))
243+
.toBe('/v1/chat/completions');
244+
});
245+
246+
it('should preserve /v1/messages exactly (Anthropic standard path)', () => {
247+
expect(buildUpstreamPath('/v1/messages', 'api.anthropic.com', ''))
248+
.toBe('/v1/messages');
249+
});
250+
251+
it('should handle URL-encoded characters in path', () => {
252+
// %2F is preserved by the URL parser (an encoded slash stays encoded)
253+
expect(buildUpstreamPath('/v1/models/gpt-4%2Fturbo', HOST, '/serving-endpoints'))
254+
.toBe('/serving-endpoints/v1/models/gpt-4%2Fturbo');
255+
});
256+
257+
it('should handle hash fragment being ignored (not forwarded in HTTP requests)', () => {
258+
// Hash fragments are never sent to the server; URL parser drops them
259+
expect(buildUpstreamPath('/v1/chat/completions#fragment', HOST, '/serving-endpoints'))
260+
.toBe('/serving-endpoints/v1/chat/completions');
261+
});
262+
263+
it('should drop empty query string marker', () => {
264+
expect(buildUpstreamPath('/v1/chat/completions?', HOST, '/serving-endpoints'))
265+
.toBe('/serving-endpoints/v1/chat/completions');
266+
});
267+
});
268+
});

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,10 +1270,18 @@ program
12701270
'--openai-api-target <host>',
12711271
'Target hostname for OpenAI API requests (default: api.openai.com)',
12721272
)
1273+
.option(
1274+
'--openai-api-base-path <path>',
1275+
'Base path prefix for OpenAI API requests (e.g. /serving-endpoints for Databricks)',
1276+
)
12731277
.option(
12741278
'--anthropic-api-target <host>',
12751279
'Target hostname for Anthropic API requests (default: api.anthropic.com)',
12761280
)
1281+
.option(
1282+
'--anthropic-api-base-path <path>',
1283+
'Base path prefix for Anthropic API requests (e.g. /anthropic)',
1284+
)
12771285
.option(
12781286
'--rate-limit-rpm <n>',
12791287
'Max requests per minute per provider (requires --enable-api-proxy)',
@@ -1623,7 +1631,9 @@ program
16231631
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
16241632
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
16251633
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
1634+
openaiApiBasePath: options.openaiApiBasePath || process.env.OPENAI_API_BASE_PATH,
16261635
anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET,
1636+
anthropicApiBasePath: options.anthropicApiBasePath || process.env.ANTHROPIC_API_BASE_PATH,
16271637
};
16281638

16291639
// Parse and validate --agent-timeout

0 commit comments

Comments
 (0)