docs/guides/middleware.md
This document guides developers on how to create and integrate custom middleware for our AI Provider framework. Middleware provides a powerful and flexible way to enhance, modify, or observe Provider method invocations — for example, logging, caching, request/response transformation, and error handling.
Our middleware architecture draws from Redux's three-layer design, combined with JavaScript Proxy to dynamically apply middleware to Provider methods.
Two main types of middleware are currently supported, sharing a similar structure but targeting different scenarios:
CompletionsMiddleware: Designed specifically for the completions method. This is the most commonly used middleware type, as it allows fine-grained control over the core chat/text generation functionality of AI models.ProviderMethodMiddleware: A generic middleware that can be applied to any other method on a Provider (e.g., translate, summarize, if those methods are also wrapped through the middleware system).CompletionsMiddlewareThe basic signature (TypeScript type) for CompletionsMiddleware is:
import { AiProviderMiddlewareCompletionsContext, CompletionsParams, MiddlewareAPI } from './AiProviderMiddlewareTypes'
export type CompletionsMiddleware = (
api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>
) => (
next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>
) => (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<void>
Let's break down this three-layer structure:
First layer (api) => { ... }:
api object.api provides the following methods:
api.getContext(): Get the current invocation context (AiProviderMiddlewareCompletionsContext).api.getOriginalArgs(): Get the original arguments array passed to the completions method (i.e., [CompletionsParams]).api.getProviderId(): Get the current Provider's ID.api.getProviderInstance(): Get the original Provider instance.Second layer (next) => { ... }:
next function.next represents the next link in the middleware chain. Calling next(context, params) passes control to the next middleware, or if the current middleware is the last in the chain, it invokes the core Provider method logic (e.g., the actual SDK call).next receives the current context and params (which may have been modified by upstream middleware).next is typically Promise<any>. For the completions method, if next invokes the actual SDK, it returns the raw SDK response (e.g., an OpenAI stream object or JSON object). You need to handle this response.Third layer (context, params) => { ... }:
context (AiProviderMiddlewareCompletionsContext) and params (CompletionsParams).next:
params. E.g., add default parameters, transform message format.context. E.g., set a timestamp for later latency calculation.next and return or throw an error (e.g., parameter validation failure).await next(context, params):
next is the raw SDK response or downstream middleware result; handle it accordingly (e.g., if it's a stream, start consuming it).next:
next. E.g., if next returned a stream, iterate over it and send data chunks via context.onChunk.context changes or next results. E.g., calculate total elapsed time, record logs.import {
AiProviderMiddlewareCompletionsContext,
CompletionsParams,
MiddlewareAPI,
} from './AiProviderMiddlewareTypes'
import { ChunkType } from '@renderer/types'
export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => {
return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => {
return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => {
return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => {
const startTime = Date.now()
const onChunk = context.onChunk
logger.debug(
`[LoggingMiddleware] Request for ${context.methodName} with params:`,
params.messages?.[params.messages.length - 1]?.content
)
try {
const rawSdkResponse = await next(context, params)
const duration = Date.now() - startTime
logger.debug(`[LoggingMiddleware] Request for ${context.methodName} completed in ${duration}ms.`)
} catch (error) {
const duration = Date.now() - startTime
logger.error(`[LoggingMiddleware] Request for ${context.methodName} failed after ${duration}ms:`, error)
if (onChunk) {
onChunk({
type: ChunkType.ERROR,
error: { message: (error as Error).message, name: (error as Error).name, stack: (error as Error).stack }
})
onChunk({ type: ChunkType.BLOCK_COMPLETE, response: {} })
}
throw error
}
}
}
}
}
AiProviderMiddlewareCompletionsContext ImportanceAiProviderMiddlewareCompletionsContext is the core object for passing state and data between middleware. It typically contains:
methodName: The current method name (always 'completions').originalArgs: The original arguments array passed to completions.providerId: The Provider's ID._providerInstance: The Provider instance.onChunk: The callback from the original CompletionsParams for streaming data chunks. All middleware should send data through context.onChunk.messages, model, assistant, mcpTools: Common fields extracted from CompletionsParams for convenient access.context.cacheHit = true.Key: When you modify params or context in middleware, these modifications propagate to downstream middleware (if made before the next call).
The execution order of middleware is critical. They execute in the order defined in the AiProviderMiddlewareConfig array.
next call results) "bubble" back in reverse order.For example, if the chain is [AuthMiddleware, CacheMiddleware, LoggingMiddleware]:
AuthMiddleware executes its "before next" logic.CacheMiddleware executes its "before next" logic.LoggingMiddleware executes its "before next" logic.LoggingMiddleware receives the result first, executing its "after next" logic.CacheMiddleware receives the result, executing its "after next" logic (e.g., storing the result).AuthMiddleware receives the result, executing its "after next" logic.Middleware is registered in src/renderer/providers/middleware/register.ts (or a similar configuration file).
// register.ts
import { AiProviderMiddlewareConfig } from './AiProviderMiddlewareTypes'
import { createSimpleLoggingMiddleware } from './common/SimpleLoggingMiddleware'
import { createCompletionsLoggingMiddleware } from './common/CompletionsLoggingMiddleware'
const middlewareConfig: AiProviderMiddlewareConfig = {
completions: [
createSimpleLoggingMiddleware(),
createCompletionsLoggingMiddleware()
// ... other completions middleware
],
methods: {
// translate: [createGenericLoggingMiddleware()],
// ... middleware for other methods
}
}
export default middlewareConfig
context or onChunk, avoid modifying global state or producing hidden side effects.try...catch within middleware to handle potential errors.onChunk) or re-throw them upstream.context carefully. Avoid polluting the context or adding overly large objects.context.next:
await next(context, params). Otherwise, downstream middleware and core logic will not execute.next and handle it correctly, especially when it's a stream. You are responsible for consuming the stream or passing it to another component/middleware that can consume it.logger.debug or a debugger at key points in your middleware to inspect params, context state, and next return values.