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
3 changes: 2 additions & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ ENV AWF_VERSION=${AWF_VERSION}
# 10001 - Anthropic API proxy
# 10002 - GitHub Copilot API proxy
# 10003 - Google Gemini API proxy
EXPOSE 10000 10001 10002 10003
# 10004 - Google Vertex AI API proxy
EXPOSE 10000 10001 10002 10003 10004
Comment on lines 55 to +59

# Use exec form so node is PID 1 and receives SIGTERM directly
CMD ["node", "server.js"]
8 changes: 8 additions & 0 deletions containers/api-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ api.openai.com or api.anthropic.com
- **10001**: Anthropic API proxy (api.anthropic.com)
- **10002**: GitHub Copilot API proxy (api.githubcopilot.com)
- **10003**: Google Gemini API proxy (generativelanguage.googleapis.com)
- **10004**: Google Vertex AI API proxy (aiplatform.googleapis.com)

## Environment Variables

Expand All @@ -39,9 +40,12 @@ Required (at least one):
- `COPILOT_GITHUB_TOKEN` - GitHub token for Copilot authentication
- `COPILOT_PROVIDER_API_KEY` - Direct upstream provider key for Copilot BYOK mode
- `GEMINI_API_KEY` - Google Gemini API key for authentication
- `GOOGLE_API_KEY` - Google Vertex AI API key for authentication

Optional:
- `COPILOT_API_TARGET` - Target hostname for GitHub Copilot API requests (default: `api.githubcopilot.com`). Useful for GHES deployments.
- `VERTEX_API_TARGET` - Target hostname for Vertex API requests (default: `aiplatform.googleapis.com`)
- `VERTEX_API_BASE_PATH` - Base path prefix for Vertex API requests
- `AWF_BYOK_EXTRA_HEADERS` - JSON object of additional headers to inject into upstream requests when the Copilot BYOK API key (`COPILOT_PROVIDER_API_KEY`) is in use. Useful for provider-native observability (e.g. OpenRouter session grouping, Helicone user tracking):
```
AWF_BYOK_EXTRA_HEADERS='{"x-session-id":"my-session","HTTP-Referer":"https://example.com"}'
Expand All @@ -57,6 +61,10 @@ Set by AWF:
- `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128)
- `HTTPS_PROXY` - Squid proxy URL (http://172.30.0.10:3128)

Agent-side routing for Vertex mode:
- `GOOGLE_VERTEX_BASE_URL=http://api-proxy:10004`
- `GOOGLE_API_KEY=google-api-key-placeholder-for-credential-isolation` (placeholder; real key remains in sidecar)

## Security

- Runs as non-root user (apiproxy)
Expand Down
5 changes: 5 additions & 0 deletions containers/api-proxy/provider-env-constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@
"PROVIDER_BASE_URL": "COPILOT_PROVIDER_BASE_URL",
"API_TARGET": "COPILOT_API_TARGET",
"API_BASE_PATH": "COPILOT_API_BASE_PATH"
},
"VERTEX_ENV": {
"KEY": "GOOGLE_API_KEY",
"TARGET": "VERTEX_API_TARGET",
"BASE_PATH": "VERTEX_API_BASE_PATH"
}
}
6 changes: 4 additions & 2 deletions containers/api-proxy/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { createOpenAIAdapter } = require('./openai');
const { createAnthropicAdapter } = require('./anthropic');
const { createCopilotAdapter } = require('./copilot');
const { createGeminiAdapter } = require('./gemini');
const { createVertexAdapter } = require('./vertex');

/**
* @typedef {Object} ProbeConfig
Expand Down Expand Up @@ -92,7 +93,7 @@ const { createGeminiAdapter } = require('./gemini');
* which providers appear in /reflect and models.json output.
*
* @param {Record<string, string|undefined>} env - Environment variables (typically process.env)
* @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform }} deps
* @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform, vertexBodyTransform }} deps
* Body-transform functions produced by server.js (to avoid circular dependencies).
* @returns {ProviderAdapter[]}
*/
Expand All @@ -101,8 +102,9 @@ function createAllAdapters(env, deps = {}) {
const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform || null });
const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform || null });
const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform || null });
const vertex = createVertexAdapter(env, { bodyTransform: deps.vertexBodyTransform || null });

