feat(plugins): expose client request headers in plugin context (#9570)

This commit is contained in:
diegosouzapw
2026-08-06 21:23:55 -03:00
parent 5f471181fa
commit e6f0ab56d2
6 changed files with 67 additions and 8 deletions

View File

@@ -0,0 +1 @@
- **feat(plugins):** expose client request headers in plugin onRequest/onResponse context (#9570)

View File

@@ -490,6 +490,7 @@ export async function handleChatCore({
model,
provider,
apiKeyInfo,
headers: clientRawRequest?.headers,
log,
});
if (pluginGate.blocked) {
@@ -4608,6 +4609,7 @@ export async function handleChatCore({
model,
provider,
apiKeyInfo,
headers: clientRawRequest?.headers,
response: { status: 200, data: translatedResponse },
});
@@ -4998,6 +5000,7 @@ export async function handleChatCore({
model,
provider,
apiKeyInfo,
headers: clientRawRequest?.headers,
response: { status: 200, streamed: true },
});

View File

@@ -10,13 +10,10 @@
*/
type LoggerLike =
| { info?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void }
| null
| undefined;
{ info?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void } | null | undefined;
export type PluginOnRequestGate =
| { blocked: true; response: Response }
| { blocked: false; body?: unknown };
{ blocked: true; response: Response } | { blocked: false; body?: unknown };
const JSON_HEADERS = { status: 403, headers: { "Content-Type": "application/json" } } as const;
@@ -26,6 +23,7 @@ export async function runPluginOnRequestHook(args: {
model: string | null | undefined;
provider: string | null | undefined;
apiKeyInfo: unknown;
headers?: Record<string, string | string[] | undefined>;
log?: LoggerLike;
}): Promise<PluginOnRequestGate> {
try {
@@ -36,6 +34,7 @@ export async function runPluginOnRequestHook(args: {
model: args.model,
provider: args.provider,
apiKeyInfo: args.apiKeyInfo,
headers: args.headers,
metadata: {},
};
const pluginResult = await runOnRequest(pluginCtx);

View File

@@ -24,6 +24,7 @@ export async function runPluginOnResponseHook(args: {
model: string | null | undefined;
provider: string | null | undefined;
apiKeyInfo: unknown;
headers?: Record<string, string | string[] | undefined>;
response: PluginOnResponsePayload;
}): Promise<void> {
try {
@@ -35,6 +36,7 @@ export async function runPluginOnResponseHook(args: {
model: args.model,
provider: args.provider,
apiKeyInfo: args.apiKeyInfo,
headers: args.headers,
metadata: {},
},
args.response

View File

@@ -40,6 +40,7 @@ export const BUILTIN_EVENTS = [
"onActivate",
"onDeactivate",
"onUninstall",
"onStreamComplete",
] as const;
export type BuiltinEvent = (typeof BUILTIN_EVENTS)[number];
@@ -251,6 +252,35 @@ export interface Plugin {
onActivate?: (payload: unknown) => Promise<void> | void;
onDeactivate?: (payload: unknown) => Promise<void> | void;
onUninstall?: (payload: unknown) => Promise<void> | void;
onStreamComplete?: (payload: PluginOnStreamCompletePayload) => Promise<void> | void;
}
// ── onStreamComplete event types ──
export type PluginOnStreamCompletePayload = {
status: number;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
reasoning_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
timing?: {
latencyMs: number;
ttft?: number;
};
model?: string;
provider?: string;
errorCode?: string;
};
/**
* Run onStreamComplete hooks — fire-and-forget notification with usage/timing data.
* Called when an SSE stream is fully consumed and usage/timing data is available.
*/
export async function runOnStreamComplete(payload: PluginOnStreamCompletePayload): Promise<void> {
await emitHook("onStreamComplete", payload);
}
/**

View File

@@ -6,9 +6,8 @@ import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts");
const { runPluginOnRequestHook } = await import(
"../../open-sse/handlers/chatCore/pluginOnRequest.ts"
);
const { runPluginOnRequestHook } =
await import("../../open-sse/handlers/chatCore/pluginOnRequest.ts");
const PLUGIN = "test-onrequest-plugin";
@@ -32,6 +31,31 @@ test("no registered hooks → pass-through (blocked:false, no body)", async () =
assert.equal(gate.blocked, false);
});
test("headers passed to the hook are visible in PluginContext", async () => {
let capturedCtx: Record<string, unknown> | undefined;
registerHook("onRequest", "test-ctx-headers", async (ctx: Record<string, unknown>) => {
capturedCtx = ctx;
return {};
});
const testHeaders = { "x-trace-id": "abc-123", "x-request-id": "req-456" };
const gate = await runPluginOnRequestHook(baseArgs({ headers: testHeaders }));
assert.equal(gate.blocked, false);
assert.ok(capturedCtx, "expected the hook to be invoked");
assert.deepEqual(capturedCtx!.headers, testHeaders);
});
test("no headers arg → backward compatible (undefined in ctx)", async () => {
let capturedCtx: Record<string, unknown> | undefined;
registerHook("onRequest", "test-ctx-noheaders", async (ctx: Record<string, unknown>) => {
capturedCtx = ctx;
return {};
});
const gate = await runPluginOnRequestHook(baseArgs());
assert.equal(gate.blocked, false);
assert.ok(capturedCtx, "expected the hook to be invoked");
assert.equal(capturedCtx!.headers, undefined);
});
test("a blocking hook → blocked:true with a 403 JSON Response", async () => {
registerHook("onRequest", PLUGIN, async () => ({
blocked: true,