harness-dispatch 0.4.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/LICENSE +21 -0
- package/README.md +582 -0
- package/config.default.yaml +397 -0
- package/data/coding_benchmarks.json +431 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +73 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +7 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +201 -0
- package/dist/billing.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +488 -0
- package/dist/bin.js.map +1 -0
- package/dist/breaker-store.d.ts +31 -0
- package/dist/breaker-store.d.ts.map +1 -0
- package/dist/breaker-store.js +103 -0
- package/dist/breaker-store.js.map +1 -0
- package/dist/circuit-breaker.d.ts +47 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +102 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +1248 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/live.d.ts +49 -0
- package/dist/dashboard/live.d.ts.map +1 -0
- package/dist/dashboard/live.js +149 -0
- package/dist/dashboard/live.js.map +1 -0
- package/dist/dispatch-log.d.ts +46 -0
- package/dist/dispatch-log.d.ts.map +1 -0
- package/dist/dispatch-log.js +89 -0
- package/dist/dispatch-log.js.map +1 -0
- package/dist/dispatchers/base.d.ts +55 -0
- package/dist/dispatchers/base.d.ts.map +1 -0
- package/dist/dispatchers/base.js +80 -0
- package/dist/dispatchers/base.js.map +1 -0
- package/dist/dispatchers/generic-cli.d.ts +30 -0
- package/dist/dispatchers/generic-cli.d.ts.map +1 -0
- package/dist/dispatchers/generic-cli.js +471 -0
- package/dist/dispatchers/generic-cli.js.map +1 -0
- package/dist/dispatchers/openai-compatible.d.ts +57 -0
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
- package/dist/dispatchers/openai-compatible.js +662 -0
- package/dist/dispatchers/openai-compatible.js.map +1 -0
- package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
- package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
- package/dist/dispatchers/shared/kill-tree.js +35 -0
- package/dist/dispatchers/shared/kill-tree.js.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
- package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.js +274 -0
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
- package/dist/dispatchers/shared/subprocess.d.ts +25 -0
- package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/subprocess.js +110 -0
- package/dist/dispatchers/shared/subprocess.js.map +1 -0
- package/dist/dispatchers/shared/which-available.d.ts +2 -0
- package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
- package/dist/dispatchers/shared/which-available.js +8 -0
- package/dist/dispatchers/shared/which-available.js.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.js +91 -0
- package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
- package/dist/http/server.d.ts +15 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +495 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/job-runner.d.ts +19 -0
- package/dist/job-runner.d.ts.map +1 -0
- package/dist/job-runner.js +42 -0
- package/dist/job-runner.js.map +1 -0
- package/dist/jobs.d.ts +116 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +542 -0
- package/dist/jobs.js.map +1 -0
- package/dist/leaderboard.d.ts +62 -0
- package/dist/leaderboard.d.ts.map +1 -0
- package/dist/leaderboard.js +287 -0
- package/dist/leaderboard.js.map +1 -0
- package/dist/mcp/config-hot-reload.d.ts +64 -0
- package/dist/mcp/config-hot-reload.d.ts.map +1 -0
- package/dist/mcp/config-hot-reload.js +144 -0
- package/dist/mcp/config-hot-reload.js.map +1 -0
- package/dist/mcp/dispatcher-factory.d.ts +20 -0
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
- package/dist/mcp/dispatcher-factory.js +30 -0
- package/dist/mcp/dispatcher-factory.js.map +1 -0
- package/dist/mcp/resources.d.ts +8 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +45 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +36 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +80 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +171 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +658 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/observability/index.d.ts +52 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +111 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/spans.d.ts +47 -0
- package/dist/observability/spans.d.ts.map +1 -0
- package/dist/observability/spans.js +72 -0
- package/dist/observability/spans.js.map +1 -0
- package/dist/quota.d.ts +91 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +331 -0
- package/dist/quota.js.map +1 -0
- package/dist/route-policy.d.ts +24 -0
- package/dist/route-policy.d.ts.map +1 -0
- package/dist/route-policy.js +92 -0
- package/dist/route-policy.js.map +1 -0
- package/dist/router.d.ts +162 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +878 -0
- package/dist/router.js.map +1 -0
- package/dist/safety.d.ts +7 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +33 -0
- package/dist/safety.js.map +1 -0
- package/dist/status.d.ts +100 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +228 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +402 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +13 -0
- package/dist/version.js.map +1 -0
- package/dist/working-dir.d.ts +15 -0
- package/dist/working-dir.d.ts.map +1 -0
- package/dist/working-dir.js +22 -0
- package/dist/working-dir.js.map +1 -0
- package/dist/workspaces.d.ts +19 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +352 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +96 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loading for harness-dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Two entry points:
|
|
5
|
+
* loadConfig(path?) — if no path, auto-detects installed CLIs on PATH.
|
|
6
|
+
* If path points to a legacy YAML with a `services:`
|
|
7
|
+
* key, returns it verbatim. Otherwise merges minimal
|
|
8
|
+
* overrides onto auto-detected defaults.
|
|
9
|
+
* watchConfig(path) — poll the file's mtime once a second and reload on
|
|
10
|
+
* change. Returns {stop} to cancel the poller.
|
|
11
|
+
*
|
|
12
|
+
* All string values are scanned for ${ENV_VAR} references and replaced with
|
|
13
|
+
* the corresponding environment variable.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, promises as fs } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import yaml from "js-yaml";
|
|
19
|
+
import which from "which";
|
|
20
|
+
import { normalizeSafetyProfile, } from "./safety.js";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Built-in harness defaults — loaded from the package's own bundled
|
|
23
|
+
// config.default.yaml's `clis:` list, NOT hardcoded here. Claude Code,
|
|
24
|
+
// Codex, Cursor, and Antigravity are not special-cased in this file; they're
|
|
25
|
+
// just the entries the shipped config happens to define, keyed by each
|
|
26
|
+
// entry's `harness:` value. See resolveShippedConfigPath()/
|
|
27
|
+
// loadDefaultHarnesses() below, near the bottom of this file (defined after
|
|
28
|
+
// the field parsers they reuse — str/num/capsFrom/parseProtocolFields/etc. —
|
|
29
|
+
// but referenced here via a hoisted function call, so definition order
|
|
30
|
+
// doesn't matter to JS).
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const SAFETY_PROFILES = ["read_only", "workspace_edit", "full_auto"];
|
|
33
|
+
/** The full set of `{{name}}` tokens expandToken() in generic-cli.ts understands. */
|
|
34
|
+
const KNOWN_ARG_PLACEHOLDERS = new Set([
|
|
35
|
+
"{{prompt}}",
|
|
36
|
+
"{{model}}",
|
|
37
|
+
"{{safety}}",
|
|
38
|
+
"{{working_dir}}",
|
|
39
|
+
"{{file_dirs}}",
|
|
40
|
+
"{{native_args}}",
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* `harness: generic` is not a harness definition — it's the escape hatch a
|
|
44
|
+
* user's own entry selects when adding a wholly new CLI, so its "defaults"
|
|
45
|
+
* are parser fallbacks, not example config. Billing is unknown/blocked until
|
|
46
|
+
* the operator classifies it, since there's no way to know an arbitrary
|
|
47
|
+
* CLI's real billing model. Command is empty so it can never be
|
|
48
|
+
* auto-detected; the user's entry must supply command + protocol.
|
|
49
|
+
*/
|
|
50
|
+
const GENERIC_DEFAULTS = {
|
|
51
|
+
command: "",
|
|
52
|
+
harness: "generic",
|
|
53
|
+
leaderboardModel: "",
|
|
54
|
+
cliCapability: 1.0,
|
|
55
|
+
tier: 3,
|
|
56
|
+
capabilities: { execute: 1.0, plan: 1.0, review: 1.0 },
|
|
57
|
+
provider: "custom",
|
|
58
|
+
surface: "custom",
|
|
59
|
+
authSource: "unknown",
|
|
60
|
+
billingKind: "unknown",
|
|
61
|
+
paidUsagePossible: true,
|
|
62
|
+
};
|
|
63
|
+
const CLI_DEFAULTS = loadDefaultHarnesses();
|
|
64
|
+
/** Named, selectable protocols — every harness in CLI_DEFAULTS that defines one, keyed by its harness name (see protocolFrom()'s string/`extends:` handling). */
|
|
65
|
+
export const PROTOCOL_PRESETS = Object.fromEntries(Object.entries(CLI_DEFAULTS)
|
|
66
|
+
.filter((entry) => entry[1].protocol !== undefined)
|
|
67
|
+
.map(([name, defaults]) => [name, defaults.protocol]));
|
|
68
|
+
// Default route id auto-detect assigns for each harness — distinct from the
|
|
69
|
+
// CLI_DEFAULTS key above, which is the harness *type* (selects the
|
|
70
|
+
// dispatcher class via dispatcher-factory.ts's HARNESS_TABLE, and is read by
|
|
71
|
+
// billing.ts/safety.ts/router.ts) and must not change. This mapping only
|
|
72
|
+
// controls what shows up as the service/route name, so it can follow the
|
|
73
|
+
// same `*_cli` convention `endpoints:` uses for `*_api` (e.g. gemini_api).
|
|
74
|
+
const AUTO_DETECT_NAME = {
|
|
75
|
+
claude_code: "claude_code_cli",
|
|
76
|
+
codex: "codex_cli",
|
|
77
|
+
cursor: "cursor_cli",
|
|
78
|
+
antigravity_cli: "antigravity_cli",
|
|
79
|
+
};
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Env var interpolation (${VAR_NAME})
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const ENV_VAR_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
84
|
+
/**
|
|
85
|
+
* `unsetVars` collects the names of ${VAR} references that resolved to
|
|
86
|
+
* nothing because the env var isn't set at all — distinct from a var
|
|
87
|
+
* deliberately set to "". Without this, a typo'd or forgotten env var
|
|
88
|
+
* (${ANTHROPIC_API_KEY} when the real name is ${ANTHROPIC_KEY}) silently
|
|
89
|
+
* becomes an empty string: `str()` then drops the field entirely, a route
|
|
90
|
+
* that needed an api_key loses it with zero feedback, and doctor reports
|
|
91
|
+
* the route "ready" right up until the first real call 401s.
|
|
92
|
+
*/
|
|
93
|
+
function interpolateEnv(value, unsetVars) {
|
|
94
|
+
const m = ENV_VAR_RE.exec(value);
|
|
95
|
+
if (!m)
|
|
96
|
+
return value;
|
|
97
|
+
const name = m[1];
|
|
98
|
+
if (!(name in process.env))
|
|
99
|
+
unsetVars.add(name);
|
|
100
|
+
return process.env[name] ?? "";
|
|
101
|
+
}
|
|
102
|
+
/** Walk an object tree and replace any "${VAR}" string leaves with env values. */
|
|
103
|
+
function interpolateTree(node, unsetVars) {
|
|
104
|
+
if (typeof node === "string") {
|
|
105
|
+
return interpolateEnv(node, unsetVars);
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(node)) {
|
|
108
|
+
return node.map((v) => interpolateTree(v, unsetVars));
|
|
109
|
+
}
|
|
110
|
+
if (node !== null && typeof node === "object") {
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [k, v] of Object.entries(node)) {
|
|
113
|
+
out[k] = interpolateTree(v, unsetVars);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
return node;
|
|
118
|
+
}
|
|
119
|
+
const defaultWhich = async (cmd) => {
|
|
120
|
+
try {
|
|
121
|
+
const r = await which(cmd, { nothrow: true });
|
|
122
|
+
return r ?? null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Normalization helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
function num(v, def) {
|
|
132
|
+
if (typeof v === "number")
|
|
133
|
+
return v;
|
|
134
|
+
if (typeof v === "string" && v !== "") {
|
|
135
|
+
const n = Number(v);
|
|
136
|
+
if (!Number.isNaN(n))
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
return def;
|
|
140
|
+
}
|
|
141
|
+
function int(v, def) {
|
|
142
|
+
return Math.trunc(num(v, def));
|
|
143
|
+
}
|
|
144
|
+
function bool(v, def) {
|
|
145
|
+
if (typeof v === "boolean")
|
|
146
|
+
return v;
|
|
147
|
+
if (typeof v === "string") {
|
|
148
|
+
if (v === "true")
|
|
149
|
+
return true;
|
|
150
|
+
if (v === "false")
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return def;
|
|
154
|
+
}
|
|
155
|
+
function str(v) {
|
|
156
|
+
if (typeof v !== "string")
|
|
157
|
+
return undefined;
|
|
158
|
+
if (v === "")
|
|
159
|
+
return undefined;
|
|
160
|
+
return v;
|
|
161
|
+
}
|
|
162
|
+
function thinkingFrom(v) {
|
|
163
|
+
if (v === "low" || v === "medium" || v === "high")
|
|
164
|
+
return v;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
function providerFrom(v) {
|
|
168
|
+
if (v === "anthropic" ||
|
|
169
|
+
v === "openai" ||
|
|
170
|
+
v === "cursor" ||
|
|
171
|
+
v === "google" ||
|
|
172
|
+
v === "local" ||
|
|
173
|
+
v === "custom") {
|
|
174
|
+
return v;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
function surfaceFrom(v) {
|
|
179
|
+
if (v === "claude_code" ||
|
|
180
|
+
v === "claude_agent_sdk" ||
|
|
181
|
+
v === "anthropic_api" ||
|
|
182
|
+
v === "codex_cli" ||
|
|
183
|
+
v === "codex_sdk" ||
|
|
184
|
+
v === "openai_api" ||
|
|
185
|
+
v === "cursor_agent_cli" ||
|
|
186
|
+
v === "antigravity_cli" ||
|
|
187
|
+
v === "gemini_api" ||
|
|
188
|
+
v === "vertex_ai" ||
|
|
189
|
+
v === "openai_compatible" ||
|
|
190
|
+
v === "local_endpoint" ||
|
|
191
|
+
v === "custom") {
|
|
192
|
+
return v;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
function authSourceFrom(v) {
|
|
197
|
+
if (v === "product_login" ||
|
|
198
|
+
v === "api_key" ||
|
|
199
|
+
v === "oauth_session" ||
|
|
200
|
+
v === "local_network" ||
|
|
201
|
+
v === "configured_endpoint" ||
|
|
202
|
+
v === "unknown") {
|
|
203
|
+
return v;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
function billingKindFrom(v) {
|
|
208
|
+
if (v === "local_compute" ||
|
|
209
|
+
v === "included_plan_usage" ||
|
|
210
|
+
v === "included_plan_then_flexible_credits" ||
|
|
211
|
+
v === "included_credit_then_optional_overage" ||
|
|
212
|
+
v === "included_usage_then_on_demand" ||
|
|
213
|
+
v === "metered_api" ||
|
|
214
|
+
v === "free_quota" ||
|
|
215
|
+
v === "unknown") {
|
|
216
|
+
return v;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
function confidenceFrom(v) {
|
|
221
|
+
if (v === "documented" || v === "inferred" || v === "unknown" || v === "unsupported") {
|
|
222
|
+
return v;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
function endpointModeFrom(v) {
|
|
227
|
+
if (v === "provider_cloud" ||
|
|
228
|
+
v === "direct_openai_compatible" ||
|
|
229
|
+
v === "harness_native_endpoint") {
|
|
230
|
+
return v;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
function endpointProviderFrom(v) {
|
|
235
|
+
if (v === "ollama" ||
|
|
236
|
+
v === "lmstudio" ||
|
|
237
|
+
v === "openai_compatible" ||
|
|
238
|
+
v === "anthropic_gateway" ||
|
|
239
|
+
v === "gemini_proxy" ||
|
|
240
|
+
v === "custom") {
|
|
241
|
+
return v;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
function wireProtocolFrom(v) {
|
|
246
|
+
if (v === "openai_chat_completions" ||
|
|
247
|
+
v === "anthropic_messages" ||
|
|
248
|
+
v === "gemini_generate_content" ||
|
|
249
|
+
v === "provider_native" ||
|
|
250
|
+
v === "unknown") {
|
|
251
|
+
return v;
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
function workspacePolicyFrom(v) {
|
|
256
|
+
if (v === "shared" || v === "shared_locked" || v === "git_worktree" || v === "copy") {
|
|
257
|
+
return v;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
function inferEndpointProvider(baseUrl) {
|
|
262
|
+
if (!baseUrl)
|
|
263
|
+
return "custom";
|
|
264
|
+
try {
|
|
265
|
+
const url = new URL(baseUrl);
|
|
266
|
+
const host = url.hostname.toLowerCase();
|
|
267
|
+
const port = url.port;
|
|
268
|
+
if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && port === "11434") {
|
|
269
|
+
return "ollama";
|
|
270
|
+
}
|
|
271
|
+
if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && port === "1234") {
|
|
272
|
+
return "lmstudio";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Fall through to substring checks for partial or nonstandard URLs.
|
|
277
|
+
}
|
|
278
|
+
const lower = baseUrl.toLowerCase();
|
|
279
|
+
if (lower.includes("ollama"))
|
|
280
|
+
return "ollama";
|
|
281
|
+
if (lower.includes("lmstudio") || lower.includes("lm-studio"))
|
|
282
|
+
return "lmstudio";
|
|
283
|
+
return "custom";
|
|
284
|
+
}
|
|
285
|
+
function endpointFields(raw, type, baseUrl) {
|
|
286
|
+
const endpointMode = endpointModeFrom(raw.endpoint_mode) ??
|
|
287
|
+
(type === "openai_compatible" ? "direct_openai_compatible" : undefined);
|
|
288
|
+
const endpointProvider = endpointProviderFrom(raw.endpoint_provider) ??
|
|
289
|
+
(type === "openai_compatible" ? inferEndpointProvider(baseUrl) : undefined);
|
|
290
|
+
const wireProtocol = wireProtocolFrom(raw.wire_protocol) ??
|
|
291
|
+
(endpointMode === "direct_openai_compatible" ||
|
|
292
|
+
endpointMode === "harness_native_endpoint"
|
|
293
|
+
? "openai_chat_completions"
|
|
294
|
+
: undefined);
|
|
295
|
+
const out = {};
|
|
296
|
+
if (endpointMode !== undefined)
|
|
297
|
+
out.endpointMode = endpointMode;
|
|
298
|
+
if (endpointProvider !== undefined)
|
|
299
|
+
out.endpointProvider = endpointProvider;
|
|
300
|
+
if (wireProtocol !== undefined)
|
|
301
|
+
out.wireProtocol = wireProtocol;
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Top-level config blocks shared by every format: `telemetry:` and
|
|
306
|
+
* `retention:`. Parsed once here so legacy `services:` configs and modern
|
|
307
|
+
* `clis:`/`endpoints:` configs behave identically.
|
|
308
|
+
*/
|
|
309
|
+
function topLevelSettings(raw) {
|
|
310
|
+
const out = {};
|
|
311
|
+
const telemetryRaw = raw.telemetry;
|
|
312
|
+
if (telemetryRaw !== null && typeof telemetryRaw === "object") {
|
|
313
|
+
const enabled = telemetryRaw.enabled;
|
|
314
|
+
if (typeof enabled === "boolean")
|
|
315
|
+
out.telemetry = { enabled };
|
|
316
|
+
}
|
|
317
|
+
const retentionRaw = raw.retention;
|
|
318
|
+
if (retentionRaw !== null && typeof retentionRaw === "object") {
|
|
319
|
+
const days = num(retentionRaw.jobs_days, Number.NaN);
|
|
320
|
+
if (Number.isFinite(days) && days >= 0)
|
|
321
|
+
out.retention = { jobsDays: days };
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
function billingFields(raw) {
|
|
326
|
+
const out = {};
|
|
327
|
+
const provider = providerFrom(raw.provider);
|
|
328
|
+
const surface = surfaceFrom(raw.surface);
|
|
329
|
+
const authSource = authSourceFrom(raw.auth_source);
|
|
330
|
+
const billingKind = billingKindFrom(raw.billing_kind);
|
|
331
|
+
const billingConfidence = confidenceFrom(raw.billing_confidence);
|
|
332
|
+
const safetyProfile = normalizeSafetyProfile(raw.safety_profile);
|
|
333
|
+
if (provider !== undefined)
|
|
334
|
+
out.provider = provider;
|
|
335
|
+
if (surface !== undefined)
|
|
336
|
+
out.surface = surface;
|
|
337
|
+
if (authSource !== undefined)
|
|
338
|
+
out.authSource = authSource;
|
|
339
|
+
if (billingKind !== undefined)
|
|
340
|
+
out.billingKind = billingKind;
|
|
341
|
+
if (billingConfidence !== undefined)
|
|
342
|
+
out.billingConfidence = billingConfidence;
|
|
343
|
+
if (typeof raw.paid_usage_possible === "boolean") {
|
|
344
|
+
out.paidUsagePossible = raw.paid_usage_possible;
|
|
345
|
+
}
|
|
346
|
+
if (typeof raw.allow_paid_usage === "boolean")
|
|
347
|
+
out.allowPaidUsage = raw.allow_paid_usage;
|
|
348
|
+
const notes = str(raw.billing_notes);
|
|
349
|
+
if (notes !== undefined)
|
|
350
|
+
out.billingNotes = notes;
|
|
351
|
+
if (safetyProfile !== undefined)
|
|
352
|
+
out.safetyProfile = safetyProfile;
|
|
353
|
+
const workspacePolicy = workspacePolicyFrom(raw.workspace_policy);
|
|
354
|
+
if (workspacePolicy !== undefined)
|
|
355
|
+
out.workspacePolicy = workspacePolicy;
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
function capsFrom(raw) {
|
|
359
|
+
const r = (raw ?? {});
|
|
360
|
+
return {
|
|
361
|
+
execute: num(r.execute, 1.0),
|
|
362
|
+
plan: num(r.plan, 1.0),
|
|
363
|
+
review: num(r.review, 1.0),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function escalateOnFrom(raw) {
|
|
367
|
+
if (!Array.isArray(raw))
|
|
368
|
+
return ["plan", "review"];
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const v of raw) {
|
|
371
|
+
if (v === "execute" || v === "plan" || v === "review" || v === "local") {
|
|
372
|
+
out.push(v);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return out.length > 0 ? out : ["plan", "review"];
|
|
376
|
+
}
|
|
377
|
+
function stringArrayFrom(raw) {
|
|
378
|
+
if (!Array.isArray(raw))
|
|
379
|
+
return undefined;
|
|
380
|
+
const out = raw.filter((v) => typeof v === "string");
|
|
381
|
+
return out.length > 0 ? out : undefined;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Parse `protocol:` for any route (see CliProtocolConfig in types.ts).
|
|
385
|
+
* Three shapes:
|
|
386
|
+
* - A string ("cursor", "codex", ...) — looked up in PROTOCOL_PRESETS by
|
|
387
|
+
* name. Lets a config select a known protocol without retyping it, and
|
|
388
|
+
* is the extension point for "others add further protocols" — a new
|
|
389
|
+
* named entry in the shipped config.default.yaml is immediately selectable here,
|
|
390
|
+
* no code changes.
|
|
391
|
+
* - An object with `extends: <preset name>` — starts from that preset and
|
|
392
|
+
* overrides only the fields present, for the common "95% the same, one
|
|
393
|
+
* flag different" case. safety merges per-profile (overriding just
|
|
394
|
+
* full_auto doesn't erase read_only/workspace_edit from the preset).
|
|
395
|
+
* - A plain object — the full protocol, no preset involved (unchanged
|
|
396
|
+
* behavior from before presets existed).
|
|
397
|
+
*
|
|
398
|
+
* Returns undefined — with a warning — for anything malformed, so a broken
|
|
399
|
+
* block degrades to "route unusable" (isAvailable() checks for a missing
|
|
400
|
+
* protocol) rather than a half-built dispatcher silently doing the wrong
|
|
401
|
+
* thing.
|
|
402
|
+
*/
|
|
403
|
+
function protocolFrom(raw, routeLabel, warnings) {
|
|
404
|
+
if (typeof raw === "string") {
|
|
405
|
+
const preset = PROTOCOL_PRESETS[raw];
|
|
406
|
+
if (!preset) {
|
|
407
|
+
warnings.push(`${routeLabel}: protocol "${raw}" is not a known preset (expected one of: ` +
|
|
408
|
+
`${Object.keys(PROTOCOL_PRESETS).join(", ")}) — entry ignored.`);
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return preset;
|
|
412
|
+
}
|
|
413
|
+
if (raw === null || typeof raw !== "object")
|
|
414
|
+
return undefined;
|
|
415
|
+
const r = raw;
|
|
416
|
+
let base;
|
|
417
|
+
if (typeof r.extends === "string") {
|
|
418
|
+
base = PROTOCOL_PRESETS[r.extends];
|
|
419
|
+
if (!base) {
|
|
420
|
+
warnings.push(`${routeLabel}: protocol.extends "${r.extends}" is not a known preset (expected one of: ` +
|
|
421
|
+
`${Object.keys(PROTOCOL_PRESETS).join(", ")}) — entry ignored.`);
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return parseProtocolFields(r, routeLabel, warnings, base);
|
|
426
|
+
}
|
|
427
|
+
function parseProtocolFields(r, routeLabel, warnings, base) {
|
|
428
|
+
const args = stringArrayFrom(r.args) ?? base?.args;
|
|
429
|
+
if (args === undefined) {
|
|
430
|
+
warnings.push(`${routeLabel}: protocol.args is required — entry ignored.`);
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const outputRaw = r.output;
|
|
434
|
+
let output = base?.output;
|
|
435
|
+
if (outputRaw !== undefined) {
|
|
436
|
+
if (outputRaw === null || typeof outputRaw !== "object") {
|
|
437
|
+
warnings.push(`${routeLabel}: protocol.output must be an object — entry ignored.`);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const o = outputRaw;
|
|
441
|
+
if (o.mode !== "text" && o.mode !== "json_field" && o.mode !== "jsonl_stream") {
|
|
442
|
+
warnings.push(`${routeLabel}: protocol.output.mode must be one of text | json_field | jsonl_stream — entry ignored.`);
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
output = { mode: o.mode };
|
|
446
|
+
const fields = stringArrayFrom(o.fields);
|
|
447
|
+
if (fields !== undefined)
|
|
448
|
+
output.fields = fields;
|
|
449
|
+
const usageRaw = o.usage;
|
|
450
|
+
if (usageRaw !== null && typeof usageRaw === "object") {
|
|
451
|
+
const u = usageRaw;
|
|
452
|
+
const input = stringArrayFrom(u.input);
|
|
453
|
+
const outputTokens = stringArrayFrom(u.output);
|
|
454
|
+
if (input !== undefined && outputTokens !== undefined)
|
|
455
|
+
output.usage = { input, output: outputTokens };
|
|
456
|
+
}
|
|
457
|
+
const eventRulesRaw = o.event_rules;
|
|
458
|
+
if (Array.isArray(eventRulesRaw)) {
|
|
459
|
+
const eventRules = [];
|
|
460
|
+
for (const [i, ruleRaw] of eventRulesRaw.entries()) {
|
|
461
|
+
const rule = eventRuleFrom(ruleRaw, `${routeLabel}: protocol.output.event_rules[${i}]`, warnings);
|
|
462
|
+
if (rule)
|
|
463
|
+
eventRules.push(rule);
|
|
464
|
+
}
|
|
465
|
+
if (eventRules.length > 0)
|
|
466
|
+
output.eventRules = eventRules;
|
|
467
|
+
}
|
|
468
|
+
const errorRaw = o.error;
|
|
469
|
+
if (errorRaw !== null && typeof errorRaw === "object") {
|
|
470
|
+
const e = errorRaw;
|
|
471
|
+
const field = str(e.field);
|
|
472
|
+
if (field !== undefined) {
|
|
473
|
+
const messageFields = stringArrayFrom(e.message_fields);
|
|
474
|
+
output.error = messageFields !== undefined ? { field, messageFields } : { field };
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
warnings.push(`${routeLabel}: protocol.output.error.field is required — error detection ignored.`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (output === undefined) {
|
|
482
|
+
warnings.push(`${routeLabel}: protocol.output is required — entry ignored.`);
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
const protocol = { args, output };
|
|
486
|
+
const stdin = typeof r.stdin === "boolean" ? r.stdin : base?.stdin;
|
|
487
|
+
if (stdin !== undefined)
|
|
488
|
+
protocol.stdin = stdin;
|
|
489
|
+
const modelRaw = r.model;
|
|
490
|
+
if (modelRaw !== undefined && modelRaw !== null) {
|
|
491
|
+
const m = modelRaw;
|
|
492
|
+
if (typeof m.flag === "string" && m.flag) {
|
|
493
|
+
protocol.model = { flag: m.flag };
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
warnings.push(`${routeLabel}: protocol.model set but missing a "flag" string — ignored.`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else if (base?.model) {
|
|
500
|
+
protocol.model = base.model;
|
|
501
|
+
}
|
|
502
|
+
const workingDirRaw = r.working_dir;
|
|
503
|
+
if (workingDirRaw !== undefined && workingDirRaw !== null) {
|
|
504
|
+
const wd = workingDirRaw;
|
|
505
|
+
if (typeof wd.flag === "string" && wd.flag) {
|
|
506
|
+
protocol.workingDir = { flag: wd.flag };
|
|
507
|
+
const extraArgsWhenSet = stringArrayFrom(wd.extra_args_when_set);
|
|
508
|
+
if (extraArgsWhenSet !== undefined)
|
|
509
|
+
protocol.workingDir.extraArgsWhenSet = extraArgsWhenSet;
|
|
510
|
+
if (wd.fallback === "home")
|
|
511
|
+
protocol.workingDir.fallback = "home";
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
warnings.push(`${routeLabel}: protocol.working_dir set but missing a "flag" string — ignored.`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else if (base?.workingDir) {
|
|
518
|
+
protocol.workingDir = base.workingDir;
|
|
519
|
+
}
|
|
520
|
+
const fileDirsRaw = r.file_dirs;
|
|
521
|
+
if (fileDirsRaw !== undefined && fileDirsRaw !== null) {
|
|
522
|
+
const fd = fileDirsRaw;
|
|
523
|
+
if (typeof fd.flag === "string" && fd.flag) {
|
|
524
|
+
protocol.fileDirs = { flag: fd.flag };
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
warnings.push(`${routeLabel}: protocol.file_dirs set but missing a "flag" string — ignored.`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else if (base?.fileDirs) {
|
|
531
|
+
protocol.fileDirs = base.fileDirs;
|
|
532
|
+
}
|
|
533
|
+
const fileListHeader = str(r.file_list_header) ?? base?.fileListHeader;
|
|
534
|
+
if (fileListHeader !== undefined)
|
|
535
|
+
protocol.fileListHeader = fileListHeader;
|
|
536
|
+
const fileListBullet = (typeof r.file_list_bullet === "string" ? r.file_list_bullet : undefined) ?? base?.fileListBullet;
|
|
537
|
+
if (fileListBullet !== undefined)
|
|
538
|
+
protocol.fileListBullet = fileListBullet;
|
|
539
|
+
const apiKeyEnvVar = str(r.api_key_env_var) ?? base?.apiKeyEnvVar;
|
|
540
|
+
if (apiKeyEnvVar !== undefined)
|
|
541
|
+
protocol.apiKeyEnvVar = apiKeyEnvVar;
|
|
542
|
+
const safetyRaw = r.safety;
|
|
543
|
+
const safety = { ...base?.safety };
|
|
544
|
+
if (safetyRaw !== null && typeof safetyRaw === "object") {
|
|
545
|
+
for (const profile of SAFETY_PROFILES) {
|
|
546
|
+
const profileArgs = stringArrayFrom(safetyRaw[profile]);
|
|
547
|
+
if (profileArgs !== undefined)
|
|
548
|
+
safety[profile] = profileArgs;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (Object.keys(safety).length > 0)
|
|
552
|
+
protocol.safety = safety;
|
|
553
|
+
if (typeof r.success_requires_output === "boolean") {
|
|
554
|
+
protocol.successRequiresOutput = r.success_requires_output;
|
|
555
|
+
}
|
|
556
|
+
else if (base?.successRequiresOutput !== undefined) {
|
|
557
|
+
protocol.successRequiresOutput = base.successRequiresOutput;
|
|
558
|
+
}
|
|
559
|
+
const endpointNativeArgsRaw = r.endpoint_native_args;
|
|
560
|
+
if (endpointNativeArgsRaw !== null && typeof endpointNativeArgsRaw === "object") {
|
|
561
|
+
const ena = {};
|
|
562
|
+
for (const [k, v] of Object.entries(endpointNativeArgsRaw)) {
|
|
563
|
+
const args2 = stringArrayFrom(v);
|
|
564
|
+
if (args2 !== undefined)
|
|
565
|
+
ena[k] = args2;
|
|
566
|
+
}
|
|
567
|
+
if (Object.keys(ena).length > 0)
|
|
568
|
+
protocol.endpointNativeArgs = ena;
|
|
569
|
+
}
|
|
570
|
+
else if (base?.endpointNativeArgs) {
|
|
571
|
+
protocol.endpointNativeArgs = base.endpointNativeArgs;
|
|
572
|
+
}
|
|
573
|
+
// Placeholder sanity checks on the FINAL merged args (so `extends:` results
|
|
574
|
+
// are covered too). A typo'd placeholder is the most likely user error in a
|
|
575
|
+
// hand-written protocol, and without these warnings it doesn't just fail
|
|
576
|
+
// silently — it "succeeds": the CLI receives the literal "{{promt}}" token,
|
|
577
|
+
// never receives the prompt, exits 0, and the run reports ok.
|
|
578
|
+
// Matches embedded forms too ("--flag={{prompt}}"), not just whole-token
|
|
579
|
+
// typos — expansion only ever substitutes a token that IS a placeholder,
|
|
580
|
+
// so anything merely containing one goes through literally.
|
|
581
|
+
for (const token of protocol.args) {
|
|
582
|
+
if (token.includes("{{") && !KNOWN_ARG_PLACEHOLDERS.has(token)) {
|
|
583
|
+
warnings.push(`${routeLabel}: protocol.args contains unrecognized placeholder "${token}" — it will be ` +
|
|
584
|
+
`passed to the CLI as a literal argument, not substituted. Placeholders only work as ` +
|
|
585
|
+
`a whole standalone argument. Known placeholders: ` +
|
|
586
|
+
`${[...KNOWN_ARG_PLACEHOLDERS].join(", ")}.`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!protocol.stdin && !protocol.args.includes("{{prompt}}")) {
|
|
590
|
+
warnings.push(`${routeLabel}: protocol.args has no {{prompt}} placeholder and stdin is not true — ` +
|
|
591
|
+
`the prompt is never sent to the CLI. Add "{{prompt}}" to args, or set stdin: true.`);
|
|
592
|
+
}
|
|
593
|
+
return protocol;
|
|
594
|
+
}
|
|
595
|
+
function eventRuleFrom(raw, label, warnings) {
|
|
596
|
+
if (raw === null || typeof raw !== "object") {
|
|
597
|
+
warnings.push(`${label}: must be an object — ignored.`);
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
const r = raw;
|
|
601
|
+
// "when" is optional — omitted or {} means "matches every line" (e.g. a
|
|
602
|
+
// usage rule that should fire regardless of event type, matching Codex's
|
|
603
|
+
// original unconditional `if (event.usage) {...}` check).
|
|
604
|
+
const whenRaw = r.when ?? {};
|
|
605
|
+
if (whenRaw === null || typeof whenRaw !== "object" || Array.isArray(whenRaw)) {
|
|
606
|
+
warnings.push(`${label}: "when" must be a {field: value} map — ignored.`);
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
const when = {};
|
|
610
|
+
for (const [k, v] of Object.entries(whenRaw)) {
|
|
611
|
+
if (typeof v === "string")
|
|
612
|
+
when[k] = v;
|
|
613
|
+
}
|
|
614
|
+
const emit = r.emit;
|
|
615
|
+
if (emit !== "text" &&
|
|
616
|
+
emit !== "tool_use" &&
|
|
617
|
+
emit !== "thinking" &&
|
|
618
|
+
emit !== "usage" &&
|
|
619
|
+
emit !== "error") {
|
|
620
|
+
warnings.push(`${label}: "emit" must be one of text | tool_use | thinking | usage | error — ignored.`);
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
const rule = { when, emit };
|
|
624
|
+
const textField = str(r.text_field);
|
|
625
|
+
if (textField !== undefined)
|
|
626
|
+
rule.textField = textField;
|
|
627
|
+
const nameField = str(r.name_field);
|
|
628
|
+
if (nameField !== undefined)
|
|
629
|
+
rule.nameField = nameField;
|
|
630
|
+
const inputField = str(r.input_field);
|
|
631
|
+
if (inputField !== undefined)
|
|
632
|
+
rule.inputField = inputField;
|
|
633
|
+
const chunkField = str(r.chunk_field);
|
|
634
|
+
if (chunkField !== undefined)
|
|
635
|
+
rule.chunkField = chunkField;
|
|
636
|
+
const inputTokenFields = stringArrayFrom(r.input_token_fields);
|
|
637
|
+
if (inputTokenFields !== undefined)
|
|
638
|
+
rule.inputTokenFields = inputTokenFields;
|
|
639
|
+
const outputTokenFields = stringArrayFrom(r.output_token_fields);
|
|
640
|
+
if (outputTokenFields !== undefined)
|
|
641
|
+
rule.outputTokenFields = outputTokenFields;
|
|
642
|
+
const messageField = str(r.message_field);
|
|
643
|
+
if (messageField !== undefined)
|
|
644
|
+
rule.messageField = messageField;
|
|
645
|
+
return rule;
|
|
646
|
+
}
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Legacy full-format parser (YAML with top-level `services:` key)
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
function buildLegacyConfig(raw) {
|
|
651
|
+
const services = {};
|
|
652
|
+
const rawServices = (raw.services ?? {});
|
|
653
|
+
const warnings = [];
|
|
654
|
+
// A top-level `services:` key selects the legacy parser entirely — it
|
|
655
|
+
// never looks at clis:/endpoints:/overrides:, so any of those sitting
|
|
656
|
+
// alongside it are silently dropped unless we say so here. The likely
|
|
657
|
+
// real-world case is a config written by an old `configure` (which used
|
|
658
|
+
// to emit `services:`; it writes clis:/endpoints: now) with modern keys
|
|
659
|
+
// later pasted in from the README or shipped config.
|
|
660
|
+
const ignoredModernKeys = ["clis", "endpoints", "overrides"].filter((key) => raw[key] !== undefined);
|
|
661
|
+
if (ignoredModernKeys.length > 0) {
|
|
662
|
+
warnings.push(`top-level "services:" is present, which selects the legacy config format and ` +
|
|
663
|
+
`IGNORES ${ignoredModernKeys.map((k) => `"${k}:"`).join(", ")} entirely — move ` +
|
|
664
|
+
`those entries into "services:" entries, or remove "services:" and use ` +
|
|
665
|
+
`clis:/endpoints:/overrides: exclusively (see the shipped config.default.yaml).`);
|
|
666
|
+
}
|
|
667
|
+
for (const [name, svc] of Object.entries(rawServices)) {
|
|
668
|
+
const type = (str(svc.type) ?? "cli");
|
|
669
|
+
// Computed once and reused below for both max-tokens fallback and the
|
|
670
|
+
// billing/safety/model-hint inheritance IIFE — legacy-format entries
|
|
671
|
+
// inherit the named harness's declared metadata (from the shipped
|
|
672
|
+
// config) exactly like clis: entries do, so `harness: cursor` here
|
|
673
|
+
// classifies correctly without repeating every field. Explicit fields
|
|
674
|
+
// on the entry itself always win.
|
|
675
|
+
const harnessDefaults = CLI_DEFAULTS[str(svc.harness) ?? ""];
|
|
676
|
+
const svcConfig = {
|
|
677
|
+
name,
|
|
678
|
+
enabled: bool(svc.enabled, true),
|
|
679
|
+
type,
|
|
680
|
+
...(str(svc.harness) !== undefined ? { harness: str(svc.harness) } : {}),
|
|
681
|
+
command: str(svc.command) ?? name,
|
|
682
|
+
...(str(svc.api_key) !== undefined ? { apiKey: str(svc.api_key) } : {}),
|
|
683
|
+
...(str(svc.base_url) !== undefined ? { baseUrl: str(svc.base_url) } : {}),
|
|
684
|
+
...(str(svc.model) !== undefined ? { model: str(svc.model) } : {}),
|
|
685
|
+
tier: int(svc.tier, 1),
|
|
686
|
+
weight: num(svc.weight, 1.0),
|
|
687
|
+
cliCapability: num(svc.cli_capability, 1.0),
|
|
688
|
+
...(str(svc.leaderboard_model) !== undefined
|
|
689
|
+
? { leaderboardModel: str(svc.leaderboard_model) }
|
|
690
|
+
: {}),
|
|
691
|
+
...(() => {
|
|
692
|
+
const t = thinkingFrom(svc.thinking_level);
|
|
693
|
+
return t !== undefined ? { thinkingLevel: t } : {};
|
|
694
|
+
})(),
|
|
695
|
+
...(str(svc.escalate_model) !== undefined
|
|
696
|
+
? { escalateModel: str(svc.escalate_model) }
|
|
697
|
+
: {}),
|
|
698
|
+
escalateOn: escalateOnFrom(svc.escalate_on),
|
|
699
|
+
capabilities: capsFrom(svc.capabilities),
|
|
700
|
+
...(() => {
|
|
701
|
+
// Declared context size drives router.ts's preferLargeContext boost
|
|
702
|
+
// (not harness name), so a legacy entry that omits these but names
|
|
703
|
+
// a known harness (e.g. antigravity_cli's 2M context) must still
|
|
704
|
+
// inherit them — otherwise the boost silently becomes a no-op.
|
|
705
|
+
const v = typeof svc.max_output_tokens === "number" ? svc.max_output_tokens : harnessDefaults?.maxOutputTokens;
|
|
706
|
+
return v !== undefined ? { maxOutputTokens: v } : {};
|
|
707
|
+
})(),
|
|
708
|
+
...(() => {
|
|
709
|
+
const v = typeof svc.max_input_tokens === "number" ? svc.max_input_tokens : harnessDefaults?.maxInputTokens;
|
|
710
|
+
return v !== undefined ? { maxInputTokens: v } : {};
|
|
711
|
+
})(),
|
|
712
|
+
...(() => {
|
|
713
|
+
if (!harnessDefaults)
|
|
714
|
+
return {};
|
|
715
|
+
return {
|
|
716
|
+
provider: harnessDefaults.provider,
|
|
717
|
+
surface: harnessDefaults.surface,
|
|
718
|
+
authSource: harnessDefaults.authSource,
|
|
719
|
+
...(harnessDefaults.billingKind !== undefined
|
|
720
|
+
? { billingKind: harnessDefaults.billingKind }
|
|
721
|
+
: {}),
|
|
722
|
+
...(harnessDefaults.paidUsagePossible !== undefined
|
|
723
|
+
? { paidUsagePossible: harnessDefaults.paidUsagePossible }
|
|
724
|
+
: {}),
|
|
725
|
+
...(harnessDefaults.effectiveSafety !== undefined
|
|
726
|
+
? { effectiveSafety: harnessDefaults.effectiveSafety }
|
|
727
|
+
: {}),
|
|
728
|
+
...(harnessDefaults.models !== undefined ? { models: harnessDefaults.models } : {}),
|
|
729
|
+
...(harnessDefaults.modelHint !== undefined
|
|
730
|
+
? { modelHint: harnessDefaults.modelHint }
|
|
731
|
+
: {}),
|
|
732
|
+
};
|
|
733
|
+
})(),
|
|
734
|
+
...billingFields(svc),
|
|
735
|
+
...(() => {
|
|
736
|
+
const effectiveSafety = normalizeSafetyProfile(svc.effective_safety);
|
|
737
|
+
return effectiveSafety !== undefined ? { effectiveSafety } : {};
|
|
738
|
+
})(),
|
|
739
|
+
...(() => {
|
|
740
|
+
const models = stringArrayFrom(svc.models);
|
|
741
|
+
return models !== undefined ? { models } : {};
|
|
742
|
+
})(),
|
|
743
|
+
...(() => {
|
|
744
|
+
const modelHint = str(svc.model_hint);
|
|
745
|
+
return modelHint !== undefined ? { modelHint } : {};
|
|
746
|
+
})(),
|
|
747
|
+
...endpointFields(svc, type, str(svc.base_url)),
|
|
748
|
+
...(typeof svc.timeout_ms === "number" ? { timeoutMs: svc.timeout_ms } : {}),
|
|
749
|
+
...(() => {
|
|
750
|
+
// Falls back to the named harness's built-in default protocol (if
|
|
751
|
+
// any) when this legacy-format entry doesn't declare its own —
|
|
752
|
+
// same behavior as clis: entries, so `harness: claude_code` here
|
|
753
|
+
// works without repeating the whole protocol block.
|
|
754
|
+
const harnessDefaults = CLI_DEFAULTS[str(svc.harness) ?? ""];
|
|
755
|
+
const protocol = protocolFrom(svc.protocol, `services "${name}"`, warnings) ?? harnessDefaults?.protocol;
|
|
756
|
+
return protocol !== undefined ? { protocol } : {};
|
|
757
|
+
})(),
|
|
758
|
+
};
|
|
759
|
+
services[name] = svcConfig;
|
|
760
|
+
}
|
|
761
|
+
const cfg = {
|
|
762
|
+
services,
|
|
763
|
+
...(Array.isArray(raw.disabled)
|
|
764
|
+
? { disabled: raw.disabled.slice() }
|
|
765
|
+
: {}),
|
|
766
|
+
...topLevelSettings(raw),
|
|
767
|
+
...(warnings.length > 0 ? { configWarnings: warnings } : {}),
|
|
768
|
+
};
|
|
769
|
+
return cfg;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Build a CLI ServiceConfig from a harness's built-in defaults plus a raw
|
|
773
|
+
* override object. Shared by auto-detect (`overrides:` keyed by route id)
|
|
774
|
+
* and explicit `clis:` entries (each entry IS the override, plus `name` and
|
|
775
|
+
* `harness` picking which defaults to start from).
|
|
776
|
+
*/
|
|
777
|
+
function buildCliServiceConfig(name, defaults, override, apiKeys, warnings = []) {
|
|
778
|
+
override = { ...override };
|
|
779
|
+
// Capabilities merge-over-defaults.
|
|
780
|
+
const caps = { ...defaults.capabilities };
|
|
781
|
+
if (override.capabilities && typeof override.capabilities === "object") {
|
|
782
|
+
const oc = override.capabilities;
|
|
783
|
+
if (oc.execute !== undefined)
|
|
784
|
+
caps.execute = num(oc.execute, caps.execute);
|
|
785
|
+
if (oc.plan !== undefined)
|
|
786
|
+
caps.plan = num(oc.plan, caps.plan);
|
|
787
|
+
if (oc.review !== undefined)
|
|
788
|
+
caps.review = num(oc.review, caps.review);
|
|
789
|
+
delete override.capabilities;
|
|
790
|
+
}
|
|
791
|
+
const apiKey = str(override.api_key) ?? (apiKeys[name] ? apiKeys[name] : undefined);
|
|
792
|
+
return {
|
|
793
|
+
name,
|
|
794
|
+
enabled: bool(override.enabled, true),
|
|
795
|
+
type: "cli",
|
|
796
|
+
harness: str(override.harness) ?? defaults.harness,
|
|
797
|
+
command: str(override.command) ?? defaults.command,
|
|
798
|
+
...(apiKey ? { apiKey } : {}),
|
|
799
|
+
...(str(override.model) !== undefined ? { model: str(override.model) } : {}),
|
|
800
|
+
...(str(override.base_url) !== undefined ? { baseUrl: str(override.base_url) } : {}),
|
|
801
|
+
weight: num(override.weight, 1.0),
|
|
802
|
+
tier: int(override.tier, defaults.tier),
|
|
803
|
+
cliCapability: num(override.cli_capability, defaults.cliCapability),
|
|
804
|
+
leaderboardModel: str(override.leaderboard_model) ?? defaults.leaderboardModel,
|
|
805
|
+
...(() => {
|
|
806
|
+
const overrideThinking = thinkingFrom(override.thinking_level);
|
|
807
|
+
if (overrideThinking !== undefined)
|
|
808
|
+
return { thinkingLevel: overrideThinking };
|
|
809
|
+
if (defaults.thinkingLevel !== undefined) {
|
|
810
|
+
return { thinkingLevel: defaults.thinkingLevel };
|
|
811
|
+
}
|
|
812
|
+
return {};
|
|
813
|
+
})(),
|
|
814
|
+
...(str(override.escalate_model) !== undefined
|
|
815
|
+
? { escalateModel: str(override.escalate_model) }
|
|
816
|
+
: {}),
|
|
817
|
+
escalateOn: escalateOnFrom(override.escalate_on),
|
|
818
|
+
capabilities: caps,
|
|
819
|
+
...(() => {
|
|
820
|
+
const m = override.max_output_tokens;
|
|
821
|
+
const v = typeof m === "number" ? m : defaults.maxOutputTokens;
|
|
822
|
+
return v !== undefined ? { maxOutputTokens: v } : {};
|
|
823
|
+
})(),
|
|
824
|
+
...(() => {
|
|
825
|
+
const m = override.max_input_tokens;
|
|
826
|
+
const v = typeof m === "number" ? m : defaults.maxInputTokens;
|
|
827
|
+
return v !== undefined ? { maxInputTokens: v } : {};
|
|
828
|
+
})(),
|
|
829
|
+
provider: providerFrom(override.provider) ?? defaults.provider,
|
|
830
|
+
surface: surfaceFrom(override.surface) ?? defaults.surface,
|
|
831
|
+
authSource: authSourceFrom(override.auth_source) ?? (apiKey ? "api_key" : defaults.authSource),
|
|
832
|
+
...(() => {
|
|
833
|
+
const billingKind = billingKindFrom(override.billing_kind) ?? (apiKey ? "metered_api" : defaults.billingKind);
|
|
834
|
+
return billingKind !== undefined ? { billingKind } : {};
|
|
835
|
+
})(),
|
|
836
|
+
...(() => {
|
|
837
|
+
const paidUsagePossible = typeof override.paid_usage_possible === "boolean"
|
|
838
|
+
? override.paid_usage_possible
|
|
839
|
+
: apiKey
|
|
840
|
+
? true
|
|
841
|
+
: defaults.paidUsagePossible;
|
|
842
|
+
return paidUsagePossible !== undefined ? { paidUsagePossible } : {};
|
|
843
|
+
})(),
|
|
844
|
+
...(typeof override.allow_paid_usage === "boolean"
|
|
845
|
+
? { allowPaidUsage: override.allow_paid_usage }
|
|
846
|
+
: {}),
|
|
847
|
+
...(() => {
|
|
848
|
+
const c = confidenceFrom(override.billing_confidence);
|
|
849
|
+
return c !== undefined ? { billingConfidence: c } : {};
|
|
850
|
+
})(),
|
|
851
|
+
...(() => {
|
|
852
|
+
const notes = str(override.billing_notes);
|
|
853
|
+
return notes !== undefined ? { billingNotes: notes } : {};
|
|
854
|
+
})(),
|
|
855
|
+
...(() => {
|
|
856
|
+
const safetyProfile = normalizeSafetyProfile(override.safety_profile);
|
|
857
|
+
return safetyProfile !== undefined ? { safetyProfile } : {};
|
|
858
|
+
})(),
|
|
859
|
+
...(() => {
|
|
860
|
+
const effectiveSafety = normalizeSafetyProfile(override.effective_safety) ?? defaults.effectiveSafety;
|
|
861
|
+
return effectiveSafety !== undefined ? { effectiveSafety } : {};
|
|
862
|
+
})(),
|
|
863
|
+
...(() => {
|
|
864
|
+
const models = stringArrayFrom(override.models) ?? defaults.models;
|
|
865
|
+
return models !== undefined ? { models } : {};
|
|
866
|
+
})(),
|
|
867
|
+
...(() => {
|
|
868
|
+
const modelHint = str(override.model_hint) ?? defaults.modelHint;
|
|
869
|
+
return modelHint !== undefined ? { modelHint } : {};
|
|
870
|
+
})(),
|
|
871
|
+
...endpointFields(override, "cli", str(override.base_url)),
|
|
872
|
+
...(typeof override.timeout_ms === "number" ? { timeoutMs: override.timeout_ms } : {}),
|
|
873
|
+
...(() => {
|
|
874
|
+
// Falls back to this harness's built-in default (if any) when no
|
|
875
|
+
// override is given, or when the override is malformed — protocolFrom
|
|
876
|
+
// already warned in the latter case; failing the whole route over a
|
|
877
|
+
// typo'd override would be worse than keeping the known-good default.
|
|
878
|
+
const protocol = protocolFrom(override.protocol, `clis "${name}"`, warnings) ?? defaults.protocol;
|
|
879
|
+
return protocol !== undefined ? { protocol } : {};
|
|
880
|
+
})(),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
async function detectServices(disabled, apiKeys, overrides, whichFn) {
|
|
884
|
+
const services = {};
|
|
885
|
+
const disabledSet = new Set(disabled);
|
|
886
|
+
for (const [harness, defaults] of Object.entries(CLI_DEFAULTS)) {
|
|
887
|
+
// "generic" has no installable binary of its own — it exists only for
|
|
888
|
+
// explicit clis: entries (addClis), never auto-detection.
|
|
889
|
+
if (harness === "generic")
|
|
890
|
+
continue;
|
|
891
|
+
const name = AUTO_DETECT_NAME[harness] ?? harness;
|
|
892
|
+
if (disabledSet.has(name))
|
|
893
|
+
continue;
|
|
894
|
+
const found = await whichFn(defaults.command);
|
|
895
|
+
if (!found)
|
|
896
|
+
continue;
|
|
897
|
+
const override = overrides[name] ?? {};
|
|
898
|
+
services[name] = buildCliServiceConfig(name, defaults, override, apiKeys);
|
|
899
|
+
}
|
|
900
|
+
return services;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Explicit `clis:` entries — same shape as `endpoints:` but for CLI
|
|
904
|
+
* harnesses: arbitrary `name`, required `harness` picks which built-in
|
|
905
|
+
* defaults to start from (claude_code | codex | cursor | antigravity_cli).
|
|
906
|
+
* Not gated on `which()` — declared explicitly, so it's added to the map
|
|
907
|
+
* unconditionally and its dispatcher's own isAvailable() reports whether the
|
|
908
|
+
* binary is actually on PATH (surfaced in status/doctor either way, instead
|
|
909
|
+
* of silently vanishing like undetected auto-detect entries do).
|
|
910
|
+
*/
|
|
911
|
+
function addClis(services, raw, apiKeys, warnings) {
|
|
912
|
+
const clis = Array.isArray(raw.clis) ? raw.clis : [];
|
|
913
|
+
for (const [index, entry] of clis.entries()) {
|
|
914
|
+
const name = str(entry.name);
|
|
915
|
+
const harness = str(entry.harness);
|
|
916
|
+
if (!name || !harness) {
|
|
917
|
+
warnings.push(`clis[${index}]: missing required "name" and/or "harness" — entry ignored.`);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const defaults = CLI_DEFAULTS[harness];
|
|
921
|
+
if (!defaults) {
|
|
922
|
+
warnings.push(`clis[${index}] "${name}": unrecognized harness "${harness}" (expected one of: ` +
|
|
923
|
+
`${Object.keys(CLI_DEFAULTS).join(", ")}) — entry ignored.`);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (harness === "generic") {
|
|
927
|
+
if (!str(entry.command)) {
|
|
928
|
+
warnings.push(`clis[${index}] "${name}": harness: generic requires an explicit "command" — entry ignored.`);
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (entry.protocol === undefined || entry.protocol === null) {
|
|
932
|
+
warnings.push(`clis[${index}] "${name}": harness: generic requires a "protocol" block — entry ignored. ` +
|
|
933
|
+
"See README.md#adding-a-harness.");
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
// Validate now so a malformed protocol block skips the whole entry,
|
|
937
|
+
// instead of silently landing a route with no `.protocol` at all.
|
|
938
|
+
// Warnings from THIS pass go to a throwaway array, not `warnings` —
|
|
939
|
+
// buildCliServiceConfig below parses the same entry.protocol again
|
|
940
|
+
// (it has to: it's shared with detectServices' overrides path, which
|
|
941
|
+
// has no prior validation pass) and would otherwise double every
|
|
942
|
+
// warning under two different labels. On failure we still surface it
|
|
943
|
+
// once, from here, since the entry gets skipped before that second
|
|
944
|
+
// parse ever runs.
|
|
945
|
+
const validation = [];
|
|
946
|
+
if (protocolFrom(entry.protocol, `clis[${index}] "${name}"`, validation) === undefined) {
|
|
947
|
+
warnings.push(...validation);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
services[name] = buildCliServiceConfig(name, defaults, entry, apiKeys, warnings);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function collectApiKeys(raw) {
|
|
955
|
+
const apiKeys = {};
|
|
956
|
+
const rawApiKeys = (raw.api_keys ?? {});
|
|
957
|
+
for (const [k, v] of Object.entries(rawApiKeys)) {
|
|
958
|
+
if (typeof v === "string" && v !== "")
|
|
959
|
+
apiKeys[k] = v;
|
|
960
|
+
}
|
|
961
|
+
// Shorthand: codex_cli_api_key, cursor_cli_api_key, etc. — keyed by the
|
|
962
|
+
// route id auto-detect assigns (AUTO_DETECT_NAME), not the harness type.
|
|
963
|
+
for (const name of Object.values(AUTO_DETECT_NAME)) {
|
|
964
|
+
const shorthand = `${name}_api_key`;
|
|
965
|
+
const v = raw[shorthand];
|
|
966
|
+
if (typeof v === "string" && v !== "")
|
|
967
|
+
apiKeys[name] = v;
|
|
968
|
+
}
|
|
969
|
+
return apiKeys;
|
|
970
|
+
}
|
|
971
|
+
function addEndpoints(services, raw) {
|
|
972
|
+
const endpoints = Array.isArray(raw.endpoints)
|
|
973
|
+
? raw.endpoints
|
|
974
|
+
: [];
|
|
975
|
+
for (const ep of endpoints) {
|
|
976
|
+
const name = str(ep.name);
|
|
977
|
+
const baseUrl = str(ep.base_url);
|
|
978
|
+
const model = str(ep.model);
|
|
979
|
+
if (!name || !baseUrl || !model)
|
|
980
|
+
continue;
|
|
981
|
+
const svc = {
|
|
982
|
+
name,
|
|
983
|
+
enabled: bool(ep.enabled, true),
|
|
984
|
+
type: "openai_compatible",
|
|
985
|
+
baseUrl,
|
|
986
|
+
model,
|
|
987
|
+
command: "",
|
|
988
|
+
...(str(ep.api_key) !== undefined ? { apiKey: str(ep.api_key) } : {}),
|
|
989
|
+
weight: num(ep.weight, 0.6),
|
|
990
|
+
tier: int(ep.tier, 3),
|
|
991
|
+
cliCapability: num(ep.cli_capability, 1.0),
|
|
992
|
+
...(str(ep.leaderboard_model) !== undefined
|
|
993
|
+
? { leaderboardModel: str(ep.leaderboard_model) }
|
|
994
|
+
: {}),
|
|
995
|
+
escalateOn: escalateOnFrom(ep.escalate_on),
|
|
996
|
+
capabilities: capsFrom(ep.capabilities),
|
|
997
|
+
...billingFields({
|
|
998
|
+
...ep,
|
|
999
|
+
provider: ep.provider ?? (inferEndpointProvider(baseUrl) === "custom" ? undefined : "local"),
|
|
1000
|
+
surface: ep.surface ?? (inferEndpointProvider(baseUrl) === "custom" ? undefined : "local_endpoint"),
|
|
1001
|
+
auth_source: ep.auth_source ?? (inferEndpointProvider(baseUrl) === "custom" ? undefined : "local_network"),
|
|
1002
|
+
billing_kind: ep.billing_kind ?? (inferEndpointProvider(baseUrl) === "custom" ? undefined : "local_compute"),
|
|
1003
|
+
paid_usage_possible: typeof ep.paid_usage_possible === "boolean"
|
|
1004
|
+
? ep.paid_usage_possible
|
|
1005
|
+
: inferEndpointProvider(baseUrl) === "custom"
|
|
1006
|
+
? ep.paid_usage_possible
|
|
1007
|
+
: false,
|
|
1008
|
+
billing_confidence: ep.billing_confidence ?? (inferEndpointProvider(baseUrl) === "custom" ? undefined : "documented"),
|
|
1009
|
+
}),
|
|
1010
|
+
...endpointFields(ep, "openai_compatible", baseUrl),
|
|
1011
|
+
...(typeof ep.timeout_ms === "number" ? { timeoutMs: ep.timeout_ms } : {}),
|
|
1012
|
+
...(typeof ep.max_output_tokens === "number" ? { maxOutputTokens: ep.max_output_tokens } : {}),
|
|
1013
|
+
...(typeof ep.max_input_tokens === "number" ? { maxInputTokens: ep.max_input_tokens } : {}),
|
|
1014
|
+
...(() => {
|
|
1015
|
+
const t = thinkingFrom(ep.thinking_level);
|
|
1016
|
+
return t !== undefined ? { thinkingLevel: t } : {};
|
|
1017
|
+
})(),
|
|
1018
|
+
...(() => {
|
|
1019
|
+
const models = stringArrayFrom(ep.models);
|
|
1020
|
+
return models !== undefined ? { models } : {};
|
|
1021
|
+
})(),
|
|
1022
|
+
...(() => {
|
|
1023
|
+
const modelHint = str(ep.model_hint);
|
|
1024
|
+
return modelHint !== undefined ? { modelHint } : {};
|
|
1025
|
+
})(),
|
|
1026
|
+
};
|
|
1027
|
+
services[name] = svc;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Load a RouterConfig.
|
|
1032
|
+
*
|
|
1033
|
+
* If `path` is omitted (or the file doesn't exist), auto-detect CLIs on PATH
|
|
1034
|
+
* and use built-in defaults. If the file has a top-level `services:` key,
|
|
1035
|
+
* parse it in legacy mode. Otherwise auto-detect and merge `overrides`.
|
|
1036
|
+
*
|
|
1037
|
+
* Supports ${ENV_VAR} interpolation for any string value.
|
|
1038
|
+
*/
|
|
1039
|
+
export async function loadConfig(path, opts = {}) {
|
|
1040
|
+
const whichFn = opts.whichFn ?? defaultWhich;
|
|
1041
|
+
let raw = {};
|
|
1042
|
+
const unsetEnvVars = new Set();
|
|
1043
|
+
if (path) {
|
|
1044
|
+
try {
|
|
1045
|
+
const text = await fs.readFile(path, "utf-8");
|
|
1046
|
+
const parsed = yaml.load(text);
|
|
1047
|
+
if (parsed && typeof parsed === "object") {
|
|
1048
|
+
raw = interpolateTree(parsed, unsetEnvVars);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
// File not found -> auto-detect mode. Any other error -> rethrow.
|
|
1053
|
+
const e = err;
|
|
1054
|
+
if (e.code !== "ENOENT")
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const envVarWarning = unsetEnvVars.size > 0
|
|
1059
|
+
? `\${VAR} reference(s) resolved to an empty string because the environment ` +
|
|
1060
|
+
`variable isn't set: ${[...unsetEnvVars].map((v) => `\${${v}}`).join(", ")} — any field ` +
|
|
1061
|
+
`using one (e.g. an api_key) silently lost its value; set the variable or fix the name.`
|
|
1062
|
+
: undefined;
|
|
1063
|
+
// Legacy full format: has a `services:` key -> use as-is.
|
|
1064
|
+
if (raw.services && typeof raw.services === "object") {
|
|
1065
|
+
const legacyCfg = buildLegacyConfig(raw);
|
|
1066
|
+
if (envVarWarning !== undefined) {
|
|
1067
|
+
return { ...legacyCfg, configWarnings: [...(legacyCfg.configWarnings ?? []), envVarWarning] };
|
|
1068
|
+
}
|
|
1069
|
+
return legacyCfg;
|
|
1070
|
+
}
|
|
1071
|
+
const disabled = Array.isArray(raw.disabled)
|
|
1072
|
+
? raw.disabled.slice()
|
|
1073
|
+
: [];
|
|
1074
|
+
const overrides = (raw.overrides ?? {});
|
|
1075
|
+
const warnings = [];
|
|
1076
|
+
const knownAutoDetectNames = new Set(Object.values(AUTO_DETECT_NAME));
|
|
1077
|
+
for (const name of disabled) {
|
|
1078
|
+
if (!knownAutoDetectNames.has(name)) {
|
|
1079
|
+
warnings.push(`disabled: "${name}" doesn't match any auto-detected route (expected one of: ` +
|
|
1080
|
+
`${[...knownAutoDetectNames].join(", ")}) — ignored. If this is left over from ` +
|
|
1081
|
+
`before a route rename, the route it used to refer to is no longer disabled.`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
for (const name of Object.keys(overrides)) {
|
|
1085
|
+
if (!knownAutoDetectNames.has(name)) {
|
|
1086
|
+
warnings.push(`overrides.${name}: doesn't match any auto-detected route (expected one of: ` +
|
|
1087
|
+
`${[...knownAutoDetectNames].join(", ")}) — ignored, none of these settings were applied.`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const apiKeys = collectApiKeys(raw);
|
|
1091
|
+
const services = await detectServices(disabled, apiKeys, overrides, whichFn);
|
|
1092
|
+
addClis(services, raw, apiKeys, warnings);
|
|
1093
|
+
addEndpoints(services, raw);
|
|
1094
|
+
if (envVarWarning !== undefined)
|
|
1095
|
+
warnings.push(envVarWarning);
|
|
1096
|
+
const cfg = {
|
|
1097
|
+
services,
|
|
1098
|
+
disabled,
|
|
1099
|
+
...topLevelSettings(raw),
|
|
1100
|
+
...(warnings.length > 0 ? { configWarnings: warnings } : {}),
|
|
1101
|
+
};
|
|
1102
|
+
return cfg;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Poll the config file's mtime once per second. When it changes, reload and
|
|
1106
|
+
* invoke `onChange`. The returned handle's stop() cancels the poller.
|
|
1107
|
+
*
|
|
1108
|
+
* Errors from reload are swallowed so a transient parse error doesn't kill
|
|
1109
|
+
* the watcher — the next successful poll will pick up a repaired file.
|
|
1110
|
+
*/
|
|
1111
|
+
export function watchConfig(path, onChange, opts = {}) {
|
|
1112
|
+
const intervalMs = opts.intervalMs ?? 1000;
|
|
1113
|
+
let lastMtime = 0;
|
|
1114
|
+
const tick = async () => {
|
|
1115
|
+
try {
|
|
1116
|
+
const stat = await fs.stat(path);
|
|
1117
|
+
const mtime = stat.mtimeMs;
|
|
1118
|
+
if (lastMtime === 0) {
|
|
1119
|
+
lastMtime = mtime;
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (mtime !== lastMtime) {
|
|
1123
|
+
lastMtime = mtime;
|
|
1124
|
+
const cfg = await loadConfig(path, opts.whichFn ? { whichFn: opts.whichFn } : {});
|
|
1125
|
+
onChange(cfg);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// ignore transient errors
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
const handle = setInterval(() => {
|
|
1133
|
+
void tick();
|
|
1134
|
+
}, intervalMs);
|
|
1135
|
+
return {
|
|
1136
|
+
stop() {
|
|
1137
|
+
clearInterval(handle);
|
|
1138
|
+
},
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
// ---------------------------------------------------------------------------
|
|
1142
|
+
// Built-in harness defaults (shipped config.default.yaml)
|
|
1143
|
+
// ---------------------------------------------------------------------------
|
|
1144
|
+
/**
|
|
1145
|
+
* Resolve the package's own bundled config.default.yaml relative to this
|
|
1146
|
+
* module — same "walk up" approach as leaderboard.ts's benchmark file, so
|
|
1147
|
+
* it works whether this is running compiled (dist/) or via tsx (src/). This
|
|
1148
|
+
* is a DIFFERENT file from a user's own config.yaml (never tracked, may
|
|
1149
|
+
* hold secrets/local overrides) — separating them means a package update to
|
|
1150
|
+
* the shipped defaults never collides with or gets clobbered by a user's
|
|
1151
|
+
* live instance, and vice versa.
|
|
1152
|
+
*/
|
|
1153
|
+
function resolveShippedConfigPath() {
|
|
1154
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
1155
|
+
let dir = here;
|
|
1156
|
+
for (let i = 0; i < 6; i += 1) {
|
|
1157
|
+
const candidate = path.join(dir, "config.default.yaml");
|
|
1158
|
+
if (existsSync(candidate))
|
|
1159
|
+
return candidate;
|
|
1160
|
+
const parent = path.dirname(dir);
|
|
1161
|
+
if (parent === dir)
|
|
1162
|
+
break;
|
|
1163
|
+
dir = parent;
|
|
1164
|
+
}
|
|
1165
|
+
return path.join(here, "..", "config.default.yaml");
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Parse one `clis:` entry of the shipped config into CliDefaults. Uses the
|
|
1169
|
+
* exact same field parsers (str/num/int/capsFrom/parseProtocolFields/...)
|
|
1170
|
+
* as a user's own `clis:` entry — the shipped file IS a config.yaml, parsed
|
|
1171
|
+
* the same way, not a special hardcoded path or bespoke shape.
|
|
1172
|
+
*/
|
|
1173
|
+
function cliDefaultsFrom(raw, warnings) {
|
|
1174
|
+
const harness = str(raw.harness);
|
|
1175
|
+
if (!harness)
|
|
1176
|
+
return undefined;
|
|
1177
|
+
const protocolRaw = raw.protocol;
|
|
1178
|
+
const protocol = protocolRaw !== undefined
|
|
1179
|
+
? parseProtocolFields(protocolRaw, `shipped config.default.yaml clis "${harness}"`, warnings, undefined)
|
|
1180
|
+
: undefined;
|
|
1181
|
+
const billingKind = billingKindFrom(raw.billing_kind);
|
|
1182
|
+
return [
|
|
1183
|
+
harness,
|
|
1184
|
+
{
|
|
1185
|
+
command: str(raw.command) ?? "",
|
|
1186
|
+
harness,
|
|
1187
|
+
leaderboardModel: str(raw.leaderboard_model) ?? "",
|
|
1188
|
+
cliCapability: num(raw.cli_capability, 1.0),
|
|
1189
|
+
tier: int(raw.tier, 1),
|
|
1190
|
+
capabilities: capsFrom(raw.capabilities),
|
|
1191
|
+
...(typeof raw.max_output_tokens === "number" ? { maxOutputTokens: raw.max_output_tokens } : {}),
|
|
1192
|
+
...(typeof raw.max_input_tokens === "number" ? { maxInputTokens: raw.max_input_tokens } : {}),
|
|
1193
|
+
provider: providerFrom(raw.provider) ?? "custom",
|
|
1194
|
+
surface: surfaceFrom(raw.surface) ?? "custom",
|
|
1195
|
+
authSource: authSourceFrom(raw.auth_source) ?? "unknown",
|
|
1196
|
+
...(billingKind !== undefined ? { billingKind } : {}),
|
|
1197
|
+
...(typeof raw.paid_usage_possible === "boolean" ? { paidUsagePossible: raw.paid_usage_possible } : {}),
|
|
1198
|
+
...(() => {
|
|
1199
|
+
const effectiveSafety = normalizeSafetyProfile(raw.effective_safety);
|
|
1200
|
+
return effectiveSafety !== undefined ? { effectiveSafety } : {};
|
|
1201
|
+
})(),
|
|
1202
|
+
...(() => {
|
|
1203
|
+
const models = stringArrayFrom(raw.models);
|
|
1204
|
+
return models !== undefined ? { models } : {};
|
|
1205
|
+
})(),
|
|
1206
|
+
...(() => {
|
|
1207
|
+
const modelHint = str(raw.model_hint);
|
|
1208
|
+
return modelHint !== undefined ? { modelHint } : {};
|
|
1209
|
+
})(),
|
|
1210
|
+
...(protocol !== undefined ? { protocol } : {}),
|
|
1211
|
+
},
|
|
1212
|
+
];
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Load the built-in harness defaults from the shipped config's `clis:` list
|
|
1216
|
+
* — the same shape as a `clis:` entry in a user's own config.yaml, keyed by
|
|
1217
|
+
* each entry's `harness:` value. Failure (missing/malformed file) degrades
|
|
1218
|
+
* to just the generic escape hatch — auto-detect simply finds nothing
|
|
1219
|
+
* built-in, rather than crashing the whole router; a user's own
|
|
1220
|
+
* `clis:`/`services:` entries are unaffected either way.
|
|
1221
|
+
*/
|
|
1222
|
+
function loadDefaultHarnesses() {
|
|
1223
|
+
const warnings = [];
|
|
1224
|
+
const out = { generic: GENERIC_DEFAULTS };
|
|
1225
|
+
try {
|
|
1226
|
+
const raw = yaml.load(readFileSync(resolveShippedConfigPath(), "utf8"));
|
|
1227
|
+
for (const entry of raw?.clis ?? []) {
|
|
1228
|
+
const parsed = cliDefaultsFrom(entry, warnings);
|
|
1229
|
+
if (parsed && parsed[0] !== "generic")
|
|
1230
|
+
out[parsed[0]] = parsed[1];
|
|
1231
|
+
}
|
|
1232
|
+
if (warnings.length > 0) {
|
|
1233
|
+
// Shipped-config warnings indicate a packaging/build problem, not a
|
|
1234
|
+
// user config mistake — surface loudly rather than folding into
|
|
1235
|
+
// configWarnings (which a user would reasonably assume is about
|
|
1236
|
+
// their own file).
|
|
1237
|
+
for (const w of warnings)
|
|
1238
|
+
console.error(`harness-dispatch: shipped config.default.yaml: ${w}`);
|
|
1239
|
+
}
|
|
1240
|
+
return out;
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
console.error(`harness-dispatch: failed to load the package's shipped config.default.yaml (${err instanceof Error ? err.message : String(err)}) ` +
|
|
1244
|
+
"— no built-in harnesses will be available; your own clis:/services: entries are unaffected.");
|
|
1245
|
+
return out;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
//# sourceMappingURL=config.js.map
|