diff --git a/changelog.d/features/9570-plugin-context-headers.md b/changelog.d/features/9570-plugin-context-headers.md new file mode 100644 index 0000000000..cecc440501 --- /dev/null +++ b/changelog.d/features/9570-plugin-context-headers.md @@ -0,0 +1 @@ +- **feat(plugins):** expose client request headers in plugin onRequest/onResponse context (#9570) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 26120abce3..760eebb6dd 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 }, }); diff --git a/open-sse/handlers/chatCore/pluginOnRequest.ts b/open-sse/handlers/chatCore/pluginOnRequest.ts index 170c276c5e..a4737d53af 100644 --- a/open-sse/handlers/chatCore/pluginOnRequest.ts +++ b/open-sse/handlers/chatCore/pluginOnRequest.ts @@ -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; log?: LoggerLike; }): Promise { 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); diff --git a/open-sse/handlers/chatCore/pluginOnResponse.ts b/open-sse/handlers/chatCore/pluginOnResponse.ts index 1d74ca2989..63055e2e74 100644 --- a/open-sse/handlers/chatCore/pluginOnResponse.ts +++ b/open-sse/handlers/chatCore/pluginOnResponse.ts @@ -24,6 +24,7 @@ export async function runPluginOnResponseHook(args: { model: string | null | undefined; provider: string | null | undefined; apiKeyInfo: unknown; + headers?: Record; response: PluginOnResponsePayload; }): Promise { 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 diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index 2ad5345308..1c46523db4 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -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; onDeactivate?: (payload: unknown) => Promise | void; onUninstall?: (payload: unknown) => Promise | void; + onStreamComplete?: (payload: PluginOnStreamCompletePayload) => Promise | 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 { + await emitHook("onStreamComplete", payload); } /** diff --git a/tests/unit/chatcore-plugin-onrequest.test.ts b/tests/unit/chatcore-plugin-onrequest.test.ts index ed343e006a..52fa16bfad 100644 --- a/tests/unit/chatcore-plugin-onrequest.test.ts +++ b/tests/unit/chatcore-plugin-onrequest.test.ts @@ -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 | undefined; + registerHook("onRequest", "test-ctx-headers", async (ctx: Record) => { + 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 | undefined; + registerHook("onRequest", "test-ctx-noheaders", async (ctx: Record) => { + 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,