feat: extend credential proxy to cover GROQ_API_KEY and OPENAI_API_KEY#999
Open
kianwoon wants to merge 3 commits intoqwibitai:mainfrom
Open
feat: extend credential proxy to cover GROQ_API_KEY and OPENAI_API_KEY#999kianwoon wants to merge 3 commits intoqwibitai:mainfrom
kianwoon wants to merge 3 commits intoqwibitai:mainfrom
Conversation
- Add hostname-based routing for multiple API services - Support Groq (api.groq.com) and OpenAI (api.openai.com) APIs - Proxy injects appropriate Authorization headers for each service - Pass GROQ_BASE_URL and OPENAI_BASE_URL to containers pointing to proxy - Container-side BASE_URL env vars route via Host header Resolves qwibitai#878 diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..c37da07 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -16,6 +16,7 @@ import { IDLE_TIMEOUT, TIMEZONE, } from './config.js'; +import { readEnvFile } from './env.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import { @@ -222,10 +223,19 @@ function buildContainerArgs( args.push('-e', `TZ=${TIMEZONE}`); // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); + const proxyBaseUrl = `http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`; + args.push('-e', `ANTHROPIC_BASE_URL=${proxyBaseUrl}`); + + // Route Groq and OpenAI through the credential proxy (if keys are configured) + const proxyServices = readEnvFile(['GROQ_API_KEY', 'OPENAI_API_KEY']); + if (proxyServices.GROQ_API_KEY) { + // Point Groq requests to the proxy (Host header will route to api.groq.com) + args.push('-e', `GROQ_BASE_URL=${proxyBaseUrl}`); + } + if (proxyServices.OPENAI_API_KEY) { + // Point OpenAI requests to the proxy (Host header will route to api.openai.com) + args.push('-e', `OPENAI_BASE_URL=${proxyBaseUrl}`); + } // Mirror the host's auth method with a placeholder value. // API key mode: SDK sends x-api-key, proxy replaces with real key. diff --git a/src/credential-proxy.test.ts b/src/credential-proxy.test.ts index de76c89..b45f604 100644 --- a/src/credential-proxy.test.ts +++ b/src/credential-proxy.test.ts @@ -88,6 +88,7 @@ describe('credential-proxy', () => { path: '/v1/messages', headers: { 'content-type': 'application/json', + 'host': 'api.anthropic.com', 'x-api-key': 'placeholder', }, }, @@ -109,6 +110,7 @@ describe('credential-proxy', () => { path: '/api/oauth/claude_cli/create_api_key', headers: { 'content-type': 'application/json', + 'host': 'api.anthropic.com', authorization: 'Bearer placeholder', }, }, @@ -133,6 +135,7 @@ describe('credential-proxy', () => { path: '/v1/messages', headers: { 'content-type': 'application/json', + 'host': 'api.anthropic.com', 'x-api-key': 'temp-key-from-exchange', }, }, @@ -153,6 +156,7 @@ describe('credential-proxy', () => { path: '/v1/messages', headers: { 'content-type': 'application/json', + 'host': 'api.anthropic.com', connection: 'keep-alive', 'keep-alive': 'timeout=5', 'transfer-encoding': 'chunked', @@ -181,7 +185,7 @@ describe('credential-proxy', () => { { method: 'POST', path: '/v1/messages', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', 'host': 'api.anthropic.com' }, }, '{}', ); diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts index 8a893dd..b90ff72 100644 --- a/src/credential-proxy.ts +++ b/src/credential-proxy.ts @@ -1,14 +1,19 @@ /** * Credential proxy for container isolation. - * Containers connect here instead of directly to the Anthropic API. + * Containers connect here instead of directly to the API endpoints. * The proxy injects real credentials so containers never see them. * - * Two auth modes: + * Supports multiple upstream services: + * - Anthropic: Two auth modes * API key: Proxy injects x-api-key on every request. * OAuth: Container CLI exchanges its placeholder token for a temp * API key via /api/oauth/claude_cli/create_api_key. * Proxy injects real OAuth token on that exchange request; * subsequent requests carry the temp key which is valid as-is. + * - Groq: Proxy injects Authorization: Bearer <GROQ_API_KEY> + * - OpenAI: Proxy injects Authorization: Bearer <OPENAI_API_KEY> + * + * Routing is determined by the Host header in incoming requests. */ import { createServer, Server } from 'http'; import { request as httpsRequest } from 'https'; @@ -23,6 +28,71 @@ export interface ProxyConfig { authMode: AuthMode; } +/** Service configuration for routing */ +interface ServiceConfig { + hostname: string; + baseUrl: string; + port: number; + isHttps: boolean; +} + +/** Service types that the proxy can route to */ +type ServiceType = 'anthropic' | 'groq' | 'openai'; + +/** Determine service type from the Host header */ +function detectServiceType(hostHeader: string | undefined): ServiceType { + if (!hostHeader) return 'anthropic'; + + const host = hostHeader.split(':')[0].toLowerCase(); + + // Route by hostname patterns + if (host === 'api.anthropic.com' || host.endsWith('.api.anthropic.com')) { + return 'anthropic'; + } + if (host === 'api.groq.com' || host.endsWith('.api.groq.com')) { + return 'groq'; + } + if (host === 'api.openai.com' || host.endsWith('.api.openai.com')) { + return 'openai'; + } + + // Default to Anthropic for backward compatibility + return 'anthropic'; +} + +/** Get service configuration based on service type */ +function getServiceConfig( + serviceType: ServiceType, + secrets: Record<string, string | undefined>, +): ServiceConfig { + switch (serviceType) { + case 'anthropic': { + const baseUrl = secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'; + const url = new URL(baseUrl); + return { + hostname: url.hostname, + baseUrl, + port: parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80), + isHttps: url.protocol === 'https:', + }; + } + case 'groq': + return { + hostname: 'api.groq.com', + baseUrl: 'https://api.groq.com', + port: 443, + isHttps: true, + }; + case 'openai': + return { + hostname: 'api.openai.com', + baseUrl: 'https://api.openai.com', + port: 443, + isHttps: true, + }; + } +} + export function startCredentialProxy( port: number, host = '127.0.0.1', @@ -32,24 +102,27 @@ export function startCredentialProxy( 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', + 'GROQ_API_KEY', + 'OPENAI_API_KEY', ]); const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth'; const oauthToken = secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN; - const upstreamUrl = new URL( - secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', - ); - const isHttps = upstreamUrl.protocol === 'https:'; - const makeRequest = isHttps ? httpsRequest : httpRequest; - return new Promise((resolve, reject) => { const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); + + // Determine the target service from the Host header + const serviceType = detectServiceType(req.headers.host); + const serviceConfig = getServiceConfig(serviceType, secrets); + const upstreamUrl = new URL(serviceConfig.baseUrl); + const makeRequest = serviceConfig.isHttps ? httpsRequest : httpRequest; + const headers: Record<string, string | number | string[] | undefined> = { ...(req.headers as Record<string, string>), @@ -62,27 +135,42 @@ export function startCredentialProxy( delete headers['keep-alive']; delete headers['transfer-encoding']; - if (authMode === 'api-key') { - // API key mode: inject x-api-key on every request - delete headers['x-api-key']; - headers['x-api-key'] = secrets.ANTHROPIC_API_KEY; - } else { - // OAuth mode: replace placeholder Bearer token with the real one - // only when the container actually sends an Authorization header - // (exchange request + auth probes). Post-exchange requests use - // x-api-key only, so they pass through without token injection. - if (headers['authorization']) { - delete headers['authorization']; - if (oauthToken) { - headers['authorization'] = `Bearer ${oauthToken}`; + // Inject appropriate credentials based on service type + if (serviceType === 'anthropic') { + if (authMode === 'api-key') { + // API key mode: inject x-api-key on every request + delete headers['x-api-key']; + headers['x-api-key'] = secrets.ANTHROPIC_API_KEY; + } else { + // OAuth mode: replace placeholder Bearer token with the real one + // only when the container actually sends an Authorization header + // (exchange request + auth probes). Post-exchange requests use + // x-api-key only, so they pass through without token injection. + if (headers['authorization']) { + delete headers['authorization']; + if (oauthToken) { + headers['authorization'] = `Bearer ${oauthToken}`; + } } } + } else if (serviceType === 'groq') { + // Groq uses Bearer token in Authorization header + if (secrets.GROQ_API_KEY) { + delete headers['authorization']; + headers['authorization'] = `Bearer ${secrets.GROQ_API_KEY}`; + } + } else if (serviceType === 'openai') { + // OpenAI uses Bearer token in Authorization header + if (secrets.OPENAI_API_KEY) { + delete headers['authorization']; + headers['authorization'] = `Bearer ${secrets.OPENAI_API_KEY}`; + } } const upstream = makeRequest( { hostname: upstreamUrl.hostname, - port: upstreamUrl.port || (isHttps ? 443 : 80), + port: upstreamUrl.port || (serviceConfig.isHttps ? 443 : 80), path: req.url, method: req.method, headers, @@ -95,7 +183,7 @@ export function startCredentialProxy( upstream.on('error', (err) => { logger.error( - { err, url: req.url }, + { err, url: req.url, service: serviceType }, 'Credential proxy upstream error', ); if (!res.headersSent) { @@ -110,7 +198,19 @@ export function startCredentialProxy( }); server.listen(port, host, () => { - logger.info({ port, host, authMode }, 'Credential proxy started'); + logger.info( + { + port, + host, + authMode, + services: { + anthropic: !!secrets.ANTHROPIC_API_KEY || !!oauthToken, + groq: !!secrets.GROQ_API_KEY, + openai: !!secrets.OPENAI_API_KEY, + }, + }, + 'Credential proxy started', + ); resolve(server); });
Fixes critical bug where SDKs don't send correct Host headers when using BASE_URL overrides. SDKs send `Host: host-gateway:4248` instead of the expected service hostname, breaking Host header-based routing. Solution: Each service (Anthropic, Groq, OpenAI) now gets its own dedicated port, eliminating the need for Host header detection entirely. Changes: - Add startCredentialProxies() returning CredentialProxyServers with separate Server instances for each service on dedicated ports - Add CREDENTIAL_PROXY_PORT_GROQ and CREDENTIAL_PROXY_PORT_OPENAI config constants (4249 and 4250 respectively) - Update container-runner.ts to pass service-specific BASE_URL to containers using the dedicated ports - Update index.ts to use new startCredentialProxies() API - Fix bug where serviceConfig was computed outside request handler - Update all tests to use new port-based routing API Test results: - 16 credential-proxy tests passing - 3 container-runner tests passing
…wibitai#827 Add defensive validation in agent-runner to detect protocol violations where LLM returns stop_reason="tool_use" but includes zero tool_use blocks. Changes: - Add hasToolUseBlocks() helper to detect tool_use content blocks - Add validateAssistantResponse() for protocol violation detection - Track last assistant message content for validation - Enhanced result processing with protocol state logging Note: This is a defensive check. The complete fix requires SDK-level changes in the EZ loop where stop_reason is directly available. Related: qwibitai#827
This was referenced Mar 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Extends the credential proxy to route Groq and OpenAI API requests through the proxy, injecting real credentials so containers never see them.
Changes
api.groq.com) and OpenAI (api.openai.com) APIsGROQ_BASE_URLandOPENAI_BASE_URLto containers pointing to proxyRouting
api.anthropic.com→ Anthropic API (x-api-key or OAuth Bearer)api.groq.com→ Groq API (Authorization: Bearer GROQ_API_KEY)api.openai.com→ OpenAI API (Authorization: Bearer OPENAI_API_KEY)Testing
Resolves #878