Skip to content

Commit eeb038b

Browse files
fix(opencode): send x-headroom-project header on all proxied requests (#2868)
## Description The OpenCode transport plugin set `HEADROOM_PROJECT` as a shell env var for child processes but never forwarded it as `x-headroom-project` on the actual proxied HTTP requests. The proxy's `classify_project` only attributes traffic via `x-headroom-project` header or `/p/<name>` URL prefix — without the header, every OpenCode request was unattributed and the Per-Project Savings dashboard showed `0 project(s)` permanently. Fixes #2847. ## Root cause `installHeadroomTransport` was called with only `{ proxyUrl, debug }`. The `project` value was computed and used only in the `shell.env` hook (for subprocess env injection), never threaded through to `mergeFetchHeaders` or `headersForNodeRequest`. ## Changes Made 1. Add `project?: string` to `InstallOptions` and `TransportState`. 2. Resolve the project value once at plugin init (`pluginOptions.project → input.project.id → input.directory`) and pass it to `installHeadroomTransport`. 3. Both header-building seams now set `x-headroom-project` when a project is present: - `mergeFetchHeaders` (wrapped `fetch` path) - `headersForNodeRequest` (wrapped `http.request` / `https.request` path) 4. Reuse the resolved `project` in the `shell.env` hook (removes the duplicate resolution that was there before). ## Changes - `plugins/opencode/src/transport.ts` — `InstallOptions.project`, `TransportState.project`; `mergeFetchHeaders`, `headersForNodeRequest`, `routedNodeOptions`, `withRoutedFetchInput`, `installHeadroomTransport` updated - `plugins/opencode/src/plugin.ts` — resolve `project` once, pass it to transport; reuse in `shell.env` - `plugins/opencode/src/transport.test.ts` — 3 new tests: project header on fetch, project header on https.request, no header when project unset - `headroom/providers/opencode/_dist/entry.opencode.js` — rebuilt with `npm run build:standalone` to match source ## Testing - [x] Unit tests pass - [x] TypeScript typecheck passes - [x] New regression tests added ### Test Output ``` cd plugins/opencode && npm test # 17 passed (14 existing + 3 new) ``` TypeScript build also passes: `npm run typecheck` (no errors). ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Real Behavior Proof - Environment: OpenCode transport plugin test environment on the current PR head. - Exact command / steps: ran the plugin test suite and TypeScript typecheck after rebuilding the standalone bundle. - Observed result: all 17 tests passed, including project-header coverage for fetch and Node HTTPS paths plus the unset-project control; typechecking passed. - Not tested: a live OpenCode session against a deployed Headroom proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --------- Signed-off-by: Radhakrishnan P <gingeekrishna@gmail.com> Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
1 parent 2cae0f8 commit eeb038b

5 files changed

Lines changed: 114 additions & 27 deletions

File tree

headroom/providers/opencode/_dist/entry.opencode.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12487,6 +12487,7 @@ var childProcess = nodeRequire("node:child_process");
1248712487
var fs = nodeRequire("node:fs");
1248812488
var BASE_URL_HEADER = "x-headroom-base-url";
1248912489
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
12490+
var PROJECT_HEADER = "x-headroom-project";
1249012491
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
1249112492
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
1249212493
function getState() {
@@ -12635,7 +12636,7 @@ function requestUrl(input) {
1263512636
}
1263612637
return new URL(String(input));
1263712638
}
12638-
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
12639+
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
1263912640
const headers = new Headers(input instanceof Request ? input.headers : void 0);
1264012641
if (init?.headers) {
1264112642
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -12647,17 +12648,20 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
1264712648
if (originalPath) {
1264812649
headers.set(ORIGINAL_PATH_HEADER, originalPath);
1264912650
}
12651+
if (project) {
12652+
headers.set(PROJECT_HEADER, project);
12653+
}
1265012654
return headers;
1265112655
}
12652-
function withRoutedFetchInput(input, init, proxy) {
12656+
function withRoutedFetchInput(input, init, proxy, project) {
1265312657
const upstream = requestUrl(input);
1265412658
if (!shouldRoute(upstream, proxy)) {
1265512659
return [input, init];
1265612660
}
1265712661
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
1265812662
const nextInit = {
1265912663
...init,
12660-
headers: mergeFetchHeaders(input, init, upstream, originalPath)
12664+
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
1266112665
};
1266212666
if (input instanceof Request) {
1266312667
return [new Request(nextUrl, input), nextInit];
@@ -12703,20 +12707,23 @@ function urlFromRequestOptions(options) {
1270312707
return void 0;
1270412708
}
1270512709
}
12706-
function headersForNodeRequest(options, upstream, originalPath) {
12710+
function headersForNodeRequest(options, upstream, originalPath, project) {
1270712711
const headers = new Headers(options.headers);
1270812712
headers.set(BASE_URL_HEADER, upstream.origin);
1270912713
if (originalPath) {
1271012714
headers.set(ORIGINAL_PATH_HEADER, originalPath);
1271112715
}
12716+
if (project) {
12717+
headers.set(PROJECT_HEADER, project);
12718+
}
1271212719
headers.delete("host");
1271312720
const result = {};
1271412721
headers.forEach((value, key) => {
1271512722
result[key] = value;
1271612723
});
1271712724
return result;
1271812725
}
12719-
function routedNodeOptions(parts, proxy) {
12726+
function routedNodeOptions(parts, proxy, project) {
1272012727
if (!parts.url || !shouldRoute(parts.url, proxy)) {
1272112728
return void 0;
1272212729
}
@@ -12747,7 +12754,7 @@ function routedNodeOptions(parts, proxy) {
1274712754
hostname: nextUrl.hostname,
1274812755
port: nextUrl.port || void 0,
1274912756
path: `${nextUrl.pathname}${nextUrl.search}`,
12750-
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
12757+
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
1275112758
};
1275212759
}
1275312760
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -12758,7 +12765,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
1275812765
}
1275912766
const proxy = normalizeProxyUrl(state.proxyUrl);
1276012767
const parts = splitNodeArgs(args);
12761-
const nextOptions = routedNodeOptions(parts, proxy);
12768+
const nextOptions = routedNodeOptions(parts, proxy, state.project);
1276212769
if (!nextOptions) {
1276312770
return Reflect.apply(originalRequest, this, args);
1276412771
}
@@ -12794,13 +12801,15 @@ function installHeadroomTransport(options) {
1279412801
if (existing) {
1279512802
existing.refs += 1;
1279612803
existing.proxyUrl = options.proxyUrl;
12804+
existing.project = options.project;
1279712805
existing.debug = Boolean(options.debug);
1279812806
installProcessEnv(options.proxyUrl);
1279912807
return () => uninstallHeadroomTransport();
1280012808
}
1280112809
const state = {
1280212810
refs: 1,
1280312811
proxyUrl: options.proxyUrl,
12812+
project: options.project,
1280412813
debug: Boolean(options.debug),
1280512814
originalFetch: globalThis.fetch,
1280612815
originalHttpRequest: http.request,
@@ -12821,7 +12830,7 @@ function installHeadroomTransport(options) {
1282112830
return state.originalFetch(...args);
1282212831
}
1282312832
const proxy = normalizeProxyUrl(current.proxyUrl);
12824-
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
12833+
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
1282512834
return state.originalFetch(nextInput, nextInit);
1282612835
};
1282712836
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);
@@ -12871,9 +12880,11 @@ function resolveProxyUrl(options) {
1287112880
var HeadroomPlugin = async (input, options = {}) => {
1287212881
const pluginOptions = options;
1287312882
const proxyUrl = resolveProxyUrl(pluginOptions);
12883+
const project = pluginOptions.project ?? input.project?.id ?? input.directory;
1287412884
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
1287512885
const uninstallTransport = installHeadroomTransport({
1287612886
proxyUrl,
12887+
project,
1287712888
debug: pluginOptions.debug
1287812889
});
1287912890
return {
@@ -12894,7 +12905,7 @@ var HeadroomPlugin = async (input, options = {}) => {
1289412905
"shell.env": async (_input, output) => {
1289512906
output.env.HEADROOM_ACTIVE = "1";
1289612907
output.env.HEADROOM_PROXY_URL = proxyUrl;
12897-
output.env.HEADROOM_PROJECT = pluginOptions.project ?? input.project.id ?? input.directory;
12908+
output.env.HEADROOM_PROJECT = project;
1289812909
if (pluginOptions.backend) {
1289912910
output.env.HEADROOM_BACKEND = pluginOptions.backend;
1290012911
}

headroom/providers/opencode/hook-shim/handler.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var childProcess = nodeRequire("node:child_process");
88
var fs = nodeRequire("node:fs");
99
var BASE_URL_HEADER = "x-headroom-base-url";
1010
var ORIGINAL_PATH_HEADER = "x-headroom-original-path";
11+
var PROJECT_HEADER = "x-headroom-project";
1112
var PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
1213
var STATE_KEY = /* @__PURE__ */ Symbol.for("headroom.opencode.transport");
1314
function getState() {
@@ -156,7 +157,7 @@ function requestUrl(input) {
156157
}
157158
return new URL(String(input));
158159
}
159-
function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
160+
function mergeFetchHeaders(input, init, upstream, originalPath = void 0, project = void 0) {
160161
const headers = new Headers(input instanceof Request ? input.headers : void 0);
161162
if (init?.headers) {
162163
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
@@ -168,17 +169,20 @@ function mergeFetchHeaders(input, init, upstream, originalPath = void 0) {
168169
if (originalPath) {
169170
headers.set(ORIGINAL_PATH_HEADER, originalPath);
170171
}
172+
if (project) {
173+
headers.set(PROJECT_HEADER, project);
174+
}
171175
return headers;
172176
}
173-
function withRoutedFetchInput(input, init, proxy) {
177+
function withRoutedFetchInput(input, init, proxy, project) {
174178
const upstream = requestUrl(input);
175179
if (!shouldRoute(upstream, proxy)) {
176180
return [input, init];
177181
}
178182
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
179183
const nextInit = {
180184
...init,
181-
headers: mergeFetchHeaders(input, init, upstream, originalPath)
185+
headers: mergeFetchHeaders(input, init, upstream, originalPath, project)
182186
};
183187
if (input instanceof Request) {
184188
return [new Request(nextUrl, input), nextInit];
@@ -224,20 +228,23 @@ function urlFromRequestOptions(options) {
224228
return void 0;
225229
}
226230
}
227-
function headersForNodeRequest(options, upstream, originalPath) {
231+
function headersForNodeRequest(options, upstream, originalPath, project) {
228232
const headers = new Headers(options.headers);
229233
headers.set(BASE_URL_HEADER, upstream.origin);
230234
if (originalPath) {
231235
headers.set(ORIGINAL_PATH_HEADER, originalPath);
232236
}
237+
if (project) {
238+
headers.set(PROJECT_HEADER, project);
239+
}
233240
headers.delete("host");
234241
const result = {};
235242
headers.forEach((value, key) => {
236243
result[key] = value;
237244
});
238245
return result;
239246
}
240-
function routedNodeOptions(parts, proxy) {
247+
function routedNodeOptions(parts, proxy, project) {
241248
if (!parts.url || !shouldRoute(parts.url, proxy)) {
242249
return void 0;
243250
}
@@ -268,7 +275,7 @@ function routedNodeOptions(parts, proxy) {
268275
hostname: nextUrl.hostname,
269276
port: nextUrl.port || void 0,
270277
path: `${nextUrl.pathname}${nextUrl.search}`,
271-
headers: headersForNodeRequest(parts.options, parts.url, originalPath)
278+
headers: headersForNodeRequest(parts.options, parts.url, originalPath, project)
272279
};
273280
}
274281
function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest) {
@@ -279,7 +286,7 @@ function wrapRequest(originalHttpRequest, originalHttpsRequest, originalRequest)
279286
}
280287
const proxy = normalizeProxyUrl(state.proxyUrl);
281288
const parts = splitNodeArgs(args);
282-
const nextOptions = routedNodeOptions(parts, proxy);
289+
const nextOptions = routedNodeOptions(parts, proxy, state.project);
283290
if (!nextOptions) {
284291
return Reflect.apply(originalRequest, this, args);
285292
}
@@ -315,13 +322,15 @@ function installHeadroomTransport(options) {
315322
if (existing) {
316323
existing.refs += 1;
317324
existing.proxyUrl = options.proxyUrl;
325+
existing.project = options.project;
318326
existing.debug = Boolean(options.debug);
319327
installProcessEnv(options.proxyUrl);
320328
return () => uninstallHeadroomTransport();
321329
}
322330
const state = {
323331
refs: 1,
324332
proxyUrl: options.proxyUrl,
333+
project: options.project,
325334
debug: Boolean(options.debug),
326335
originalFetch: globalThis.fetch,
327336
originalHttpRequest: http.request,
@@ -342,7 +351,7 @@ function installHeadroomTransport(options) {
342351
return state.originalFetch(...args);
343352
}
344353
const proxy = normalizeProxyUrl(current.proxyUrl);
345-
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
354+
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy, current.project);
346355
return state.originalFetch(nextInput, nextInit);
347356
};
348357
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest);

plugins/opencode/src/plugin.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
2828
export const HeadroomPlugin: Plugin = async (input, options = {}) => {
2929
const pluginOptions = options as HeadroomOpenCodePluginOptions;
3030
const proxyUrl = resolveProxyUrl(pluginOptions);
31+
const project =
32+
pluginOptions.project ??
33+
(input.project as { id?: string } | undefined)?.id ??
34+
input.directory;
3135
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
3236
const uninstallTransport = installHeadroomTransport({
3337
proxyUrl,
38+
project,
3439
debug: pluginOptions.debug,
3540
});
3641

@@ -54,10 +59,7 @@ export const HeadroomPlugin: Plugin = async (input, options = {}) => {
5459
"shell.env": async (_input, output) => {
5560
output.env.HEADROOM_ACTIVE = "1";
5661
output.env.HEADROOM_PROXY_URL = proxyUrl;
57-
output.env.HEADROOM_PROJECT =
58-
pluginOptions.project ??
59-
(input.project as { id?: string }).id ??
60-
input.directory;
62+
output.env.HEADROOM_PROJECT = project;
6163
if (pluginOptions.backend) {
6264
output.env.HEADROOM_BACKEND = pluginOptions.backend;
6365
}

plugins/opencode/src/transport.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,58 @@ describe("Headroom OpenCode transport", () => {
386386
}
387387
});
388388

389+
it("sends x-headroom-project header on routed fetch calls when project is set", async () => {
390+
const originalFetch = globalThis.fetch;
391+
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
392+
globalThis.fetch = fetchMock as unknown as typeof fetch;
393+
394+
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1", project: "my-project" });
395+
396+
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
397+
398+
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
399+
expect(headers.get("x-headroom-project")).toBe("my-project");
400+
401+
globalThis.fetch = originalFetch;
402+
});
403+
404+
it("omits x-headroom-project header when project is not set", async () => {
405+
const originalFetch = globalThis.fetch;
406+
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
407+
globalThis.fetch = fetchMock as unknown as typeof fetch;
408+
409+
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
410+
411+
await fetch("https://api.anthropic.com/v1/messages", { method: "POST" });
412+
413+
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
414+
expect(headers.get("x-headroom-project")).toBeNull();
415+
416+
globalThis.fetch = originalFetch;
417+
});
418+
419+
it("sends x-headroom-project header on routed Node https.request calls when project is set", async () => {
420+
const proxy = await proxyServer();
421+
installHeadroomTransport({ proxyUrl: proxy.url, project: "my-project" });
422+
423+
await new Promise<void>((resolve, reject) => {
424+
const req = https.request(
425+
"https://api.anthropic.com/v1/messages",
426+
{ method: "POST" },
427+
(res) => {
428+
res.resume();
429+
res.on("end", resolve);
430+
},
431+
);
432+
req.on("error", reject);
433+
req.end("{}");
434+
});
435+
436+
expect(proxy.seen[0].headers["x-headroom-project"]).toBe("my-project");
437+
438+
await proxy.close();
439+
});
440+
389441
it("restores patched transports only after the final disposer", () => {
390442
const originalFetch = globalThis.fetch;
391443
const originalHttpRequest = http.request;

0 commit comments

Comments
 (0)