Local Caching Middleware

Learn how to create a caching middleware for local development.

5 min readstreamingcachingmiddlewareView source

When developing AI applications, you'll often find yourself repeatedly making the same API calls during development. This can lead to increased costs and slower development cycles. A caching middleware allows you to store responses locally and reuse them when the same inputs are provided. This approach is particularly useful in two scenarios: 1. Iterating on UI/UX - When you're focused on styling and user experience, you don't want to regenerate AI responses for every code change. 2. Working on evals - When developing evals, you need to repeatedly test the same prompts, but don't need new generations each time.

Run it locally

$ npm install ai

Implementation

In this implementation, you create a JSON file to store responses. When a request is made, you first check if you have already seen this exact request. If you have, you return the cached response immediately (as a one-off generation or chunks of tokens). If not, you trigger the generation, save the response, and return it.

<Note>

Make sure to add the path of your local cache to your .gitignore so you do

not commit it.

</Note>

### How it works

For regular generations, you store and retrieve complete responses. Instead, the streaming implementation captures each token as it arrives, stores the full sequence, and on cache hits uses the SDK's simulateReadableStream utility to recreate the token-by-token streaming experience at a controlled speed (defaults to 10ms between chunks).

This approach gives you the best of both worlds:

- Instant responses for repeated queries

- Preserved streaming behavior for UI development

The middleware handles all transformations needed to make cached responses indistinguishable from fresh ones, including normalizing tool calls and fixing timestamp formats.

### Middleware

import {

type LanguageModelV3Middleware,

type LanguageModelV3StreamPart,

type LanguageModelV3CallOptions,

type LanguageModelV3,

} from '@ai-toolkit/provider';

import 'dotenv/config';

import fs from 'fs';

import path from 'path';

import { wrapLanguageModel, simulateReadableStream } from 'ai-toolkit';

const CACHE_FILE = path.join(process.cwd(), '.cache/ai-cache.json');

export const cached = (model: LanguageModelV3) =>

wrapLanguageModel({

middleware: cacheMiddleware,

model,

});

const ensureCacheFile = () => {

const cacheDir = path.dirname(CACHE_FILE);

if (!fs.existsSync(cacheDir)) {

fs.mkdirSync(cacheDir, { recursive: true });

}

if (!fs.existsSync(CACHE_FILE)) {

fs.writeFileSync(CACHE_FILE, '{}');

}

};

const getCachedResult = (key: string | object) => {

ensureCacheFile();

const cacheKey = typeof key === 'object' ? JSON.stringify(key) : key;

try {

const cacheContent = fs.readFileSync(CACHE_FILE, 'utf-8');

const cache = JSON.parse(cacheContent);

const result = cache[cacheKey];

return result ?? null;

} catch (error) {

console.error('Cache error:', error);

return null;

}

};

const updateCache = (key: string, value: any) => {

ensureCacheFile();

try {

const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));

const updatedCache = { ...cache, [key]: value };

fs.writeFileSync(CACHE_FILE, JSON.stringify(updatedCache, null, 2));

} catch (error) {

console.error('Failed to update cache:', error);

}

};

const cleanPrompt = (prompt: LanguageModelV3CallOptions['prompt']) => {

return prompt.map(m => {

if (m.role === 'assistant') {

return {

...m,

content: m.content.map(part =>

part.type === 'tool-call' ? { ...part, toolCallId: 'cached' } : part,

),

};

}

if (m.role === 'tool') {

return {

...m,

content: m.content.map(tc => ({

...tc,

toolCallId: 'cached',

result: {},

})),

};

}

return m;

});

};

export const cacheMiddleware: LanguageModelV3Middleware = {

specificationVersion: 'v3',

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

const cacheKey = JSON.stringify({

prompt: cleanPrompt(params.prompt),

_function: 'generate',

model: model.modelId,

});

const cached = getCachedResult(cacheKey);

if (cached && cached !== null) {

return {

...cached,

response: {

...cached.response,

timestamp: cached?.response?.timestamp

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

: undefined,

},

};

}

const result = await doGenerate();

updateCache(cacheKey, result);

return result;

},

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

const cacheKey = JSON.stringify({

prompt: cleanPrompt(params.prompt),

_function: 'stream',

model: model.modelId,

});

const cached = getCachedResult(cacheKey);

