Skip to content

Commit 244f1de

Browse files
authored
feat(coding-agent): add before_provider_headers extension hook (#6350)
* feat(coding-agent): add before_provider_headers extension hook Extensions can already rewrite the request payload through before_provider_request, but there is no way to adjust the outgoing HTTP headers of a provider call. This hook fills that gap for cases like request tracing, session correlation, or tenant routing. Handlers mutate the headers map in place - a null value deletes a header - and the return value is ignored, so a handler cannot accidentally drop auth or attribution headers by forgetting to spread. * docs(coding-agent): document before_provider_headers extension hook Add the before_provider_headers section and lifecycle-diagram entry to extensions.md; drop the now-superseded proposal file.
1 parent cfaa52e commit 244f1de

7 files changed

Lines changed: 136 additions & 8 deletions

File tree

packages/coding-agent/docs/extensions.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ user sends prompt ────────────────────
294294
│ │ │ │
295295
│ ├─► turn_start │ │
296296
│ ├─► context (can modify messages) │ │
297+
│ ├─► before_provider_headers (can mutate headers) |
297298
│ ├─► before_provider_request (can inspect or replace payload)
298299
│ ├─► after_provider_response (status + headers, before stream consume)
299300
│ │ │ │
@@ -643,6 +644,24 @@ pi.on("context", async (event, ctx) => {
643644
});
644645
```
645646

647+
#### before_provider_headers
648+
649+
Fired after the outgoing HTTP headers are assembled. Use it to add, override, or remove request headers.
650+
651+
Handlers mutate `event.headers` in place. Set a key to a string to add or override it, or to `null` to delete it.
652+
653+
```typescript
654+
pi.on("before_provider_headers", (event, ctx) => {
655+
// Add or override — e.g. a session id for gateway tracing/attribution
656+
event.headers["x-session-id"] = ctx.sessionManager.getSessionId();
657+
658+
// Drop a tracking header pi adds for this call
659+
event.headers["X-OpenRouter-Title"] = null;
660+
});
661+
```
662+
663+
Runs once per provider request; retries reuse the same headers rather than re-firing the hook.
664+
646665
#### before_provider_request
647666

648667
Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request.

packages/coding-agent/src/core/extensions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type {
3535
BashToolResultEvent,
3636
BeforeAgentStartEvent,
3737
BeforeAgentStartEventResult,
38+
BeforeProviderHeadersEvent,
3839
BeforeProviderRequestEvent,
3940
BeforeProviderRequestEventResult,
4041
BuildSystemPromptOptions,

packages/coding-agent/src/core/extensions/runner.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import type { AgentMessage } from "@earendil-works/pi-agent-core";
6-
import type { ImageContent, Model } from "@earendil-works/pi-ai";
6+
import type { ImageContent, Model, ProviderHeaders } from "@earendil-works/pi-ai";
77
import type { KeyId } from "@earendil-works/pi-tui";
88
import { type Theme, theme } from "../../modes/interactive/theme/theme.ts";
99
import type { ResourceDiagnostic } from "../diagnostics.ts";
@@ -14,6 +14,7 @@ import type { BuildSystemPromptOptions } from "../system-prompt.ts";
1414
import type {
1515
BeforeAgentStartEvent,
1616
BeforeAgentStartEventResult,
17+
BeforeProviderHeadersEvent,
1718
BeforeProviderRequestEvent,
1819
CompactOptions,
1920
ContextEvent,
@@ -145,6 +146,7 @@ type RunnerEmitEvent = Exclude<
145146
| UserBashEvent
146147
| ContextEvent
147148
| BeforeProviderRequestEvent
149+
| BeforeProviderHeadersEvent
148150
| BeforeAgentStartEvent
149151
| MessageEndEvent
150152
| ResourcesDiscoverEvent
@@ -1013,6 +1015,37 @@ export class ExtensionRunner {
10131015
return currentPayload;
10141016
}
10151017

1018+
async emitBeforeProviderHeaders(headers: ProviderHeaders): Promise<ProviderHeaders> {
1019+
const ctx = this.createContext();
1020+
1021+
for (const ext of this.extensions) {
1022+
const handlers = ext.handlers.get("before_provider_headers");
1023+
if (!handlers || handlers.length === 0) continue;
1024+
1025+
for (const handler of handlers) {
1026+
try {
1027+
// Handlers mutate `headers` in place; the return value is ignored.
1028+
const event: BeforeProviderHeadersEvent = {
1029+
type: "before_provider_headers",
1030+
headers,
1031+
};
1032+
await handler(event, ctx);
1033+
} catch (err) {
1034+
const message = err instanceof Error ? err.message : String(err);
1035+
const stack = err instanceof Error ? err.stack : undefined;
1036+
this.emitError({
1037+
extensionPath: ext.path,
1038+
event: "before_provider_headers",
1039+
error: message,
1040+
stack,
1041+
});
1042+
}
1043+
}
1044+
}
1045+
1046+
return headers;
1047+
}
1048+
10161049
async emitBeforeAgentStart(
10171050
prompt: string,
10181051
images: ImageContent[] | undefined,

packages/coding-agent/src/core/extensions/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
Model,
2525
OAuthCredentials,
2626
OAuthLoginCallbacks,
27+
ProviderHeaders,
2728
SimpleStreamOptions,
2829
TextContent,
2930
ToolResultMessage,
@@ -663,6 +664,16 @@ export interface BeforeProviderRequestEvent {
663664
payload: unknown;
664665
}
665666

667+
/**
668+
* Fired after request headers are assembled, before the provider HTTP call.
669+
* Handlers mutate `headers` in place (e.g. to inject tracing/session headers);
670+
* the return value is ignored. A `null` value deletes that header.
671+
*/
672+
export interface BeforeProviderHeadersEvent {
673+
type: "before_provider_headers";
674+
headers: ProviderHeaders;
675+
}
676+
666677
/** Fired after a provider response is received and before the response stream is consumed. */
667678
export interface AfterProviderResponseEvent {
668679
type: "after_provider_response";
@@ -1005,6 +1016,7 @@ export type ExtensionEvent =
10051016
| SessionEvent
10061017
| ContextEvent
10071018
| BeforeProviderRequestEvent
1019+
| BeforeProviderHeadersEvent
10081020
| AfterProviderResponseEvent
10091021
| BeforeAgentStartEvent
10101022
| AgentStartEvent
@@ -1171,6 +1183,7 @@ export interface ExtensionAPI {
11711183
event: "before_provider_request",
11721184
handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,
11731185
): void;
1186+
on(event: "before_provider_headers", handler: ExtensionHandler<BeforeProviderHeadersEvent>): void;
11741187
on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>): void;
11751188
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
11761189
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;

packages/coding-agent/src/core/sdk.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
313313
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
314314
const websocketConnectTimeoutMs =
315315
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
316+
let headers = mergeProviderAttributionHeaders(
317+
model,
318+
settingsManager,
319+
options?.sessionId,
320+
auth.headers,
321+
options?.headers,
322+
);
323+
// Let extensions inject/adjust per-request headers (e.g. tracing, session correlation)
324+
// after static assembly, before the provider HTTP call.
325+
const headerRunner = extensionRunnerRef.current;
326+
if (headerRunner?.hasHandlers("before_provider_headers")) {
327+
headers = await headerRunner.emitBeforeProviderHeaders(headers ?? {});
328+
}
316329
return streamSimple(model, context, {
317330
...options,
318331
apiKey: auth.apiKey,
@@ -321,13 +334,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
321334
websocketConnectTimeoutMs,
322335
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
323336
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
324-
headers: mergeProviderAttributionHeaders(
325-
model,
326-
settingsManager,
327-
options?.sessionId,
328-
auth.headers,
329-
options?.headers,
330-
),
337+
headers,
331338
});
332339
},
333340
onPayload: async (payload, _model) => {

packages/coding-agent/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export type {
6969
BashToolCallEvent,
7070
BeforeAgentStartEvent,
7171
BeforeAgentStartEventResult,
72+
BeforeProviderHeadersEvent,
7273
BeforeProviderRequestEvent,
7374
BeforeProviderRequestEventResult,
7475
BuildSystemPromptOptions,

packages/coding-agent/test/extensions-runner.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,4 +906,58 @@ describe("ExtensionRunner", () => {
906906
expect(runner.hasHandlers("agent_end")).toBe(false);
907907
});
908908
});
909+
910+
describe("before_provider_headers", () => {
911+
it("lets a handler mutate headers in place and preserves existing headers", async () => {
912+
const extCode = `
913+
export default function(pi) {
914+
pi.on("before_provider_headers", (event) => {
915+
event.headers["X-Turn-Index"] = "3";
916+
});
917+
}
918+
`;
919+
fs.writeFileSync(path.join(extensionsDir, "headers.ts"), extCode);
920+
921+
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
922+
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
923+
924+
expect(runner.hasHandlers("before_provider_headers")).toBe(true);
925+
926+
const headers = await runner.emitBeforeProviderHeaders({ "User-Agent": "kimchi/1.0" });
927+
expect(headers["X-Turn-Index"]).toBe("3");
928+
expect(headers["User-Agent"]).toBe("kimchi/1.0");
929+
});
930+
931+
it("isolates a throwing handler and still applies the others", async () => {
932+
const throwing = `
933+
export default function(pi) {
934+
pi.on("before_provider_headers", () => {
935+
throw new Error("header handler boom");
936+
});
937+
}
938+
`;
939+
const good = `
940+
export default function(pi) {
941+
pi.on("before_provider_headers", (event) => {
942+
event.headers["X-Good"] = "yes";
943+
});
944+
}
945+
`;
946+
fs.writeFileSync(path.join(extensionsDir, "a-throwing.ts"), throwing);
947+
fs.writeFileSync(path.join(extensionsDir, "b-good.ts"), good);
948+
949+
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
950+
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
951+
const errors: Array<{ event: string; error: string }> = [];
952+
runner.onError((err) => errors.push(err));
953+
954+
const headers = await runner.emitBeforeProviderHeaders({ "User-Agent": "x" });
955+
956+
expect(headers["X-Good"]).toBe("yes");
957+
expect(headers["User-Agent"]).toBe("x");
958+
expect(errors).toHaveLength(1);
959+
expect(errors[0].event).toBe("before_provider_headers");
960+
expect(errors[0].error).toContain("header handler boom");
961+
});
962+
});
909963
});

0 commit comments

Comments
 (0)