@workos/quickstudy 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,6 +118,37 @@ experiment module — changing them changes the experiment's identity, by
118
118
  design. See the benchmark repository's `examples/weekly-sweep.yml` for what
119
119
  a full scheduled sweep costs and looks like in CI.
120
120
 
121
+ ### Routing through an LLM gateway
122
+
123
+ The vendor CLIs and the diagnosis analyst all honor endpoint overrides, so a
124
+ gateway or proxy (Cloudflare AI Gateway, LiteLLM, a corporate egress) is an
125
+ env change, not a code change. Each adapter declares its overrides
126
+ (`optionalHostEnv`); the harness forwards them into attempt containers only
127
+ when set, adds a base URL's host to the egress allowlist, and points the
128
+ preflight key probe at it:
129
+
130
+ | Agent | Overrides | How the CLI gets them |
131
+ | ------ | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
132
+ | claude | `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS` | env, read natively by Claude Code (and by the `diagnose` analyst) |
133
+ | codex | `OPENAI_BASE_URL`, `OPENAI_CUSTOM_HEADERS` | rendered into `config.toml` as a custom model provider (`base_url`, `http_headers`); codex has no env channel for either |
134
+
135
+ The `*_CUSTOM_HEADERS` bundles are `Name: Value` lines and are redacted from
136
+ artifacts like a key; a `cf-aig-metadata` line is how a gateway attributes
137
+ the traffic per app. With Cloudflare AI Gateway holding the provider keys,
138
+ the gateway token *is* the API key:
139
+
140
+ ```bash
141
+ export ANTHROPIC_BASE_URL=https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic
142
+ export ANTHROPIC_API_KEY=<gateway token>
143
+ export ANTHROPIC_CUSTOM_HEADERS="cf-aig-authorization: Bearer <gateway token>"
144
+ export OPENAI_BASE_URL=https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/openai
145
+ export OPENAI_API_KEY=<gateway token>
146
+ export OPENAI_CUSTOM_HEADERS='cf-aig-metadata: {"app":"my-evals"}'
147
+ ```
148
+
149
+ Leave every override unset and both agents run vendor-direct against
150
+ api.anthropic.com and api.openai.com — nothing is rendered, nothing changes.
151
+
121
152
  ## How it works
122
153
 
123
154
  Every attempt is one point in a 2-D plan:
@@ -181,7 +212,9 @@ failing pair's harness-selected evidence (per-check tallies, transcript
181
212
  excerpts, egress denials) to a structured LLM call and writes `diagnosis.json`
182
213
  beside `report.json` — findings are evidence-linked hypotheses, never
183
214
  verdicts, keeping the report itself attribution-free. Requires
184
- `ANTHROPIC_API_KEY`; the UI renders the result as a Diagnosis view.
215
+ `ANTHROPIC_API_KEY` (and follows the claude adapter's gateway overrides, see
216
+ [Routing through an LLM gateway](#routing-through-an-llm-gateway)); the UI
217
+ renders the result as a Diagnosis view.
185
218
 
186
219
  ## Writing evals
187
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { McpServerConfig } from "../isolation/mcp.ts";
17
+ import { endpointUrl, parseHeaderLines } from "../secrets.ts";
17
18
  import { parseClaudeStream } from "./parse.ts";
18
19
  import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
19
20
 
@@ -38,10 +39,21 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
38
39
  readonly identity;
39
40
  readonly versionCommand = ["claude", "--version"] as const;
40
41
  readonly requiredHostEnv = ["ANTHROPIC_API_KEY"] as const;
42
+ /**
43
+ * Endpoint overrides, forwarded only when set. ANTHROPIC_BASE_URL routes
44
+ * the CLI (and the key probe) through an Anthropic-compatible gateway —
45
+ * e.g. Cloudflare AI Gateway's `/anthropic` endpoint, where the gateway
46
+ * token doubles as ANTHROPIC_API_KEY; ANTHROPIC_CUSTOM_HEADERS (`Name:
47
+ * Value` lines) carries whatever else that gateway wants, such as
48
+ * `cf-aig-authorization` or `cf-aig-metadata`. Claude Code reads both
49
+ * natively, so no flag changes.
50
+ */
51
+ readonly optionalHostEnv = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS"] as const;
41
52
  /**
42
53
  * Egress-proxy allowlist (harness-default layer). Claude Code documents
43
54
  * corporate-proxy support via HTTPS_PROXY/HTTP_PROXY; api.anthropic.com is
44
- * the LLM endpoint, statsig/sentry are its telemetry (allowed so denial
55
+ * the default LLM endpoint (an ANTHROPIC_BASE_URL override adds its own
56
+ * host at run time), statsig/sentry are its telemetry (allowed so denial
45
57
  * logs stay signal, not noise). Verified live by the egress-proxy regression
46
58
  * check (`bun scripts/dev-attempt.ts --experiment <id> --egress-proxy`).
47
59
  */
@@ -66,8 +78,12 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
66
78
 
67
79
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
80
  return {
69
- url: "https://api.anthropic.com/v1/models",
70
- headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "anthropic-version": "2023-06-01" },
81
+ url: endpointUrl(env["ANTHROPIC_BASE_URL"], "https://api.anthropic.com", "v1/models"),
82
+ headers: {
83
+ ...parseHeaderLines(env["ANTHROPIC_CUSTOM_HEADERS"]),
84
+ "x-api-key": env["ANTHROPIC_API_KEY"] ?? "",
85
+ "anthropic-version": "2023-06-01",
86
+ },
71
87
  };
72
88
  }
