@workos/quickstudy 0.12.0 → 0.13.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
@@ -149,6 +149,50 @@ export OPENAI_CUSTOM_HEADERS='cf-aig-metadata: {"app":"my-evals"}'
149
149
  Leave every override unset and both agents run vendor-direct against
150
150
  api.anthropic.com and api.openai.com — nothing is rendered, nothing changes.
151
151
 
152
+ #### Per-experiment routing (`agent.endpoint`)
153
+
154
+ The variables above are per adapter: every codex arm in a run reads the
155
+ same `OPENAI_BASE_URL`. To route ONE experiment elsewhere — a second
156
+ gateway, or a gateway's unified OpenAI-compatible API serving a third-party
157
+ model — the experiment names the host variables that carry its key, base
158
+ URL and headers, and the host sets them next to the vendor ones:
159
+
160
+ ```ts
161
+ export default defineExperiment({
162
+ id: "acme-gateway-mcp",
163
+ comparisonGroup: "acme-gateway",
164
+ treatment: "mcp",
165
+ agent: {
166
+ adapter: "codex",
167
+ provider: "acme", // recorded as-is in the manifest and report
168
+ model: "acme/coder-9", // whatever the endpoint expects in `model`
169
+ reasoning: "high",
170
+ endpoint: {
171
+ apiKeyEnv: "GATEWAY_API_TOKEN", // secret-named: redacted like a key
172
+ baseUrlEnv: "GATEWAY_BASE_URL", // not secret-named: identity + egress
173
+ headersEnv: "GATEWAY_HEADERS", // secret-named; `Name: Value` lines
174
+ wireApi: "chat", // codex only: chat completions
175
+ },
176
+ },
177
+ runtime: ...,
178
+ });
179
+ ```
180
+
181
+ Unset fields keep the vendor defaults. The names must be POSIX env var
182
+ names; `defineExperiment` refuses a key or headers variable that is not
183
+ secret-named, a base URL variable that is, and `wireApi` on anything but
184
+ codex. The forwarded values travel exactly as before: the key as a provider
185
+ key, the base URL into the pair environment and the egress allowlist, the
186
+ headers redacted from artifacts. Codex renders them into its custom model
187
+ provider (`env_key`, `base_url`, `http_headers`, `wire_api`); Claude Code
188
+ only reads the vendor names, so the adapter re-exports custom ones under
189
+ them inside the container before exec — a custom key as both
190
+ `ANTHROPIC_API_KEY` (`x-api-key`) and `ANTHROPIC_AUTH_TOKEN`
191
+ (`Authorization: Bearer`), since a provider-native Anthropic route reads
192
+ the former and a gateway's unified API the latter. The preflight key probe
193
+ follows the experiment's variables, so a bad gateway token still fails
194
+ before any container starts.
195
+
152
196
  ## How it works
153
197
 
154
198
  Every attempt is one point in a 2-D plan:
@@ -9,7 +9,7 @@
9
9
  # Framework toolchains (Rails, Django, ...) layer on top in Phase 3 — this
10
10
  # image stays agent-only.
11
11
 
12
- FROM node:22-bookworm-slim
12
+ FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d
13
13
 
14
14
  ARG CLAUDE_CODE_VERSION=2.1.215
15
15
  ARG CODEX_VERSION=0.149.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -13,6 +13,8 @@
13
13
  * across experiments.
14
14
  */
15
15
 
16
+ import { type ResolvedEndpoint, resolveEndpoint } from "../experiments/endpoint.ts";
17
+ import type { AgentEndpointSpec } from "../experiments/types.ts";
16
18
  import type { McpServerConfig } from "../isolation/mcp.ts";
17
19
  import { endpointUrl, parseHeaderLines } from "../secrets.ts";
18
20
  import { parseClaudeStream } from "./parse.ts";
@@ -21,6 +23,18 @@ import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } f
21
23
  /** Container path of the MCP config Claude Code auto-discovers. */
22
24
  export const CLAUDE_MCP_CONFIG_PATH = "/workspace/.mcp.json";
23
25
 
