@workos/quickstudy 0.12.1 → 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 +44 -0
- package/package.json +1 -1
- package/src/adapters/claude.ts +74 -12
- package/src/adapters/codex.ts +74 -26
- package/src/build-info.generated.ts +1 -1
- package/src/experiments/endpoint.ts +138 -0
- package/src/experiments/groups.ts +2 -1
- package/src/experiments/types.ts +38 -0
- package/src/index.ts +8 -1
- package/src/manifest.ts +16 -1
- package/src/runner/execute.ts +36 -9
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:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/quickstudy",
|
|
3
|
-
"version": "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",
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
47
|
-
*
|
|
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
|
|
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(
|
|
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[
|
|
110
|
+
url: endpointUrl(env[this.endpoint.baseUrlEnv], "https://api.anthropic.com", "v1/models"),
|
|
82
111
|
headers: {
|
|
83
|
-
...parseHeaderLines(env[
|
|
84
|
-
"x-api-key": env[
|
|
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
|
-
|
|
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 {
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
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(
|
|
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[
|
|
126
|
-
headers: {
|
|
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?.[
|
|
133
|
-
const headers = parseHeaderLines(ctx.overrides?.[
|
|
134
|
-
// Either override needs the custom provider
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
149
|
-
// for parseStream; the key reaches login via stdin,
|
|
150
|
-
|
|
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
|
+
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
|
|
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: {
|
package/src/experiments/types.ts
CHANGED
|
@@ -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 {
|
|
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,
|
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
|
|
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,
|
package/src/runner/execute.ts
CHANGED
|
@@ -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) =>
|
|
184
|
-
|
|
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
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
|
414
|
-
|
|
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([...
|
|
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:
|