Caching Middleware

Learn how to create a caching middleware with Next.js and KV.

4 min readnextstreamingcachingmiddlewareView source

Let's create a simple chat interface that uses `LanguageModelMiddleware` to cache the assistant's responses in fast KV storage.

Run it locally

$ npm install ai

Client

Let's create a simple chat interface that allows users to send messages to the assistant and receive responses. You will integrate the useChat hook from @ai-toolkit/react to stream responses.

'use client';

import { useChat } from '@ai-toolkit/react';

export default function Chat() {

const { messages, input, handleInputChange, handleSubmit, error } = useChat();

if (error) return <div>{error.message}</div>;

return (

<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">

<div className="space-y-4">

{messages.map(m => (

<div key={m.id} className="whitespace-pre-wrap">

<div>

<div className="font-bold">{m.role}</div>

{m.toolInvocations ? (

<pre>{JSON.stringify(m.toolInvocations, null, 2)}</pre>

) : (

<p>{m.content}</p>

)}

</div>

</div>

))}

</div>

<form onSubmit={handleSubmit}>

<input

className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"

value={input}

placeholder="Say something..."

onChange={handleInputChange}

/>

</form>

</div>

);

}

app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3export default function Chat() {
  4. 4 const { messages, input, handleInputChange, handleSubmit, error } = useChat();
  5. 5 if (error) return <div>{error.message}</div>;
  6. 6 return (
  7. 7 <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
  8. 8 <div className="space-y-4">
  9. 9 {messages.map(m => (
  10. 10 <div key={m.id} className="whitespace-pre-wrap">
  11. 11 <div>
  12. 12 <div className="font-bold">{m.role}</div>
  13. 13 {m.toolInvocations ? (
  14. 14 <pre>{JSON.stringify(m.toolInvocations, null, 2)}</pre>
  15. 15 ) : (
  16. 16 <p>{m.content}</p>
  17. 17 )}
  18. 18 </div>
  19. 19 </div>
  20. 20 ))}
  21. 21 </div>
  22. 22 <form onSubmit={handleSubmit}>
  23. 23 <input
  24. 24 className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
  25. 25 value={input}
  26. 26 placeholder="Say something..."
  27. 27 onChange={handleInputChange}
  28. 28 />
  29. 29 </form>
  30. 30 </div>
  31. 31 );
  32. 32}

Middleware

Next, you will create a LanguageModelMiddleware that caches the assistant's responses in KV storage.

LanguageModelMiddleware has two methods: wrapGenerate and wrapStream.

wrapGenerate is called when using `generateText` and `generateObject`, while wrapStream is called when using `streamText` and `streamObject`.

For wrapGenerate, you can cache the response directly.

Instead, for wrapStream, you cache an array of the stream parts, which can then be used with `simulateReadableStream` function to create a simulated ReadableStream that returns the cached response.

In this way, the cached response is returned chunk-by-chunk as if it were being generated by the model.

You can control the initial delay and delay between chunks by adjusting the initialDelayInMs and chunkDelayInMs parameters of simulateReadableStream.

import { Redis } from '@upstash/redis';

import {

type LanguageModelV1,

type LanguageModelV3Middleware,

type LanguageModelV1StreamPart,

simulateReadableStream,

} from 'ai-toolkit';

const redis = new Redis({

url: process.env.KV_URL,

token: process.env.KV_TOKEN,

});

export const cacheMiddleware: LanguageModelV3Middleware = {

wrapGenerate: async ({ doGenerate, params }) => {

const cacheKey = JSON.stringify(params);

const cached = (await redis.get(cacheKey)) as Awaited<

ReturnType<LanguageModelV1['doGenerate']>

> | null;

if (cached !== null) {

return {

...cached,

response: {

...cached.response,

timestamp: cached?.response?.timestamp

? new Date(cached?.response?.timestamp)

: undefined,

},

};

}

const result = await doGenerate();

redis.set(cacheKey, result);

return result;

},

wrapStream: async ({ doStream, params }) => {

const cacheKey = JSON.stringify(params);

// Check if the result is in the cache

const cached = await redis.get(cacheKey);

// If cached, return a simulated ReadableStream that yields the cached result

if (cached !== null) {

// Format the timestamps in the cached response

const formattedChunks = (cached as LanguageModelV1StreamPart[]).map(p => {

if (p.type === 'response-metadata' && p.timestamp) {

return { ...p, timestamp: new Date(p.timestamp) };

} else return p;

});

return {

stream: simulateReadableStream({

initialDelayInMs: 0,

chunkDelayInMs: 10,

chunks: formattedChunks,

}),

};

}

// If not cached, proceed with streaming

const { stream, ...rest } = await doStream();

const fullResponse: LanguageModelV1StreamPart[] = [];

const transformStream = new TransformStream<

LanguageModelV1StreamPart,

LanguageModelV1StreamPart

>({

transform(chunk, controller) {

fullResponse.push(chunk);

controller.enqueue(chunk);

},

flush() {

// Store the full response in the cache after streaming is complete

redis.set(cacheKey, fullResponse);

},

});

return {

stream: stream.pipeThrough(transformStream),

...rest,

};

},

};

<Note>

This example uses @upstash/redis to store and retrieve the assistant's

responses but you can use any KV storage provider you would like.

</Note>

