headroom-opencode 0.40.0 → 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");
@@ -210,7 +216,7 @@ function isExcludedHost(hostname3, excludeHosts) {
210
216
  function isLlmEndpointPath(pathname) {
211
217
  return pathname.endsWith("/chat/completions") || pathname.endsWith("/responses") || pathname.endsWith("/messages") || pathname.endsWith(":generateContent") || pathname.endsWith(":streamGenerateContent");
212
218
  }
213
- function shouldRoute(url2, proxy, excludeHosts) {
219
+ function isRoutableUpstream(url2, proxy, excludeHosts) {
214
220
  if (url2.protocol !== "http:" && url2.protocol !== "https:") {
215
221
  return false;
216
222
  }
@@ -223,7 +229,17 @@ function shouldRoute(url2, proxy, excludeHosts) {
223
229
  if (isExcludedHost(url2.hostname, excludeHosts)) {
224
230
  return false;
225
231
  }
226
- return isLlmEndpointPath(url2.pathname);
232
+ return true;
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
+ }
227
243
  }
228
244
  function routedUrl(upstream, proxy) {
229
245
  return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
@@ -259,7 +275,7 @@ function requestUrl(input) {
259
275
  }
260
276
  return new URL(String(input));
261
277
  }
262
- 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) {
263
279
  const headers = new Headers(input instanceof Request ? input.headers : void 0);
264
280
  if (init?.headers) {
265
281
  new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -274,9 +290,12 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project
274
290
  if (project) {
275
291
  headers.set(PROJECT_HEADER, project);
276
292
  }
293
+ if (sessionToken) {
294
+ headers.set(SESSION_TOKEN_HEADER, sessionToken);
295
+ }
277
296
  return headers;
278
297
  }
279
- function withRoutedFetchInput(input, init, proxy, project, excludeHosts) {
298
+ function withRoutedFetchInput(input, init, proxy, project, excludeHosts, sessionToken) {
280
299
  const upstream = requestUrl(input);
281
300
  if (!shouldRoute(upstream, proxy, excludeHosts)) {
282
301
  return [input, init];
@@ -284,7 +303,7 @@ function withRoutedFetchInput(input, init, proxy, project, excludeHosts) {
284
303
  const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
285
304
  const nextInit = {
286
305
  ...init,
287
- headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
306
+ headers: mergeFetchHeaders(input, init, upstream, originalPath, project, sessionToken)
288
307
  };
289
308
  if (input instanceof Request) {
290
309
  return [new Request(nextUrl, input), nextInit];
@@ -330,7 +349,7 @@ function urlFromRequestOptions(options) {
330
349
  return void 0;
331
350
  }
332
351
  }
333
- function headersForNodeRequest(options, upstream, originalPath, project) {
352
+ function headersForNodeRequest(options, upstream, originalPath, project, sessionToken) {
334
353
  const headers = new Headers(options.headers);
335
354
  headers.set(BASE_URL_HEADER, upstream.origin);
336
355
  if (originalPath) {
@@ -339,6 +358,9 @@ function headersForNodeRequest(options, upstream, originalPath, project) {
339
358
  if (project) {
340
359
  headers.set(PROJECT_HEADER, project);
341
360
  }
361
+ if (sessionToken) {
362
+ headers.set(SESSION_TOKEN_HEADER, sessionToken);
363
+ }
342
364
  headers.delete("host");
343
365
  const result = {};
344
366
  headers.forEach((value, key) => {
@@ -346,7 +368,7 @@ function headersForNodeRequest(options, upstream, originalPath, project) {
346
368
  });
347
369
  return result;
348
370
  }
349
- function routedNodeOptions(parts, proxy, project, excludeHosts) {
371
+ function routedNodeOptions(parts, proxy, project, excludeHosts, sessionToken) {
350
372
  if (!parts.url || !shouldRoute(parts.url, proxy, excludeHosts)) {
351
373
  return void 0;
352
374
  }
@@ -377,7 +399,7 @@ function routedNodeOptions(parts, proxy, project, excludeHosts) {
377
399
  hostname: nextUrl.hostname,
378
400
  port: nextUrl.port || void 0,
379
401
  path: `${nextUrl.pathname}${nextUrl.search}`,
380
- headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
402
+ headers: headersForNodeRequest(parts.options, parts.url, originalPath, project, sessionToken)
381
403
  };
382
404
  }
383
405
  function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -388,7 +410,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
388
410
  }
389
411
  const proxy = normalizeProxyUrl(state.proxyUrl);
390
412
  const parts = splitNodeArgs(args);
391
- const nextOptions = routedNodeOptions(parts, proxy, state.project, state.excludeHosts);
413
+ const nextOptions = routedNodeOptions(parts, proxy, state.project, state.excludeHosts, state.sessionToken);
392
414
  if (!nextOptions) {
393
415
  return Reflect.apply(originalRequest, this, args);
394
416
  }
@@ -418,6 +440,7 @@ function installHeadroomTransport(options) {
418
440
  existing.project = options.project;
419
441
  existing.excludeHosts = excludeHosts;
420
442
  existing.debug = Boolean(options.debug);
443
+ existing.sessionToken = options.sessionToken;
421
444
  installProcessEnv(options.proxyUrl, excludeHosts);
422
445
  return () => uninstallHeadroomTransport();
423
446
  }
@@ -427,6 +450,7 @@ function installHeadroomTransport(options) {
427
450
  project: options.project,
428
451
  excludeHosts,
429
452
  debug: Boolean(options.debug),
453
+ sessionToken: options.sessionToken,
430
454
  originalFetch: globalThis.fetch,
431
455
  originalHttpRequest: http.request,
432
456
  originalHttpGet: http.get,
@@ -446,7 +470,7 @@ function installHeadroomTransport(options) {
446
470
  return state.originalFetch(...args);
447
471
  }
448
472
  const proxy = normalizeProxyUrl(current.proxyUrl);
449
- 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);
450
474
  return state.originalFetch(nextInput, nextInit);
451
475
  };
452
476
  http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@@ -12904,14 +12928,59 @@ function date4(params) {
12904
12928
  config(en_default());
12905
12929
 
12906
12930
  // src/plugin.ts
12931
+ var HEADROOM_PLUGIN_ID = "headroom";
12907
12932
  function normalizeProxyUrl2(url2) {
12908
- return url2.replace(/\/+$/, "");
12933
+ return trimTrailingSlashes(url2);
12909
12934
  }
12910
12935
  function resolveProxyUrl(options) {
12911
12936
  return normalizeProxyUrl2(
12912
12937
  options?.proxyUrl ?? process.env.HEADROOM_PROXY_URL ?? process.env.HEADROOM_BASE_URL ?? getDefaultProxyUrl()
12913
12938
  );
12914
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
+ }
12915
12984
  var HeadroomPlugin = async (input, options = {}) => {
12916
12985
  const pluginOptions = options;
12917
12986
  const proxyUrl = resolveProxyUrl(pluginOptions);
@@ -12921,7 +12990,8 @@ var HeadroomPlugin = async (input, options = {}) => {
12921
12990
  proxyUrl,
12922
12991
  project,
12923
12992
  excludeHosts: pluginOptions.excludeHosts,
12924
- debug: pluginOptions.debug
12993
+ debug: pluginOptions.debug,
12994
+ sessionToken: resolveSessionToken(pluginOptions)
12925
12995
  });
12926
12996
  return {
12927
12997
  dispose: async () => {
@@ -12939,24 +13009,70 @@ var HeadroomPlugin = async (input, options = {}) => {
12939
13009
  })
12940
13010
  },
12941
13011
  "shell.env": async (_input, output) => {
12942
- output.env.HEADROOM_ACTIVE = "1";
12943
- output.env.HEADROOM_PROXY_URL = proxyUrl;
12944
- output.env.HEADROOM_PROJECT = project;
12945
- if (pluginOptions.backend) {
12946
- output.env.HEADROOM_BACKEND = pluginOptions.backend;
12947
- }
13012
+ Object.assign(output.env, headroomShellEnv(proxyUrl, project, pluginOptions));
12948
13013
  }
12949
13014
  };
12950
13015
  };
12951
- 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;
12952
13064
 
12953
13065
  export {
12954
13066
  setDefaultProxyUrl,
12955
13067
  getDefaultProxyUrl,
13068
+ trimTrailingSlashes,
12956
13069
  createHeadroomRetrieveTool,
12957
13070
  compressWithHeadroom,
12958
13071
  installHeadroomTransport,
13072
+ HEADROOM_PLUGIN_ID,
12959
13073
  HeadroomPlugin,
13074
+ headroomSetup,
13075
+ HeadroomOpenCodePlugin,
12960
13076
  plugin_default
12961
13077
  };
12962
- //# sourceMappingURL=chunk-RFZCFX4B.js.map
13078
+ //# sourceMappingURL=chunk-XS6Z2EJR.js.map