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.
@@ -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
- /** The cache instance (e.g., from cache-manager). */
11
+ /** Cache instance, for example a cache-manager Cache. */
12
12
  cache?: CacheLike;
13
- /** A prefix for all cache keys to avoid collisions. Defaults to 'http-cache'. */
13
+ /** Prefix for all cache keys. Defaults to `http-cache`. */
14
14
  prefix?: string;
15
- /** Time-to-live for cache entries, in milliseconds. */
15
+ /** Time-to-live in milliseconds. */
16
16
  ttl?: number;
17
- /** Request timeout in milliseconds. If not provided, no timeout is applied. */
17
+ /** Request timeout in milliseconds. */
18
18
  timeout?: number;
19
- /** User-Agent string for requests. */
19
+ /** User-Agent header to add to requests. */
20
20
  userAgent?: string;
21
- /** Optional custom fetch implementation. Defaults to global fetch. */
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
- * Creates a deterministic hash of headers for cache key generation.
14
- * Headers are sorted alphabetically to ensure consistency.
15
- */
16
- function hashHeaders(headers) {
17
- if (!headers)
18
- return '';
19
- let headerEntries;
20
- if (headers instanceof Headers) {
21
- headerEntries = Array.from(headers.entries());
22
- }
23
- else if (Array.isArray(headers)) {
24
- headerEntries = headers;
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
- else {
27
- headerEntries = Object.entries(headers);
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
- if (headerEntries.length === 0)
30
- return '';
31
- // Sort alphabetically by key for deterministic ordering
32
- headerEntries.sort((a, b) => a[0].localeCompare(b[0]));
33
- const headerString = headerEntries
34
- .map(([key, value]) => `${key}:${value}`)
35
- .join('|');
36
- return crypto.createHash('md5').update(headerString).digest('hex');
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
- const fetchWithTimeout = async (url, options) => {
47
- const headers = new Headers(options?.headers);
48
- if (userAgent) {
49
- headers.set('User-Agent', userAgent);
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
- ...options,
53
- headers,
54
- };
55
- if (!timeout) {
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
- return await fetchImpl(url, finalOptions);
125
+ isCacheable = await shouldCache(response.clone());
58
126
  }
59
127
  catch (error) {
60
- throw error;
128
+ console.warn('[Cache Check Error] shouldCache threw an error, skipping cache', error);
129
+ isCacheable = false;
61
130
  }
62
131
  }
63
- const controller = new AbortController();
64
- const timeoutId = setTimeout(() => {
65
- const urlString = typeof url === 'string' ? url : url.toString();
66
- console.log(`[Fetch Timeout] Request timed out after ${timeout}ms for: ${urlString}`);
67
- controller.abort();
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
- else {
109
- try {
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
- // Hash all request headers into cache key
120
- const headersHash = hashHeaders(options?.headers);
121
- if (headersHash) {
122
- cacheKey += `:headers:${headersHash}`;
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
- // 1. Check the cache
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-fns",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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.