headroom-opencode 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,32 +35,36 @@ The generated provider uses `@ai-sdk/openai-compatible` and points model request
35
35
 
36
36
  ## Native OpenCode Plugin
37
37
 
38
- Use `HeadroomPlugin` when OpenCode should intercept provider traffic in-process and expose Headroom tooling from a plugin.
38
+ The package's default export is an OpenCode plugin that loads on both OpenCode 1.x and 2.x. Register it in `opencode.json`:
39
39
 
40
- ```ts
41
- import { HeadroomPlugin } from "headroom-opencode";
42
-
43
- export default async function plugin(input) {
44
- return HeadroomPlugin(input, {
45
- proxyUrl: process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787",
46
- });
40
+ ```json
41
+ {
42
+ "plugin": [
43
+ ["headroom-opencode", { "proxyUrl": "http://127.0.0.1:8787" }]
44
+ ]
47
45
  }
48
46
  ```
49
47
 
50
- `HeadroomPlugin`:
48
+ OpenCode 2.x migrates the 1.x `plugin` key automatically. To configure it the 2.x way, use `"plugins": [{ "package": "headroom-opencode", "options": { ... } }]`.
49
+
50
+ The default export is `{ id: "headroom", server, setup }`. OpenCode 1.x calls `server` (`HeadroomPlugin`) and 2.x calls `setup` (`headroomSetup`); each ignores the other's field. Both entry points:
51
+
52
+ - install Headroom transport interception for OpenCode provider traffic.
53
+ - expose the `headroom_retrieve` tool (a direct tool on 2.x, not only through code mode).
54
+ - add `HEADROOM_ACTIVE`, `HEADROOM_PROXY_URL`, `HEADROOM_PROJECT` (and `HEADROOM_BACKEND` when set) to shell command environments.
55
+ - default to `HEADROOM_PROXY_URL`, then `http://127.0.0.1:8787`, when no `proxyUrl` option is supplied.
56
+ - skip routing for hosts listed in `excludeHosts` (or `HEADROOM_OPENCODE_EXCLUDE_HOSTS`); those requests go straight upstream with no compression or telemetry.
57
+
58
+ On 2.x, setup returns a cleanup that releases the transport, so hot reloads do not stack interceptors.
51
59
 
52
- - installs Headroom transport interception for OpenCode provider traffic.
53
- - exposes the `headroom_retrieve` tool.
54
- - publishes `HEADROOM_PROXY_URL` in the plugin output env.
55
- - defaults to `http://127.0.0.1:8787` when no proxy URL is supplied.
56
- - skips routing for hosts listed in `excludeHosts` (or `HEADROOM_OPENCODE_EXCLUDE_HOSTS`); those requests go straight upstream with no compression or telemetry.
60
+ OpenCode 2.x keeps its own reference to `fetch` from before plugins load, so patching `fetch` alone does not catch its model traffic. On 2.x the plugin also rewrites each remote model that uses the OpenAI chat-completions or responses format so that it points at the proxy. Headers name the real upstream, as the transport does. Models on other formats (Anthropic messages, Google) are left alone. `headroom wrap opencode` routes native Anthropic and OpenAI through the proxy separately, via their `baseURL`.
57
61
 
58
62
  To keep a provider off the proxy, list its host. An entry matches the host and all of its subdomains, and `.example.com` / `*.example.com` are accepted as spellings of `example.com`:
59
63
 
60
64
  ```jsonc
61
65
  // opencode.json
62
66
  {
63
- "plugin": [["/path/to/headroom/providers/opencode/_dist/entry.opencode.js", { "excludeHosts": ["opencode.ai"] }]]
67
+ "plugin": [["/path/to/headroom/providers/opencode/_dist", { "excludeHosts": ["opencode.ai"] }]]
64
68
  }
65
69
  ```
66
70
 
