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
22 changes: 13 additions & 9 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,16 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) {
}

/**
* Strip the `key` query parameter from a Gemini request URL.
* Strip all known Gemini API-key query parameters from a request URL.
*
* The @google/genai SDK (and older Gemini SDK versions) may append `?key=<value>`
* to every request URL in addition to setting the `x-goog-api-key` header.
* The proxy injects the real key via the header, so the placeholder `key=`
* value must be removed before forwarding to Google to prevent
* API_KEY_INVALID errors.
* The @google/genai SDK (and older Gemini SDK versions) may append auth params
* (`?key=`, `?apiKey=`, or `?api_key=`) to every request URL in addition to
* setting the `x-goog-api-key` header. The proxy injects the real key via the
* header, so any placeholder param must be removed before forwarding to Google
* to prevent API_KEY_INVALID errors.
*
* @param {string} reqUrl - The incoming request URL (must start with exactly one '/')
* @returns {string} URL with the `key` query parameter removed
* @returns {string} URL with all Gemini auth query parameters removed
*/
function stripGeminiKeyParam(reqUrl) {
// Only operate on relative request paths that begin with exactly one slash.
Expand All @@ -211,6 +211,8 @@ function stripGeminiKeyParam(reqUrl) {
}
const parsed = new URL(reqUrl, 'http://localhost');
parsed.searchParams.delete('key');
parsed.searchParams.delete('apiKey');
parsed.searchParams.delete('api_key');
// Reconstruct relative path only — never emit the scheme/host from the dummy base.
return parsed.pathname + parsed.search;
}
Expand Down Expand Up @@ -1068,7 +1070,7 @@ if (require.main === module) {
const contentLength = parseInt(req.headers['content-length'], 10) || 0;
if (checkRateLimit(req, res, 'gemini', contentLength)) return;

// Strip any ?key= query parameter — the @google/genai SDK may append it to the URL.
// Strip any auth query params (?key=, ?apiKey=, ?api_key=) — the SDK may append them.
// The proxy injects the real key via x-goog-api-key header instead.
req.url = stripGeminiKeyParam(req.url);

Expand All @@ -1078,13 +1080,14 @@ if (require.main === module) {
});

geminiServer.on('upgrade', (req, socket, head) => {
// Strip any ?key= query parameter — the @google/genai SDK may append it to the URL.
// Strip any auth query params (?key=, ?apiKey=, ?api_key=) — the SDK may append them.
req.url = stripGeminiKeyParam(req.url);
proxyWebSocket(req, socket, head, GEMINI_API_TARGET, {
'x-goog-api-key': GEMINI_API_KEY,
}, 'gemini', GEMINI_API_BASE_PATH);
});

logRequest('info', 'server_start', { message: `GEMINI_API_KEY configured (length=${GEMINI_API_KEY.length})` });
geminiServer.listen(10003, '0.0.0.0', () => {
Comment on lines 1089 to 1091
logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET });
});
Expand All @@ -1107,6 +1110,7 @@ if (require.main === module) {
socket.destroy();
});

logRequest('warn', 'server_start', { message: 'GEMINI_API_KEY not set — Gemini proxy will return 503' });
geminiServer.listen(10003, '0.0.0.0', () => {
logRequest('info', 'server_start', { message: 'Gemini endpoint listening on port 10003 (Gemini not configured — returning 503)' });
});
Expand Down
30 changes: 30 additions & 0 deletions containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,36 @@ describe('stripGeminiKeyParam', () => {
const result = stripGeminiKeyParam('/v1/generateContent?key=abc');
expect(result).toBe('/v1/generateContent');
});

it('should remove the apiKey= query parameter', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?apiKey=placeholder'))
.toBe('/v1/models/gemini-pro:generateContent');
});

it('should remove the api_key= query parameter', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?api_key=placeholder'))
.toBe('/v1/models/gemini-pro:generateContent');
});

it('should remove apiKey= while preserving other query parameters', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?apiKey=placeholder&alt=json'))
.toBe('/v1/models/gemini-pro:generateContent?alt=json');
});

it('should remove api_key= while preserving other query parameters', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?api_key=placeholder&alt=json'))
.toBe('/v1/models/gemini-pro:generateContent?alt=json');
});

it('should remove all auth params when multiple variants are present', () => {
expect(stripGeminiKeyParam('/v1/models/gemini-pro:generateContent?key=foo&apiKey=bar&api_key=baz&alt=json'))
.toBe('/v1/models/gemini-pro:generateContent?alt=json');
});

it('should handle path with only api_key= param, leaving no trailing ?', () => {
const result = stripGeminiKeyParam('/v1/generateContent?api_key=abc');
expect(result).toBe('/v1/generateContent');
});
Comment on lines +543 to +571
});

// ── Helpers for proxyWebSocket tests ──────────────────────────────────────────
Expand Down
Loading