73
89
 
@@ -9,15 +9,60 @@
9
9
  * container create, but codex >= 0.144 no longer reads it directly — the
10
10
  * command wrapper pipes it into `codex login --with-api-key` (stdin, never
11
11
  * argv) to write the auth.json the CLI requires.
12
+ *
13
+ * Endpoint overrides: an OPENAI_BASE_URL set on the host (an LLM gateway) is
14
+ * rendered into the same config.toml as a custom model provider — codex
15
+ * 0.149 ignores the env var for its built-in provider (verified live: it
16
+ * still dialed api.openai.com), while a `[model_providers.*]` entry with
17
+ * `env_key = "OPENAI_API_KEY"` routes every request through the override.
18
+ * OPENAI_CUSTOM_HEADERS (`Name: Value` lines, the Codex counterpart of Claude
19
+ * Code's ANTHROPIC_CUSTOM_HEADERS) becomes that provider's `http_headers` —
20
+ * e.g. `cf-aig-metadata` so a gateway can attribute the traffic. Codex has
21
+ * no env-var channel for extra headers, so the values land in config.toml
22
+ * inside the attempt container: the same trust boundary as the env that
23
+ * already carries them, written over stdin, destroyed with the container.
24
+ * With neither override set nothing is rendered and the built-in provider
25
+ * talks to api.openai.com — vendor-direct stays the default.
12
26
  */
13
27
 
14
28
  import type { McpServerConfig } from "../isolation/mcp.ts";
29
+ import { endpointUrl, parseHeaderLines } from "../secrets.ts";
15
30
  import { parseCodexStream } from "./parse.ts";
16
31
  import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
17
32
 
18
33
  /** Container path of the Codex config (containers run as root). */
19
34
  export const CODEX_CONFIG_PATH = "/root/.codex/config.toml";
20
35
 
36
+ /** The config.toml model provider id the OPENAI_* overrides are rendered as. */
37
+ export const CODEX_GATEWAY_PROVIDER = "quickstudy-gateway";
38
+
39
+ /** Codex's own default for the built-in provider; the probe and a headers-only override use it. */
40
+ const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
41
+
42
+ /**
43
+ * A custom model provider pointing at `baseUrl` (the Responses API, which
44
+ * codex requires), carrying `headers` on every request, selected as the
45
+ * default. Top-level keys must precede any table, so this block always
46
+ * renders first in the file. JSON string escapes are valid TOML basic-string
47
+ * escapes, which keeps header values such as JSON metadata intact.
48
+ */
49
+ function renderCodexProviderToml(baseUrl: string, headers: Readonly<Record<string, string>>): string {
50
+ const entries = Object.entries(headers);
51
+ return [
52
+ `model_provider = ${JSON.stringify(CODEX_GATEWAY_PROVIDER)}`,
53
+ "",
54
+ `[model_providers.${CODEX_GATEWAY_PROVIDER}]`,
55
+ 'name = "OpenAI (quickstudy endpoint overrides)"',
56
+ `base_url = ${JSON.stringify(baseUrl)}`,
57
+ 'env_key = "OPENAI_API_KEY"',
58
+ 'wire_api = "responses"',
59
+ ...(entries.length > 0
60
+ ? [`http_headers = { ${entries.map(([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`).join(", ")} }`]
61
+ : []),
62
+ "",
63
+ ].join("\n");
64
+ }
65
+
21
66
  /**
22
67
  * `config.toml` MCP table rendered from the runtime's MCP servers (url-based,
23
68
  * credential-free — authed servers already point at the token-proxy sidecar).
@@ -37,13 +82,24 @@ export class CodexAdapter implements ContainerAgentAdapter {
37
82
  readonly identity;
38
83
  readonly versionCommand = ["codex", "--version"] as const;
39
84
  readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
85
+ /**
86
+ * Endpoint overrides, forwarded only when set: OPENAI_BASE_URL (vendor
87
+ * default https://api.openai.com/v1) — e.g. Cloudflare AI Gateway's
88
+ * `/openai` endpoint, where the gateway token doubles as OPENAI_API_KEY —
89
+ * and OPENAI_CUSTOM_HEADERS (`Name: Value` lines) for whatever else the
90
+ * endpoint wants on each request, such as `cf-aig-metadata`. Both are
91
+ * rendered into config.toml by setupFiles (see the module comment); the
92
+ * key probe follows them.
93
+ */
94
+ readonly optionalHostEnv = ["OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"] as const;
40
95
  /**
41
96
  * Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
42
97
  * reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
43
98
  * verified live by the egress-proxy regression check (the CONNECT tunnel
44
99
  * to api.openai.com round-tripped). chatgpt.com came from that run's
45
100
  * denial log: codex probes its ChatGPT-auth endpoints even under key auth.
46
- * (Its github.com update-check denials are left denied on purpose.)
101
+ * (Its github.com update-check denials are left denied on purpose.) An
102
+ * OPENAI_BASE_URL override adds its own host at run time.
47
103
  */
48
104
  readonly egressHosts = ["api.openai.com", "auth.openai.com", "chatgpt.com", "*.chatgpt.com"] as const;
49
105
 
@@ -66,14 +122,22 @@ export class CodexAdapter implements ContainerAgentAdapter {
66
122
 
67
123
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
124
  return {
69
- url: "https://api.openai.com/v1/models",
70
- headers: { Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
125
+ url: endpointUrl(env["OPENAI_BASE_URL"], OPENAI_DEFAULT_BASE_URL, "models"),
126
+ headers: { ...parseHeaderLines(env["OPENAI_CUSTOM_HEADERS"]), Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
71
127
  };
72
128
  }
73
129
 
74
130
  setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
75
- if (ctx.mcpServers === undefined) return {};
76
- return { [CODEX_CONFIG_PATH]: renderCodexMcpToml(ctx.mcpServers) };
131
+ const sections: string[] = [];
132
+ const baseUrl = ctx.overrides?.["OPENAI_BASE_URL"];
133
+ const headers = parseHeaderLines(ctx.overrides?.["OPENAI_CUSTOM_HEADERS"]);
134
+ // Either override needs the custom provider; headers alone keep the vendor endpoint.
135
+ if (baseUrl !== undefined || Object.keys(headers).length > 0) {
136
+ sections.push(renderCodexProviderToml(baseUrl ?? OPENAI_DEFAULT_BASE_URL, headers));
137
+ }
138
+ if (ctx.mcpServers !== undefined) sections.push(renderCodexMcpToml(ctx.mcpServers));
139
+ if (sections.length === 0) return {};
140
+ return { [CODEX_CONFIG_PATH]: sections.join("\n") };
77
141
  }
78
142
 
79
143
  command(ctx: ContainerAttemptContext): string[] {
@@ -132,6 +132,15 @@ export interface ContainerAttemptContext {
132
132
  mcpServers?: Readonly<Record<string, McpServerConfig>>;
133
133
  /** The runtime's native web-tool policy (default: blocked). */
134
134
  webPolicy: WebPolicy;
135
+ /**
136
+ * The adapter's own endpoint overrides that are set on the host (every
137
+ * `optionalHostEnv` value, header bundles included — never the provider
138
+ * keys), for a CLI that takes a config-file entry rather than an env var.
139
+ * Anything rendered from these is written inside the attempt container
140
+ * only, the same boundary the env already crosses. Absent when none are
141
+ * set.
142
+ */
143
+ overrides?: Readonly<Record<string, string>>;
135
144
  }
136
145
 
137
146
  /**
@@ -153,10 +162,11 @@ export interface ContainerAgentAdapter {
153
162
  /** Command used during preflight to record the executable's exact version. */
154
163
  versionCommand?: readonly string[];
155
164
  /**
156
- * Cheap authenticated request composed from the collected provider keys,
157
- * probed at run preflight so a present-but-rejected key aborts BEFORE any
158
- * container spend. A 401/403 response stops the run; network failures only
159
- * warn — an unreachable probe must not gate a run the agent might complete.
165
+ * Cheap authenticated request composed from the collected provider keys
166
+ * (plus any `optionalHostEnv` overrides that are set), probed at run
167
+ * preflight so a present-but-rejected key aborts BEFORE any container
168
+ * spend. A 401/403 response stops the run; network failures only warn — an
169
+ * unreachable probe must not gate a run the agent might complete.
160
170
  */
161
171
  keyProbe?(env: Record<string, string>): { url: string; headers: Record<string, string> };
162
172
  /**
@@ -164,6 +174,15 @@ export interface ContainerAgentAdapter {
164
174
  * (shared provider keys — see src/secrets.ts).
165
175
  */
166
176
  requiredHostEnv: readonly string[];
177
+ /**
178
+ * Host env vars forwarded into the container when set, never required:
179
+ * endpoint overrides such as a vendor base URL that routes the CLI through
180
+ * an LLM gateway or proxy, and the custom headers that gateway needs. A
181
+ * value that is an absolute URL also adds its host to the egress allowlist
182
+ * and retargets the key probe; secret-named values (headers carrying a
183
+ * bearer token) are redacted from artifacts like provider keys.
184
+ */
185
+ optionalHostEnv?: readonly string[];
167
186
  /**
168
187
  * Hosts this agent's CLI must reach (its LLM/vendor endpoints) — the
169
188
  * harness-default layer of the egress allowlist when the run uses the
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.8.1", // x-release-please-version
12
+ version: "0.10.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -27,6 +27,13 @@ export interface PairEnvironment {
27
27
  /** Runtime text prepended to the eval prompt for this pair (null = prompt verbatim). */
28
28
  promptPreamble: string | null;
29
29
  budgets: LifecycleBudgets;
30
+ /**
31
+ * The agent's endpoint overrides in force (non-secret base URLs, e.g.
32
+ * OPENAI_BASE_URL → an LLM gateway route), recorded in full so a changed
33
+ * route changes identity and a resume cannot mix routes. Absent when the
34
+ * agent runs vendor-direct — such pairs hash exactly as before.
35
+ */
36
+ endpoints?: Record<string, string>;
30
37
  }
31
38
 
32
39
  /** The prompt an agent receives: the runtime's preamble (if any), a blank line, the eval's verbatim body. */
@@ -43,6 +50,8 @@ export function resolvePairEnvironments(options: {
43
50
  images?: Record<string, RuntimeImageIdentity>;
44
51
  egressProxy?: boolean;
45
52
  adapterHosts?: Record<string, readonly string[]>;
53
+ /** Per experiment: the agent's non-secret endpoint overrides (see PairEnvironment.endpoints). */
54
+ adapterEndpoints?: Record<string, Record<string, string>>;
46
55
  budgets?: Partial<LifecycleBudgets>;
47
56
  mcpHashes?: Record<string, string>;
48
57
  imageTags?: Record<string, string>;
@@ -77,6 +86,10 @@ export function resolvePairEnvironments(options: {
77
86
  return text === "" ? null : text;
78
87
  })(),
79
88
  budgets: { ...budgets },
89
+ ...(() => {
90
+ const endpoints = options.adapterEndpoints?.[experiment.id];
91
+ return endpoints !== undefined && Object.keys(endpoints).length > 0 ? { endpoints: { ...endpoints } } : {};
92
+ })(),
80
93
  };
81
94
  }
82
95
  return pairs;
@@ -59,15 +59,20 @@ export interface ExecResult {
59
59
  timedOut: boolean;
60
60
  }
61
61
 
62
- /** Run `docker <args>`, capturing output. Never throws for non-zero exits. */
62
+ /**
63
+ * Run `docker <args>`, capturing output. Never throws for non-zero exits.
64
+ * `env` is added to the docker CLI's own environment — the channel for
65
+ * values named on argv by `-e KEY` alone.
66
+ */
63
67
  async function runDocker(
64
68
  args: string[],
65
- opts: { timeoutMs?: number; stdin?: string } = {},
69
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
66
70
  ): Promise<ExecResult> {
67
71
  const proc = Bun.spawn(["docker", ...args], {
68
72
  stdin: opts.stdin === undefined ? "ignore" : Buffer.from(opts.stdin),
69
73
  stdout: "pipe",
70
74
  stderr: "pipe",
75
+ ...(opts.env !== undefined ? { env: { ...process.env, ...opts.env } } : {}),
71
76
  });
72
77
  let timedOut = false;
73
78
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -87,7 +92,10 @@ async function runDocker(
87
92
  }
88
93
 
89
94
  /** Run `docker <args>` and throw a DockerError on non-zero exit. */
90
- async function mustDocker(args: string[], opts: { timeoutMs?: number; stdin?: string } = {}): Promise<ExecResult> {
95
+ async function mustDocker(
96
+ args: string[],
97
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
98
+ ): Promise<ExecResult> {
91
99
  const result = await runDocker(args, opts);
92
100
  if (result.exitCode !== 0) throw new DockerError(args, result.exitCode, result.stderr);
93
101
  return result;
@@ -198,36 +206,55 @@ export interface AttemptContainer {
198
206
  * fixture into /workspace. On any failure the partial container is removed
199
207
  * before the error propagates — a failed create never leaks a container.
200
208
  */
209
+ /**
210
+ * Route an attempt's env between its two argv-free channels. Single-line
211
+ * values become env-file lines; a value that itself contains newlines (a
212
+ * `Name: Value` header bundle) cannot be expressed in a line-based env-file,
213
+ * so it rides the docker CLI's own environment instead, named on argv by
214
+ * `-e KEY` alone — the CLI forwards the value intact and argv never shows it.
215
+ */
216
+ export function splitAttemptEnv(env: Record<string, string>): { envFileLines: string[]; inherited: Record<string, string> } {
217
+ const envFileLines: string[] = [];
218
+ const inherited: Record<string, string> = {};
219
+ for (const [key, value] of Object.entries(env)) {
220
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable name "${key}"`);
221
+ if (value.includes("\n")) inherited[key] = value;
222
+ else envFileLines.push(`${key}=${value}`);
223
+ }
224
+ return { envFileLines, inherited };
225
+ }
226
+
201
227
  export async function createAttemptContainer(opts: CreateAttemptContainerOptions): Promise<AttemptContainer> {
202
228
  // Secrets travel via a transient env-file, not argv (argv is visible in
203
- // host process listings while `docker run` executes).
229
+ // host process listings while `docker run` executes); multi-line values
230
+ // take the CLI-environment channel (see splitAttemptEnv).
204
231
  const envDir = await mkdtemp(join(tmpdir(), "quickstudy-env-"));
205
232
  const envFile = join(envDir, "attempt.env");
206
- const lines = Object.entries(opts.env).map(([key, value]) => {
207
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable name "${key}"`);
208
- if (value.includes("\n")) throw new Error(`environment variable "${key}" contains a newline`);
209
- return `${key}=${value}`;
210
- });
211
- await writeFile(envFile, lines.join("\n") + (lines.length > 0 ? "\n" : ""), { mode: 0o600 });
233
+ const { envFileLines, inherited } = splitAttemptEnv(opts.env);
234
+ await writeFile(envFile, envFileLines.join("\n") + (envFileLines.length > 0 ? "\n" : ""), { mode: 0o600 });
212
235
 
213
236
  let id: string;
214
237
  try {
215
- const created = await mustDocker([
216
- "run",
217
- "-d",
218
- "--label",
219
- `${ATTEMPT_LABEL}=${opts.attemptId}`,
220
- "--env-file",
221
- envFile,
222
- "-v",
223
- `${resolve(opts.resultsDir)}:${CONTAINER_RESULTS}`,
224
- "-w",
225
- CONTAINER_WORKSPACE,
226
- ...(opts.network !== undefined ? ["--network", opts.network] : []),
227
- opts.image,
228
- "sleep",
229
- "infinity",
230
- ]);
238
+ const created = await mustDocker(
239
+ [
240
+ "run",
241
+ "-d",
242
+ "--label",
243
+ `${ATTEMPT_LABEL}=${opts.attemptId}`,
244
+ "--env-file",
245
+ envFile,
246
+ ...Object.keys(inherited).flatMap((key) => ["-e", key]),
247
+ "-v",
248
+ `${resolve(opts.resultsDir)}:${CONTAINER_RESULTS}`,
249
+ "-w",
250
+ CONTAINER_WORKSPACE,
251
+ ...(opts.network !== undefined ? ["--network", opts.network] : []),
252
+ opts.image,
253
+ "sleep",
254
+ "infinity",
255
+ ],
256
+ { env: inherited },
257
+ );
231
258
  id = created.stdout.trim();
232
259
  } catch (err) {
233
260
  // `docker run` can fail AFTER creating the container (start failure) —
package/src/llm.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import Anthropic from "@anthropic-ai/sdk";
16
16
  import type { z } from "zod";
17
+ import { parseHeaderLines } from "./secrets.ts";
17
18
 
18
19
  /** One structured-output request. `schema` is a JSON Schema object. */
19
20
  export interface StructuredRequest {
@@ -47,16 +48,23 @@ export class ModelOutputError extends Error {
47
48
  const API_MAX_RETRIES = 3;
48
49
 
49
50
  /**
50
- * Anthropic-backed client. Reuses ANTHROPIC_API_KEY from the environment;
51
- * transport-level retries are the SDK's (429/5xx/connection errors).
51
+ * Anthropic-backed client. Reuses ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL
52
+ * from the environment (the SDK reads both), plus ANTHROPIC_CUSTOM_HEADERS
53
+ * (`Name: Value` lines — Claude Code's convention) as default headers, so the
54
+ * analyst takes the same route as the agent CLI when both are pointed at an
55
+ * LLM gateway. Transport-level retries are the SDK's (429/5xx/connection
56
+ * errors).
52
57
  */
53
58
  export class AnthropicModelClient implements StructuredModelClient {
54
59
  private readonly client: Anthropic;
55
60
  private lastResolvedModel: string | null = null;
56
61
 
57
- constructor(options: { apiKey?: string } = {}) {
62
+ constructor(options: { apiKey?: string; baseURL?: string; defaultHeaders?: Record<string, string> } = {}) {
63
+ const defaultHeaders = options.defaultHeaders ?? parseHeaderLines(process.env["ANTHROPIC_CUSTOM_HEADERS"]);
58
64
  this.client = new Anthropic({
59
65
  ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
66
+ ...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
67
+ ...(Object.keys(defaultHeaders).length > 0 ? { defaultHeaders } : {}),
60
68
  maxRetries: API_MAX_RETRIES,
61
69
  });
62
70
  }
@@ -70,7 +70,7 @@ import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeIm
70
70
  import { isMovingModelAlias } from "../model-identity.ts";
71
71
  import type { AttemptPlan } from "../plan.ts";
72
72
  import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
73
- import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
73
+ import { collectOptionalHostEnv, collectProviderKeys, endpointHosts, headerBundleSecrets, hostSecretEnv, isSecretEnvName, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
74
74
  import { ArtifactsStore } from "../store/artifacts.ts";
75
75
  import { activeAttempts, type ErrorKind, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
76
76
  import { acquireRunLock } from "./run-lock.ts";
@@ -402,7 +402,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
402
402
  probesByAdapter.set(adapter.name, {
403
403
  name: adapter.name,
404
404
  envVars: adapter.requiredHostEnv,
405
- ...adapter.keyProbe(collectProviderKeys([adapter])),
405
+ ...adapter.keyProbe({ ...collectProviderKeys([adapter]), ...collectOptionalHostEnv([adapter]) }),
406
406
  });
407
407
  }
408
408
  await validateProviderKeys([...probesByAdapter.values()], { log });
@@ -458,8 +458,22 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
458
458
  const scorer = await importEvalScorer(loaded.scorerPath);
459
459
  if (scorer.identity) scorerIdentities[loaded.metadata.id] = scorer.identity;
460
460
  }
461
+ // Each agent's endpoint overrides set on the host: the non-secret base
462
+ // URLs join the pair environment (identity), their hosts join the egress
463
+ // allowlist — but only under the proxy, where the allowlist is live and an
464
+ // IP-literal gateway is refused loudly by design; without it the list is
465
+ // inert and an IP-literal override must not stop the run.
466
+ const overridesByExperiment = new Map([...containerAgents].map(([id, adapter]) => [id, collectOptionalHostEnv([adapter])] as const));
467
+ const adapterEndpoints = Object.fromEntries([...overridesByExperiment].flatMap(([id, overrides]) => {
468
+ const endpoints = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
469
+ return Object.keys(endpoints).length > 0 ? [[id, endpoints] as const] : [];
470
+ }));
461
471
  const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
462
- adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, adapter.egressHosts ?? []])),
472
+ adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, [
473
+ ...(adapter.egressHosts ?? []),
474
+ ...(options.egressProxy ? endpointHosts(overridesByExperiment.get(id) ?? {}) : []),
475
+ ]])),
476
+ adapterEndpoints,
463
477
  budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
464
478
  mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
465
479
  });
@@ -806,12 +820,25 @@ async function runContainerAttemptV3(
806
820
  // every auth block and pointed authed servers at the proxy sidecar.
807
821
  env["QUICKSTUDY_MCP_SERVERS"] = JSON.stringify(rewrittenServers);
808
822
  }
823
+ // Endpoint overrides set on the host for this agent (gateway base URLs
824
+ // and the headers they need) — see ContainerAgentAdapter.optionalHostEnv.
825
+ const overrides = agentAdapter !== undefined ? collectOptionalHostEnv([agentAdapter]) : {};
809
826
  if (agentAdapter !== undefined) {
810
827
  // The agent's provider keys, validated at run preflight — injected
811
828
  // at create via the 0600 env-file channel, never argv.
812
829
  const providerKeys = collectProviderKeys([agentAdapter]);
813
830
  Object.assign(env, providerKeys);
814
831
  Object.assign(secretEnv, providerKeys);
832
+ // The overrides ride the same channel; only the secret-named ones
833
+ // feed redaction — a base URL in a transcript is diagnostic, a
834
+ // bearer header is not. A header bundle's credentials are targets of
835
+ // their own: a transcript that prints just the token is still scrubbed.
836
+ Object.assign(env, overrides);
837
+ for (const [name, value] of Object.entries(overrides)) {
838
+ if (!isSecretEnvName(name)) continue;
839
+ secretEnv[name] = value;
840
+ Object.assign(secretEnv, headerBundleSecrets(name, value));
841
+ }
815
842
  }
816
843
  if (egress) {
817
844
  const proxyEnv = egress.sidecar.proxyEnv();
@@ -874,6 +901,10 @@ async function runContainerAttemptV3(
874
901
  trialIndex: entry.trialIndex,
875
902
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
876
903
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
904
+ // The adapter's own overrides (never the provider keys): a CLI
905
+ // that wants a config-file entry (codex's model provider and its
906
+ // http_headers) renders them from here, inside the container.
907
+ ...(Object.keys(overrides).length > 0 ? { overrides } : {}),
877
908
  };
878
909
  // Treatment config files (e.g. the MCP treatment's vendor config).
879
910
  await inStage("environment", async () => {
package/src/secrets.ts CHANGED
@@ -48,6 +48,12 @@ export interface ProviderKeyRequirement {
48
48
  name: string;
49
49
  /** Host env var names the adapter requires. */
50
50
  requiredHostEnv: readonly string[];
51
+ /**
52
+ * Host env var names forwarded when set and never required: endpoint
53
+ * overrides (a vendor base URL that routes the CLI through an LLM gateway
54
+ * or proxy) and the custom headers such a gateway needs.
55
+ */
56
+ optionalHostEnv?: readonly string[];
51
57
  }
52
58
 
53
59
  /** One preflight probe: a cheap authenticated GET a provider will 401 on a bad key. */
@@ -128,6 +134,111 @@ export function collectProviderKeys(
128
134
  return keys;
129
135
  }
130
136
 
137
+ /**
138
+ * Read every OPTIONAL host env var the adapters declare (see
139
+ * `ProviderKeyRequirement.optionalHostEnv`). Absent or empty variables are
140
+ * simply not forwarded — an empty `ANTHROPIC_BASE_URL=` from a CI template
141
+ * must not reach a CLI that would treat "" as a base URL.
142
+ */
143
+ export function collectOptionalHostEnv(
144
+ requirements: readonly ProviderKeyRequirement[],
145
+ hostEnv: Record<string, string | undefined> = process.env,
146
+ ): Record<string, string> {
147
+ const env: Record<string, string> = {};
148
+ for (const requirement of requirements) {
149
+ for (const envVar of requirement.optionalHostEnv ?? []) {
150
+ const value = hostEnv[envVar];
151
+ if (value !== undefined && value !== "") env[envVar] = value;
152
+ }
153
+ }
154
+ return env;
155
+ }
156
+
157
+ /**
158
+ * Hostnames of every forwarded value that is an absolute http(s) URL — the
159
+ * endpoints an override points at, which the egress allowlist must admit
160
+ * alongside the adapter's vendor defaults. Non-URL values (header lines)
161
+ * contribute nothing.
162
+ */
163
+ export function endpointHosts(env: Record<string, string>): string[] {
164
+ const hosts: string[] = [];
165
+ for (const value of Object.values(env)) {
166
+ let url: URL;
167
+ try {
168
+ url = new URL(value);
169
+ } catch {
170
+ continue;
171
+ }
172
+ if ((url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "") hosts.push(url.hostname);
173
+ }
174
+ return hosts;
175
+ }
176
+
177
+ /**
178
+ * `<base>/<path>` for a probe: the override when set (trailing slashes
179
+ * tolerated — gateways document both forms), else the vendor's own base.
180
+ */
181
+ export function endpointUrl(override: string | undefined, vendorBase: string, path: string): string {
182
+ const base = override !== undefined && override !== "" ? override : vendorBase;
183
+ return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
184
+ }
185
+
186
+ /**
187
+ * Parse `Name: Value` header lines (newline-separated — the format Claude
188
+ * Code reads from ANTHROPIC_CUSTOM_HEADERS). Blank lines and lines without a
189
+ * colon are skipped; the first colon splits name from value.
190
+ */
191
+ export function parseHeaderLines(text: string | undefined): Record<string, string> {
192
+ const headers: Record<string, string> = {};
193
+ for (const line of (text ?? "").split(/\r?\n/)) {
194
+ const colon = line.indexOf(":");
195
+ if (colon <= 0) continue;
196
+ const name = line.slice(0, colon).trim();
197
+ const value = line.slice(colon + 1).trim();
198
+ if (name !== "") headers[name] = value;
199
+ }
200
+ return headers;
201
+ }
202
+
203
+ /**
204
+ * Does an env var NAME look secret-bearing? Keys, tokens, passwords,
205
+ * credentials — and header bundles, which carry a gateway's bearer token.
206
+ * Endpoint URLs deliberately do not match: they are diagnostic, not secret.
207
+ */
208
+ export function isSecretEnvName(name: string): boolean {
209
+ return /key|token|password|secret|credential|header/i.test(name);
210
+ }
211
+
212
+ /** Header names whose values are credentials (`cf-aig-authorization`, `x-api-key`, …). */
213
+ const SECRET_HEADER = /auth|key|token|secret|credential|password/i;
214
+
215
+ /**
216
+ * The redaction targets inside one header bundle (an env var holding `Name:
217
+ * Value` lines): each credential header's full value, plus the bare
218
+ * credential behind a `Bearer`/`Basic`/`Token` scheme — a transcript that
219
+ * prints only the token, not the whole bundle, must still be scrubbed. Keys
220
+ * name the bundle and header so the redaction marker says what was removed.
221
+ */
222
+ export function headerBundleSecrets(bundleName: string, bundle: string): Record<string, string> {
223
+ const derived: Record<string, string> = {};
224
+ for (const [header, value] of Object.entries(parseHeaderLines(bundle))) {
225
+ if (!SECRET_HEADER.test(header)) continue;
226
+ derived[`${bundleName}[${header}]`] = value;
227
+ const scheme = /^(?:Bearer|Basic|Token)\s+(\S+)$/i.exec(value);
228
+ if (scheme?.[1] !== undefined) derived[`${bundleName}[${header}]:credential`] = scheme[1];
229
+ }
230
+ return derived;
231
+ }
232
+
233
+ /** A secret env map plus the credentials carried inside any header bundle it holds. */
234
+ export function expandSecretEnv(env: Record<string, string>): Record<string, string> {
235
+ const expanded = { ...env };
236
+ for (const [name, value] of Object.entries(env)) {
237
+ if (/header/i.test(name)) Object.assign(expanded, headerBundleSecrets(name, value));
238
+ }
239
+ return expanded;
240
+ }
241
+
131
242
  /** Values shorter than this are never redacted (they would shred ordinary text). */
132
243
  const MIN_REDACTABLE_LENGTH = 6;
133
244
 
@@ -145,7 +256,9 @@ export function redactSecrets(text: string, env: Record<string, string>): string
145
256
  return redacted;
146
257
  }
147
258
 
148
- /** Provider/judge secret env only; ordinary PATH and workspace values are evidence. */
259
+ /** Provider/judge secret env only (header-bundle credentials included); ordinary PATH and workspace values are evidence. */
149
260
  export function hostSecretEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
150
- return Object.fromEntries(Object.entries(env).filter(([name, value]) => /key|token|password|secret|credential/i.test(name) && typeof value === "string")) as Record<string, string>;
261
+ return expandSecretEnv(
262
+ Object.fromEntries(Object.entries(env).filter(([name, value]) => isSecretEnvName(name) && typeof value === "string")) as Record<string, string>,
263
+ );
151
264
  }