26
+ /**
27
+ * The vendor's env names: what every claude experiment gets unless its
28
+ * `agent.endpoint` names something else (experiments/endpoint.ts). The wire
29
+ * is fixed — Claude Code speaks the Messages API and nothing else.
30
+ */
31
+ export const CLAUDE_DEFAULT_ENDPOINT: ResolvedEndpoint = {
32
+ apiKeyEnv: "ANTHROPIC_API_KEY",
33
+ baseUrlEnv: "ANTHROPIC_BASE_URL",
34
+ headersEnv: "ANTHROPIC_CUSTOM_HEADERS",
35
+ wireApi: "responses",
36
+ };
37
+
24
38
  /**
25
39
  * Project-scope `.mcp.json` rendered from the runtime's MCP servers
26
40
  * (streamable HTTP). The configs arrive credential-free — authed servers
@@ -38,17 +52,26 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
38
52
  readonly name: string;
39
53
  readonly identity;
40
54
  readonly versionCommand = ["claude", "--version"] as const;
41
- readonly requiredHostEnv = ["ANTHROPIC_API_KEY"] as const;
42
55
  /**
43
- * Endpoint overrides, forwarded only when set. ANTHROPIC_BASE_URL routes
56
+ * The env names this experiment's traffic is routed by: the vendor's
57
+ * (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL / ANTHROPIC_CUSTOM_HEADERS)
58
+ * unless `agent.endpoint` named others. Claude Code only ever reads the
59
+ * vendor names, so custom names are re-exported under them by a shell shim
60
+ * in `command` — inside the container, from env the harness already placed.
61
+ */
62
+ readonly endpoint: ResolvedEndpoint;
63
+ /** The key variable: required on the host, forwarded into the container. */
64
+ readonly requiredHostEnv: readonly string[];
65
+ /**
66
+ * Endpoint overrides, forwarded only when set. The base URL variable routes
44
67
  * the CLI (and the key probe) through an Anthropic-compatible gateway —
45
68
  * 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
69
+ * token doubles as the API key; the headers variable (`Name: Value`
70
+ * lines) carries whatever else that gateway wants, such as
48
71
  * `cf-aig-authorization` or `cf-aig-metadata`. Claude Code reads both
49
- * natively, so no flag changes.
72
+ * natively under the vendor names, so no flag changes.
50
73
  */
51
- readonly optionalHostEnv = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS"] as const;
74
+ readonly optionalHostEnv: readonly string[];
52
75
  /**
53
76
  * Egress-proxy allowlist (harness-default layer). Claude Code documents
54
77
  * corporate-proxy support via HTTPS_PROXY/HTTP_PROXY; api.anthropic.com is
@@ -59,10 +82,16 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
59
82
  */
60
83
  readonly egressHosts = ["api.anthropic.com", "statsig.anthropic.com", "*.sentry.io"] as const;
61
84
 
62
- constructor(name = "claude", config: { model?: string; reasoning?: string } = {}) {
85
+ constructor(
86
+ name = "claude",
87
+ config: { model?: string; reasoning?: string; provider?: string; endpoint?: AgentEndpointSpec } = {},
88
+ ) {
63
89
  this.name = name;
90
+ this.endpoint = resolveEndpoint(name, CLAUDE_DEFAULT_ENDPOINT, config.endpoint);
91
+ this.requiredHostEnv = [this.endpoint.apiKeyEnv];
92
+ this.optionalHostEnv = [this.endpoint.baseUrlEnv, this.endpoint.headersEnv];
64
93
  this.identity = {
65
- provider: "anthropic",
94
+ provider: config.provider ?? "anthropic",
66
95
  model: config.model ?? "UNPINNED",
67
96
  reasoning: config.reasoning ?? "UNPINNED",
68
97
  cliVersion: "unknown",
@@ -78,15 +107,43 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
78
107
 
79
108
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
80
109
  return {
81
- url: endpointUrl(env["ANTHROPIC_BASE_URL"], "https://api.anthropic.com", "v1/models"),
110
+ url: endpointUrl(env[this.endpoint.baseUrlEnv], "https://api.anthropic.com", "v1/models"),
82
111
  headers: {
83
- ...parseHeaderLines(env["ANTHROPIC_CUSTOM_HEADERS"]),
84
- "x-api-key": env["ANTHROPIC_API_KEY"] ?? "",
112
+ ...parseHeaderLines(env[this.endpoint.headersEnv]),
113
+ "x-api-key": env[this.endpoint.apiKeyEnv] ?? "",
85
114
  "anthropic-version": "2023-06-01",
86
115
  },
87
116
  };
88
117
  }
89
118
 
119
+ /**
120
+ * `export VENDOR="$CUSTOM"` lines for every endpoint variable whose name
121
+ * differs from the vendor's — empty when nothing differs, so the default
122
+ * invocation stays the bare `claude` argv. Optional variables are only
123
+ * re-exported when set: an empty ANTHROPIC_BASE_URL would be a base URL.
124
+ *
125
+ * A custom key variable is presented both ways Claude Code can send a
126
+ * credential — `x-api-key` (ANTHROPIC_API_KEY) and `Authorization:
127
+ * Bearer` (ANTHROPIC_AUTH_TOKEN) — because gateways differ on which one
128
+ * they read: a provider-native Anthropic route takes the key header, a
129
+ * unified API takes the bearer. The vendor's own route only ever sees
130
+ * the vendor variable, so this changes nothing for it.
131
+ */
132
+ private endpointExports(): string[] {
133
+ const lines: string[] = [];
134
+ const { apiKeyEnv, baseUrlEnv, headersEnv } = this.endpoint;
135
+ if (apiKeyEnv !== CLAUDE_DEFAULT_ENDPOINT.apiKeyEnv) {
136
+ lines.push(`export ANTHROPIC_API_KEY="$${apiKeyEnv}"`, `export ANTHROPIC_AUTH_TOKEN="$${apiKeyEnv}"`);
137
+ }
138
+ if (baseUrlEnv !== CLAUDE_DEFAULT_ENDPOINT.baseUrlEnv) {
139
+ lines.push(`[ -n "\${${baseUrlEnv}:-}" ] && export ANTHROPIC_BASE_URL="$${baseUrlEnv}"`);
140
+ }
141
+ if (headersEnv !== CLAUDE_DEFAULT_ENDPOINT.headersEnv) {
142
+ lines.push(`[ -n "\${${headersEnv}:-}" ] && export ANTHROPIC_CUSTOM_HEADERS="$${headersEnv}"`);
143
+ }
144
+ return lines;
145
+ }
146
+
90
147
  setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
91
148
  if (ctx.mcpServers === undefined) return {};
92
149
  return { [CLAUDE_MCP_CONFIG_PATH]: renderMcpJson(ctx.mcpServers) };
@@ -114,7 +171,12 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
114
171
  args.push("--mcp-config", CLAUDE_MCP_CONFIG_PATH);
115
172
  }
116
173
  if (ctx.webPolicy !== "native-web-allowed") args.push("--disallowedTools", "WebSearch,WebFetch");
117
- return args;
174
+ const exports = this.endpointExports();
175
+ if (exports.length === 0) return args;
176
+ // Custom endpoint names: re-export them under the names Claude Code reads,
177
+ // then exec the same argv. Names only reach the shell — resolveEndpoint
178
+ // admits POSIX env names and nothing else; values stay in the environment.
179
+ return ["sh", "-c", `${exports.join("; ")}; exec "$@"`, "sh", ...args];
118
180
  }
