-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathapi-proxy-service-config.ts
More file actions
250 lines (237 loc) · 11.8 KB
/
api-proxy-service-config.ts
File metadata and controls
250 lines (237 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import * as path from 'path';
import {
API_PROXY_CONTAINER_NAME,
SQUID_PORT,
} from '../constants';
import { stripScheme } from '../host-env';
import { buildRuntimeImageRef } from '../image-tag';
import { WrapperConfig, API_PROXY_HEALTH_PORT } from '../types';
import { getConfigEnvValue, getLowerCaseProcessEnvValue, pickEnvVars } from '../env-utils';
import { NetworkConfig, ImageBuildConfig } from './squid-service';
import { applyHostPathPrefixToVolumes } from './host-path-prefix';
import { buildContainerSecurityHardening } from './service-security';
interface ApiProxyServiceConfigParams {
config: WrapperConfig;
networkConfig: NetworkConfig;
apiProxyLogsPath: string;
imageConfig: ImageBuildConfig;
}
/**
* Builds provider API target/basePath environment variables for the api-proxy container.
* Centralizes the repetitive per-provider target/basePath conditional env generation.
*/
function buildProviderTargetEnv(config: WrapperConfig): Record<string, string> {
const copilotProviderType = config.copilotProviderType || getConfigEnvValue(config, 'COPILOT_PROVIDER_TYPE');
const copilotProviderBaseUrl = config.copilotProviderBaseUrl || getConfigEnvValue(config, 'COPILOT_PROVIDER_BASE_URL');
const copilotProviderApiKey = config.copilotProviderApiKey;
const env: Record<string, string> = {};
const providers: Array<{ target?: string; basePath?: string; envTarget: string; envBasePath: string; stripTarget?: boolean }> = [
{ target: config.copilotApiTarget, basePath: config.copilotApiBasePath, envTarget: 'COPILOT_API_TARGET', envBasePath: 'COPILOT_API_BASE_PATH', stripTarget: true },
{ target: config.openaiApiTarget, basePath: config.openaiApiBasePath, envTarget: 'OPENAI_API_TARGET', envBasePath: 'OPENAI_API_BASE_PATH', stripTarget: true },
{ target: config.anthropicApiTarget, basePath: config.anthropicApiBasePath, envTarget: 'ANTHROPIC_API_TARGET', envBasePath: 'ANTHROPIC_API_BASE_PATH', stripTarget: true },
{ target: config.geminiApiTarget, basePath: config.geminiApiBasePath, envTarget: 'GEMINI_API_TARGET', envBasePath: 'GEMINI_API_BASE_PATH', stripTarget: true },
];
for (const { target, basePath, envTarget, envBasePath, stripTarget } of providers) {
if (target) env[envTarget] = stripTarget ? stripScheme(target) : target;
if (basePath) env[envBasePath] = basePath;
}
// Copilot-specific provider passthrough
if (copilotProviderType) env.COPILOT_PROVIDER_TYPE = copilotProviderType;
if (copilotProviderBaseUrl) env.COPILOT_PROVIDER_BASE_URL = copilotProviderBaseUrl;
if (copilotProviderApiKey) env.COPILOT_PROVIDER_API_KEY = copilotProviderApiKey;
// Pre-startup model validation (non-sensitive config value)
if (config.requestedModel) env.AWF_REQUESTED_MODEL = config.requestedModel;
if (config.copilotByokExtraHeaders !== undefined) {
env.AWF_BYOK_EXTRA_HEADERS = JSON.stringify(config.copilotByokExtraHeaders);
}
if (config.copilotByokExtraBodyFields !== undefined) {
env.AWF_BYOK_EXTRA_BODY_FIELDS = JSON.stringify(config.copilotByokExtraBodyFields);
}
const providerSessionId = resolveProviderSessionId(config);
if (providerSessionId) {
env.AWF_PROVIDER_SESSION_ID = providerSessionId;
}
return env;
}
function resolveProviderSessionId(config: WrapperConfig): string | undefined {
const value = getConfigEnvValue(config, 'AWF_PROVIDER_SESSION_ID')
?? process.env.AWF_PROVIDER_SESSION_ID
?? process.env.GH_AW_GITHUB_RUN_ID
?? process.env.GITHUB_RUN_ID;
const normalizedValue = value?.trim();
return normalizedValue || undefined;
}
export function buildApiProxyServiceConfig(params: ApiProxyServiceConfigParams): any {
const { config, networkConfig, apiProxyLogsPath, imageConfig } = params;
if (!networkConfig.proxyIp) {
throw new Error('buildApiProxyServiceConfig: networkConfig.proxyIp is required');
}
const { useGHCR, registry, parsedTag, projectRoot } = imageConfig;
const normalizedAuthType = getLowerCaseProcessEnvValue('AWF_AUTH_TYPE') || '';
const proxyService: any = {
container_name: API_PROXY_CONTAINER_NAME,
networks: {
'awf-net': {
ipv4_address: networkConfig.proxyIp,
},
},
volumes: applyHostPathPrefixToVolumes(
[
// Mount log directory for api-proxy logs
`${apiProxyLogsPath}:/var/log/api-proxy:rw`,
],
config.dockerHostPathPrefix,
),
environment: {
// Pass API keys securely to sidecar (not visible to agent)
...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }),
...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }),
...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }),
...(config.geminiApiKey && { GEMINI_API_KEY: config.geminiApiKey }),
// Configurable API targets (for GHES/GHEC / custom endpoints)
// Strip any scheme prefix — server.js also normalizes defensively, but
// stripping here prevents a scheme-prefixed hostname from reaching the
// container at all (belt-and-suspenders for gh-aw#25137).
...buildProviderTargetEnv(config),
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
// Forward GITHUB_API_URL so api-proxy can route /models to the correct GitHub REST API
// target on GHES/GHEC (e.g. api.mycompany.ghe.com instead of api.github.com)
...(process.env.GITHUB_API_URL && { GITHUB_API_URL: process.env.GITHUB_API_URL }),
// Note: AWF_VERSION is intentionally NOT forwarded here. It is baked into the api-proxy
// container image at release build time (via --build-arg AWF_VERSION=...), so the
// token-usage.jsonl _schema field reflects the api-proxy image version rather than
// the CLI version. This ensures correct versioning when --image-tag pins the proxy
// to a different release.
// Route through Squid to respect domain whitelisting
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
// Prevent curl health check from routing localhost through Squid
NO_PROXY: 'localhost,127.0.0.1,::1',
no_proxy: 'localhost,127.0.0.1,::1',
// OpenTelemetry distributed tracing — forward endpoint, headers, service name, and
// parent trace context so api-proxy spans are children of the workflow trace.
// OTEL_EXPORTER_OTLP_ENDPOINT activates OTLP/HTTP export (via Squid); when absent
// spans are written to /var/log/api-proxy/otel.jsonl as a local file fallback.
...pickEnvVars(
'OTEL_EXPORTER_OTLP_ENDPOINT',
'OTEL_EXPORTER_OTLP_HEADERS',
'GITHUB_AW_OTEL_TRACE_ID',
'GITHUB_AW_OTEL_PARENT_SPAN_ID',
),
OTEL_SERVICE_NAME: process.env.OTEL_SERVICE_NAME || 'awf-api-proxy',
// Rate limiting configuration
...(config.rateLimitConfig && {
AWF_RATE_LIMIT_ENABLED: String(config.rateLimitConfig.enabled),
AWF_RATE_LIMIT_RPM: String(config.rateLimitConfig.rpm),
AWF_RATE_LIMIT_RPH: String(config.rateLimitConfig.rph),
AWF_RATE_LIMIT_BYTES_PM: String(config.rateLimitConfig.bytesPm),
}),
...(config.maxEffectiveTokens !== undefined && {
AWF_MAX_EFFECTIVE_TOKENS: String(config.maxEffectiveTokens),
}),
...(config.effectiveTokenModelMultipliers && {
AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS: JSON.stringify(config.effectiveTokenModelMultipliers),
}),
...(config.effectiveTokenDefaultModelMultiplier !== undefined && {
AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER: String(config.effectiveTokenDefaultModelMultiplier),
}),
...(config.maxModelMultiplierCap !== undefined && {
AWF_MAX_MODEL_MULTIPLIER: String(config.maxModelMultiplierCap),
}),
...(config.maxRuns !== undefined && {
AWF_MAX_RUNS: String(config.maxRuns),
}),
...(config.maxPermissionDenied !== undefined && {
AWF_MAX_PERMISSION_DENIED: String(config.maxPermissionDenied),
}),
...(config.agentTimeout !== undefined && {
AWF_AGENT_TIMEOUT_MINUTES: String(config.agentTimeout),
}),
// Model alias configuration
...(config.modelAliases && {
AWF_MODEL_ALIASES: JSON.stringify({ models: config.modelAliases }),
}),
...(config.modelFallback && {
AWF_MODEL_FALLBACK: JSON.stringify(config.modelFallback),
}),
// Anthropic prompt-cache optimizations
...(config.anthropicAutoCache && {
AWF_ANTHROPIC_AUTO_CACHE: '1',
...(config.anthropicCacheTailTtl && { AWF_ANTHROPIC_CACHE_TAIL_TTL: config.anthropicCacheTailTtl }),
}),
// Enable token steering when explicitly requested
...(config.enableTokenSteering && { AWF_ENABLE_TOKEN_STEERING: 'true' }),
// Token and model-alias diagnostic logging
...(config.debugTokens && { AWF_DEBUG_TOKENS: '1' }),
...(config.tokenLogDir && { AWF_TOKEN_LOG_DIR: config.tokenLogDir }),
// OIDC authentication (Azure, AWS, GCP, Anthropic)
...pickEnvVars(
'AWF_AUTH_TYPE',
'AWF_AUTH_PROVIDER',
'AWF_AUTH_OIDC_AUDIENCE',
// Azure
'AWF_AUTH_AZURE_TENANT_ID',
'AWF_AUTH_AZURE_CLIENT_ID',
'AWF_AUTH_AZURE_SCOPE',
'AWF_AUTH_AZURE_CLOUD',
// AWS
'AWF_AUTH_AWS_ROLE_ARN',
'AWF_AUTH_AWS_REGION',
'AWF_AUTH_AWS_ROLE_SESSION_NAME',
// GCP
'AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER',
'AWF_AUTH_GCP_SERVICE_ACCOUNT',
'AWF_AUTH_GCP_SCOPE',
// Anthropic
'AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID',
'AWF_AUTH_ANTHROPIC_ORGANIZATION_ID',
'AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID',
'AWF_AUTH_ANTHROPIC_WORKSPACE_ID',
'AWF_AUTH_ANTHROPIC_TOKEN_URL',
),
// GitHub Actions OIDC runtime tokens (needed by OIDC token provider in api-proxy)
...(normalizedAuthType === 'github-oidc' && pickEnvVars(
'ACTIONS_ID_TOKEN_REQUEST_URL',
'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
)),
// Anthropic request optimisations (all opt-in via env vars on the host)
...pickEnvVars(
'AWF_ANTHROPIC_AUTO_CACHE',
'AWF_ANTHROPIC_CACHE_TAIL_TTL',
'AWF_ANTHROPIC_DROP_TOOLS',
'AWF_ANTHROPIC_STRIP_ANSI',
),
// Custom auth header names for internal AI gateways
...(config.openaiApiAuthHeader && { AWF_OPENAI_AUTH_HEADER: config.openaiApiAuthHeader }),
...(config.anthropicApiAuthHeader && { AWF_ANTHROPIC_AUTH_HEADER: config.anthropicApiAuthHeader }),
...(config.anthropicTokenUrl && { AWF_AUTH_ANTHROPIC_TOKEN_URL: config.anthropicTokenUrl }),
// NOTE: AWF_ANTHROPIC_TRANSFORM_FILE is intentionally NOT forwarded from the host.
// The api-proxy container holds live API credentials; loading arbitrary host-side JS
// files into it would create an arbitrary-code-execution risk. If you need a custom
// transform, bake your hook.js into a custom container image and set the env var
// directly in that image's Dockerfile / entrypoint — do NOT forward from the host.
},
healthcheck: {
test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`],
interval: '2s',
timeout: '3s',
retries: 15,
start_period: '30s',
},
// Security hardening and resource limits to prevent DoS attacks
...buildContainerSecurityHardening({ memLimit: '512m', pidsLimit: 100, cpuShares: 512 }),
stop_grace_period: '2s',
};
// Use GHCR image or build locally
if (useGHCR) {
proxyService.image = buildRuntimeImageRef(registry, 'api-proxy', parsedTag);
} else {
proxyService.build = {
context: path.join(projectRoot, 'containers/api-proxy'),
dockerfile: 'Dockerfile',
};
}
return proxyService;
}