return [openai, anthropic, copilot, gemini];
return [openai, anthropic, copilot, gemini, vertex];
}

module.exports = {
Expand Down
76 changes: 76 additions & 0 deletions containers/api-proxy/providers/vertex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict';

/**
* Google Vertex AI provider adapter.
*
* Port: 10004 (always bound — returns 503 when no key is configured)
* Auth: x-goog-api-key header
* Credentials: GOOGLE_API_KEY
* Target: VERTEX_API_TARGET (default: aiplatform.googleapis.com)
* Base path: VERTEX_API_BASE_PATH
*
* Used by the Gemini CLI (google-gemini/gemini-cli) when authType === USE_VERTEX
* (i.e. GOOGLE_GENAI_USE_VERTEXAI=true). Setting GOOGLE_VERTEX_BASE_URL routes
* all Vertex AI traffic through the api-proxy sidecar instead of calling
* aiplatform.googleapis.com directly, enabling credential isolation.
*/

const { makeUnconfiguredHealthResponse } = require('../proxy-utils');
const { createProviderAuthScaffold, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory');
const { VERTEX_ENV } = require('../provider-env-constants');

/**
* Create the Google Vertex AI provider adapter.
*
* @param {Record<string, string|undefined>} env - Environment variables
* @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies
* @returns {import('./index').ProviderAdapter}
*/
function createVertexAdapter(env, deps = {}) {
const { apiKey, rawTarget, basePath, bodyTransform } = createProviderAuthScaffold(env, deps, {
keyEnvVar: VERTEX_ENV.KEY,
targetEnvVar: VERTEX_ENV.TARGET,
basePathEnvVar: VERTEX_ENV.BASE_PATH,
defaultTarget: 'aiplatform.googleapis.com',
});
const buildAuthHeaders = () => ({ 'x-goog-api-key': apiKey });

const adapterMethods = createAdapterMethods({
apiKey,
rawTarget,
basePath,
provider: 'vertex',
port: 10004,
defaultTarget: 'aiplatform.googleapis.com',
validationPath: '/v1/projects',
validationHeaders: buildAuthHeaders,
modelsPath: null,
modelsFetchHeaders: null,
});

return buildProviderAdapter({
name: 'vertex',
port: 10004,
isManagementPort: false,
alwaysBind: true,
adapterMethods,
getAuthHeaders() {
return buildAuthHeaders();
},
bodyTransform,
isEnabled() { return !!apiKey; },
/** Response returned for all requests when no GOOGLE_API_KEY is configured. */
getUnconfiguredResponse() {
return {
statusCode: 503,
body: { error: 'Vertex AI proxy not configured (no GOOGLE_API_KEY). Set GOOGLE_API_KEY in the AWF runner environment to enable credential isolation.' },
};
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
return makeUnconfiguredHealthResponse('awf-api-proxy-vertex', 'GOOGLE_API_KEY not configured in api-proxy sidecar');
},
});
}

module.exports = { createVertexAdapter };
12 changes: 6 additions & 6 deletions containers/api-proxy/server.models.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,17 +359,17 @@ describe('buildModelsJson', () => {
expect(result).toHaveProperty('model_aliases');
});

it('should include all four providers', () => {
it('should include all five providers', () => {
const result = buildModelsJson();
const providerKeys = Object.keys(result.providers);
expect(providerKeys).toHaveLength(4);
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini']));
expect(providerKeys).toHaveLength(5);
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'vertex']));
});

it('should set models to null for uncached providers', () => {
const result = buildModelsJson();
// Without populating cachedModels, all models fields should be null
for (const provider of ['openai', 'anthropic', 'copilot', 'gemini']) {
for (const provider of ['openai', 'anthropic', 'copilot', 'gemini', 'vertex']) {
expect(result.providers[provider].models).toBeNull();
}
});
Expand Down Expand Up @@ -505,8 +505,8 @@ describe('writeModelsJson', () => {
expect(typeof data.timestamp).toBe('string');
expect(typeof data.providers).toBe('object');
const providerKeys = Object.keys(data.providers);
expect(providerKeys).toHaveLength(4);
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini']));
expect(providerKeys).toHaveLength(5);
expect(providerKeys).toEqual(expect.arrayContaining(['openai', 'anthropic', 'copilot', 'gemini', 'vertex']));
expect(data).toHaveProperty('model_aliases');
});

Expand Down
9 changes: 6 additions & 3 deletions containers/api-proxy/server.network.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,15 +376,15 @@ describe('reflectEndpoints', () => {
resetModelCacheState();
});

it('should return an array of 4 endpoints', () => {
it('should return an array of 5 endpoints', () => {
const result = reflectEndpoints();
expect(result.endpoints).toHaveLength(4);
expect(result.endpoints).toHaveLength(5);
});

it('should include all expected providers', () => {
const result = reflectEndpoints();
const providers = result.endpoints.map((e) => e.provider);
expect(providers).toEqual(['openai', 'anthropic', 'copilot', 'gemini']);
expect(providers).toEqual(['openai', 'anthropic', 'copilot', 'gemini', 'vertex']);
});

it('should report models_fetch_complete false before fetch runs', () => {
Expand All @@ -404,6 +404,7 @@ describe('reflectEndpoints', () => {
anthropic: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false },
copilot: { enabled: false, strategy: 'middle_power', excludeEngines: [], suppressed: true, suppression_reason: 'copilot_standard_authoritative' },
gemini: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false },
vertex: { enabled: true, strategy: 'middle_power', excludeEngines: [], suppressed: false },
});
});

Expand Down Expand Up @@ -559,6 +560,7 @@ describe('reflectEndpoints', () => {
anthropic: 10001,
copilot: 10002,
gemini: 10003,
vertex: 10004,
});
});

Expand All @@ -569,5 +571,6 @@ describe('reflectEndpoints', () => {
expect(urlMap.anthropic).toBe('http://api-proxy:10001/v1/models');
expect(urlMap.copilot).toBe('http://api-proxy:10002/models');
expect(urlMap.gemini).toBe('http://api-proxy:10003/v1beta/models');
expect(urlMap.vertex).toBeNull();
});
});
10 changes: 8 additions & 2 deletions docs/api-proxy-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ The API proxy sidecar receives **real credentials** and routing configuration:
| `COPILOT_PROVIDER_API_KEY` | Real API key | `--enable-api-proxy` and env set | BYOK provider API key (e.g. Azure / OpenRouter) injected into upstream requests. **Independently** triggers Copilot sidecar routing (no `COPILOT_GITHUB_TOKEN` required); typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream. |
| `COPILOT_PROVIDER_BASE_URL` | Real upstream URL | `--enable-api-proxy` and env set | User-supplied upstream URL for direct-BYOK mode; sidecar forwards Copilot CLI requests there instead of `api.githubcopilot.com`. |
| `GEMINI_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Gemini API key (injected into requests) |
| `GOOGLE_API_KEY` | Real API key | `--enable-api-proxy` and env set | Google Vertex AI API key (injected into `x-goog-api-key` header) |
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |

Expand All @@ -152,6 +153,8 @@ The agent container receives **redacted placeholders** and proxy URLs:
| `GOOGLE_GEMINI_BASE_URL` | `http://172.30.0.30:10003` | `GEMINI_API_KEY` provided to host | Redirects Gemini CLI to proxy (primary var read by Gemini CLI) |
| `GEMINI_API_BASE_URL` | `http://172.30.0.30:10003` | `GEMINI_API_KEY` provided to host | Redirects Gemini SDK to proxy (kept for backward compatibility) |
| `GEMINI_API_KEY` | `gemini-api-key-placeholder-for-credential-isolation` | `GEMINI_API_KEY` provided to host | Placeholder so Gemini CLI auth check passes (real key in sidecar) |
| `GOOGLE_VERTEX_BASE_URL` | `http://172.30.0.30:10004` | `GOOGLE_API_KEY` provided to host | Redirects Vertex AI requests to proxy |
| `GOOGLE_API_KEY` | `google-api-key-placeholder-for-credential-isolation` | `GOOGLE_API_KEY` provided to host | Placeholder so Vertex mode auth checks pass (real key in sidecar) |
| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy |
Expand All @@ -161,7 +164,7 @@ The agent container receives **redacted placeholders** and proxy URLs:
| `AWF_ONE_SHOT_TOKENS` | `COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,...` | Always | Tokens protected by one-shot-token library |

:::note[Gemini setup is conditional]
`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, the `GEMINI_API_KEY` placeholder, the `~/.gemini` home directory mount, and the `AWF_GEMINI_ENABLED` signal are only configured when `GEMINI_API_KEY` is provided to the host AWF process. This avoids spurious log entries and unnecessary directory setup in non-Gemini runs (e.g. Copilot-only workflows).
`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_BASE_URL`, the `GEMINI_API_KEY` placeholder, the `GOOGLE_VERTEX_BASE_URL`/`GOOGLE_API_KEY` placeholders, the `~/.gemini` home directory mount, and the `AWF_GEMINI_ENABLED` signal are only configured when `GEMINI_API_KEY` or `GOOGLE_API_KEY` is provided to the host AWF process. This avoids spurious log entries and unnecessary directory setup in non-Gemini runs (e.g. Copilot-only workflows).

`GOOGLE_GEMINI_BASE_URL` is the primary variable read by the Gemini CLI (`google-gemini/gemini-cli`). `GEMINI_API_BASE_URL` is kept for backward compatibility with older SDK versions.

Expand Down Expand Up @@ -288,6 +291,7 @@ sudo awf --enable-api-proxy [OPTIONS] -- COMMAND
- `OPENAI_API_KEY` — OpenAI API key
- `ANTHROPIC_API_KEY` — Anthropic API key
- `GEMINI_API_KEY` — Google Gemini API key
- `GOOGLE_API_KEY` — Google Vertex AI API key
- `COPILOT_GITHUB_TOKEN` — GitHub Copilot access token. Sidecar routes Copilot CLI to `api.githubcopilot.com` (CAPI BYOK / offline mode).
- `COPILOT_PROVIDER_API_KEY` — BYOK provider API key (Azure Foundry / OpenRouter / custom OpenAI-compatible upstream). **Independently** enables Copilot sidecar routing without `COPILOT_GITHUB_TOKEN`; typically combined with `COPILOT_PROVIDER_BASE_URL` to point at an arbitrary upstream.

Expand Down Expand Up @@ -317,6 +321,8 @@ If the key is present only in `secrets.*` but not exported into the step's `env:
| `--openai-api-target <host>` | `api.openai.com` | Custom upstream for OpenAI API requests (e.g. Azure OpenAI or an internal LLM router). Can also be set via `OPENAI_API_TARGET` env var. |
| `--anthropic-api-target <host>` | `api.anthropic.com` | Custom upstream for Anthropic API requests (e.g. an internal Claude router). Can also be set via `ANTHROPIC_API_TARGET` env var. |
| `--copilot-api-target <host>` | auto-derived | Custom upstream for GitHub Copilot API requests (useful for GHES). Can also be set via `COPILOT_API_TARGET` env var. |
| `--vertex-api-target <host>` | `aiplatform.googleapis.com` | Custom upstream for Vertex API requests. Can also be set via `VERTEX_API_TARGET` env var. |
| `--vertex-api-base-path <path>` | empty | Base path prefix for Vertex API requests. Can also be set via `VERTEX_API_BASE_PATH` env var. |

> **Important**: When using a custom `--openai-api-target` or `--anthropic-api-target`, you must add the target domain to `--allow-domains` so the firewall permits outbound traffic. AWF will emit a warning if a custom target is set but not in the allowlist.

Expand Down Expand Up @@ -367,7 +373,7 @@ The sidecar container:
- **Image**: `ghcr.io/github/gh-aw-firewall/api-proxy:latest`
- **Base**: `node:22-alpine`
- **Network**: `awf-net` at `172.30.0.30`
- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini)
- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot), 10003 (Google Gemini), 10004 (Google Vertex AI)
- **Proxy**: Routes via Squid at `http://172.30.0.10:3128`