119
181
 
120
182
  parseStream(raw: string): AttemptMetrics {
@@ -25,6 +25,8 @@
25
25
  * talks to api.openai.com — vendor-direct stays the default.
26
26
  */
27
27
 
28
+ import { type ResolvedEndpoint, type WireApi, resolveEndpoint } from "../experiments/endpoint.ts";
29
+ import type { AgentEndpointSpec } from "../experiments/types.ts";
28
30
  import type { McpServerConfig } from "../isolation/mcp.ts";
29
31
  import { endpointUrl, parseHeaderLines } from "../secrets.ts";
30
32
  import { parseCodexStream } from "./parse.ts";
@@ -40,13 +42,31 @@ export const CODEX_GATEWAY_PROVIDER = "quickstudy-gateway";
40
42
  const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
41
43
 
42
44
  /**
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.
45
+ * The vendor's env names and wire: what every codex experiment gets unless
46
+ * its `agent.endpoint` names something else (experiments/endpoint.ts).
48
47
  */
49
- function renderCodexProviderToml(baseUrl: string, headers: Readonly<Record<string, string>>): string {
48
+ export const CODEX_DEFAULT_ENDPOINT: ResolvedEndpoint = {
49
+ apiKeyEnv: "OPENAI_API_KEY",
50
+ baseUrlEnv: "OPENAI_BASE_URL",
51
+ headersEnv: "OPENAI_CUSTOM_HEADERS",
52
+ wireApi: "responses",
53
+ };
54
+
55
+ /**
56
+ * A custom model provider pointing at `baseUrl`, speaking `wireApi` (the
57
+ * Responses API codex prefers, or chat completions for endpoints that only
58
+ * offer that), reading its key from `envKey`, carrying `headers` on every
59
+ * request, selected as the default. Top-level keys must precede any table,
60
+ * so this block always renders first in the file. JSON string escapes are
61
+ * valid TOML basic-string escapes, which keeps header values such as JSON
62
+ * metadata intact.
63
+ */
64
+ function renderCodexProviderToml(
65
+ baseUrl: string,
66
+ headers: Readonly<Record<string, string>>,
67
+ envKey: string,
68
+ wireApi: WireApi,
69
+ ): string {
50
70
  const entries = Object.entries(headers);
51
71
  return [
52
72
  `model_provider = ${JSON.stringify(CODEX_GATEWAY_PROVIDER)}`,
@@ -54,8 +74,8 @@ function renderCodexProviderToml(baseUrl: string, headers: Readonly<Record<strin
54
74
  `[model_providers.${CODEX_GATEWAY_PROVIDER}]`,
55
75
  'name = "OpenAI (quickstudy endpoint overrides)"',
56
76
  `base_url = ${JSON.stringify(baseUrl)}`,
57
- 'env_key = "OPENAI_API_KEY"',
58
- 'wire_api = "responses"',
77
+ `env_key = ${JSON.stringify(envKey)}`,
78
+ `wire_api = ${JSON.stringify(wireApi)}`,
59
79
  ...(entries.length > 0
60
80
  ? [`http_headers = { ${entries.map(([name, value]) => `${JSON.stringify(name)} = ${JSON.stringify(value)}`).join(", ")} }`]
61
81
  : []),
@@ -81,17 +101,25 @@ export class CodexAdapter implements ContainerAgentAdapter {
81
101
  readonly name: string;
82
102
  readonly identity;
83
103
  readonly versionCommand = ["codex", "--version"] as const;
84
- readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
85
104
  /**
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
105
+ * The env names this experiment's traffic is routed by: the vendor's
106
+ * (OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_CUSTOM_HEADERS) unless
107
+ * `agent.endpoint` named others — the way two codex arms in one run reach
108
+ * two gateways, or one gateway's unified API serving a third-party model.
109
+ */
110
+ readonly endpoint: ResolvedEndpoint;
111
+ /** The key variable: required on the host, forwarded into the container. */
112
+ readonly requiredHostEnv: readonly string[];
113
+ /**
114
+ * Endpoint overrides, forwarded only when set: the base URL variable
115
+ * (vendor default https://api.openai.com/v1) — e.g. Cloudflare AI Gateway's
116
+ * `/openai` endpoint, where the gateway token doubles as the API key —
117
+ * and the headers variable (`Name: Value` lines) for whatever else the
90
118
  * endpoint wants on each request, such as `cf-aig-metadata`. Both are
91
119
  * rendered into config.toml by setupFiles (see the module comment); the
92
120
  * key probe follows them.
93
121
  */
94
- readonly optionalHostEnv = ["OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS"] as const;
122
+ readonly optionalHostEnv: readonly string[];
95
123
  /**
96
124
  * Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
97
125
  * reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
@@ -103,10 +131,16 @@ export class CodexAdapter implements ContainerAgentAdapter {
103
131
  */
104
132
  readonly egressHosts = ["api.openai.com", "auth.openai.com", "chatgpt.com", "*.chatgpt.com"] as const;
105
133
 
106
- constructor(name = "codex", config: { model?: string; reasoning?: string } = {}) {
134
+ constructor(
135
+ name = "codex",
136
+ config: { model?: string; reasoning?: string; provider?: string; endpoint?: AgentEndpointSpec } = {},
137
+ ) {
107
138
  this.name = name;
139
+ this.endpoint = resolveEndpoint(name, CODEX_DEFAULT_ENDPOINT, config.endpoint);
140
+ this.requiredHostEnv = [this.endpoint.apiKeyEnv];
141
+ this.optionalHostEnv = [this.endpoint.baseUrlEnv, this.endpoint.headersEnv];
108
142
  this.identity = {
109
- provider: "openai",
143
+ provider: config.provider ?? "openai",
110
144
  model: config.model ?? "UNPINNED",
111
145
  reasoning: config.reasoning ?? "UNPINNED",
112
146
  cliVersion: "unknown",
@@ -122,18 +156,30 @@ export class CodexAdapter implements ContainerAgentAdapter {
122
156
 
123
157
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
124
158
  return {
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"] ?? ""}` },
159
+ url: endpointUrl(env[this.endpoint.baseUrlEnv], OPENAI_DEFAULT_BASE_URL, "models"),
160
+ headers: {
161
+ ...parseHeaderLines(env[this.endpoint.headersEnv]),
162
+ Authorization: `Bearer ${env[this.endpoint.apiKeyEnv] ?? ""}`,
163
+ },
127
164
  };
128
165
  }
129
166
 
130
167
  setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
131
168
  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));
169
+ const baseUrl = ctx.overrides?.[this.endpoint.baseUrlEnv];
170
+ const headers = parseHeaderLines(ctx.overrides?.[this.endpoint.headersEnv]);
171
+ // Either override needs the custom provider (headers alone keep the
172
+ // vendor endpoint); so does a non-vendor key variable or wire, which the
173
+ // built-in provider cannot express.
174
+ const custom =
175
+ baseUrl !== undefined ||
176
+ Object.keys(headers).length > 0 ||
177
+ this.endpoint.apiKeyEnv !== CODEX_DEFAULT_ENDPOINT.apiKeyEnv ||
178
+ this.endpoint.wireApi !== CODEX_DEFAULT_ENDPOINT.wireApi;
179
+ if (custom) {
180
+ sections.push(
181
+ renderCodexProviderToml(baseUrl ?? OPENAI_DEFAULT_BASE_URL, headers, this.endpoint.apiKeyEnv, this.endpoint.wireApi),
182
+ );
137
183
  }
138
184
  if (ctx.mcpServers !== undefined) sections.push(renderCodexMcpToml(ctx.mcpServers));
139
185
  if (sections.length === 0) return {};
@@ -145,9 +191,11 @@ export class CodexAdapter implements ContainerAgentAdapter {
145
191
  "sh",
146
192
  "-c",
147
193
  // codex 0.144+ ignores the OPENAI_API_KEY env var; mint auth.json from
148
- // it in-container. Login chatter is silenced so stdout stays pure JSONL
149
- // for parseStream; the key reaches login via stdin, never argv.
150
- 'printenv OPENAI_API_KEY | codex login --with-api-key >/dev/null 2>&1 && exec "$@"',
194
+ // the key variable in-container. Login chatter is silenced so stdout
195
+ // stays pure JSONL for parseStream; the key reaches login via stdin,
196
+ // never argv. The variable NAME is interpolated — resolveEndpoint
197
+ // admits only POSIX env names, so nothing else can reach the shell.
198
+ `printenv ${this.endpoint.apiKeyEnv} | codex login --with-api-key >/dev/null 2>&1 && exec "$@"`,
151
199
  "sh",
152
200
  "codex",
153
201
  "exec",
@@ -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.12.0", // x-release-please-version
12
+ version: "0.13.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `agent.endpoint` resolution: the experiment names the HOST ENV VARS that
3
+ * carry an agent's API key, base URL and extra headers; the adapter fills in
4
+ * its vendor defaults for whatever is unset and checks the names before any
5
+ * container starts. Names, never values, live here — the values stay on the
6
+ * host and ride the same channels the vendor defaults always have.
7
+ */
8
+
9
+ import { isSecretEnvName } from "../secrets.ts";
10
+ import type { AgentEndpointSpec } from "./types.ts";
11
+
12
+ /** POSIX env var names the shell shims interpolate verbatim; nothing else is accepted. */
13
+ const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
14
+
15
+ export const WIRE_APIS = ["responses", "chat"] as const;
16
+ export type WireApi = (typeof WIRE_APIS)[number];
17
+
18
+ /** An adapter's endpoint with every field decided. */
19
+ export interface ResolvedEndpoint {
20
+ apiKeyEnv: string;
21
+ baseUrlEnv: string;
22
+ headersEnv: string;
23
+ wireApi: WireApi;
24
+ }
25
+
26
+ /** Thrown for an `agent.endpoint` the harness refuses; the message names the field. */
27
+ export class InvalidAgentEndpointError extends Error {
28
+ constructor(detail: string) {
29
+ super(`agent.endpoint: ${detail}`);
30
+ this.name = "InvalidAgentEndpointError";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check an experiment's `agent.endpoint` without an adapter: every name must
36
+ * be a POSIX env var name, the key and headers variables must be
37
+ * secret-named (so they are redacted from artifacts like a key), the base
38
+ * URL variable must not be (its value is identity and joins the egress
39
+ * allowlist), and the three must differ. `wireApi` is codex's alone.
40
+ */
41
+ export function validateAgentEndpoint(spec: AgentEndpointSpec | undefined, adapter: string): void {
42
+ if (spec === undefined) return;
43
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
44
+ throw new InvalidAgentEndpointError("must be an object");
45
+ }
46
+ const names = new Map<string, string>();
47
+ for (const field of ["apiKeyEnv", "baseUrlEnv", "headersEnv"] as const) {
48
+ const value = spec[field];
49
+ if (value === undefined) continue;
50
+ if (typeof value !== "string" || !ENV_NAME.test(value)) {
51
+ throw new InvalidAgentEndpointError(
52
+ `${field} must be an env var name matching ${ENV_NAME} (got ${JSON.stringify(value)})`,
53
+ );
54
+ }
55
+ const clash = names.get(value);
56
+ if (clash !== undefined) throw new InvalidAgentEndpointError(`${field} and ${clash} both name ${value}`);
57
+ names.set(value, field);
58
+ }
59
+ if (spec.apiKeyEnv !== undefined && !isSecretEnvName(spec.apiKeyEnv)) {
60
+ throw new InvalidAgentEndpointError(
61
+ `apiKeyEnv ${spec.apiKeyEnv} must be secret-named (contain KEY, TOKEN, SECRET, PASSWORD or CREDENTIAL) so its value is redacted`,
62
+ );
63
+ }
64
+ if (spec.headersEnv !== undefined && !isSecretEnvName(spec.headersEnv)) {
65
+ throw new InvalidAgentEndpointError(
66
+ `headersEnv ${spec.headersEnv} must be secret-named (a HEADERS suffix will do) so its value is redacted`,
67
+ );
68
+ }
69
+ if (spec.baseUrlEnv !== undefined && isSecretEnvName(spec.baseUrlEnv)) {
70
+ throw new InvalidAgentEndpointError(
71
+ `baseUrlEnv ${spec.baseUrlEnv} must not be secret-named: a base URL is identity and must survive redaction`,
72
+ );
73
+ }
74
+ if (spec.wireApi !== undefined) {
75
+ if (!(WIRE_APIS as readonly string[]).includes(spec.wireApi)) {
76
+ throw new InvalidAgentEndpointError(`wireApi must be one of ${WIRE_APIS.join(", ")} (got ${JSON.stringify(spec.wireApi)})`);
77
+ }
78
+ if (adapter !== "codex") {
79
+ throw new InvalidAgentEndpointError(`wireApi applies to the codex adapter only (this experiment uses "${adapter}")`);
80
+ }
81
+ }
82
+ }
83
+
84
+ const NAME_FIELDS = ["apiKeyEnv", "baseUrlEnv", "headersEnv"] as const;
85
+
86
+ /**
87
+ * Validate `spec` for `adapter` and fill the unset fields from `defaults`.
88
+ * A declared name may not be one of the adapter's vendor variables for a
89
+ * different role: `apiKeyEnv: "ANTHROPIC_CUSTOM_HEADERS"` would make the
90
+ * shim read the header bundle as the key and then overwrite it, and the
91
+ * three resolved names must stay distinct for the same reason.
92
+ */
93
+ export function resolveEndpoint(adapter: string, defaults: ResolvedEndpoint, spec: AgentEndpointSpec = {}): ResolvedEndpoint {
94
+ validateAgentEndpoint(spec, adapter);
95
+ for (const field of NAME_FIELDS) {
96
+ const value = spec[field];
97
+ if (value === undefined) continue;
98
+ for (const role of NAME_FIELDS) {
99
+ if (role !== field && value === defaults[role]) {
100
+ throw new InvalidAgentEndpointError(
101
+ `${field} ${value} is the ${adapter} adapter's ${role} variable; a name cannot serve two roles`,
102
+ );
103
+ }
104
+ }
105
+ }
106
+ const resolved: ResolvedEndpoint = {
107
+ apiKeyEnv: spec.apiKeyEnv ?? defaults.apiKeyEnv,
108
+ baseUrlEnv: spec.baseUrlEnv ?? defaults.baseUrlEnv,
109
+ headersEnv: spec.headersEnv ?? defaults.headersEnv,
110
+ wireApi: spec.wireApi ?? defaults.wireApi,
111
+ };
112
+ const seen = new Map<string, string>();
113
+ for (const field of NAME_FIELDS) {
114
+ const clash = seen.get(resolved[field]);
115
+ if (clash !== undefined) {
116
+ throw new InvalidAgentEndpointError(`${field} and ${clash} both resolve to ${resolved[field]}`);
117
+ }
118
+ seen.set(resolved[field], field);
119
+ }
120
+ return resolved;
121
+ }
122
+
123
+ /**
124
+ * The endpoint as identity: the declared fields, verbatim, in a fixed key
125
+ * order — what the run manifest records and comparison groups diff. Two
126
+ * arms that differ only here are two configurations, never one treatment.
127
+ * `undefined` when nothing is declared, so an experiment without an
128
+ * endpoint keeps the identity it had before the field existed.
129
+ */
130
+ export function endpointIdentity(spec: AgentEndpointSpec | undefined): Record<string, string> | undefined {
131
+ if (spec === undefined) return undefined;
132
+ const identity: Record<string, string> = {};
133
+ for (const field of [...NAME_FIELDS, "wireApi"] as const) {
134
+ const value = spec[field];
135
+ if (value !== undefined) identity[field] = value;
136
+ }
137
+ return Object.keys(identity).length === 0 ? undefined : identity;
138
+ }
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { pairKey } from "../execution-policy.ts";
26
26
  import { diffIdentity, type IdentityMismatch } from "../identity-diff.ts";
27
- import type { RunManifest } from "../manifest.ts";
27
+ import { agentEndpointComponent, type RunManifest } from "../manifest.ts";
28
28
  import type { Experiment } from "./types.ts";
29
29
 
30
30
  export interface ComparisonGroup {
@@ -62,6 +62,7 @@ function comparableView(experiment: Experiment, manifest: RunManifest): Record<s
62
62
  model: experiment.agent.model ?? "unspecified",
63
63
  reasoning: experiment.agent.reasoning ?? "unspecified",
64
64
  cliVersion: experiment.agent.cliVersion ?? "unspecified",
65
+ ...agentEndpointComponent(experiment.agent.endpoint),
65
66
  },
66
67
  image: components?.image ?? null,
67
68
  runtime: {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { EvalMetadata } from "../evals/types.ts";
10
10
  import { validateWorkspaceFilesRuntime, type WorkspaceFiles } from "../runtime/workspace-files.ts";
11
+ import { validateAgentEndpoint } from "./endpoint.ts";
11
12
  import type { SurfaceUsageConfig } from "../surface-usage.ts";
12
13
  import type { RuntimeCapabilities } from "../runtime/types.ts";
13
14
 
@@ -17,6 +18,36 @@ export interface RuntimeCapacity {
17
18
  limit: number;
18
19
  }
19
20
 
21
+ /**
22
+ * Where an agent's LLM traffic goes, named by HOST ENV VARS rather than
23
+ * values: the experiment says which variables carry its API key, base URL
24
+ * and extra headers, and the host supplies them at run time. Every field is
25
+ * optional; unset ones keep the adapter's vendor defaults (`OPENAI_API_KEY`,
26
+ * `OPENAI_BASE_URL`, … for codex; `ANTHROPIC_*` for claude).
27
+ *
28
+ * This is what lets two experiments of one adapter reach different
29
+ * endpoints in the same run — one vendor-direct, one through a gateway's
30
+ * unified API serving a third-party model — without the adapter growing a
31
+ * vendor table. The values stay secrets or identity exactly as before: the
32
+ * key variable must be secret-named (`isSecretEnvName`), the base-URL
33
+ * variable must not be (its value joins the pair environment and the
34
+ * egress allowlist), and the headers variable is redacted like a key.
35
+ */
36
+ export interface AgentEndpointSpec {
37
+ /** Host env var holding the API key the CLI authenticates with. Secret-named. */
38
+ apiKeyEnv?: string;
39
+ /** Host env var holding the base URL override. Non-secret name; forwarded only when set. */
40
+ baseUrlEnv?: string;
41
+ /** Host env var holding `Name: Value` header lines. Secret-named; forwarded only when set. */
42
+ headersEnv?: string;
43
+ /**
44
+ * codex only: the custom provider's wire protocol. `responses` (default)
45
+ * is codex's native Responses API; `chat` is OpenAI chat completions, the
46
+ * shape most OpenAI-compatible gateways and third-party models accept.
47
+ */
48
+ wireApi?: "responses" | "chat";
49
+ }
50
+
20
51
  /** Which agent executes the attempt, plus the identity fields recorded in the manifest. */
21
52
  export interface AgentSpec {
22
53
  /** Adapter name, e.g. "echo" (the built-in no-op pipeline smoke agent). */
@@ -25,6 +56,8 @@ export interface AgentSpec {
25
56
  model?: string;
26
57
  reasoning?: string;
27
58
  cliVersion?: string;
59
+ /** Endpoint routing by host env var name; see AgentEndpointSpec. Hashed into identity like every other field. */
60
+ endpoint?: AgentEndpointSpec;
28
61
  }
29
62
 
30
63
  /**
@@ -142,5 +175,10 @@ export function defineExperiment(experiment: Experiment): Experiment {
142
175
  );
143
176
  }
144
177
  validateWorkspaceFilesRuntime(experiment.runtime);
178
+ try {
179
+ validateAgentEndpoint(experiment.agent.endpoint, experiment.agent.adapter);
180
+ } catch (err) {
181
+ throw new Error(`defineExperiment: experiment "${experiment.id}" ${err instanceof Error ? err.message : String(err)}`);
182
+ }
145
183
  return deepFreeze(experiment);
146
184
  }
package/src/index.ts CHANGED
@@ -21,7 +21,14 @@ export { defineWorkspaceFiles, WORKSPACE_FILES_LIMITS } from "./runtime/workspac
21
21
  export type { WorkspaceFile, WorkspaceFiles, WorkspaceFilesIdentity } from "./runtime/workspace-files.ts";
22
22
  export type { WorkspaceFilesEvidence } from "./isolation/workspace-files.ts";
23
23
  export { defineExperiment } from "./experiments/types.ts";
24
- export type { AgentSpec, Experiment, LoadedExperiment, Runtime, RuntimeCapacity } from "./experiments/types.ts";
24
+ export type {
25
+ AgentEndpointSpec,
26
+ AgentSpec,
27
+ Experiment,
28
+ LoadedExperiment,
29
+ Runtime,
30
+ RuntimeCapacity,
31
+ } from "./experiments/types.ts";
25
32
  export type {
26
33
  ProvisionedEnvironment,
27
34
  RuntimeCapabilities,
@@ -23,7 +23,7 @@ export const IMAGE_CONTEXT_FILES: Record<string, string> = {
23
23
  # Framework toolchains (Rails, Django, ...) layer on top in Phase 3 — this
24
24
  # image stays agent-only.
25
25
 
26
- FROM node:22-bookworm-slim
26
+ FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d
27
27
 
28
28
  ARG CLAUDE_CODE_VERSION=2.1.215
29
29
  ARG CODEX_VERSION=0.149.1
package/src/manifest.ts CHANGED
@@ -19,7 +19,8 @@ import { sourceIdentity } from "./source-identity.ts";
19
19
  import { pairKey, resolvePairEnvironments, type PairEnvironment } from "./execution-policy.ts";
20
20
  import { canonicalJson } from "./identity-diff.ts";
21
21
  import type { EvalMetadata, LoadedEval } from "./evals/types.ts";
22
- import type { LoadedExperiment } from "./experiments/types.ts";
22
+ import { endpointIdentity } from "./experiments/endpoint.ts";
23
+ import type { AgentEndpointSpec, LoadedExperiment } from "./experiments/types.ts";
23
24
  import type { AttemptPlan } from "./plan.ts";
24
25
 
25
26
  export const MANIFEST_VERSION = "v4";
@@ -65,6 +66,17 @@ export interface EvalIdentity {
65
66
  solution: string | null;
66
67
  }
67
68
 
69
+ /**
70
+ * The agent's `endpoint` component, spread into the recorded agent fields:
71
+ * `{ endpoint }` when the experiment declares one, nothing otherwise, so an
72
+ * experiment without one keeps the exact identity it had before the field
73
+ * existed. Shared with the comparison-group view so both read one shape.
74
+ */
75
+ export function agentEndpointComponent(spec: AgentEndpointSpec | undefined): { endpoint?: Record<string, string> } {
76
+ const endpoint = endpointIdentity(spec);
77
+ return endpoint === undefined ? {} : { endpoint };
78
+ }
79
+
68
80
  /** Per-experiment identity components — phase 2's comparison-group diff reads these. */
69
81
  export interface ExperimentIdentityComponents {
70
82
  /** Hash of the experiment's defining module source. */
@@ -76,6 +88,8 @@ export interface ExperimentIdentityComponents {
76
88
  model: string;
77
89
  reasoning: string;
78
90
  cliVersion: string;
91
+ /** The declared `agent.endpoint` (experiments/endpoint.ts endpointIdentity); absent when none. */
92
+ endpoint?: Record<string, string>;
79
93
  };
80
94
  /** Hash of the canonicalized runtime configuration. */
81
95
  runtime: string;
@@ -192,6 +206,7 @@ export function buildRunManifest(options: BuildRunManifestOptions): RunManifest
192
206
  model: experiment.agent.model ?? "unspecified",
193
207
  reasoning: experiment.agent.reasoning ?? "unspecified",
194
208
  cliVersion: options.cliVersions?.[experiment.id] ?? experiment.agent.cliVersion ?? "unspecified",
209
+ ...agentEndpointComponent(experiment.agent.endpoint),
195
210
  },
196
211
  runtime: contentHash({
197
212
  kind: experiment.runtime.kind,
@@ -172,6 +172,16 @@ async function inStage<T>(kind: ErrorKind, work: () => Promise<T>): Promise<T> {
172
172
  }
173
173
  }
174
174
 
175
+ /**
176
+ * What makes one key probe distinct from another: the adapter and the host
177
+ * variables it reads. Same adapter, same variables, same values — one probe
178
+ * covers every experiment that shares them; a different `agent.endpoint`
179
+ * names different variables and gets its own.
180
+ */
181
+ export function keyProbeIdentity(adapter: Pick<ContainerAgentAdapter, "name" | "requiredHostEnv" | "optionalHostEnv">): string {
182
+ return [adapter.name, [...adapter.requiredHostEnv].join(","), [...(adapter.optionalHostEnv ?? [])].join(",")].join("|");
183
+ }
184
+
175
185
  function defaultHostAdapters(): Record<string, AgentAdapter> {
176
186
  const echo = new EchoAdapter();
177
187
  const oracle = new OracleAdapter();
@@ -180,8 +190,20 @@ function defaultHostAdapters(): Record<string, AgentAdapter> {
180
190
 
181
191
  function defaultContainerAdapters(): Record<string, (agent: AgentSpec) => ContainerAgentAdapter> {
182
192
  return {
183
- claude: (agent) => new ClaudeCodeAdapter("claude", { model: agent.model, reasoning: agent.reasoning }),
184
- codex: (agent) => new CodexAdapter("codex", { model: agent.model, reasoning: agent.reasoning }),
193
+ claude: (agent) =>
194
+ new ClaudeCodeAdapter("claude", {
195
+ model: agent.model,
196
+ reasoning: agent.reasoning,
197
+ provider: agent.provider,
198
+ endpoint: agent.endpoint,
199
+ }),
200
+ codex: (agent) =>
201
+ new CodexAdapter("codex", {
202
+ model: agent.model,
203
+ reasoning: agent.reasoning,
204
+ provider: agent.provider,
205
+ endpoint: agent.endpoint,
206
+ }),
185
207
  oracle: () => new OracleContainerAdapter(),
186
208
  };
187
209
  }
@@ -405,19 +427,24 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
405
427
 
406
428
  // Live key preflight: a key that is present but rejected by the provider
407
429
  // would burn a container per attempt just to 401 inside it. Probe each
408
- // unique agent's endpoint once — before any docker, sidecar, or run-row
409
- // work — and say plainly whether the run starts. Adapters without a probe
410
- // (tests, custom adapters) skip it.
411
- const probesByAdapter = new Map<string, ProviderKeyProbe>();
430
+ // unique endpoint once — before any docker, sidecar, or run-row work —
431
+ // and say plainly whether the run starts. "Unique" is the adapter plus
432
+ // the host variables it reads (keyProbeIdentity): two codex arms routed
433
+ // by different `agent.endpoint` variables are two probes, while arms
434
+ // sharing the vendor defaults stay one. Adapters without a probe (tests,
435
+ // custom adapters) skip it.
436
+ const probesByEndpoint = new Map<string, ProviderKeyProbe>();
412
437
  for (const adapter of containerAgents.values()) {
413
- if (adapter.keyProbe === undefined || probesByAdapter.has(adapter.name)) continue;
414
- probesByAdapter.set(adapter.name, {
438
+ if (adapter.keyProbe === undefined) continue;
439
+ const identity = keyProbeIdentity(adapter);
440
+ if (probesByEndpoint.has(identity)) continue;
441
+ probesByEndpoint.set(identity, {
415
442
  name: adapter.name,
416
443
  envVars: adapter.requiredHostEnv,
417
444
  ...adapter.keyProbe({ ...collectProviderKeys([adapter]), ...collectOptionalHostEnv([adapter]) }),
418
445
  });
419
446
  }
420
- await validateProviderKeys([...probesByAdapter.values()], { log });
447
+ await validateProviderKeys([...probesByEndpoint.values()], { log });
421
448
  log(`run starting: ${plan.length} attempt(s)`);
422
449
 
423
450
  // Resolve image digests and agent CLI versions BEFORE building the manifest: