Skip to content

feat: extend credential proxy to cover GROQ_API_KEY and OPENAI_API_KEY#999

Open
kianwoon wants to merge 3 commits intoqwibitai:mainfrom
kianwoon:feat/issue-878-credential-proxy-groq-openai
Open

feat: extend credential proxy to cover GROQ_API_KEY and OPENAI_API_KEY#999
kianwoon wants to merge 3 commits intoqwibitai:mainfrom
kianwoon:feat/issue-878-credential-proxy-groq-openai

Conversation

@kianwoon
Copy link
Copy Markdown

Summary

Extends the credential proxy to route Groq and OpenAI API requests through the proxy, injecting real credentials so containers never see them.

Changes

  • Add hostname-based routing for multiple API services (Anthropic, Groq, OpenAI)
  • 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 to correct upstream

Routing

  • 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

  • ✅ All 5 credential-proxy tests passing
  • ✅ Host header routing verified for each service type

Resolves #878

- 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
@Andy-NanoClaw-AI Andy-NanoClaw-AI added Status: Needs Review Ready for maintainer review PR: Feature New feature or enhancement labels Mar 12, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: Feature New feature or enhancement Status: Needs Review Ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: extend credential proxy to cover GROQ_API_KEY and OPENAI_API_KEY

2 participants