llm-fns 1.0.26 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/createCachedFetcher.d.ts +8 -16
- package/dist/createCachedFetcher.js +146 -157
- package/dist/createCachedFetcher.spec.d.ts +1 -0
- package/dist/createCachedFetcher.spec.js +83 -0
- package/package.json +1 -1
- package/readme.md +22 -0
|
@@ -8,23 +8,19 @@ export type FetcherOptions = RequestInit & {
|
|
|
8
8
|
};
|
|
9
9
|
export type Fetcher = (url: string | URL | Request, options?: FetcherOptions) => Promise<Response>;
|
|
10
10
|
export interface CreateFetcherDependencies {
|
|
11
|
-
/**
|
|
11
|
+
/** Cache instance, for example a cache-manager Cache. */
|
|
12
12
|
cache?: CacheLike;
|
|
13
|
-
/**
|
|
13
|
+
/** Prefix for all cache keys. Defaults to `http-cache`. */
|
|
14
14
|
prefix?: string;
|
|
15
|
-
/** Time-to-live
|
|
15
|
+
/** Time-to-live in milliseconds. */
|
|
16
16
|
ttl?: number;
|
|
17
|
-
/** Request timeout in milliseconds.
|
|
17
|
+
/** Request timeout in milliseconds. */
|
|
18
18
|
timeout?: number;
|
|
19
|
-
/** User-Agent
|
|
19
|
+
/** User-Agent header to add to requests. */
|
|
20
20
|
userAgent?: string;
|
|
21
|
-
/**
|
|
21
|
+
/** Fetch implementation. Defaults to global fetch. */
|
|
22
22
|
fetch?: (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
23
|
-
/**
|
|
24
|
-
* Optional callback to determine if a response should be cached.
|
|
25
|
-
* It receives a cloned response that can be read (e.g. .json()).
|
|
26
|
-
* If it returns false, the response is not cached.
|
|
27
|
-
*/
|
|
23
|
+
/** Return false to prevent a successful response from being cached. */
|
|
28
24
|
shouldCache?: (response: Response) => Promise<boolean> | boolean;
|
|
29
25
|
}
|
|
30
26
|
export declare class CachedResponse extends Response {
|
|
@@ -32,9 +28,5 @@ export declare class CachedResponse extends Response {
|
|
|
32
28
|
constructor(body: BodyInit | null, init: ResponseInit, finalUrl: string);
|
|
33
29
|
get url(): string;
|
|
34
30
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Factory function that creates a `fetch` replacement with a caching layer.
|
|
37
|
-
* @param deps - Dependencies including the cache instance, prefix, TTL, and timeout.
|
|
38
|
-
* @returns A function with the same signature as native `fetch`.
|
|
39
|
-
*/
|
|
31
|
+
/** Creates a fetch-compatible function backed by deterministic response caching. */
|
|
40
32
|
export declare function createCachedFetcher(deps: CreateFetcherDependencies): Fetcher;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
2
|
export class CachedResponse extends Response {
|
|
3
3
|
#finalUrl;
|
|
4
4
|
constructor(body, init, finalUrl) {
|
|
@@ -9,177 +9,166 @@ export class CachedResponse extends Response {
|
|
|
9
9
|
return this.#finalUrl;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
function hashBytes(value) {
|
|
13
|
+
return crypto.createHash('sha256').update(typeof value === 'string' ? value : Buffer.from(value instanceof ArrayBuffer
|
|
14
|
+
? value
|
|
15
|
+
: value.buffer, value instanceof ArrayBuffer ? undefined : value.byteOffset, value instanceof ArrayBuffer ? undefined : value.byteLength)).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
function normalizedHeaders(headers) {
|
|
18
|
+
return [...headers.entries()]
|
|
19
|
+
.map(([name, value]) => {
|
|
20
|
+
const normalizedName = name.toLowerCase();
|
|
21
|
+
const normalizedValue = normalizedName === 'content-type' && /^multipart\/form-data\b/iu.test(value)
|
|
22
|
+
? 'multipart/form-data'
|
|
23
|
+
: value.trim();
|
|
24
|
+
return [normalizedName, normalizedValue];
|
|
25
|
+
})
|
|
26
|
+
// Content length and multipart boundaries are transport details, not request semantics.
|
|
27
|
+
.filter(([name]) => name !== 'content-length')
|
|
28
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
29
|
+
}
|
|
30
|
+
async function hashFormData(formData) {
|
|
31
|
+
const hash = crypto.createHash('sha256');
|
|
32
|
+
for (const [name, value] of formData.entries()) {
|
|
33
|
+
const nameBytes = Buffer.from(name);
|
|
34
|
+
hash.update(`name:${nameBytes.byteLength}:`);
|
|
35
|
+
hash.update(nameBytes);
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
const valueBytes = Buffer.from(value);
|
|
38
|
+
hash.update(`:text:${valueBytes.byteLength}:`);
|
|
39
|
+
hash.update(valueBytes);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const fileName = 'name' in value && typeof value.name === 'string' ? value.name : '';
|
|
43
|
+
const metadata = Buffer.from(`${fileName}\0${value.type}`);
|
|
44
|
+
const bytes = await value.arrayBuffer();
|
|
45
|
+
hash.update(`:blob:${metadata.byteLength}:`);
|
|
46
|
+
hash.update(metadata);
|
|
47
|
+
hash.update(`:${bytes.byteLength}:`);
|
|
48
|
+
hash.update(Buffer.from(bytes));
|
|
25
49
|
}
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
return hash.digest('hex');
|
|
51
|
+
}
|
|
52
|
+
async function hashBody(body) {
|
|
53
|
+
if (body instanceof FormData)
|
|
54
|
+
return `form-data:${await hashFormData(body)}`;
|
|
55
|
+
if (body instanceof URLSearchParams)
|
|
56
|
+
return `url-search-params:${hashBytes(body.toString())}`;
|
|
57
|
+
if (typeof body === 'string')
|
|
58
|
+
return `text:${hashBytes(body)}`;
|
|
59
|
+
if (body instanceof Blob)
|
|
60
|
+
return `blob:${body.type}:${hashBytes(await body.arrayBuffer())}`;
|
|
61
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body))
|
|
62
|
+
return `bytes:${hashBytes(body)}`;
|
|
63
|
+
return `stream:${hashBytes(await new Response(body).arrayBuffer())}`;
|
|
64
|
+
}
|
|
65
|
+
async function hashRequestBody(request) {
|
|
66
|
+
if (!request.body)
|
|
67
|
+
return 'none';
|
|
68
|
+
const clone = request.clone();
|
|
69
|
+
const contentType = clone.headers.get('content-type') ?? '';
|
|
70
|
+
if (/^multipart\/form-data\b/iu.test(contentType)) {
|
|
71
|
+
return `form-data:${await hashFormData(await clone.formData())}`;
|
|
28
72
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
73
|
+
return `bytes:${hashBytes(await clone.arrayBuffer())}`;
|
|
74
|
+
}
|
|
75
|
+
async function createRequestFingerprint(input, method, headers, body) {
|
|
76
|
+
const bodyHash = body !== undefined && body !== null
|
|
77
|
+
? await hashBody(body)
|
|
78
|
+
: input instanceof Request
|
|
79
|
+
? await hashRequestBody(input)
|
|
80
|
+
: 'none';
|
|
81
|
+
return hashBytes(JSON.stringify({
|
|
82
|
+
version: 2,
|
|
83
|
+
method: method.toUpperCase(),
|
|
84
|
+
url: input instanceof Request ? input.url : input.toString(),
|
|
85
|
+
headers: normalizedHeaders(headers),
|
|
86
|
+
body: bodyHash,
|
|
87
|
+
}));
|
|
37
88
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Factory function that creates a `fetch` replacement with a caching layer.
|
|
40
|
-
* @param deps - Dependencies including the cache instance, prefix, TTL, and timeout.
|
|
41
|
-
* @returns A function with the same signature as native `fetch`.
|
|
42
|
-
*/
|
|
89
|
+
/** Creates a fetch-compatible function backed by deterministic response caching. */
|
|
43
90
|
export function createCachedFetcher(deps) {
|
|
44
91
|
const { cache, prefix = 'http-cache', ttl, timeout, userAgent, fetch: customFetch, shouldCache } = deps;
|
|
45
92
|
const fetchImpl = customFetch ?? fetch;
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
return async (input, options) => {
|
|
94
|
+
const { ttl: requestTtl, ...requestOptions } = options ?? {};
|
|
95
|
+
const method = requestOptions.method ?? (input instanceof Request ? input.method : 'GET');
|
|
96
|
+
const headers = new Headers(requestOptions.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
97
|
+
if (userAgent)
|
|
98
|
+
headers.set('user-agent', userAgent);
|
|
99
|
+
let body = requestOptions.body;
|
|
100
|
+
// Tee streaming bodies so fingerprinting never consumes the copy sent over the network.
|
|
101
|
+
if (body instanceof ReadableStream) {
|
|
102
|
+
const [transportBody, fingerprintBody] = body.tee();
|
|
103
|
+
body = fingerprintBody;
|
|
104
|
+
requestOptions.body = transportBody;
|
|
50
105
|
}
|
|
51
|
-
const finalOptions = {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
106
|
+
const finalOptions = { ...requestOptions, headers };
|
|
107
|
+
const urlString = input instanceof Request ? input.url : input.toString();
|
|
108
|
+
if (!cache)
|
|
109
|
+
return fetchWithTimeout(fetchImpl, input, finalOptions, timeout, urlString);
|
|
110
|
+
const fingerprint = await createRequestFingerprint(input, method, headers, body);
|
|
111
|
+
const cacheKey = `${prefix}:v2:${fingerprint}`;
|
|
112
|
+
const cachedItem = await cache.get(cacheKey);
|
|
113
|
+
if (cachedItem) {
|
|
114
|
+
return new CachedResponse(Buffer.from(cachedItem.bodyBase64, 'base64'), {
|
|
115
|
+
status: cachedItem.status,
|
|
116
|
+
headers: cachedItem.headers,
|
|
117
|
+
}, cachedItem.finalUrl);
|
|
118
|
+
}
|
|
119
|
+
const response = await fetchWithTimeout(fetchImpl, input, finalOptions, timeout, urlString);
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
return response;
|
|
122
|
+
let isCacheable = true;
|
|
123
|
+
if (shouldCache) {
|
|
56
124
|
try {
|
|
57
|
-
|
|
125
|
+
isCacheable = await shouldCache(response.clone());
|
|
58
126
|
}
|
|
59
127
|
catch (error) {
|
|
60
|
-
|
|
128
|
+
console.warn('[Cache Check Error] shouldCache threw an error, skipping cache', error);
|
|
129
|
+
isCacheable = false;
|
|
61
130
|
}
|
|
62
131
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}, timeout);
|
|
69
|
-
finalOptions.signal = controller.signal;
|
|
70
|
-
try {
|
|
71
|
-
const response = await fetchImpl(url, finalOptions);
|
|
72
|
-
return response;
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
76
|
-
const urlString = typeof url === 'string' ? url : url.toString();
|
|
77
|
-
throw new Error(`Request to ${urlString} timed out after ${timeout}ms`);
|
|
78
|
-
}
|
|
79
|
-
throw error;
|
|
80
|
-
}
|
|
81
|
-
finally {
|
|
82
|
-
clearTimeout(timeoutId);
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
return async (url, options) => {
|
|
86
|
-
let method = 'GET';
|
|
87
|
-
if (options?.method) {
|
|
88
|
-
method = options.method;
|
|
89
|
-
}
|
|
90
|
-
else if (url instanceof Request) {
|
|
91
|
-
method = url.method;
|
|
92
|
-
}
|
|
93
|
-
const urlString = typeof url === 'string' ? url : url.toString();
|
|
94
|
-
if (!cache) {
|
|
95
|
-
console.log(`[Cache SKIP] Cache not configured for request to: ${urlString}`);
|
|
96
|
-
return fetchWithTimeout(url, options);
|
|
97
|
-
}
|
|
98
|
-
let cacheKey = `${prefix}:${urlString}`;
|
|
99
|
-
// Hash body for POST requests
|
|
100
|
-
if (method.toUpperCase() === 'POST' && options?.body) {
|
|
101
|
-
let bodyStr = '';
|
|
102
|
-
if (typeof options.body === 'string') {
|
|
103
|
-
bodyStr = options.body;
|
|
104
|
-
}
|
|
105
|
-
else if (options.body instanceof URLSearchParams) {
|
|
106
|
-
bodyStr = options.body.toString();
|
|
132
|
+
else if (response.headers.get('content-type')?.includes('application/json')) {
|
|
133
|
+
try {
|
|
134
|
+
const responseBody = await response.clone().json();
|
|
135
|
+
if (responseBody && typeof responseBody === 'object' && 'error' in responseBody)
|
|
136
|
+
isCacheable = false;
|
|
107
137
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
bodyStr = JSON.stringify(options.body);
|
|
111
|
-
}
|
|
112
|
-
catch (e) {
|
|
113
|
-
bodyStr = 'unserializable';
|
|
114
|
-
}
|
|
138
|
+
catch {
|
|
139
|
+
// A malformed JSON content type does not make an otherwise successful response uncacheable.
|
|
115
140
|
}
|
|
116
|
-
const bodyHash = crypto.createHash('md5').update(bodyStr).digest('hex');
|
|
117
|
-
cacheKey += `:body:${bodyHash}`;
|
|
118
141
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
if (isCacheable) {
|
|
143
|
+
const bodyBase64 = Buffer.from(await response.clone().arrayBuffer()).toString('base64');
|
|
144
|
+
await cache.set(cacheKey, {
|
|
145
|
+
bodyBase64,
|
|
146
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
147
|
+
status: response.status,
|
|
148
|
+
finalUrl: response.url,
|
|
149
|
+
}, requestTtl ?? ttl);
|
|
123
150
|
}
|
|
124
|
-
|
|
125
|
-
const cachedItem = await cache.get(cacheKey);
|
|
126
|
-
if (cachedItem) {
|
|
127
|
-
const body = Buffer.from(cachedItem.bodyBase64, 'base64');
|
|
128
|
-
return new CachedResponse(body, {
|
|
129
|
-
status: cachedItem.status,
|
|
130
|
-
headers: cachedItem.headers,
|
|
131
|
-
}, cachedItem.finalUrl);
|
|
132
|
-
}
|
|
133
|
-
// 2. Perform the actual fetch if not in cache
|
|
134
|
-
const fetchAndCache = async () => {
|
|
135
|
-
const response = await fetchWithTimeout(url, options);
|
|
136
|
-
// 3. Store in cache on success
|
|
137
|
-
if (response.ok) {
|
|
138
|
-
let isCacheable = true;
|
|
139
|
-
if (shouldCache) {
|
|
140
|
-
const checkClone = response.clone();
|
|
141
|
-
try {
|
|
142
|
-
isCacheable = await shouldCache(checkClone);
|
|
143
|
-
}
|
|
144
|
-
catch (e) {
|
|
145
|
-
console.warn('[Cache Check Error] shouldCache threw an error, skipping cache', e);
|
|
146
|
-
isCacheable = false;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
const contentType = response.headers.get('content-type');
|
|
151
|
-
if (contentType && contentType.includes('application/json')) {
|
|
152
|
-
const checkClone = response.clone();
|
|
153
|
-
try {
|
|
154
|
-
const body = await checkClone.json();
|
|
155
|
-
if (body && typeof body === 'object' && 'error' in body) {
|
|
156
|
-
console.log(`[Cache SKIP] JSON response contains .error property for: ${urlString}`);
|
|
157
|
-
isCacheable = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
catch (e) {
|
|
161
|
-
// Ignore JSON parse errors, assume cacheable if status is OK
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (isCacheable) {
|
|
166
|
-
const responseClone = response.clone();
|
|
167
|
-
const bodyBuffer = await responseClone.arrayBuffer();
|
|
168
|
-
const bodyBase64 = Buffer.from(bodyBuffer).toString('base64');
|
|
169
|
-
const headers = Object.fromEntries(response.headers.entries());
|
|
170
|
-
const itemToCache = {
|
|
171
|
-
bodyBase64,
|
|
172
|
-
headers,
|
|
173
|
-
status: response.status,
|
|
174
|
-
finalUrl: response.url,
|
|
175
|
-
};
|
|
176
|
-
await cache.set(cacheKey, itemToCache, options?.ttl ?? ttl);
|
|
177
|
-
console.log(`[Cache SET] for: ${cacheKey}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
// 4. Return the original response
|
|
181
|
-
return response;
|
|
182
|
-
};
|
|
183
|
-
return fetchAndCache();
|
|
151
|
+
return response;
|
|
184
152
|
};
|
|
185
153
|
}
|
|
154
|
+
async function fetchWithTimeout(fetchImpl, input, options, timeout, urlString) {
|
|
155
|
+
if (!timeout)
|
|
156
|
+
return fetchImpl(input, options);
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
159
|
+
const signal = options.signal
|
|
160
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
161
|
+
: controller.signal;
|
|
162
|
+
try {
|
|
163
|
+
return await fetchImpl(input, { ...options, signal });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (controller.signal.aborted && !options.signal?.aborted) {
|
|
167
|
+
throw new Error(`Request to ${urlString} timed out after ${timeout}ms`, { cause: error });
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
clearTimeout(timeoutId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import OpenAI, { toFile } from 'openai';
|
|
3
|
+
import { createCachedFetcher } from './createCachedFetcher.js';
|
|
4
|
+
function createMemoryCache() {
|
|
5
|
+
const values = new Map();
|
|
6
|
+
const cache = {
|
|
7
|
+
get: async (key) => values.get(key),
|
|
8
|
+
set: async (key, value) => { values.set(key, value); },
|
|
9
|
+
};
|
|
10
|
+
return { cache, values };
|
|
11
|
+
}
|
|
12
|
+
describe('createCachedFetcher', () => {
|
|
13
|
+
it('caches equivalent multipart requests and distinguishes file contents', async () => {
|
|
14
|
+
const { cache } = createMemoryCache();
|
|
15
|
+
const transport = vi.fn(async (_input, init) => {
|
|
16
|
+
const data = init?.body;
|
|
17
|
+
return Response.json({ file: await data.get('file').text() });
|
|
18
|
+
});
|
|
19
|
+
const cachedFetch = createCachedFetcher({ cache, fetch: transport });
|
|
20
|
+
const request = (contents) => {
|
|
21
|
+
const data = new FormData();
|
|
22
|
+
data.append('model', 'scribe');
|
|
23
|
+
data.append('file', new Blob([contents], { type: 'audio/flac' }), 'sample.flac');
|
|
24
|
+
return cachedFetch('https://speech.example/transcriptions', { method: 'POST', body: data });
|
|
25
|
+
};
|
|
26
|
+
expect(await (await request('first audio')).json()).toEqual({ file: 'first audio' });
|
|
27
|
+
expect(await (await request('first audio')).json()).toEqual({ file: 'first audio' });
|
|
28
|
+
expect(await (await request('different audio')).json()).toEqual({ file: 'different audio' });
|
|
29
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
30
|
+
});
|
|
31
|
+
it('fingerprints bodies supplied by Request objects without consuming them', async () => {
|
|
32
|
+
const { cache } = createMemoryCache();
|
|
33
|
+
const transport = vi.fn(async (input) => Response.json({ body: await input.text() }));
|
|
34
|
+
const cachedFetch = createCachedFetcher({ cache, fetch: transport });
|
|
35
|
+
const makeRequest = (body) => new Request('https://example.test/items', {
|
|
36
|
+
method: 'PUT', body, headers: { authorization: 'Bearer secret' },
|
|
37
|
+
});
|
|
38
|
+
expect(await (await cachedFetch(makeRequest('one'))).json()).toEqual({ body: 'one' });
|
|
39
|
+
expect(await (await cachedFetch(makeRequest('one'))).json()).toEqual({ body: 'one' });
|
|
40
|
+
expect(await (await cachedFetch(makeRequest('two'))).json()).toEqual({ body: 'two' });
|
|
41
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
42
|
+
});
|
|
43
|
+
it('keeps credentials opaque while separating requests with different headers', async () => {
|
|
44
|
+
const { cache, values } = createMemoryCache();
|
|
45
|
+
const transport = vi.fn(async () => Response.json({ ok: true }));
|
|
46
|
+
const cachedFetch = createCachedFetcher({ cache, prefix: 'test', fetch: transport });
|
|
47
|
+
await cachedFetch('https://example.test', { headers: { authorization: 'Bearer first-secret' } });
|
|
48
|
+
await cachedFetch('https://example.test', { headers: { authorization: 'Bearer second-secret' } });
|
|
49
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
50
|
+
expect([...values.keys()].join('\n')).not.toContain('secret');
|
|
51
|
+
});
|
|
52
|
+
it('preserves a streaming request body after hashing it', async () => {
|
|
53
|
+
const { cache } = createMemoryCache();
|
|
54
|
+
const transport = vi.fn(async (_input, init) => Response.json({ body: await new Response(init?.body).text() }));
|
|
55
|
+
const cachedFetch = createCachedFetcher({ cache, fetch: transport });
|
|
56
|
+
const stream = new Blob(['stream contents']).stream();
|
|
57
|
+
expect(await (await cachedFetch('https://example.test/upload', {
|
|
58
|
+
method: 'POST', body: stream, duplex: 'half',
|
|
59
|
+
})).json()).toEqual({ body: 'stream contents' });
|
|
60
|
+
});
|
|
61
|
+
it('caches multipart uploads produced by the OpenAI SDK', async () => {
|
|
62
|
+
const { cache } = createMemoryCache();
|
|
63
|
+
const transport = vi.fn(async (input) => {
|
|
64
|
+
if (input.toString() === 'data:,')
|
|
65
|
+
return new Response();
|
|
66
|
+
return Response.json({ words: [] });
|
|
67
|
+
});
|
|
68
|
+
const client = new OpenAI({
|
|
69
|
+
apiKey: 'test-key',
|
|
70
|
+
fetch: createCachedFetcher({ cache, fetch: transport }),
|
|
71
|
+
});
|
|
72
|
+
const transcribe = async (contents) => client.audio.transcriptions.create({
|
|
73
|
+
file: await toFile(Buffer.from(contents), 'sample.flac'),
|
|
74
|
+
model: 'whisper-1',
|
|
75
|
+
response_format: 'verbose_json',
|
|
76
|
+
});
|
|
77
|
+
await transcribe('same audio');
|
|
78
|
+
await transcribe('same audio');
|
|
79
|
+
await transcribe('different audio');
|
|
80
|
+
const apiCalls = transport.mock.calls.filter(([input]) => input.toString().startsWith('https://'));
|
|
81
|
+
expect(apiCalls).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -10,6 +10,28 @@ Designed for power users who need to switch between simple string prompts and co
|
|
|
10
10
|
npm install openai zod cache-manager p-queue ajv
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Cached Fetch
|
|
14
|
+
|
|
15
|
+
`createCachedFetcher` is a fetch-compatible response cache that can be injected into SDKs such as OpenAI:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import OpenAI from 'openai';
|
|
19
|
+
import { createCachedFetcher } from 'llm-fns';
|
|
20
|
+
|
|
21
|
+
const cachedFetch = createCachedFetcher({
|
|
22
|
+
cache,
|
|
23
|
+
fetch: globalThis.fetch,
|
|
24
|
+
prefix: 'openai',
|
|
25
|
+
ttl: 24 * 60 * 60 * 1000,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, fetch: cachedFetch });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Cache keys fingerprint the effective method, URL, normalized headers, and body. Standard Fetch bodies are supported, including strings, binary data, blobs, streams, `Request` bodies, URL-encoded forms, and multipart `FormData` with file contents. Multipart boundaries and content length are ignored because they are transport details; file bytes, filenames, media types, and form fields remain part of the fingerprint. Header values, including credentials, affect the fingerprint but are never written into the cache key in plain text.
|
|
32
|
+
|
|
33
|
+
The cache-key format is versioned. Version 1.0.27 intentionally does not read entries created by older releases.
|
|
34
|
+
|
|
13
35
|
## Quick Start (Factory)
|
|
14
36
|
|
|
15
37
|
The `createLlm` factory bundles all functionality (Basic, Retry, Zod) into a single client.
|