llm-fns 1.0.25 → 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/dist/createCandidateSelector.js +1 -2
- package/dist/createLlmClient.d.ts +13 -0
- package/dist/createLlmClient.js +151 -8
- package/dist/createLlmRetryClient.d.ts +2 -1
- package/dist/createLlmRetryClient.js +8 -1
- package/dist/extractBinary.d.ts +7 -0
- package/dist/extractBinary.js +22 -3
- package/dist/llmFactory.d.ts +4 -0
- package/dist/llmFactory.js +1 -0
- package/package.json +9 -8
- package/readme.md +73 -8
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
1
|
export function createCandidateSelector(params) {
|
|
3
2
|
const { candidateCount, generate, judge, onCandidateError } = params;
|
|
4
|
-
async function run(input, baseSalt =
|
|
3
|
+
async function run(input, baseSalt = 'candidate') {
|
|
5
4
|
// 1. Generate Candidates in Parallel
|
|
6
5
|
const promises = [];
|
|
7
6
|
for (let i = 0; i < candidateCount; i++) {
|
|
@@ -23,6 +23,7 @@ export type OpenRouterResponseFormat = {
|
|
|
23
23
|
schema: object;
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
|
+
export type LlmAudioTransport = 'auto' | 'chat' | 'chat-stream';
|
|
26
27
|
/**
|
|
27
28
|
* Request-level options passed to the OpenAI SDK.
|
|
28
29
|
* These are separate from the body parameters.
|
|
@@ -49,6 +50,12 @@ export interface LlmCommonOptions {
|
|
|
49
50
|
response_format?: OpenRouterResponseFormat;
|
|
50
51
|
modalities?: string[];
|
|
51
52
|
audio?: OpenAI.Chat.Completions.ChatCompletionAudioParam;
|
|
53
|
+
/**
|
|
54
|
+
* Selects how audio output is requested. `auto` prefers streaming because
|
|
55
|
+
* OpenRouter audio output requires it, then falls back to non-streaming chat
|
|
56
|
+
* if streaming is rejected by the provider.
|
|
57
|
+
*/
|
|
58
|
+
audioTransport?: LlmAudioTransport;
|
|
52
59
|
image_config?: {
|
|
53
60
|
aspect_ratio?: string;
|
|
54
61
|
};
|
|
@@ -64,6 +71,7 @@ export interface LlmCommonOptions {
|
|
|
64
71
|
user?: string;
|
|
65
72
|
tools?: OpenAI.Chat.Completions.ChatCompletionTool[];
|
|
66
73
|
tool_choice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption;
|
|
74
|
+
stream_options?: OpenAI.Chat.Completions.ChatCompletionStreamOptions;
|
|
67
75
|
}
|
|
68
76
|
/**
|
|
69
77
|
* Options for the individual "prompt" function calls.
|
|
@@ -119,8 +127,13 @@ export declare function createLlmClient(params: CreateLlmClientParams): {
|
|
|
119
127
|
(content: string, options?: LlmCommonOptions): Promise<Buffer>;
|
|
120
128
|
(options: LlmPromptOptions): Promise<Buffer>;
|
|
121
129
|
};
|
|
130
|
+
promptAudioStream: {
|
|
131
|
+
(content: string, options?: LlmCommonOptions): AsyncIterable<Buffer>;
|
|
132
|
+
(options: LlmPromptOptions): AsyncIterable<Buffer>;
|
|
133
|
+
};
|
|
122
134
|
};
|
|
123
135
|
export type PromptFunction = ReturnType<typeof createLlmClient>['prompt'];
|
|
124
136
|
export type PromptTextFunction = ReturnType<typeof createLlmClient>['promptText'];
|
|
125
137
|
export type PromptImageFunction = ReturnType<typeof createLlmClient>['promptImage'];
|
|
126
138
|
export type PromptAudioFunction = ReturnType<typeof createLlmClient>['promptAudio'];
|
|
139
|
+
export type PromptAudioStreamFunction = ReturnType<typeof createLlmClient>['promptAudioStream'];
|
package/dist/createLlmClient.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { executeWithRetry } from './retryUtils.js';
|
|
2
2
|
import { truncateMessages, getPromptSummary } from './util.js';
|
|
3
|
-
import { extractImageBuffer, extractAudioBuffer } from './extractBinary.js';
|
|
3
|
+
import { extractImageBuffer, extractAudioBuffer, extractAudioDeltaData } from './extractBinary.js';
|
|
4
4
|
import { createDnsFetcher } from './createDnsFetcher.js';
|
|
5
5
|
export class LlmFatalError extends Error {
|
|
6
6
|
cause;
|
|
@@ -63,8 +63,47 @@ export function normalizeOptions(arg1, arg2) {
|
|
|
63
63
|
export function createLlmClient(params) {
|
|
64
64
|
const { openai, defaultModel: factoryDefaultModel, maxConversationChars, queue, defaultRequestOptions, retryBaseDelay: factoryRetryBaseDelay = 1000, fetch: factoryFetch } = params;
|
|
65
65
|
const fetchImpl = factoryFetch ?? createDnsFetcher();
|
|
66
|
+
const getErrorMessage = (error) => [
|
|
67
|
+
error?.message,
|
|
68
|
+
error?.cause?.message,
|
|
69
|
+
error?.error?.message,
|
|
70
|
+
error?.cause?.error?.message,
|
|
71
|
+
error?.response?.data?.error?.message
|
|
72
|
+
].filter(Boolean).join('\n');
|
|
73
|
+
const isStreamingUnsupportedError = (error) => {
|
|
74
|
+
const message = getErrorMessage(error);
|
|
75
|
+
return /stream(ing)?\s+(is\s+)?not\s+supported|does\s+not\s+support\s+stream|stream\s+unsupported/i.test(message);
|
|
76
|
+
};
|
|
77
|
+
const withAudioOutputDefaults = (promptParams, defaultFormat) => {
|
|
78
|
+
const factoryModelConfig = typeof factoryDefaultModel === 'object' && factoryDefaultModel !== null
|
|
79
|
+
? factoryDefaultModel
|
|
80
|
+
: {};
|
|
81
|
+
const callModelConfig = typeof promptParams.model === 'object' && promptParams.model !== null
|
|
82
|
+
? promptParams.model
|
|
83
|
+
: {};
|
|
84
|
+
const nextModalities = [
|
|
85
|
+
...(factoryModelConfig.modalities ?? []),
|
|
86
|
+
...(callModelConfig.modalities ?? []),
|
|
87
|
+
...(promptParams.modalities ?? [])
|
|
88
|
+
];
|
|
89
|
+
if (!nextModalities.includes('text'))
|
|
90
|
+
nextModalities.push('text');
|
|
91
|
+
if (!nextModalities.includes('audio'))
|
|
92
|
+
nextModalities.push('audio');
|
|
93
|
+
return {
|
|
94
|
+
...promptParams,
|
|
95
|
+
modalities: [...new Set(nextModalities)],
|
|
96
|
+
audio: {
|
|
97
|
+
voice: 'alloy',
|
|
98
|
+
format: defaultFormat,
|
|
99
|
+
...(factoryModelConfig.audio ?? {}),
|
|
100
|
+
...(callModelConfig.audio ?? {}),
|
|
101
|
+
...(promptParams.audio ?? {})
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
};
|
|
66
105
|
const getCompletionParams = (promptParams) => {
|
|
67
|
-
const { model: callSpecificModel, messages, retries, retryBaseDelay: callSpecificRetryBaseDelay, requestOptions, ...restApiOptions } = promptParams;
|
|
106
|
+
const { model: callSpecificModel, messages, retries, retryBaseDelay: callSpecificRetryBaseDelay, requestOptions, audioTransport: _audioTransport, ...restApiOptions } = promptParams;
|
|
68
107
|
const finalMessages = maxConversationChars
|
|
69
108
|
? truncateMessages(messages, maxConversationChars)
|
|
70
109
|
: messages;
|
|
@@ -146,11 +185,115 @@ export function createLlmClient(params) {
|
|
|
146
185
|
return extractImageBuffer(response, fetchImpl);
|
|
147
186
|
}
|
|
148
187
|
async function promptAudio(arg1, arg2) {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
const normalizedParams = normalizeOptions(arg1, arg2);
|
|
189
|
+
const transport = normalizedParams.audioTransport ?? 'auto';
|
|
190
|
+
if (transport === 'chat') {
|
|
191
|
+
const response = await prompt(withAudioOutputDefaults(normalizedParams, 'mp3'));
|
|
192
|
+
return extractAudioBuffer(response);
|
|
193
|
+
}
|
|
194
|
+
const streamParams = withAudioOutputDefaults(normalizedParams, 'pcm16');
|
|
195
|
+
try {
|
|
196
|
+
return await promptAudioViaChatStream(streamParams);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (transport === 'auto' && isStreamingUnsupportedError(error)) {
|
|
200
|
+
const response = await prompt(withAudioOutputDefaults(normalizedParams, 'mp3'));
|
|
201
|
+
return extractAudioBuffer(response);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function* promptAudioStream(arg1, arg2) {
|
|
207
|
+
const promptParams = withAudioOutputDefaults(normalizeOptions(arg1, arg2), 'pcm16');
|
|
208
|
+
const { completionParams, finalMessages, retries, requestOptions, retryBaseDelay: baseDelay } = getCompletionParams(promptParams);
|
|
209
|
+
const promptSummary = getPromptSummary(finalMessages);
|
|
210
|
+
const streamParams = {
|
|
211
|
+
...completionParams,
|
|
212
|
+
stream: true
|
|
213
|
+
};
|
|
214
|
+
const streamEvents = [];
|
|
215
|
+
let wakeConsumer;
|
|
216
|
+
const pushStreamEvent = (event) => {
|
|
217
|
+
streamEvents.push(event);
|
|
218
|
+
wakeConsumer?.();
|
|
219
|
+
wakeConsumer = undefined;
|
|
220
|
+
};
|
|
221
|
+
const nextStreamEvent = async () => {
|
|
222
|
+
while (streamEvents.length === 0) {
|
|
223
|
+
await new Promise(resolve => {
|
|
224
|
+
wakeConsumer = resolve;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return streamEvents.shift();
|
|
228
|
+
};
|
|
229
|
+
let emittedChunkCount = 0;
|
|
230
|
+
async function readStream() {
|
|
231
|
+
let chunkCount = 0;
|
|
232
|
+
try {
|
|
233
|
+
const stream = await openai.chat.completions.create(streamParams, requestOptions);
|
|
234
|
+
for await (const chunk of stream) {
|
|
235
|
+
const audioData = extractAudioDeltaData(chunk);
|
|
236
|
+
if (audioData) {
|
|
237
|
+
chunkCount += 1;
|
|
238
|
+
emittedChunkCount += 1;
|
|
239
|
+
pushStreamEvent({ type: 'chunk', chunk: Buffer.from(audioData, 'base64') });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return chunkCount;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error?.status === 400 || error?.status === 401 || error?.status === 403) {
|
|
246
|
+
throw new LlmFatalError(error.message || 'Fatal API Error', error, finalMessages);
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const task = () => executeWithRetry(() => readStream(), async (chunkCount) => {
|
|
252
|
+
if (chunkCount === 0) {
|
|
253
|
+
return {
|
|
254
|
+
isValid: false,
|
|
255
|
+
feedbackForNextAttempt: {
|
|
256
|
+
type: 'EMPTY_AUDIO_STREAM',
|
|
257
|
+
message: 'LLM returned no streamed audio content.'
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return { isValid: true, data: chunkCount };
|
|
262
|
+
}, retries ?? 3, undefined, (error) => {
|
|
263
|
+
if (emittedChunkCount > 0)
|
|
264
|
+
return false;
|
|
265
|
+
if (error instanceof LlmFatalError)
|
|
266
|
+
return false;
|
|
267
|
+
if (error?.status === 400 || error?.status === 401 || error?.status === 403 || error?.code === 'invalid_api_key') {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}, baseDelay);
|
|
272
|
+
const producer = (queue
|
|
273
|
+
? queue.add(task, { id: promptSummary, messages: finalMessages })
|
|
274
|
+
: task());
|
|
275
|
+
producer
|
|
276
|
+
.then(() => pushStreamEvent({ type: 'done' }))
|
|
277
|
+
.catch(error => pushStreamEvent({ type: 'error', error }));
|
|
278
|
+
while (true) {
|
|
279
|
+
const event = await nextStreamEvent();
|
|
280
|
+
if (event.type === 'chunk') {
|
|
281
|
+
yield event.chunk;
|
|
282
|
+
}
|
|
283
|
+
else if (event.type === 'error') {
|
|
284
|
+
throw event.error;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function promptAudioViaChatStream(promptParams) {
|
|
292
|
+
const chunks = [];
|
|
293
|
+
for await (const chunk of promptAudioStream(promptParams)) {
|
|
294
|
+
chunks.push(chunk);
|
|
295
|
+
}
|
|
296
|
+
return Buffer.concat(chunks);
|
|
154
297
|
}
|
|
155
|
-
return { prompt, promptText, promptImage, promptAudio };
|
|
298
|
+
return { prompt, promptText, promptImage, promptAudio, promptAudioStream };
|
|
156
299
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import { PromptFunction, LlmCommonOptions, LlmPromptOptions } from "./createLlmClient.js";
|
|
2
|
+
import { PromptFunction, PromptAudioFunction, LlmCommonOptions, LlmPromptOptions } from "./createLlmClient.js";
|
|
3
3
|
import { ConversationState } from './createConversation.js';
|
|
4
4
|
export declare class LlmRetryError extends Error {
|
|
5
5
|
readonly message: string;
|
|
@@ -38,6 +38,7 @@ export interface LlmRetryOptions<T = any> extends LlmCommonOptions {
|
|
|
38
38
|
}
|
|
39
39
|
export interface CreateLlmRetryClientParams {
|
|
40
40
|
prompt: PromptFunction;
|
|
41
|
+
promptAudio?: PromptAudioFunction;
|
|
41
42
|
fallbackPrompt?: PromptFunction;
|
|
42
43
|
retryBaseDelay?: number;
|
|
43
44
|
/** Optional custom fetch implementation for binary extraction */
|
|
@@ -68,7 +68,7 @@ function constructLlmMessages(initialMessages, attemptNumber, previousError) {
|
|
|
68
68
|
return messages;
|
|
69
69
|
}
|
|
70
70
|
export function createLlmRetryClient(params) {
|
|
71
|
-
const { prompt, fallbackPrompt, retryBaseDelay: factoryRetryBaseDelay = 0, fetch: factoryFetch } = params;
|
|
71
|
+
const { prompt, promptAudio, fallbackPrompt, retryBaseDelay: factoryRetryBaseDelay = 0, fetch: factoryFetch } = params;
|
|
72
72
|
const fetchImpl = factoryFetch ?? createDnsFetcher();
|
|
73
73
|
async function runPromptLoop(retryParams) {
|
|
74
74
|
const { maxRetries = 3, validate, messages: initialMessages, retryBaseDelay = factoryRetryBaseDelay, state, ...restOptions } = retryParams;
|
|
@@ -192,6 +192,13 @@ export function createLlmRetryClient(params) {
|
|
|
192
192
|
async function promptAudioRetry(arg1, arg2) {
|
|
193
193
|
const retryParams = normalizeRetryOptions(arg1, arg2);
|
|
194
194
|
const userValidate = retryParams.validate;
|
|
195
|
+
if (promptAudio && !userValidate && !retryParams.state && !fallbackPrompt) {
|
|
196
|
+
const { maxRetries, validate, state, ...promptOptions } = retryParams;
|
|
197
|
+
return await promptAudio({
|
|
198
|
+
...promptOptions,
|
|
199
|
+
retries: maxRetries ?? promptOptions.retries
|
|
200
|
+
});
|
|
201
|
+
}
|
|
195
202
|
retryParams.validate = async (completion, info) => {
|
|
196
203
|
try {
|
|
197
204
|
const buffer = extractAudioBuffer(completion);
|
package/dist/extractBinary.d.ts
CHANGED
|
@@ -19,3 +19,10 @@ export declare function extractImageBuffer(completion: OpenAI.Chat.Completions.C
|
|
|
19
19
|
* Extracts an audio buffer from a ChatCompletion.
|
|
20
20
|
*/
|
|
21
21
|
export declare function extractAudioBuffer(completion: OpenAI.Chat.Completions.ChatCompletion): Buffer;
|
|
22
|
+
/**
|
|
23
|
+
* Extracts Base64 audio payloads from streamed Chat Completion chunks.
|
|
24
|
+
* OpenRouter delivers audio output at `choices[0].delta.audio.data`.
|
|
25
|
+
* Some OpenAI-compatible providers may use adjacent fields, so this accepts the
|
|
26
|
+
* common variants without widening the public client API.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractAudioDeltaData(chunk: unknown): string | undefined;
|
package/dist/extractBinary.js
CHANGED
|
@@ -18,7 +18,7 @@ export function isAudioResponse(completion) {
|
|
|
18
18
|
if (message?.audio)
|
|
19
19
|
return true;
|
|
20
20
|
if (Array.isArray(message?.content)) {
|
|
21
|
-
return message.content.some((part) => part.type === 'input_audio');
|
|
21
|
+
return message.content.some((part) => part.type === 'input_audio' || part.type === 'audio');
|
|
22
22
|
}
|
|
23
23
|
return false;
|
|
24
24
|
}
|
|
@@ -66,11 +66,30 @@ export function extractAudioBuffer(completion) {
|
|
|
66
66
|
}
|
|
67
67
|
// 2. Check standard content parts
|
|
68
68
|
else if (Array.isArray(message?.content)) {
|
|
69
|
-
const part = message.content.find((p) => p.type === 'input_audio');
|
|
70
|
-
audioData = part?.input_audio?.data;
|
|
69
|
+
const part = message.content.find((p) => p.type === 'input_audio' || p.type === 'audio');
|
|
70
|
+
audioData = part?.input_audio?.data || part?.audio?.data || part?.data;
|
|
71
71
|
}
|
|
72
72
|
if (audioData) {
|
|
73
73
|
return Buffer.from(audioData, 'base64');
|
|
74
74
|
}
|
|
75
75
|
throw new Error("LLM returned no audio content.");
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Extracts Base64 audio payloads from streamed Chat Completion chunks.
|
|
79
|
+
* OpenRouter delivers audio output at `choices[0].delta.audio.data`.
|
|
80
|
+
* Some OpenAI-compatible providers may use adjacent fields, so this accepts the
|
|
81
|
+
* common variants without widening the public client API.
|
|
82
|
+
*/
|
|
83
|
+
export function extractAudioDeltaData(chunk) {
|
|
84
|
+
const delta = chunk?.choices?.[0]?.delta;
|
|
85
|
+
const audio = delta?.audio || delta?.output_audio;
|
|
86
|
+
if (typeof audio === 'string')
|
|
87
|
+
return audio;
|
|
88
|
+
if (audio?.data)
|
|
89
|
+
return audio.data;
|
|
90
|
+
if (audio?.delta)
|
|
91
|
+
return audio.delta;
|
|
92
|
+
if (delta?.audio_data)
|
|
93
|
+
return delta.audio_data;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
package/dist/llmFactory.d.ts
CHANGED
|
@@ -47,5 +47,9 @@ export declare function createLlm(params: CreateLlmFactoryParams): {
|
|
|
47
47
|
(content: string, options?: import("./createLlmClient.js").LlmCommonOptions): Promise<Buffer>;
|
|
48
48
|
(options: import("./createLlmClient.js").LlmPromptOptions): Promise<Buffer>;
|
|
49
49
|
};
|
|
50
|
+
promptAudioStream: {
|
|
51
|
+
(content: string, options?: import("./createLlmClient.js").LlmCommonOptions): AsyncIterable<Buffer>;
|
|
52
|
+
(options: import("./createLlmClient.js").LlmPromptOptions): AsyncIterable<Buffer>;
|
|
53
|
+
};
|
|
50
54
|
};
|
|
51
55
|
export type LlmClient = ReturnType<typeof createLlm>;
|
package/dist/llmFactory.js
CHANGED
|
@@ -7,6 +7,7 @@ export function createLlm(params) {
|
|
|
7
7
|
const baseClient = createLlmClient(params);
|
|
8
8
|
const retryClient = createLlmRetryClient({
|
|
9
9
|
prompt: baseClient.prompt,
|
|
10
|
+
promptAudio: baseClient.promptAudio,
|
|
10
11
|
retryBaseDelay: params.retryBaseDelay,
|
|
11
12
|
fetch: params.fetch
|
|
12
13
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-fns",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.27",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"dist"
|
|
10
10
|
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"release": "scripts/release.sh",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"watch": "tsc -b -w"
|
|
16
|
+
},
|
|
11
17
|
"keywords": [],
|
|
12
18
|
"author": "",
|
|
13
19
|
"license": "MIT",
|
|
20
|
+
"packageManager": "pnpm@10.6.5",
|
|
14
21
|
"dependencies": {
|
|
15
22
|
"ajv": "^8.17.1",
|
|
16
23
|
"openai": "^6.15.0",
|
|
@@ -26,11 +33,5 @@
|
|
|
26
33
|
"p-queue": "^9.0.1",
|
|
27
34
|
"typescript": "^5.9.3",
|
|
28
35
|
"vitest": "^1.6.1"
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"test": "vitest run",
|
|
32
|
-
"release": "scripts/release.sh",
|
|
33
|
-
"build": "tsc",
|
|
34
|
-
"watch": "tsc -b -w"
|
|
35
36
|
}
|
|
36
|
-
}
|
|
37
|
+
}
|
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.
|
|
@@ -22,7 +44,7 @@ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
|
22
44
|
|
|
23
45
|
const llm = createLlm({
|
|
24
46
|
openai,
|
|
25
|
-
defaultModel: 'google/gemini-
|
|
47
|
+
defaultModel: '~google/gemini-pro-latest',
|
|
26
48
|
// optional:
|
|
27
49
|
// cache: Cache instance (cache-manager)
|
|
28
50
|
// queue: PQueue instance for concurrency control
|
|
@@ -251,7 +273,50 @@ const buffer2 = await llm.promptImage({
|
|
|
251
273
|
|
|
252
274
|
---
|
|
253
275
|
|
|
254
|
-
# Use Case 3:
|
|
276
|
+
# Use Case 3: Audio (`llm.promptAudio` / `llm.promptAudioStream`)
|
|
277
|
+
|
|
278
|
+
Generates audio and returns it as a `Buffer`, or yields audio chunks as they arrive. This supports providers that only expose audio output through streaming Chat Completions, including OpenRouter routes to audio-capable OpenAI models.
|
|
279
|
+
|
|
280
|
+
**Return Type:** `Promise<Buffer>` for `promptAudio`, `AsyncIterable<Buffer>` for `promptAudioStream`
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import fs from 'node:fs/promises';
|
|
284
|
+
|
|
285
|
+
const audio = await llm.promptAudio({
|
|
286
|
+
model: 'openai/gpt-audio-mini',
|
|
287
|
+
messages: 'Say exactly: BatchPrompt audio test.',
|
|
288
|
+
modalities: ['text', 'audio'],
|
|
289
|
+
audio: {
|
|
290
|
+
voice: 'alloy',
|
|
291
|
+
format: 'pcm16'
|
|
292
|
+
},
|
|
293
|
+
audioTransport: 'chat-stream'
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await fs.writeFile('batchprompt-audio-test.pcm', audio);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
For callers that want to process chunks as they arrive, use `promptAudioStream`:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
const chunks: Buffer[] = [];
|
|
303
|
+
|
|
304
|
+
for await (const chunk of llm.promptAudioStream({
|
|
305
|
+
model: 'openai/gpt-audio-mini',
|
|
306
|
+
messages: 'Read this sentence aloud.',
|
|
307
|
+
audio: { voice: 'alloy', format: 'pcm16' }
|
|
308
|
+
})) {
|
|
309
|
+
chunks.push(chunk);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await fs.writeFile('streamed-audio.pcm', Buffer.concat(chunks));
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`promptAudio` defaults to streaming transport because some providers require it for audio output. When streaming, the default audio format is `pcm16`; OpenAI rejects `mp3` for streamed `gpt-audio-mini` responses. If you need non-streaming Chat Completions audio, pass `audioTransport: 'chat'`, where the default format is `mp3`.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
# Use Case 4: Structured Data (`llm.promptJson` & `llm.promptZod`)
|
|
255
320
|
|
|
256
321
|
This is a high-level wrapper that employs a **Re-asking Loop**. If the LLM outputs invalid JSON or data that fails the schema validation, the client automatically feeds the error back to the LLM and asks it to fix it (up to `maxRetries`).
|
|
257
322
|
|
|
@@ -303,7 +368,7 @@ const gameState = await llm.promptZod(
|
|
|
303
368
|
history, // Arg 1: Context
|
|
304
369
|
GameStateSchema, // Arg 2: Schema
|
|
305
370
|
{ // Arg 3: Options Override
|
|
306
|
-
model: "google/gemini-flash-
|
|
371
|
+
model: "~google/gemini-flash-latest",
|
|
307
372
|
disableJsonFixer: true, // Turn off the automatic JSON repair agent
|
|
308
373
|
maxRetries: 0, // Fail immediately on error
|
|
309
374
|
}
|
|
@@ -392,7 +457,7 @@ const SafeSchema = z.object({
|
|
|
392
457
|
|
|
393
458
|
---
|
|
394
459
|
|
|
395
|
-
# Use Case
|
|
460
|
+
# Use Case 5: Agentic Retry Loops (`llm.promptTextRetry`)
|
|
396
461
|
|
|
397
462
|
The library exposes the "Conversational Retry" engine used internally by `promptZod`. You can provide a `validate` function. If it throws a `LlmRetryError`, the error message is fed back to the LLM, and it tries again.
|
|
398
463
|
|
|
@@ -420,7 +485,7 @@ const poem = await llm.promptTextRetry({
|
|
|
420
485
|
|
|
421
486
|
---
|
|
422
487
|
|
|
423
|
-
# Use Case
|
|
488
|
+
# Use Case 6: Iterative Refinement (`createIterativeRefiner`)
|
|
424
489
|
|
|
425
490
|
For complex tasks where an LLM needs to "try, check, and fix" its own output (like code generation or complex configuration), use the `IterativeRefiner`.
|
|
426
491
|
|
|
@@ -615,7 +680,7 @@ function getAllResponses(error: LlmRetryExhaustedError): string[] {
|
|
|
615
680
|
|
|
616
681
|
---
|
|
617
682
|
|
|
618
|
-
# Use Case
|
|
683
|
+
# Use Case 7: Architecture & Composition
|
|
619
684
|
|
|
620
685
|
How to build the client manually to enable **Fallback Chains** and **Smart Routing**.
|
|
621
686
|
|
|
@@ -628,13 +693,13 @@ import { createLlmClient } from './src';
|
|
|
628
693
|
// 1. Define a CHEAP model
|
|
629
694
|
const cheapClient = createLlmClient({
|
|
630
695
|
openai,
|
|
631
|
-
defaultModel: 'google/gemini-flash-
|
|
696
|
+
defaultModel: '~google/gemini-flash-latest'
|
|
632
697
|
});
|
|
633
698
|
|
|
634
699
|
// 2. Define a STRONG model
|
|
635
700
|
const strongClient = createLlmClient({
|
|
636
701
|
openai,
|
|
637
|
-
defaultModel: 'google/gemini-
|
|
702
|
+
defaultModel: '~google/gemini-pro-latest'
|
|
638
703
|
});
|
|
639
704
|
```
|
|
640
705
|
|