## Model Fallback
Expand Down
22 changes: 22 additions & 0 deletions src/api-proxy-config-domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,28 @@ describe('resolveApiTargetsToAllowedDomains', () => {
expect(domains).toContain('https://flag.gemini.internal');
expect(domains).not.toContain('https://env.gemini.internal');
});

it('should add vertex-api-target option to allowed domains', () => {
const domains: string[] = ['github.com'];
resolveApiTargetsToAllowedDomains({ vertexApiTarget: 'custom.vertex.internal' }, domains);
expect(domains).not.toContain('custom.vertex.internal');
expect(domains).toContain('https://custom.vertex.internal');
});

it('should read VERTEX_API_TARGET from env when flag not set', () => {
const domains: string[] = [];
const env = { VERTEX_API_TARGET: 'env.vertex.internal' };
resolveApiTargetsToAllowedDomains({}, domains, env);
expect(domains).toContain('https://env.vertex.internal');
});

it('should prefer vertexApiTarget option over VERTEX_API_TARGET env var', () => {
const domains: string[] = [];
const env = { VERTEX_API_TARGET: 'env.vertex.internal' };
resolveApiTargetsToAllowedDomains({ vertexApiTarget: 'flag.vertex.internal' }, domains, env);
expect(domains).toContain('https://flag.vertex.internal');
expect(domains).not.toContain('https://env.vertex.internal');
});
});

describe('extractGhesDomainsFromEngineApiTarget (via resolveApiTargetsToAllowedDomains)', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/api-proxy-config-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function resolveApiTargetsToAllowedDomains(
openaiApiTarget?: string;
anthropicApiTarget?: string;
geminiApiTarget?: string;
vertexApiTarget?: string;
},
allowedDomains: string[],
env: Record<string, string | undefined> = process.env,
Expand Down Expand Up @@ -144,6 +145,12 @@ export function resolveApiTargetsToAllowedDomains(
apiTargets.push(env['GEMINI_API_TARGET']);
}

if (options.vertexApiTarget) {
apiTargets.push(options.vertexApiTarget);
} else if (env['VERTEX_API_TARGET']) {
apiTargets.push(env['VERTEX_API_TARGET']);
}

// Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant
const ghecDomains = extractGhecDomainsFromServerUrl(env);
if (ghecDomains.length > 0) {
Expand Down
9 changes: 7 additions & 2 deletions src/api-proxy-config-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ApiProxyValidationResult {
* @param hasCopilotKey - Whether a GitHub Copilot API key is present
* @param hasGeminiKey - Whether a Google Gemini API key is present
* @param hasAnthropicWif - Whether Anthropic WIF (GitHub OIDC) auth is configured
* @param hasGoogleApiKey - Whether a Google API key for Vertex AI is present
* @returns ApiProxyValidationResult with warnings and debug messages
*/
export function validateApiProxyConfig(
Expand All @@ -29,6 +30,7 @@ export function validateApiProxyConfig(
hasCopilotKey?: boolean,
hasGeminiKey?: boolean,
hasAnthropicWif?: boolean,
hasGoogleApiKey?: boolean,
): ApiProxyValidationResult {
if (!enableApiProxy) {
return { enabled: false, warnings: [], debugMessages: [] };
Expand All @@ -37,9 +39,9 @@ export function validateApiProxyConfig(
const warnings: string[] = [];
const debugMessages: string[] = [];

if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey && !hasAnthropicWif) {
if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey && !hasAnthropicWif && !hasGoogleApiKey) {
warnings.push('⚠️ API proxy enabled but no API keys found in environment');
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, or GEMINI_API_KEY to use the proxy');
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY to use the proxy');
}
if (hasOpenaiKey) {
debugMessages.push('OpenAI API key detected - will be held securely in sidecar');
Expand All @@ -56,6 +58,9 @@ export function validateApiProxyConfig(
if (hasGeminiKey) {
debugMessages.push('Google Gemini API key detected - will be held securely in sidecar');
}
if (hasGoogleApiKey) {
debugMessages.push('Google API key (Vertex AI) detected - will be held securely in sidecar');
}

return { enabled: true, warnings, debugMessages };
}
Expand Down
Loading
Loading