if (cached && cached !== null) {

const { chunks, ...rest } = cached;

const formattedChunks = (chunks as LanguageModelV3StreamPart[]).map(p => {

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

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

}

return p;

});

return {

stream: simulateReadableStream({

initialDelayInMs: 0,

chunkDelayInMs: 10,

chunks: formattedChunks,

}),

...rest,

};

}

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

const fullResponse: LanguageModelV3StreamPart[] = [];

const transformStream = new TransformStream<

LanguageModelV3StreamPart,

LanguageModelV3StreamPart

>({

transform(chunk, controller) {

fullResponse.push(chunk);

controller.enqueue(chunk);

},

flush() {

updateCache(cacheKey, { chunks: fullResponse, ...rest });

},

});

return {

stream: stream.pipeThrough(transformStream),

...rest,

};

},

};

ts
  1. 1import {
  2. 2 type LanguageModelV3Middleware,
  3. 3 type LanguageModelV3StreamPart,
  4. 4 type LanguageModelV3CallOptions,
  5. 5 type LanguageModelV3,
  6. 6} from '@ai-toolkit/provider';
  7. 7import 'dotenv/config';
  8. 8import fs from 'fs';
  9. 9import path from 'path';
  10. 10import { wrapLanguageModel, simulateReadableStream } from 'ai-toolkit';
  11. 11const CACHE_FILE = path.join(process.cwd(), '.cache/ai-cache.json');
  12. 12export const cached = (model: LanguageModelV3) =>
  13. 13 wrapLanguageModel({
  14. 14 middleware: cacheMiddleware,
  15. 15 model,
  16. 16 });
  17. 17const ensureCacheFile = () => {
  18. 18 const cacheDir = path.dirname(CACHE_FILE);
  19. 19 if (!fs.existsSync(cacheDir)) {
  20. 20 fs.mkdirSync(cacheDir, { recursive: true });
  21. 21 }
  22. 22 if (!fs.existsSync(CACHE_FILE)) {
  23. 23 fs.writeFileSync(CACHE_FILE, '{}');
  24. 24 }
  25. 25};
  26. 26const getCachedResult = (key: string | object) => {
  27. 27 ensureCacheFile();
  28. 28 const cacheKey = typeof key === 'object' ? JSON.stringify(key) : key;
  29. 29 try {
  30. 30 const cacheContent = fs.readFileSync(CACHE_FILE, 'utf-8');
  31. 31 const cache = JSON.parse(cacheContent);
  32. 32 const result = cache[cacheKey];
  33. 33 return result ?? null;
  34. 34 } catch (error) {
  35. 35 console.error('Cache error:', error);
  36. 36 return null;
  37. 37 }
  38. 38};
  39. 39const updateCache = (key: string, value: any) => {
  40. 40 ensureCacheFile();
  41. 41 try {
  42. 42 const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
  43. 43 const updatedCache = { ...cache, [key]: value };
  44. 44 fs.writeFileSync(CACHE_FILE, JSON.stringify(updatedCache, null, 2));
  45. 45 } catch (error) {
  46. 46 console.error('Failed to update cache:', error);
  47. 47 }
  48. 48};
  49. 49const cleanPrompt = (prompt: LanguageModelV3CallOptions['prompt']) => {
  50. 50 return prompt.map(m => {
  51. 51 if (m.role === 'assistant') {
  52. 52 return {
  53. 53 ...m,
  54. 54 content: m.content.map(part =>
  55. 55 part.type === 'tool-call' ? { ...part, toolCallId: 'cached' } : part,
  56. 56 ),
  57. 57 };
  58. 58 }
  59. 59 if (m.role === 'tool') {
  60. 60 return {
  61. 61 ...m,
  62. 62 content: m.content.map(tc => ({
  63. 63 ...tc,
  64. 64 toolCallId: 'cached',
  65. 65 result: {},
  66. 66 })),
  67. 67 };
  68. 68 }
  69. 69 return m;
  70. 70 });
  71. 71};
  72. 72export const cacheMiddleware: LanguageModelV3Middleware = {
  73. 73 specificationVersion: 'v3',
  74. 74 wrapGenerate: async ({ doGenerate, params, model }) => {
  75. 75 const cacheKey = JSON.stringify({
  76. 76 prompt: cleanPrompt(params.prompt),
  77. 77 _function: 'generate',
  78. 78 model: model.modelId,
  79. 79 });
  80. 80 const cached = getCachedResult(cacheKey);
  81. 81 if (cached && cached !== null) {
  82. 82 return {
  83. 83 ...cached,
  84. 84 response: {
  85. 85 ...cached.response,
  86. 86 timestamp: cached?.response?.timestamp
  87. 87 ? new Date(cached?.response?.timestamp)
  88. 88 : undefined,
  89. 89 },
  90. 90 };
  91. 91 }
  92. 92 const result = await doGenerate();
  93. 93 updateCache(cacheKey, result);
  94. 94 return result;
  95. 95 },
  96. 96 wrapStream: async ({ doStream, params, model }) => {
  97. 97 const cacheKey = JSON.stringify({
  98. 98 prompt: cleanPrompt(params.prompt),
  99. 99 _function: 'stream',
  100. 100 model: model.modelId,
  101. 101 });
  102. 102 const cached = getCachedResult(cacheKey);
  103. 103 if (cached && cached !== null) {
  104. 104 const { chunks, ...rest } = cached;
  105. 105 const formattedChunks = (chunks as LanguageModelV3StreamPart[]).map(p => {
  106. 106 if (p.type === 'response-metadata' && p.timestamp) {
  107. 107 return { ...p, timestamp: new Date(p.timestamp) };
  108. 108 }
  109. 109 return p;
  110. 110 });
  111. 111 return {
  112. 112 stream: simulateReadableStream({
  113. 113 initialDelayInMs: 0,
  114. 114 chunkDelayInMs: 10,
  115. 115 chunks: formattedChunks,
  116. 116 }),
  117. 117 ...rest,
  118. 118 };
  119. 119 }
  120. 120 const { stream, ...rest } = await doStream();
  121. 121 const fullResponse: LanguageModelV3StreamPart[] = [];
  122. 122 const transformStream = new TransformStream<
  123. 123 LanguageModelV3StreamPart,
  124. 124 LanguageModelV3StreamPart
  125. 125 >({
  126. 126 transform(chunk, controller) {
  127. 127 fullResponse.push(chunk);
  128. 128 controller.enqueue(chunk);
  129. 129 },
  130. 130 flush() {
  131. 131 updateCache(cacheKey, { chunks: fullResponse, ...rest });
  132. 132 },
  133. 133 });
  134. 134 return {
  135. 135 stream: stream.pipeThrough(transformStream),
  136. 136 ...rest,
  137. 137 };
  138. 138 },
  139. 139};

