headroom-openclaw 0.1.0 → 0.36.1
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 +174 -6
- package/dist/README.md +225 -0
- package/dist/hook-shim/HOOK.md +21 -0
- package/dist/hook-shim/handler.js +3 -0
- package/dist/index.d.ts +65 -37
- package/dist/index.js +782 -232
- package/dist/index.js.map +1 -1
- package/dist/openclaw.plugin.json +125 -0
- package/dist/package.json +36 -0
- package/hook-shim/HOOK.md +21 -0
- package/hook-shim/handler.js +3 -0
- package/openclaw.plugin.json +94 -10
- package/package.json +21 -5
package/dist/index.js
CHANGED
|
@@ -3,14 +3,11 @@ import { compress } from "headroom-ai";
|
|
|
3
3
|
|
|
4
4
|
// src/proxy-manager.ts
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
var STARTUP_TIMEOUT_MS = 15e3;
|
|
12
|
-
var RESTART_DELAY_MS = 2e3;
|
|
13
|
-
var MAX_RESTART_ATTEMPTS = 3;
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import os from "os";
|
|
14
11
|
var defaultLogger = {
|
|
15
12
|
info: (m) => console.log(`[headroom] ${m}`),
|
|
16
13
|
warn: (m) => console.warn(`[headroom] ${m}`),
|
|
@@ -18,249 +15,412 @@ var defaultLogger = {
|
|
|
18
15
|
debug: () => {
|
|
19
16
|
}
|
|
20
17
|
};
|
|
18
|
+
var HEADROOM_MODULE_DISCOVERY_SNIPPET = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
|
|
21
19
|
var ProxyManager = class {
|
|
22
20
|
config;
|
|
23
21
|
logger;
|
|
24
|
-
process = null;
|
|
25
22
|
proxyUrl = null;
|
|
26
|
-
weStartedIt = false;
|
|
27
|
-
restartCount = 0;
|
|
28
|
-
healthInterval = null;
|
|
29
|
-
disposed = false;
|
|
30
23
|
constructor(config = {}, logger) {
|
|
31
24
|
this.config = config;
|
|
32
25
|
this.logger = logger ?? defaultLogger;
|
|
33
26
|
}
|
|
34
27
|
/**
|
|
35
|
-
* Ensure a proxy is available. Returns the URL.
|
|
36
|
-
*
|
|
37
|
-
* 1. If proxyUrl is configured, use it
|
|
38
|
-
* 2. Check if proxy is already running on default port
|
|
39
|
-
* 3. If autoStart, spawn one
|
|
28
|
+
* Ensure a proxy is available. Returns the normalized URL origin.
|
|
40
29
|
*/
|
|
41
30
|
async start() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
const port = this.getProxyPort();
|
|
32
|
+
const rawExplicitUrl = typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0 ? normalizeAndValidateProxyUrl(this.config.proxyUrl) : null;
|
|
33
|
+
const explicitUrl = rawExplicitUrl ? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl : null;
|
|
34
|
+
const defaultCandidates = this.getDefaultProxyCandidates(port);
|
|
35
|
+
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
|
|
36
|
+
const probeByUrl = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const url of candidateUrls) {
|
|
38
|
+
const probe = await probeHeadroomProxy(url);
|
|
39
|
+
probeByUrl.set(url, probe);
|
|
40
|
+
if (probe.reachable && probe.isHeadroom) {
|
|
45
41
|
this.proxyUrl = url;
|
|
46
|
-
this.logger.info(`
|
|
42
|
+
this.logger.info(`Headroom proxy already running at ${url}`);
|
|
47
43
|
return url;
|
|
48
44
|
}
|
|
49
|
-
throw new Error(`Headroom proxy not reachable at ${url}`);
|
|
50
|
-
}
|
|
51
|
-
const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
52
|
-
if (await this.healthCheck(defaultUrl)) {
|
|
53
|
-
this.proxyUrl = defaultUrl;
|
|
54
|
-
this.logger.info(`Found running proxy at ${defaultUrl}`);
|
|
55
|
-
this.startHealthMonitor();
|
|
56
|
-
return defaultUrl;
|
|
57
45
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
46
|
+
if (explicitUrl) {
|
|
47
|
+
const explicitProbe = probeByUrl.get(explicitUrl);
|
|
48
|
+
if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
60
53
|
}
|
|
61
|
-
|
|
62
|
-
"Headroom proxy not running. Start with: headroom proxy --port 8787\nOr install: pip install 'headroom-ai[proxy]'"
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Spawn the headroom proxy as a child process.
|
|
67
|
-
*/
|
|
68
|
-
async spawnProxy() {
|
|
69
|
-
const pythonPath = await this.findPython();
|
|
70
|
-
if (!pythonPath) {
|
|
54
|
+
if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
|
|
71
55
|
throw new Error(
|
|
72
|
-
|
|
56
|
+
`Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`
|
|
73
57
|
);
|
|
74
58
|
}
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
if (this.config.autoStart === true) {
|
|
60
|
+
const startupUrl = explicitUrl ?? defaultCandidates[0];
|
|
61
|
+
const startupProbe = probeByUrl.get(startupUrl);
|
|
62
|
+
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
this.logger.info(
|
|
68
|
+
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`
|
|
69
|
+
);
|
|
70
|
+
await this.startHeadroomProxy(startupUrl, port);
|
|
71
|
+
const startedProbe = await waitForHeadroomProxy(
|
|
72
|
+
startupUrl,
|
|
73
|
+
this.config.startupTimeoutMs ?? 2e4
|
|
74
|
+
);
|
|
75
|
+
if (startedProbe.reachable && startedProbe.isHeadroom) {
|
|
76
|
+
this.proxyUrl = startupUrl;
|
|
77
|
+
this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
|
|
78
|
+
return startupUrl;
|
|
79
|
+
}
|
|
77
80
|
throw new Error(
|
|
78
|
-
|
|
81
|
+
`Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
|
-
|
|
82
|
-
const actualPort = port === 0 ? await this.findFreePort() : port;
|
|
83
|
-
const url = `http://127.0.0.1:${actualPort}`;
|
|
84
|
-
this.logger.info(`Starting proxy on port ${actualPort}...`);
|
|
85
|
-
const logDir = join(homedir(), ".headroom", "logs");
|
|
86
|
-
const logPath = join(logDir, "openclaw-proxy.log");
|
|
87
|
-
let logStream = null;
|
|
88
|
-
try {
|
|
89
|
-
const { mkdirSync } = await import("fs");
|
|
90
|
-
mkdirSync(logDir, { recursive: true });
|
|
91
|
-
logStream = createWriteStream(logPath, { flags: "a" });
|
|
92
|
-
} catch {
|
|
93
|
-
}
|
|
94
|
-
const proc = spawn(
|
|
95
|
-
pythonPath,
|
|
96
|
-
["-m", "headroom.cli", "proxy", "--port", String(actualPort)],
|
|
97
|
-
{
|
|
98
|
-
env: { ...process.env, PYTHONIOENCODING: "utf-8" },
|
|
99
|
-
stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"],
|
|
100
|
-
detached: false
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
if (logStream) {
|
|
104
|
-
proc.stdout?.pipe(logStream);
|
|
105
|
-
proc.stderr?.pipe(logStream);
|
|
106
|
-
}
|
|
107
|
-
proc.on("exit", (code) => {
|
|
108
|
-
if (!this.disposed && this.weStartedIt) {
|
|
109
|
-
this.logger.warn(`Proxy exited with code ${code}`);
|
|
110
|
-
this.handleCrash();
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
this.process = proc;
|
|
114
|
-
this.weStartedIt = true;
|
|
115
|
-
const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS);
|
|
116
|
-
if (!healthy) {
|
|
117
|
-
proc.kill();
|
|
118
|
-
this.process = null;
|
|
84
|
+
if (explicitUrl) {
|
|
119
85
|
throw new Error(
|
|
120
|
-
`
|
|
86
|
+
`Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`
|
|
121
87
|
);
|
|
122
88
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). Set proxyUrl explicitly or enable autoStart.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
getProxyPort() {
|
|
94
|
+
const rawPort = this.config.proxyPort;
|
|
95
|
+
if (!Number.isInteger(rawPort) || rawPort === void 0) return 8787;
|
|
96
|
+
if (rawPort < 1 || rawPort > 65535) {
|
|
97
|
+
throw new Error("proxyPort must be an integer between 1 and 65535");
|
|
98
|
+
}
|
|
99
|
+
return rawPort;
|
|
100
|
+
}
|
|
101
|
+
getDefaultProxyCandidates(port) {
|
|
102
|
+
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
|
127
103
|
}
|
|
128
104
|
/**
|
|
129
|
-
* Stop
|
|
105
|
+
* Stop manager state. Spawned proxy processes are detached and externally managed.
|
|
130
106
|
*/
|
|
131
107
|
async stop() {
|
|
132
|
-
this.
|
|
133
|
-
if (this.healthInterval) {
|
|
134
|
-
clearInterval(this.healthInterval);
|
|
135
|
-
this.healthInterval = null;
|
|
136
|
-
}
|
|
137
|
-
if (this.process && this.weStartedIt) {
|
|
138
|
-
this.logger.info("Stopping proxy...");
|
|
139
|
-
this.process.kill("SIGTERM");
|
|
140
|
-
await new Promise((resolve) => {
|
|
141
|
-
const timeout = setTimeout(() => {
|
|
142
|
-
this.process?.kill("SIGKILL");
|
|
143
|
-
resolve();
|
|
144
|
-
}, 3e3);
|
|
145
|
-
this.process?.on("exit", () => {
|
|
146
|
-
clearTimeout(timeout);
|
|
147
|
-
resolve();
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
this.process = null;
|
|
151
|
-
}
|
|
108
|
+
this.proxyUrl = null;
|
|
152
109
|
}
|
|
153
110
|
getUrl() {
|
|
154
111
|
return this.proxyUrl;
|
|
155
112
|
}
|
|
156
113
|
// --- Internal ---
|
|
157
|
-
async
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
114
|
+
async startHeadroomProxy(proxyUrl, defaultPort) {
|
|
115
|
+
const parsed = new URL(proxyUrl);
|
|
116
|
+
const host = parsed.hostname;
|
|
117
|
+
const port = parsed.port || String(defaultPort);
|
|
118
|
+
const specs = this.buildLaunchSpecs(host, port);
|
|
119
|
+
const errors = [];
|
|
120
|
+
for (const spec of specs) {
|
|
121
|
+
if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
|
|
122
|
+
this.logger.debug(`Launcher unavailable: ${spec.label}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const child = spawn(spec.command, spec.args, {
|
|
127
|
+
detached: true,
|
|
128
|
+
shell: spec.useShell === true,
|
|
129
|
+
stdio: "ignore"
|
|
130
|
+
});
|
|
131
|
+
child.unref();
|
|
132
|
+
this.logger.info(`Auto-start launcher selected: ${spec.label}`);
|
|
133
|
+
return;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
errors.push(`${spec.label}: ${String(error)}`);
|
|
136
|
+
}
|
|
165
137
|
}
|
|
138
|
+
throw new Error(
|
|
139
|
+
"No usable Headroom launcher found. Tried PATH, local npm, global npm, and Python. Install headroom-ai (npm or pip) and ensure one launcher is available.\n" + (errors.length > 0 ? `Launch errors: ${errors.join("; ")}` : "")
|
|
140
|
+
);
|
|
166
141
|
}
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
142
|
+
buildLaunchSpecs(host, port) {
|
|
143
|
+
const commonArgs = ["proxy", "--host", host, "--port", port];
|
|
144
|
+
const retryMaxAttempts = this.config.retryMaxAttempts;
|
|
145
|
+
if (Number.isInteger(retryMaxAttempts)) {
|
|
146
|
+
commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
|
|
172
147
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
148
|
+
const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
|
|
149
|
+
if (Number.isInteger(connectTimeoutSeconds)) {
|
|
150
|
+
commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
|
|
151
|
+
}
|
|
152
|
+
const specs = [];
|
|
153
|
+
const configuredPython = this.getConfiguredPythonCommand();
|
|
154
|
+
if (configuredPython) {
|
|
155
|
+
specs.push({
|
|
156
|
+
label: `Configured Python: ${configuredPython} -m headroom.cli`,
|
|
157
|
+
command: configuredPython,
|
|
158
|
+
args: ["-m", "headroom.cli", ...commonArgs],
|
|
159
|
+
checkCommand: configuredPython,
|
|
160
|
+
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET]
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (process.platform === "win32") {
|
|
164
|
+
const pyenvHeadroom = this.getPyenvResolvedHeadroom();
|
|
165
|
+
if (pyenvHeadroom) {
|
|
166
|
+
specs.push({
|
|
167
|
+
label: `pyenv: ${pyenvHeadroom}`,
|
|
168
|
+
command: pyenvHeadroom,
|
|
169
|
+
args: commonArgs,
|
|
170
|
+
checkCommand: pyenvHeadroom,
|
|
171
|
+
checkArgs: ["--version"],
|
|
172
|
+
useShell: false
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
specs.push({
|
|
177
|
+
label: "PATH: headroom",
|
|
178
|
+
command: "headroom",
|
|
179
|
+
args: commonArgs,
|
|
180
|
+
checkCommand: process.platform === "win32" ? "where.exe" : "sh",
|
|
181
|
+
checkArgs: process.platform === "win32" ? ["headroom"] : ["-c", "command -v headroom >/dev/null 2>&1"],
|
|
182
|
+
useShell: process.platform === "win32",
|
|
183
|
+
checkUseShell: false
|
|
184
|
+
});
|
|
185
|
+
const uvBin = join(
|
|
186
|
+
os.homedir(),
|
|
187
|
+
".local",
|
|
188
|
+
"bin",
|
|
189
|
+
"headroom"
|
|
190
|
+
);
|
|
191
|
+
if (existsSync(uvBin)) {
|
|
192
|
+
specs.push({
|
|
193
|
+
label: `uv tool: ${uvBin}`,
|
|
194
|
+
command: uvBin,
|
|
195
|
+
args: commonArgs,
|
|
196
|
+
checkCommand: uvBin,
|
|
197
|
+
checkArgs: ["--version"]
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
201
|
+
const packageRoot = dirname(moduleDir);
|
|
202
|
+
const localBinDir = join(packageRoot, "node_modules", ".bin");
|
|
203
|
+
const localBins = process.platform === "win32" ? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")] : [join(localBinDir, "headroom")];
|
|
204
|
+
for (const localBin of localBins) {
|
|
205
|
+
if (!existsSync(localBin)) continue;
|
|
206
|
+
specs.push({
|
|
207
|
+
label: `Local npm: ${localBin}`,
|
|
208
|
+
command: localBin,
|
|
209
|
+
args: commonArgs,
|
|
210
|
+
checkCommand: localBin,
|
|
211
|
+
checkArgs: ["--version"],
|
|
212
|
+
useShell: process.platform === "win32"
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const npmPrefix = this.getNpmGlobalPrefix();
|
|
216
|
+
if (npmPrefix) {
|
|
217
|
+
const globalBins = process.platform === "win32" ? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")] : [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];
|
|
218
|
+
for (const globalBin of globalBins) {
|
|
219
|
+
if (!existsSync(globalBin)) continue;
|
|
220
|
+
specs.push({
|
|
221
|
+
label: `Global npm: ${globalBin}`,
|
|
222
|
+
command: globalBin,
|
|
223
|
+
args: commonArgs,
|
|
224
|
+
checkCommand: globalBin,
|
|
225
|
+
checkArgs: ["--version"],
|
|
226
|
+
useShell: process.platform === "win32"
|
|
227
|
+
});
|
|
181
228
|
}
|
|
182
|
-
}, HEALTH_CHECK_INTERVAL_MS);
|
|
183
|
-
}
|
|
184
|
-
async handleCrash() {
|
|
185
|
-
if (this.disposed) return;
|
|
186
|
-
if (this.restartCount >= MAX_RESTART_ATTEMPTS) {
|
|
187
|
-
this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`);
|
|
188
|
-
return;
|
|
189
229
|
}
|
|
190
|
-
this.
|
|
191
|
-
|
|
192
|
-
|
|
230
|
+
const pythonCommands = this.getPythonCommands();
|
|
231
|
+
for (const pyCmd of pythonCommands) {
|
|
232
|
+
if (configuredPython && pyCmd === configuredPython) continue;
|
|
233
|
+
specs.push({
|
|
234
|
+
label: `Python: ${pyCmd} -m headroom.cli`,
|
|
235
|
+
command: pyCmd,
|
|
236
|
+
args: ["-m", "headroom.cli", ...commonArgs],
|
|
237
|
+
checkCommand: pyCmd,
|
|
238
|
+
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET]
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return specs;
|
|
242
|
+
}
|
|
243
|
+
getConfiguredPythonCommand() {
|
|
244
|
+
const configured = typeof this.config.pythonPath === "string" ? this.config.pythonPath.trim() : "";
|
|
245
|
+
return configured.length > 0 ? configured : null;
|
|
246
|
+
}
|
|
247
|
+
getPyenvResolvedHeadroom() {
|
|
248
|
+
if (process.platform !== "win32") return null;
|
|
193
249
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
250
|
+
const result = spawnSync("pyenv", ["which", "headroom"], {
|
|
251
|
+
encoding: "utf8",
|
|
252
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
253
|
+
timeout: 5e3
|
|
254
|
+
});
|
|
255
|
+
if (result.error || result.status !== 0) return null;
|
|
256
|
+
const resolved = (result.stdout ?? "").trim().split(/\r?\n/, 1)[0];
|
|
257
|
+
if (!resolved || !existsSync(resolved)) return null;
|
|
258
|
+
return resolved;
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
197
261
|
}
|
|
198
262
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
263
|
+
getPythonCommands() {
|
|
264
|
+
const commands = [];
|
|
265
|
+
const configured = this.getConfiguredPythonCommand() ?? "";
|
|
266
|
+
if (configured.length > 0) {
|
|
267
|
+
commands.push(configured);
|
|
268
|
+
}
|
|
269
|
+
for (const fallback of ["python", "python3", "py"]) {
|
|
270
|
+
if (!commands.includes(fallback)) commands.push(fallback);
|
|
209
271
|
}
|
|
210
|
-
return
|
|
272
|
+
return commands;
|
|
211
273
|
}
|
|
212
|
-
|
|
274
|
+
canExecute(command, args, useShell = false) {
|
|
213
275
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
276
|
+
const result = spawnSync(command, args, {
|
|
277
|
+
shell: useShell,
|
|
278
|
+
stdio: "ignore",
|
|
279
|
+
timeout: 5e3
|
|
218
280
|
});
|
|
219
|
-
return
|
|
281
|
+
if (result.error) return false;
|
|
282
|
+
return result.status === 0;
|
|
220
283
|
} catch {
|
|
221
284
|
return false;
|
|
222
285
|
}
|
|
223
286
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (addr && typeof addr === "object") {
|
|
231
|
-
const port = addr.port;
|
|
232
|
-
server.close(() => resolve(port));
|
|
233
|
-
} else {
|
|
234
|
-
reject(new Error("Could not find free port"));
|
|
235
|
-
}
|
|
287
|
+
getNpmGlobalPrefix() {
|
|
288
|
+
try {
|
|
289
|
+
const result = spawnSync("npm", ["prefix", "-g"], {
|
|
290
|
+
encoding: "utf8",
|
|
291
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
292
|
+
timeout: 5e3
|
|
236
293
|
});
|
|
237
|
-
|
|
294
|
+
if (result.error || result.status !== 0) return null;
|
|
295
|
+
const prefix = (result.stdout ?? "").trim();
|
|
296
|
+
return prefix.length > 0 ? prefix : null;
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
238
300
|
}
|
|
239
301
|
};
|
|
302
|
+
function parseProxyUrl(proxyUrl) {
|
|
303
|
+
try {
|
|
304
|
+
return new URL(proxyUrl);
|
|
305
|
+
} catch {
|
|
306
|
+
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function normalizeAndValidateProxyUrl(proxyUrl) {
|
|
310
|
+
const parsed = parseProxyUrl(proxyUrl);
|
|
311
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
312
|
+
throw new Error("proxyUrl must use http:// or https://");
|
|
313
|
+
}
|
|
314
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
315
|
+
throw new Error("proxyUrl must not include a path, query, or hash");
|
|
316
|
+
}
|
|
317
|
+
return parsed.origin;
|
|
318
|
+
}
|
|
319
|
+
function isLocalProxyUrl(proxyUrl) {
|
|
320
|
+
try {
|
|
321
|
+
const parsed = new URL(proxyUrl);
|
|
322
|
+
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function withDefaultPort(proxyUrl, defaultPort) {
|
|
328
|
+
const parsed = parseProxyUrl(proxyUrl);
|
|
329
|
+
if (!parsed.port) {
|
|
330
|
+
parsed.port = String(defaultPort);
|
|
331
|
+
}
|
|
332
|
+
return parsed.origin;
|
|
333
|
+
}
|
|
334
|
+
async function probeHeadroomProxy(proxyUrl) {
|
|
335
|
+
const origin = normalizeAndValidateProxyUrl(proxyUrl);
|
|
336
|
+
const probeEndpoint = async (path, options = {}) => {
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch(`${origin}${path}`, {
|
|
339
|
+
signal: AbortSignal.timeout(3e3)
|
|
340
|
+
});
|
|
341
|
+
const body = response.ok && options.readBody ? await response.text().catch(() => void 0) : void 0;
|
|
342
|
+
return { reachable: true, ok: response.ok, status: response.status, body };
|
|
343
|
+
} catch {
|
|
344
|
+
return { reachable: false, ok: false };
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const ready = await probeEndpoint("/readyz");
|
|
348
|
+
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
|
|
349
|
+
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
|
|
350
|
+
return { reachable: true, isHeadroom: true };
|
|
351
|
+
}
|
|
352
|
+
const stats = await probeEndpoint("/stats", { readBody: true });
|
|
353
|
+
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
|
|
354
|
+
return { reachable: true, isHeadroom: true };
|
|
355
|
+
}
|
|
356
|
+
const health = await probeEndpoint("/health");
|
|
357
|
+
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
|
|
358
|
+
if (!anyReachable) {
|
|
359
|
+
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
|
|
360
|
+
}
|
|
361
|
+
const reasons = [
|
|
362
|
+
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
|
|
363
|
+
retrieveStats.reachable ? `retrieve stats HTTP ${retrieveStats.status}` : "retrieve stats endpoint unavailable",
|
|
364
|
+
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
|
|
365
|
+
health.reachable ? `health HTTP ${health.status}` : "health check failed"
|
|
366
|
+
];
|
|
367
|
+
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
|
|
368
|
+
}
|
|
369
|
+
function hasHeadroomStatsShape(body) {
|
|
370
|
+
if (!body) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(body);
|
|
375
|
+
return parsed !== null && typeof parsed === "object" && (Object.hasOwn(parsed, "proxy_inbound") || Object.hasOwn(parsed, "api_requests") || Object.hasOwn(parsed, "provider_tokens") || Object.hasOwn(parsed, "proxy_compression_saved") || Object.hasOwn(parsed, "store") || Object.hasOwn(parsed, "recent_retrievals"));
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function waitForHeadroomProxy(proxyUrl, timeoutMs) {
|
|
381
|
+
const start = Date.now();
|
|
382
|
+
while (Date.now() - start < timeoutMs) {
|
|
383
|
+
const result = await probeHeadroomProxy(proxyUrl);
|
|
384
|
+
if (result.reachable && result.isHeadroom) {
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
388
|
+
}
|
|
389
|
+
return probeHeadroomProxy(proxyUrl);
|
|
390
|
+
}
|
|
240
391
|
|
|
241
392
|
// src/convert.ts
|
|
242
393
|
function agentToOpenAI(messages) {
|
|
243
394
|
const result = [];
|
|
244
395
|
for (const msg of messages) {
|
|
245
|
-
const
|
|
396
|
+
const normalized = normalizeAgentMessage(msg);
|
|
397
|
+
const role = normalized.role;
|
|
398
|
+
const buildMeta = () => {
|
|
399
|
+
const meta = { ...normalized };
|
|
400
|
+
delete meta.role;
|
|
401
|
+
delete meta.content;
|
|
402
|
+
return meta;
|
|
403
|
+
};
|
|
246
404
|
if (role === "system") {
|
|
247
405
|
result.push({
|
|
248
406
|
role: "system",
|
|
249
|
-
content: typeof
|
|
407
|
+
content: typeof normalized.content === "string" ? normalized.content : extractText(normalized.content),
|
|
408
|
+
_headroomMeta: buildMeta()
|
|
250
409
|
});
|
|
251
410
|
continue;
|
|
252
411
|
}
|
|
253
412
|
if (role === "user") {
|
|
254
413
|
result.push({
|
|
255
414
|
role: "user",
|
|
256
|
-
content: typeof
|
|
415
|
+
content: typeof normalized.content === "string" ? normalized.content : extractText(normalized.content),
|
|
416
|
+
_headroomMeta: buildMeta()
|
|
257
417
|
});
|
|
258
418
|
continue;
|
|
259
419
|
}
|
|
260
420
|
if (role === "assistant") {
|
|
261
|
-
const content =
|
|
421
|
+
const content = normalized.content;
|
|
262
422
|
if (typeof content === "string") {
|
|
263
|
-
result.push({ role: "assistant", content });
|
|
423
|
+
result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
|
|
264
424
|
continue;
|
|
265
425
|
}
|
|
266
426
|
if (Array.isArray(content)) {
|
|
@@ -271,20 +431,22 @@ function agentToOpenAI(messages) {
|
|
|
271
431
|
textParts.push(block);
|
|
272
432
|
} else if (block.type === "text") {
|
|
273
433
|
textParts.push(block.text);
|
|
274
|
-
} else if (block.type === "tool_use") {
|
|
434
|
+
} else if (block.type === "tool_use" || block.type === "toolCall") {
|
|
435
|
+
const args = block.type === "toolCall" ? block.arguments : block.input;
|
|
275
436
|
toolCalls.push({
|
|
276
437
|
id: block.id,
|
|
277
438
|
type: "function",
|
|
278
439
|
function: {
|
|
279
440
|
name: block.name,
|
|
280
|
-
arguments: typeof
|
|
441
|
+
arguments: typeof args === "string" ? args : JSON.stringify(args ?? {})
|
|
281
442
|
}
|
|
282
443
|
});
|
|
283
444
|
}
|
|
284
445
|
}
|
|
285
446
|
const openaiMsg = {
|
|
286
447
|
role: "assistant",
|
|
287
|
-
content: textParts.length > 0 ? textParts.join("") : null
|
|
448
|
+
content: textParts.length > 0 ? textParts.join("") : null,
|
|
449
|
+
_headroomMeta: buildMeta()
|
|
288
450
|
};
|
|
289
451
|
if (toolCalls.length > 0) {
|
|
290
452
|
openaiMsg.tool_calls = toolCalls;
|
|
@@ -294,17 +456,19 @@ function agentToOpenAI(messages) {
|
|
|
294
456
|
continue;
|
|
295
457
|
}
|
|
296
458
|
if (role === "toolResult" || role === "tool_result") {
|
|
297
|
-
const content = typeof
|
|
459
|
+
const content = typeof normalized.content === "string" ? normalized.content : Array.isArray(normalized.content) ? extractText(normalized.content) : JSON.stringify(normalized.content);
|
|
298
460
|
result.push({
|
|
299
461
|
role: "tool",
|
|
300
462
|
content,
|
|
301
|
-
tool_call_id:
|
|
463
|
+
tool_call_id: normalized.toolCallId ?? normalized.tool_use_id ?? normalized.id ?? "unknown",
|
|
464
|
+
_headroomMeta: buildMeta()
|
|
302
465
|
});
|
|
303
466
|
continue;
|
|
304
467
|
}
|
|
305
468
|
result.push({
|
|
306
469
|
role: "user",
|
|
307
|
-
content: typeof
|
|
470
|
+
content: typeof normalized.content === "string" ? normalized.content : JSON.stringify(normalized.content),
|
|
471
|
+
_headroomMeta: buildMeta()
|
|
308
472
|
});
|
|
309
473
|
}
|
|
310
474
|
return result;
|
|
@@ -312,11 +476,13 @@ function agentToOpenAI(messages) {
|
|
|
312
476
|
function openAIToAgent(messages) {
|
|
313
477
|
const result = [];
|
|
314
478
|
for (const msg of messages) {
|
|
479
|
+
const meta = msg._headroomMeta ?? {};
|
|
480
|
+
const timestamp = typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
|
|
315
481
|
if (msg.role === "system") {
|
|
316
482
|
result.push({
|
|
317
483
|
role: "system",
|
|
318
484
|
content: msg.content ?? "",
|
|
319
|
-
timestamp
|
|
485
|
+
timestamp
|
|
320
486
|
});
|
|
321
487
|
continue;
|
|
322
488
|
}
|
|
@@ -324,7 +490,7 @@ function openAIToAgent(messages) {
|
|
|
324
490
|
result.push({
|
|
325
491
|
role: "user",
|
|
326
492
|
content: msg.content ?? "",
|
|
327
|
-
timestamp
|
|
493
|
+
timestamp
|
|
328
494
|
});
|
|
329
495
|
continue;
|
|
330
496
|
}
|
|
@@ -342,32 +508,55 @@ function openAIToAgent(messages) {
|
|
|
342
508
|
input = tc.function.arguments ?? {};
|
|
343
509
|
}
|
|
344
510
|
blocks.push({
|
|
345
|
-
type: "
|
|
511
|
+
type: "toolCall",
|
|
346
512
|
id: tc.id,
|
|
347
513
|
name: tc.function.name,
|
|
348
|
-
input
|
|
514
|
+
arguments: input
|
|
349
515
|
});
|
|
350
516
|
}
|
|
351
517
|
}
|
|
352
518
|
result.push({
|
|
519
|
+
...meta,
|
|
353
520
|
role: "assistant",
|
|
354
521
|
content: blocks,
|
|
355
|
-
|
|
522
|
+
api: typeof meta.api === "string" ? meta.api : "headroom",
|
|
523
|
+
provider: typeof meta.provider === "string" ? meta.provider : "headroom",
|
|
524
|
+
model: typeof meta.model === "string" ? meta.model : "headroom",
|
|
525
|
+
usage: isRecord(meta.usage) ? meta.usage : {
|
|
526
|
+
input: 0,
|
|
527
|
+
output: 0,
|
|
528
|
+
cacheRead: 0,
|
|
529
|
+
cacheWrite: 0,
|
|
530
|
+
totalTokens: 0,
|
|
531
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
532
|
+
},
|
|
533
|
+
stopReason: typeof meta.stopReason === "string" ? meta.stopReason : "stop",
|
|
534
|
+
timestamp
|
|
356
535
|
});
|
|
357
536
|
continue;
|
|
358
537
|
}
|
|
359
538
|
if (msg.role === "tool") {
|
|
539
|
+
const textContent = typeof msg.content === "string" ? msg.content : msg.content == null ? "" : JSON.stringify(msg.content);
|
|
540
|
+
const toolCallId = msg.tool_call_id ?? "unknown";
|
|
360
541
|
result.push({
|
|
542
|
+
...meta,
|
|
361
543
|
role: "toolResult",
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
544
|
+
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
|
|
545
|
+
content: [{ type: "text", text: textContent }],
|
|
546
|
+
toolCallId: typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
|
|
547
|
+
tool_use_id: typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
|
|
548
|
+
toolName: typeof meta.toolName === "string" ? meta.toolName : "headroom",
|
|
549
|
+
isError: typeof meta.isError === "boolean" ? meta.isError : false,
|
|
550
|
+
timestamp
|
|
365
551
|
});
|
|
366
552
|
continue;
|
|
367
553
|
}
|
|
368
554
|
}
|
|
369
555
|
return result;
|
|
370
556
|
}
|
|
557
|
+
function normalizeAgentMessages(messages) {
|
|
558
|
+
return messages.map((message) => normalizeAgentMessage(message));
|
|
559
|
+
}
|
|
371
560
|
function extractText(content) {
|
|
372
561
|
if (typeof content === "string") return content;
|
|
373
562
|
if (!Array.isArray(content)) return JSON.stringify(content);
|
|
@@ -380,8 +569,115 @@ function extractText(content) {
|
|
|
380
569
|
return "";
|
|
381
570
|
}).filter(Boolean).join("\n");
|
|
382
571
|
}
|
|
572
|
+
function normalizeAgentMessage(message) {
|
|
573
|
+
if (!isRecord(message)) return message;
|
|
574
|
+
if (message.role === "assistant") {
|
|
575
|
+
return normalizeAssistantMessage(message);
|
|
576
|
+
}
|
|
577
|
+
if (message.role === "toolResult" || message.role === "tool_result") {
|
|
578
|
+
return normalizeToolResultMessage(message);
|
|
579
|
+
}
|
|
580
|
+
return message;
|
|
581
|
+
}
|
|
582
|
+
function normalizeAssistantMessage(message) {
|
|
583
|
+
const normalizedContent = normalizeAssistantContent(message.content);
|
|
584
|
+
return {
|
|
585
|
+
...message,
|
|
586
|
+
content: normalizedContent,
|
|
587
|
+
api: typeof message.api === "string" ? message.api : "headroom",
|
|
588
|
+
provider: typeof message.provider === "string" ? message.provider : "headroom",
|
|
589
|
+
model: typeof message.model === "string" ? message.model : "headroom",
|
|
590
|
+
usage: isRecord(message.usage) ? message.usage : {
|
|
591
|
+
input: 0,
|
|
592
|
+
output: 0,
|
|
593
|
+
cacheRead: 0,
|
|
594
|
+
cacheWrite: 0,
|
|
595
|
+
totalTokens: 0,
|
|
596
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
597
|
+
},
|
|
598
|
+
stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
|
|
599
|
+
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now()
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function normalizeToolResultMessage(message) {
|
|
603
|
+
const normalizedContent = normalizeToolResultContent(message.content);
|
|
604
|
+
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : typeof message.tool_use_id === "string" ? message.tool_use_id : typeof message.id === "string" ? message.id : "unknown";
|
|
605
|
+
return {
|
|
606
|
+
...message,
|
|
607
|
+
role: "toolResult",
|
|
608
|
+
content: normalizedContent,
|
|
609
|
+
toolCallId,
|
|
610
|
+
tool_use_id: typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
|
|
611
|
+
toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
|
|
612
|
+
isError: typeof message.isError === "boolean" ? message.isError : false,
|
|
613
|
+
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now()
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function normalizeAssistantContent(content) {
|
|
617
|
+
if (Array.isArray(content)) {
|
|
618
|
+
return content.flatMap((block) => {
|
|
619
|
+
if (typeof block === "string") return [{ type: "text", text: block }];
|
|
620
|
+
if (!isRecord(block) || typeof block.type !== "string") return [];
|
|
621
|
+
if (block.type === "text" && typeof block.text === "string") return [block];
|
|
622
|
+
if (block.type === "thinking" && typeof block.thinking === "string") return [block];
|
|
623
|
+
if ((block.type === "toolCall" || block.type === "tool_use") && typeof block.name === "string") {
|
|
624
|
+
return [
|
|
625
|
+
{
|
|
626
|
+
type: "toolCall",
|
|
627
|
+
id: typeof block.id === "string" ? block.id : "unknown",
|
|
628
|
+
name: block.name,
|
|
629
|
+
arguments: "arguments" in block ? block.arguments : "input" in block ? block.input : {}
|
|
630
|
+
}
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
return [];
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (typeof content === "string" && content.length > 0) {
|
|
637
|
+
return [{ type: "text", text: content }];
|
|
638
|
+
}
|
|
639
|
+
if (content == null) {
|
|
640
|
+
return [];
|
|
641
|
+
}
|
|
642
|
+
return [{ type: "text", text: JSON.stringify(content) }];
|
|
643
|
+
}
|
|
644
|
+
function normalizeToolResultContent(content) {
|
|
645
|
+
if (Array.isArray(content)) {
|
|
646
|
+
return content.flatMap((block) => {
|
|
647
|
+
if (typeof block === "string") return [{ type: "text", text: block }];
|
|
648
|
+
if (!isRecord(block) || typeof block.type !== "string") return [];
|
|
649
|
+
if (block.type === "text" && typeof block.text === "string") return [block];
|
|
650
|
+
if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
|
|
651
|
+
return [block];
|
|
652
|
+
}
|
|
653
|
+
if (block.type === "tool_result" && "content" in block) {
|
|
654
|
+
return normalizeToolResultContent(block.content);
|
|
655
|
+
}
|
|
656
|
+
return [];
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (typeof content === "string" && content.length > 0) {
|
|
660
|
+
return [{ type: "text", text: content }];
|
|
661
|
+
}
|
|
662
|
+
if (content == null) {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
return [{ type: "text", text: JSON.stringify(content) }];
|
|
666
|
+
}
|
|
667
|
+
function isRecord(value) {
|
|
668
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
669
|
+
}
|
|
383
670
|
|
|
384
671
|
// src/engine.ts
|
|
672
|
+
function withTimeout(promise, ms) {
|
|
673
|
+
let timerId;
|
|
674
|
+
const timer = new Promise((_, reject) => {
|
|
675
|
+
timerId = setTimeout(() => reject(new Error(`headroom compress() timed out after ${ms}ms`)), ms);
|
|
676
|
+
});
|
|
677
|
+
return Promise.race([promise, timer]).finally(() => {
|
|
678
|
+
if (timerId !== void 0) clearTimeout(timerId);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
385
681
|
var HeadroomContextEngine = class {
|
|
386
682
|
info = {
|
|
387
683
|
id: "headroom",
|
|
@@ -393,21 +689,19 @@ var HeadroomContextEngine = class {
|
|
|
393
689
|
proxyUrl = null;
|
|
394
690
|
config;
|
|
395
691
|
logger;
|
|
692
|
+
proxyReadyListeners = /* @__PURE__ */ new Set();
|
|
693
|
+
proxyStartupPromise = null;
|
|
694
|
+
proxyStartupError = null;
|
|
396
695
|
stats = {
|
|
397
696
|
totalCompressions: 0,
|
|
398
697
|
totalTokensSaved: 0,
|
|
399
698
|
totalTokensBefore: 0,
|
|
400
699
|
compactions: 0
|
|
401
700
|
};
|
|
701
|
+
circuit = { errors: 0, openUntilMs: 0 };
|
|
402
702
|
constructor(config = {}, logger) {
|
|
403
703
|
this.config = config;
|
|
404
|
-
this.logger = logger ??
|
|
405
|
-
info: (m) => console.log(`[headroom] ${m}`),
|
|
406
|
-
warn: (m) => console.warn(`[headroom] ${m}`),
|
|
407
|
-
error: (m) => console.error(`[headroom] ${m}`),
|
|
408
|
-
debug: () => {
|
|
409
|
-
}
|
|
410
|
-
};
|
|
704
|
+
this.logger = logger ?? defaultLogger;
|
|
411
705
|
this.proxyManager = new ProxyManager(config, this.logger);
|
|
412
706
|
}
|
|
413
707
|
// === ContextEngine Lifecycle ===
|
|
@@ -415,14 +709,8 @@ var HeadroomContextEngine = class {
|
|
|
415
709
|
if (this.config.enabled === false) {
|
|
416
710
|
return { bootstrapped: false, reason: "disabled" };
|
|
417
711
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
this.logger.info(`Engine bootstrapped (proxy: ${this.proxyUrl})`);
|
|
421
|
-
return { bootstrapped: true };
|
|
422
|
-
} catch (error) {
|
|
423
|
-
this.logger.error(`Bootstrap failed: ${error}`);
|
|
424
|
-
return { bootstrapped: false, reason: String(error) };
|
|
425
|
-
}
|
|
712
|
+
this.ensureProxyStarted();
|
|
713
|
+
return { bootstrapped: true, reason: "proxy startup scheduled" };
|
|
426
714
|
}
|
|
427
715
|
async ingest(params) {
|
|
428
716
|
return { ingested: true };
|
|
@@ -437,20 +725,33 @@ var HeadroomContextEngine = class {
|
|
|
437
725
|
*/
|
|
438
726
|
async assemble(params) {
|
|
439
727
|
if (!this.proxyUrl || this.config.enabled === false) {
|
|
440
|
-
|
|
728
|
+
this.ensureProxyStarted();
|
|
729
|
+
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
|
|
730
|
+
}
|
|
731
|
+
if (this.isCircuitOpen()) {
|
|
732
|
+
this.logger.warn("[headroom] Circuit open \u2014 using uncompressed messages");
|
|
733
|
+
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
|
|
441
734
|
}
|
|
442
735
|
try {
|
|
443
736
|
const openaiMessages = agentToOpenAI(params.messages);
|
|
444
|
-
const result = await
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
737
|
+
const result = await withTimeout(
|
|
738
|
+
compress(openaiMessages, {
|
|
739
|
+
model: params.model ?? "claude-sonnet-4-5",
|
|
740
|
+
baseUrl: this.proxyUrl,
|
|
741
|
+
fallback: true,
|
|
742
|
+
tokenBudget: params.tokenBudget
|
|
743
|
+
}),
|
|
744
|
+
this.config.requestTimeoutMs ?? 3e4
|
|
745
|
+
);
|
|
450
746
|
if (!result.compressed || result.tokensSaved === 0) {
|
|
451
|
-
|
|
747
|
+
this.resetCircuit();
|
|
748
|
+
return {
|
|
749
|
+
messages: normalizeAgentMessages(params.messages),
|
|
750
|
+
estimatedTokens: result.tokensBefore
|
|
751
|
+
};
|
|
452
752
|
}
|
|
453
753
|
const compressedAgentMessages = openAIToAgent(result.messages);
|
|
754
|
+
this.resetCircuit();
|
|
454
755
|
this.stats.totalCompressions++;
|
|
455
756
|
this.stats.totalTokensSaved += result.tokensSaved;
|
|
456
757
|
this.stats.totalTokensBefore += result.tokensBefore;
|
|
@@ -464,7 +765,8 @@ var HeadroomContextEngine = class {
|
|
|
464
765
|
};
|
|
465
766
|
} catch (error) {
|
|
466
767
|
this.logger.error(`Assemble failed: ${error}`);
|
|
467
|
-
|
|
768
|
+
this.tripCircuit(error);
|
|
769
|
+
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
|
|
468
770
|
}
|
|
469
771
|
}
|
|
470
772
|
/**
|
|
@@ -512,36 +814,201 @@ var HeadroomContextEngine = class {
|
|
|
512
814
|
getProxyUrl() {
|
|
513
815
|
return this.proxyUrl;
|
|
514
816
|
}
|
|
817
|
+
getProxyStartupError() {
|
|
818
|
+
return this.proxyStartupError;
|
|
819
|
+
}
|
|
820
|
+
isCircuitOpen() {
|
|
821
|
+
const threshold = this.config.circuitBreakerThreshold ?? 3;
|
|
822
|
+
if (this.circuit.errors < threshold) return false;
|
|
823
|
+
if (Date.now() < this.circuit.openUntilMs) return true;
|
|
824
|
+
this.circuit = { errors: 0, openUntilMs: 0 };
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
tripCircuit(error) {
|
|
828
|
+
this.circuit.errors += 1;
|
|
829
|
+
const threshold = this.config.circuitBreakerThreshold ?? 3;
|
|
830
|
+
if (this.circuit.errors < threshold) return;
|
|
831
|
+
const cooldownMs = this.config.circuitBreakerCooldownMs ?? 6e4;
|
|
832
|
+
this.circuit.openUntilMs = Date.now() + cooldownMs;
|
|
833
|
+
this.logger.warn(
|
|
834
|
+
`[headroom] Circuit breaker opened after ${this.circuit.errors} errors (last: ${String(error)}); bypassing compression for ${cooldownMs}ms`
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
resetCircuit() {
|
|
838
|
+
this.circuit = { errors: 0, openUntilMs: 0 };
|
|
839
|
+
}
|
|
840
|
+
ensureProxyStarted() {
|
|
841
|
+
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
this.proxyStartupError = null;
|
|
845
|
+
this.proxyStartupPromise = this.proxyManager.start().then(async (proxyUrl) => {
|
|
846
|
+
this.proxyUrl = proxyUrl;
|
|
847
|
+
this.proxyStartupError = null;
|
|
848
|
+
await this.notifyProxyReady(proxyUrl);
|
|
849
|
+
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
|
|
850
|
+
return proxyUrl;
|
|
851
|
+
}).catch((error) => {
|
|
852
|
+
this.proxyStartupError = error;
|
|
853
|
+
this.logger.warn(`Headroom proxy unavailable: ${error}`);
|
|
854
|
+
throw error;
|
|
855
|
+
}).finally(() => {
|
|
856
|
+
this.proxyStartupPromise = null;
|
|
857
|
+
});
|
|
858
|
+
void this.proxyStartupPromise.catch(() => {
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
onProxyReady(listener) {
|
|
862
|
+
this.proxyReadyListeners.add(listener);
|
|
863
|
+
return () => {
|
|
864
|
+
this.proxyReadyListeners.delete(listener);
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async ensureProxyUrl() {
|
|
868
|
+
if (this.proxyUrl) {
|
|
869
|
+
return this.proxyUrl;
|
|
870
|
+
}
|
|
871
|
+
this.ensureProxyStarted();
|
|
872
|
+
if (!this.proxyStartupPromise) {
|
|
873
|
+
throw new Error("Headroom proxy startup is disabled");
|
|
874
|
+
}
|
|
875
|
+
return this.proxyStartupPromise;
|
|
876
|
+
}
|
|
877
|
+
async notifyProxyReady(proxyUrl) {
|
|
878
|
+
for (const listener of this.proxyReadyListeners) {
|
|
879
|
+
try {
|
|
880
|
+
await listener(proxyUrl);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
515
886
|
};
|
|
516
887
|
|
|
888
|
+
// src/gateway-config.ts
|
|
889
|
+
var DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"];
|
|
890
|
+
var DEFAULT_PROVIDER_BASE_URLS = {
|
|
891
|
+
"openai-codex": "https://chatgpt.com/backend-api"
|
|
892
|
+
};
|
|
893
|
+
var GATEWAY_PROVIDER_ID_ALIASES = {
|
|
894
|
+
codex: "openai-codex",
|
|
895
|
+
claude: "anthropic",
|
|
896
|
+
copilot: "github-copilot",
|
|
897
|
+
gemini: "google"
|
|
898
|
+
};
|
|
899
|
+
var EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = /* @__PURE__ */ new Set(["github-copilot"]);
|
|
900
|
+
function resolveGatewayProviderIds(config) {
|
|
901
|
+
const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
|
|
902
|
+
if (configuredProviderIds.length > 0) {
|
|
903
|
+
return configuredProviderIds;
|
|
904
|
+
}
|
|
905
|
+
if (config?.routeCodexViaProxy === false) {
|
|
906
|
+
return [];
|
|
907
|
+
}
|
|
908
|
+
return [...DEFAULT_GATEWAY_PROVIDER_IDS];
|
|
909
|
+
}
|
|
910
|
+
function normalizeGatewayProviderIds(value) {
|
|
911
|
+
if (!Array.isArray(value)) {
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
const seen = /* @__PURE__ */ new Set();
|
|
915
|
+
const normalized = [];
|
|
916
|
+
for (const entry of value) {
|
|
917
|
+
if (typeof entry !== "string") {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const rawProviderId = entry.trim();
|
|
921
|
+
const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
|
|
922
|
+
if (!providerId || seen.has(providerId)) {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
seen.add(providerId);
|
|
926
|
+
normalized.push(providerId);
|
|
927
|
+
}
|
|
928
|
+
return normalized;
|
|
929
|
+
}
|
|
930
|
+
function applyGatewayProviderBaseUrls(cfg, proxyUrl, providerIds) {
|
|
931
|
+
const next = structuredClone(cfg ?? {});
|
|
932
|
+
const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
|
|
933
|
+
return { changed, config: next };
|
|
934
|
+
}
|
|
935
|
+
function applyGatewayProviderBaseUrlsInPlace(cfg, proxyUrl, providerIds) {
|
|
936
|
+
if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
const models = cfg.models ??= {};
|
|
940
|
+
const providers = models.providers ??= {};
|
|
941
|
+
let changed = false;
|
|
942
|
+
for (const providerId of providerIds) {
|
|
943
|
+
const currentValue = providers[providerId];
|
|
944
|
+
const currentConfig = currentValue && typeof currentValue === "object" && !Array.isArray(currentValue) ? currentValue : {};
|
|
945
|
+
const nextConfig = { ...currentConfig };
|
|
946
|
+
const currentBaseUrl = typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0 ? nextConfig.baseUrl : void 0;
|
|
947
|
+
const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
|
|
948
|
+
if (!currentBaseUrl && !defaultBaseUrl && EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)) {
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const nextBaseUrl = routeBaseUrlThroughProxy({
|
|
952
|
+
providerId,
|
|
953
|
+
proxyUrl,
|
|
954
|
+
currentBaseUrl
|
|
955
|
+
});
|
|
956
|
+
if (!Array.isArray(nextConfig.models)) {
|
|
957
|
+
nextConfig.models = [];
|
|
958
|
+
changed = true;
|
|
959
|
+
}
|
|
960
|
+
if (nextConfig.baseUrl === nextBaseUrl) {
|
|
961
|
+
providers[providerId] = nextConfig;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
nextConfig.baseUrl = nextBaseUrl;
|
|
965
|
+
providers[providerId] = nextConfig;
|
|
966
|
+
changed = true;
|
|
967
|
+
}
|
|
968
|
+
return changed;
|
|
969
|
+
}
|
|
970
|
+
function routeBaseUrlThroughProxy(params) {
|
|
971
|
+
const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
|
|
972
|
+
if (!upstreamBaseUrl) {
|
|
973
|
+
return params.proxyUrl;
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const proxy = new URL(params.proxyUrl);
|
|
977
|
+
const upstream = new URL(upstreamBaseUrl);
|
|
978
|
+
proxy.pathname = upstream.pathname;
|
|
979
|
+
proxy.search = upstream.search;
|
|
980
|
+
proxy.hash = "";
|
|
981
|
+
return proxy.toString().replace(/\/$/, "");
|
|
982
|
+
} catch {
|
|
983
|
+
return params.proxyUrl;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
517
987
|
// src/tools/headroom-retrieve.ts
|
|
518
988
|
function createHeadroomRetrieveTool(config) {
|
|
989
|
+
const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
|
|
519
990
|
return {
|
|
520
991
|
name: "headroom_retrieve",
|
|
521
|
-
description: "Retrieve original uncompressed content from Headroom's compression store. Use when compressed context mentions a hash and you need the full details. Pass the hash from the compression marker (24 hex characters).
|
|
992
|
+
description: "Retrieve original uncompressed content from Headroom's compression store. Use when compressed context mentions a hash and you need the full details. Pass the hash from the compression marker (24 hex characters). Retrieval is by hash and always returns the full original content.",
|
|
522
993
|
parameters: {
|
|
523
994
|
type: "object",
|
|
524
995
|
properties: {
|
|
525
996
|
hash: {
|
|
526
997
|
type: "string",
|
|
527
998
|
description: "The 24-character hex hash from the compression marker"
|
|
528
|
-
},
|
|
529
|
-
query: {
|
|
530
|
-
type: "string",
|
|
531
|
-
description: "Optional search query to filter results within the original content"
|
|
532
999
|
}
|
|
533
1000
|
},
|
|
534
1001
|
required: ["hash"]
|
|
535
1002
|
},
|
|
536
1003
|
execute: async (args) => {
|
|
537
|
-
const { hash
|
|
1004
|
+
const { hash } = args;
|
|
538
1005
|
if (!/^[a-f0-9]{24}$/i.test(hash)) {
|
|
539
1006
|
return JSON.stringify({
|
|
540
1007
|
error: "Invalid hash format. Expected 24 hex characters."
|
|
541
1008
|
});
|
|
542
1009
|
}
|
|
543
1010
|
try {
|
|
544
|
-
const url =
|
|
1011
|
+
const url = `${proxyOrigin}/v1/retrieve/${hash}`;
|
|
545
1012
|
const resp = await fetch(url, {
|
|
546
1013
|
signal: AbortSignal.timeout(1e4)
|
|
547
1014
|
});
|
|
@@ -557,7 +1024,7 @@ function createHeadroomRetrieveTool(config) {
|
|
|
557
1024
|
} catch (error) {
|
|
558
1025
|
return JSON.stringify({
|
|
559
1026
|
error: `Retrieval failed: ${error}`,
|
|
560
|
-
hint: "The compressed content may have expired (default TTL:
|
|
1027
|
+
hint: "The compressed content may have expired (default TTL: 30 minutes)"
|
|
561
1028
|
});
|
|
562
1029
|
}
|
|
563
1030
|
}
|
|
@@ -565,29 +1032,112 @@ function createHeadroomRetrieveTool(config) {
|
|
|
565
1032
|
}
|
|
566
1033
|
|
|
567
1034
|
// src/plugin/index.ts
|
|
568
|
-
|
|
1035
|
+
var plugin_default = {
|
|
1036
|
+
register: registerHeadroomPlugin
|
|
1037
|
+
};
|
|
1038
|
+
function registerHeadroomPlugin(api) {
|
|
569
1039
|
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
|
|
570
1040
|
const logger = api.logger ?? console;
|
|
571
|
-
const
|
|
1041
|
+
const rawProxyUrl = config.proxyUrl;
|
|
1042
|
+
const proxyUrl = typeof rawProxyUrl === "string" && rawProxyUrl.trim().length > 0 ? normalizeAndValidateProxyUrl(rawProxyUrl) : void 0;
|
|
1043
|
+
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
|
|
572
1044
|
info: (m) => logger.info(m),
|
|
573
1045
|
warn: (m) => logger.warn(m),
|
|
574
1046
|
error: (m) => logger.error(m),
|
|
575
1047
|
debug: (m) => logger.debug?.(m)
|
|
576
1048
|
});
|
|
1049
|
+
const gatewayProviderIds = resolveGatewayProviderIds(config);
|
|
1050
|
+
let validatedConfiguredProxyUrl = null;
|
|
1051
|
+
let configuredProxyProbePromise = null;
|
|
1052
|
+
const applyGatewayRouting = async (activeProxyUrl) => {
|
|
1053
|
+
if (gatewayProviderIds.length === 0) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
|
|
1058
|
+
if (changed) {
|
|
1059
|
+
logger.info(
|
|
1060
|
+
`[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`
|
|
1061
|
+
);
|
|
1062
|
+
} else {
|
|
1063
|
+
logger.info(
|
|
1064
|
+
`[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
const getConfiguredRoutingProxyUrl = async () => {
|
|
1072
|
+
if (!proxyUrl) {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
if (validatedConfiguredProxyUrl === proxyUrl) {
|
|
1076
|
+
return validatedConfiguredProxyUrl;
|
|
1077
|
+
}
|
|
1078
|
+
if (!configuredProxyProbePromise) {
|
|
1079
|
+
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl).then((probe) => {
|
|
1080
|
+
if (probe.reachable && probe.isHeadroom) {
|
|
1081
|
+
validatedConfiguredProxyUrl = proxyUrl;
|
|
1082
|
+
return proxyUrl;
|
|
1083
|
+
}
|
|
1084
|
+
logger.warn(
|
|
1085
|
+
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` + (probe.reason ? ` (${probe.reason})` : "")
|
|
1086
|
+
);
|
|
1087
|
+
return null;
|
|
1088
|
+
}).catch((error) => {
|
|
1089
|
+
logger.warn(
|
|
1090
|
+
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`
|
|
1091
|
+
);
|
|
1092
|
+
return null;
|
|
1093
|
+
}).finally(() => {
|
|
1094
|
+
configuredProxyProbePromise = null;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
return configuredProxyProbePromise;
|
|
1098
|
+
};
|
|
1099
|
+
const ensureGatewayRouting = async () => {
|
|
1100
|
+
if (gatewayProviderIds.length === 0) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const activeProxyUrl = engine.getProxyUrl() ?? await getConfiguredRoutingProxyUrl();
|
|
1104
|
+
if (!activeProxyUrl) {
|
|
1105
|
+
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
await applyGatewayRouting(activeProxyUrl);
|
|
1109
|
+
};
|
|
1110
|
+
engine.onProxyReady(async (activeProxyUrl) => {
|
|
1111
|
+
await applyGatewayRouting(activeProxyUrl);
|
|
1112
|
+
});
|
|
577
1113
|
api.registerContextEngine("headroom", () => engine);
|
|
578
1114
|
api.registerTool((ctx) => {
|
|
579
|
-
const
|
|
580
|
-
if (!
|
|
581
|
-
return createHeadroomRetrieveTool({ proxyUrl });
|
|
1115
|
+
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
|
|
1116
|
+
if (!activeProxyUrl) return null;
|
|
1117
|
+
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
|
|
1118
|
+
}, { names: ["headroom_retrieve"] });
|
|
1119
|
+
api.on("gateway_start", async () => {
|
|
1120
|
+
await ensureGatewayRouting();
|
|
582
1121
|
});
|
|
1122
|
+
void ensureGatewayRouting();
|
|
583
1123
|
logger.info("[headroom] Plugin registered");
|
|
584
1124
|
}
|
|
585
1125
|
export {
|
|
1126
|
+
DEFAULT_GATEWAY_PROVIDER_IDS,
|
|
586
1127
|
HeadroomContextEngine,
|
|
587
1128
|
ProxyManager,
|
|
588
1129
|
agentToOpenAI,
|
|
1130
|
+
applyGatewayProviderBaseUrls,
|
|
1131
|
+
applyGatewayProviderBaseUrlsInPlace,
|
|
589
1132
|
createHeadroomRetrieveTool,
|
|
590
|
-
|
|
591
|
-
|
|
1133
|
+
plugin_default as default,
|
|
1134
|
+
defaultLogger,
|
|
1135
|
+
isLocalProxyUrl,
|
|
1136
|
+
normalizeAgentMessages,
|
|
1137
|
+
normalizeAndValidateProxyUrl,
|
|
1138
|
+
openAIToAgent,
|
|
1139
|
+
probeHeadroomProxy,
|
|
1140
|
+
registerHeadroomPlugin,
|
|
1141
|
+
resolveGatewayProviderIds
|
|
592
1142
|
};
|
|
593
1143
|
//# sourceMappingURL=index.js.map
|