ai/middleware.ts
tsx
  1. 1import { Redis } from '@upstash/redis';
  2. 2import {
  3. 3 type LanguageModelV1,
  4. 4 type LanguageModelV3Middleware,
  5. 5 type LanguageModelV1StreamPart,
  6. 6 simulateReadableStream,
  7. 7} from 'ai-toolkit';
  8. 8const redis = new Redis({
  9. 9 url: process.env.KV_URL,
  10. 10 token: process.env.KV_TOKEN,
  11. 11});
  12. 12export const cacheMiddleware: LanguageModelV3Middleware = {
  13. 13 wrapGenerate: async ({ doGenerate, params }) => {
  14. 14 const cacheKey = JSON.stringify(params);
  15. 15 const cached = (await redis.get(cacheKey)) as Awaited<
  16. 16 ReturnType<LanguageModelV1['doGenerate']>
  17. 17 > | null;
  18. 18 if (cached !== null) {
  19. 19 return {
  20. 20 ...cached,
  21. 21 response: {
  22. 22 ...cached.response,
  23. 23 timestamp: cached?.response?.timestamp
  24. 24 ? new Date(cached?.response?.timestamp)
  25. 25 : undefined,
  26. 26 },
  27. 27 };
  28. 28 }
  29. 29 const result = await doGenerate();
  30. 30 redis.set(cacheKey, result);
  31. 31 return result;
  32. 32 },
  33. 33 wrapStream: async ({ doStream, params }) => {
  34. 34 const cacheKey = JSON.stringify(params);
  35. 35 // Check if the result is in the cache
  36. 36 const cached = await redis.get(cacheKey);
  37. 37 // If cached, return a simulated ReadableStream that yields the cached result
  38. 38 if (cached !== null) {
  39. 39 // Format the timestamps in the cached response
  40. 40 const formattedChunks = (cached as LanguageModelV1StreamPart[]).map(p => {
  41. 41 if (p.type === 'response-metadata' && p.timestamp) {
  42. 42 return { ...p, timestamp: new Date(p.timestamp) };
  43. 43 } else return p;
  44. 44 });
  45. 45 return {
  46. 46 stream: simulateReadableStream({
  47. 47 initialDelayInMs: 0,
  48. 48 chunkDelayInMs: 10,
  49. 49 chunks: formattedChunks,
  50. 50 }),
  51. 51 };
  52. 52 }
  53. 53 // If not cached, proceed with streaming
  54. 54 const { stream, ...rest } = await doStream();
  55. 55 const fullResponse: LanguageModelV1StreamPart[] = [];
  56. 56 const transformStream = new TransformStream<
  57. 57 LanguageModelV1StreamPart,
  58. 58 LanguageModelV1StreamPart
  59. 59 >({
  60. 60 transform(chunk, controller) {
  61. 61 fullResponse.push(chunk);
  62. 62 controller.enqueue(chunk);
  63. 63 },
  64. 64 flush() {
  65. 65 // Store the full response in the cache after streaming is complete
  66. 66 redis.set(cacheKey, fullResponse);
  67. 67 },
  68. 68 });
  69. 69 return {
  70. 70 stream: stream.pipeThrough(transformStream),
  71. 71 ...rest,
  72. 72 };
  73. 73 },
  74. 74};

Server

Finally, you will create an API route for api/chat to handle the assistant's messages and responses. You can use your cache middleware by wrapping the model with wrapLanguageModel and passing the middleware as an argument.

import { cacheMiddleware } from '@/ai/middleware';

import { wrapLanguageModel, streamText, tool } from 'ai-toolkit';

import { z } from 'zod';

const wrappedModel = wrapLanguageModel({

model: 'openai/gpt-4o-mini',

middleware: cacheMiddleware,

});

export async function POST(req: Request) {

const { messages } = await req.json();

const result = streamText({

model: wrappedModel,

messages,

tools: {

weather: tool({

description: 'Get the weather in a location',

inputSchema: z.object({

location: z.string().describe('The location to get the weather for'),

}),

execute: async ({ location }) => ({

location,

temperature: 72 + Math.floor(Math.random() * 21) - 10,

}),

}),

},

});

return result.toUIMessageStreamResponse();

}

app/api/chat/route.ts
tsx
  1. 1import { cacheMiddleware } from '@/ai/middleware';
  2. 2import { wrapLanguageModel, streamText, tool } from 'ai-toolkit';
  3. 3import { z } from 'zod';
  4. 4const wrappedModel = wrapLanguageModel({
  5. 5 model: 'openai/gpt-4o-mini',
  6. 6 middleware: cacheMiddleware,
  7. 7});
  8. 8export async function POST(req: Request) {
  9. 9 const { messages } = await req.json();
  10. 10 const result = streamText({
  11. 11 model: wrappedModel,
  12. 12 messages,
  13. 13 tools: {
  14. 14 weather: tool({
  15. 15 description: 'Get the weather in a location',
  16. 16 inputSchema: z.object({
  17. 17 location: z.string().describe('The location to get the weather for'),
  18. 18 }),
  19. 19 execute: async ({ location }) => ({
  20. 20 location,
  21. 21 temperature: 72 + Math.floor(Math.random() * 21) - 10,
  22. 22 }),
  23. 23 }),
  24. 24 },
  25. 25 });
  26. 26 return result.toUIMessageStreamResponse();
  27. 27}