Using the Middleware

The middleware can be easily integrated into your existing AI TOOLKIT setup:

import { openai } from '@ai-toolkit/openai';

import { streamText } from 'ai-toolkit';

import 'dotenv/config';

import { cached } from '../middleware/your-cache-middleware';

async function main() {

const result = streamText({

model: cached(openai('gpt-4o')),

maxOutputTokens: 512,

temperature: 0.3,

maxRetries: 5,

prompt: 'Invent a new holiday and describe its traditions.',

});

for await (const textPart of result.textStream) {

process.stdout.write(textPart);

}

console.log();

console.log('Token usage:', await result.usage);

console.log('Finish reason:', await result.finishReason);

}

main().catch(console.error);

ts
  1. 1import { openai } from '@ai-toolkit/openai';
  2. 2import { streamText } from 'ai-toolkit';
  3. 3import 'dotenv/config';
  4. 4import { cached } from '../middleware/your-cache-middleware';
  5. 5async function main() {
  6. 6 const result = streamText({
  7. 7 model: cached(openai('gpt-4o')),
  8. 8 maxOutputTokens: 512,
  9. 9 temperature: 0.3,
  10. 10 maxRetries: 5,
  11. 11 prompt: 'Invent a new holiday and describe its traditions.',
  12. 12 });
  13. 13 for await (const textPart of result.textStream) {
  14. 14 process.stdout.write(textPart);
  15. 15 }
  16. 16 console.log();
  17. 17 console.log('Token usage:', await result.usage);
  18. 18 console.log('Finish reason:', await result.finishReason);
  19. 19}
  20. 20main().catch(console.error);

Considerations

When using this caching middleware, keep these points in mind:

1. Development Only - This approach is intended for local development, not production environments

2. Cache Invalidation - You'll need to clear the cache (delete the cache file) when you want fresh responses

3. Multi-Step Flows - When using stopWhen, be aware that caching occurs at the individual language model response level, not across the entire execution flow. This means that while the model's generation is cached, the tool call is not and will run on each generation.