@@ -13,8 +13,13 @@ function setDefaultProxyUrl(url2) {
13
13
  function getDefaultProxyUrl() {
14
14
  return _proxyUrlCache ?? process.env.HEADROOM_BASE_URL ?? "http://localhost:8787";
15
15
  }
16
+ function trimTrailingSlashes(value) {
17
+ let end = value.length;
18
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
19
+ return value.slice(0, end);
20
+ }
16
21
  function createHeadroomRetrieveTool(config2) {
17
- const origin = config2.proxyBaseUrl.replace(/\/+$/, "");
22
+ const origin = trimTrailingSlashes(config2.proxyBaseUrl);
18
23
  return {
19
24
  name: "headroom_retrieve",
20
25
  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.",
@@ -78,6 +83,7 @@ var fs = nodeRequire("node:fs");
78
83
  var BASE_URL_HEADER = "x-headroom-base-url";
79
84
  var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
80
85
  var PROJECT_HEADER = "x-headroom-project";
86
+ var SESSION_TOKEN_HEADER = "x-headroom-session-token";
81
87
  var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
82
88
  var EXCLUDE_HOSTS_ENV = "HEADROOM_OPENCODE_EXCLUDE_HOSTS";
83
89
  var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
@@ -134,6 +140,9 @@ function injectOptionsEnv(args, optionIndex, state) {
134
140
  const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : void 0;
135
141
  const existing = isOptions(nextArgs[optionIndex]) ? { ...nextArgs[optionIndex] } : {};
136
142
  existing.env = withShimEnv(existing.env, state.proxyUrl, state.excludeHosts);
143
+ if (process.platform === "win32" && existing.windowsHide === void 0) {
144
+ existing.windowsHide = true;
145
+ }
137
146
  if (isOptions(nextArgs[optionIndex])) {
138
147
  nextArgs[optionIndex] = existing;
139
148
  } else {
@@ -204,7 +213,10 @@ function isExcludedHost(hostname3, excludeHosts) {
204
213
  const normalized = hostname3.toLowerCase().replace(/^\[|\]$/g, "");
205
214
  return excludeHosts.some((host) => normalized === host || normalized.endsWith(`.${host}`));
206
215
  }
207
- function shouldRoute(url2, proxy, excludeHosts) {
216
+ function isLlmEndpointPath(pathname) {
217
+ return pathname.endsWith("/chat/completions") || pathname.endsWith("/responses") || pathname.endsWith("/messages") || pathname.endsWith(":generateContent") || pathname.endsWith(":streamGenerateContent");
218
+ }
219
+ function isRoutableUpstream(url2, proxy, excludeHosts) {
208
220
  if (url2.protocol !== "http:" && url2.protocol !== "https:") {
209
221
  return false;
210
222
  }
@@ -219,6 +231,16 @@ function shouldRoute(url2, proxy, excludeHosts) {
219
231
  }
220
232
  return true;
221
233
  }
234
+ function shouldRoute(url2, proxy, excludeHosts) {
235
+ return isRoutableUpstream(url2, proxy, excludeHosts) && isLlmEndpointPath(url2.pathname);
236
+ }
237
+ function modelBaseRoutesThroughProxy(baseUrl, proxyUrl, excludeHosts = []) {
238
+ try {
239
+ return isRoutableUpstream(new URL(baseUrl), normalizeProxyUrl(proxyUrl), excludeHosts);
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
222
244
  function routedUrl(upstream, proxy) {
223
245
  return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
224
246
  }
@@ -253,7 +275,7 @@ function requestUrl(input) {
253
275
  }
254
276
  return new URL(String(input));
255
277
  }
256
- function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
278
+ function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0, sessionToken = void 0) {
257
279
  const headers = new Headers(input instanceof Request ? input.headers : void 0);
258
280
  if (init?.headers) {
259
281
  new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -268,9 +290,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project
268
290
  if (project) {
269
291
  headers.set(PROJECT_HEADER, project);
270
292
  }
293
+ if (sessionToken) {
294
+ headers.set(SESSION_TOKEN_HEADER, sessionToken);
295
+ }
271
296
  return headers;
272
297
  }
273
- function withRoutedFetchInput(input, init, proxy, project, excludeHosts) {
298
+ function withRoutedFetchInput(input, init, proxy, project, excludeHosts, sessionToken) {
274
299
  const upstream = requestUrl(input);
275
300
  if (!shouldRoute(upstream, proxy, excludeHosts)) {
276
301
  return [input, init];
@@ -278,7 +303,7 @@ function withRoutedFetchInput(input, init, proxy, project, excludeHosts) {
278
303
  const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
279
304
  const nextInit = {
280
305
  ...init,
281
- headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
306
+ headers: mergeFetchHeaders(input, init, upstream, originalPath, project, sessionToken)
282
307
  };
283
308
  if (input instanceof Request) {
284
309
  return [new Request(nextUrl, input), nextInit];
@@ -324,7 +349,7 @@ function urlFromRequestOptions(options) {
324
349
  return void 0;
325
350
  }
326
351
  }
327
- function headersForNodeRequest(options, upstream, originalPath, project) {
352
+ function headersForNodeRequest(options, upstream, originalPath, project, sessionToken) {
328
353
  const headers = new Headers(options.headers);
329
354
  headers.set(BASE_URL_HEADER, upstream.origin);
330
355
  if (originalPath) {
@@ -333,6 +358,9 @@ function headersForNodeRequest(options, upstream, originalPath, project) {
333
358
  if (project) {
334
359
  headers.set(PROJECT_HEADER, project);
335
360
  }
361
+ if (sessionToken) {
362
+ headers.set(SESSION_TOKEN_HEADER, sessionToken);
363
+ }
336
364
  headers.delete("host");
337
365
  const result = {};
338
366
  headers.forEach((value, key) => {
@@ -340,7 +368,7 @@ function headersForNodeRequest(options, upstream, originalPath, project) {
340
368
  });
341
369
  return result;
342
370
  }
343
- function routedNodeOptions(parts, proxy, project, excludeHosts) {
371
+ function routedNodeOptions(parts, proxy, project, excludeHosts, sessionToken) {
344
372
  if (!parts.url || !shouldRoute(parts.url, proxy, excludeHosts)) {
345
373
  return void 0;
346
374
  }
@@ -371,7 +399,7 @@ function routedNodeOptions(parts, proxy, project, excludeHosts) {
371
399
  hostname: nextUrl.hostname,
372
400
  port: nextUrl.port || void 0,
373
401
  path: `${nextUrl.pathname}${nextUrl.search}`,
374
- headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
402
+ headers: headersForNodeRequest(parts.options, parts.url, originalPath, project, sessionToken)
375
403
  };
376
404
  }
377
405
  function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -382,7 +410,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
382
410
  }
383
411
  const proxy = normalizeProxyUrl(state.proxyUrl);
384
412
  const parts = splitNodeArgs(args);
385
- const nextOptions = routedNodeOptions(parts, proxy, state.project, state.excludeHosts);
413
+ const nextOptions = routedNodeOptions(parts, proxy, state.project, state.excludeHosts, state.sessionToken);
386
414
  if (!nextOptions) {
387
415
  return Reflect.apply(originalRequest, this, args);
388
416
  }
@@ -399,18 +427,8 @@ function wrapGet(request) {
399
427
  };
400
428
  }
401
429
  function wrapHttp2Connect(originalConnect) {
402
- return function headroomHttp2Connect(authority, ...args) {
403
- const state = getState();
404
- if (state) {
405
- const proxy = normalizeProxyUrl(state.proxyUrl);
406
- const upstream = authority instanceof URL ? authority : new URL(String(authority));
407
- if (shouldRoute(upstream, proxy, state.excludeHosts)) {
408
- throw new Error(
409
- `Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. Use fetch, http, or https so traffic can be routed through Headroom.`
410
- );
411
- }
412
- }
413
- return Reflect.apply(originalConnect, this, [authority, ...args]);
430
+ return function headroomHttp2Connect(...args) {
431
+ return Reflect.apply(originalConnect, this, args);
414
432
  };
415
433
  }
416
434
  function installHeadroomTransport(options) {
@@ -422,6 +440,7 @@ function installHeadroomTransport(options) {
422
440
  existing.project = options.project;
423
441
  existing.excludeHosts = excludeHosts;
424
442
  existing.debug = Boolean(options.debug);
443
+ existing.sessionToken = options.sessionToken;
425
444
  installProcessEnv(options.proxyUrl, excludeHosts);
426
445
  return () => uninstallHeadroomTransport();
427
446
  }
@@ -431,6 +450,7 @@ function installHeadroomTransport(options) {
431
450
  project: options.project,
432
451
  excludeHosts,
433
452
  debug: Boolean(options.debug),
453
+ sessionToken: options.sessionToken,
434
454
  originalFetch: globalThis.fetch,
435
455
  originalHttpRequest: http.request,
436
456
  originalHttpGet: http.get,
@@ -450,7 +470,7 @@ function installHeadroomTransport(options) {
450
470
  return state.originalFetch(...args);
451
471
  }
452
472
  const proxy = normalizeProxyUrl(current.proxyUrl);
453
- const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project, current.excludeHosts);
473
+ const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project, current.excludeHosts, current.sessionToken);
454
474
  return state.originalFetch(nextInput, nextInit);
455
475
  };
456
476
  http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@@ -12908,14 +12928,59 @@ function date4(params) {
12908
12928
  config(en_default());
12909
12929
 
12910
12930
  // src/plugin.ts
12931
+ var HEADROOM_PLUGIN_ID = "headroom";
12911
12932
  function normalizeProxyUrl2(url2) {
12912
- return url2.replace(/\/+$/, "");
12933
+ return trimTrailingSlashes(url2);
12913
12934
  }
12914
12935
  function resolveProxyUrl(options) {
12915
12936
  return normalizeProxyUrl2(
12916
12937
  options?.proxyUrl ?? process.env.HEADROOM_PROXY_URL ?? process.env.HEADROOM_BASE_URL ?? getDefaultProxyUrl()
12917
12938
  );
12918
12939
  }
12940
+ function resolveSessionToken(options) {
12941
+ return options?.sessionToken ?? process.env.HEADROOM_OPENCODE_SESSION_TOKEN;
12942
+ }
12943
+ function resolveExcludeHosts(options) {
12944
+ return normalizeExcludeHosts(options?.excludeHosts ?? process.env[EXCLUDE_HOSTS_ENV] ?? "");
12945
+ }
12946
+ function headroomShellEnv(proxyUrl, project, options) {
12947
+ return {
12948
+ HEADROOM_ACTIVE: "1",
12949
+ HEADROOM_PROXY_URL: proxyUrl,
12950
+ HEADROOM_PROJECT: project,
12951
+ ...options.backend ? { HEADROOM_BACKEND: options.backend } : {}
12952
+ };
12953
+ }
12954
+ function openAiWireSuffix(pkg) {
12955
+ if (!pkg?.startsWith("@opencode/ai/providers/")) return void 0;
12956
+ if (pkg === "@opencode/ai/providers/openai-compatible" || pkg.endsWith("/chat")) {
12957
+ return "/chat/completions";
12958
+ }
12959
+ if (pkg.endsWith("-responses") || pkg.endsWith("/responses")) return "/responses";
12960
+ return void 0;
12961
+ }
12962
+ function routeModelsThroughProxy(models, proxyUrl, project, excludeHosts, sessionToken) {
12963
+ for (const model of models.list()) {
12964
+ const providerID = String(model.providerID);
12965
+ const modelID = String(model.id);
12966
+ const provider = models.provider.get(providerID)?.provider;
12967
+ const suffix = openAiWireSuffix(model.package ?? provider?.package);
12968
+ const baseURL = model.settings?.baseURL ?? provider?.settings?.baseURL;
12969
+ if (!suffix || typeof baseURL !== "string") continue;
12970
+ if (!modelBaseRoutesThroughProxy(baseURL, proxyUrl, excludeHosts)) continue;
12971
+ const upstream = new URL(baseURL);
12972
+ models.update(providerID, modelID, (draft) => {
12973
+ draft.settings = { ...draft.settings, baseURL: `${proxyUrl}/v1` };
12974
+ draft.headers = {
12975
+ ...draft.headers,
12976
+ [BASE_URL_HEADER]: upstream.origin,
12977
+ [ORIGINAL_PATH_HEADER]: `${trimTrailingSlashes(upstream.pathname)}${suffix}`,
12978
+ [PROJECT_HEADER]: project,
12979
+ ...sessionToken ? { [SESSION_TOKEN_HEADER]: sessionToken } : {}
12980
+ };
12981
+ });
12982
+ }
12983
+ }
12919
12984
  var HeadroomPlugin = async (input, options = {}) => {
12920
12985
  const pluginOptions = options;
12921
12986
  const proxyUrl = resolveProxyUrl(pluginOptions);
@@ -12925,7 +12990,8 @@ var HeadroomPlugin = async (input, options = {}) => {
12925
12990
  proxyUrl,
12926
12991
  project,
12927
12992
  excludeHosts: pluginOptions.excludeHosts,
12928
- debug: pluginOptions.debug
12993
+ debug: pluginOptions.debug,
12994
+ sessionToken: resolveSessionToken(pluginOptions)
12929
12995
  });
12930
12996
  return {
12931
12997
  dispose: async () => {
@@ -12943,24 +13009,70 @@ var HeadroomPlugin = async (input, options = {}) => {
12943
13009
  })
12944
13010
  },
12945
13011
  "shell.env": async (_input, output) => {
12946
- output.env.HEADROOM_ACTIVE = "1";
12947
- output.env.HEADROOM_PROXY_URL = proxyUrl;
12948
- output.env.HEADROOM_PROJECT = project;
12949
- if (pluginOptions.backend) {
12950
- output.env.HEADROOM_BACKEND = pluginOptions.backend;
12951
- }
13012
+ Object.assign(output.env, headroomShellEnv(proxyUrl, project, pluginOptions));
12952
13013
  }
12953
13014
  };
12954
13015
  };
12955
- var plugin_default = HeadroomPlugin;
13016
+ var headroomSetup = async (ctx) => {
13017
+ const pluginOptions = ctx.options;
13018
+ const proxyUrl = resolveProxyUrl(pluginOptions);
13019
+ const project = pluginOptions.project ?? ctx.location.project.id ?? ctx.location.directory;
13020
+ const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
13021
+ const excludeHosts = resolveExcludeHosts(pluginOptions);
13022
+ const sessionToken = resolveSessionToken(pluginOptions);
13023
+ const uninstallTransport = installHeadroomTransport({
13024
+ proxyUrl,
13025
+ project,
13026
+ excludeHosts,
13027
+ debug: pluginOptions.debug,
13028
+ sessionToken
13029
+ });
13030
+ try {
13031
+ await ctx.model.transform((models) => {
13032
+ routeModelsThroughProxy(models, proxyUrl, project, excludeHosts, sessionToken);
13033
+ });
13034
+ await ctx.tool.transform((editor) => {
13035
+ editor.add({
13036
+ name: retrieveTool.name,
13037
+ description: retrieveTool.description,
13038
+ input: retrieveTool.parameters,
13039
+ // Offer the tool directly, like OpenCode's built-ins, rather than only
13040
+ // through the code-mode `execute` tool.
13041
+ options: { codemode: false },
13042
+ async execute(args) {
13043
+ return { content: await retrieveTool.execute(args) };
13044
+ }
13045
+ });
13046
+ });
13047
+ await ctx.shell.hook("create.before", (shell) => {
13048
+ Object.assign(shell.env, headroomShellEnv(proxyUrl, project, pluginOptions));
13049
+ });
13050
+ } catch (error45) {
13051
+ uninstallTransport();
13052
+ throw error45;
13053
+ }
13054
+ return () => {
13055
+ uninstallTransport();
13056
+ };
13057
+ };
13058
+ var HeadroomOpenCodePlugin = {
13059
+ id: HEADROOM_PLUGIN_ID,
13060
+ server: HeadroomPlugin,
13061
+ setup: headroomSetup
13062
+ };
13063
+ var plugin_default = HeadroomOpenCodePlugin;
12956
13064
 
12957
13065
  export {
12958
13066
  setDefaultProxyUrl,
12959
13067
  getDefaultProxyUrl,
13068
+ trimTrailingSlashes,
12960
13069
  createHeadroomRetrieveTool,
12961
13070
  compressWithHeadroom,
12962
13071
  installHeadroomTransport,
13072
+ HEADROOM_PLUGIN_ID,
12963
13073
  HeadroomPlugin,
13074
+ headroomSetup,
13075
+ HeadroomOpenCodePlugin,
12964
13076
  plugin_default
12965
13077
  };
12966
- //# sourceMappingURL=chunk-6I5ODRCW.js.map
13078
+ //# sourceMappingURL=chunk-XS6Z2EJR.js.map