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.
@@ -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
+ });
@@ -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 = crypto.randomUUID()) {
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'];
@@ -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 promptParams = normalizeOptions(arg1, arg2);
150
- // Ensure modalities includes audio if not explicitly set, though user should ideally provide it.
151
- // We won't force it here to avoid overriding user intent, but promptAudio implies audio output.
152
- const response = await prompt(promptParams);
153
- return extractAudioBuffer(response);
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);
@@ -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;
@@ -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
+ }
@@ -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>;
@@ -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.25",
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-3-pro-preview',
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: Structured Data (`llm.promptJson` & `llm.promptZod`)
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-1.5",
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 4: Agentic Retry Loops (`llm.promptTextRetry`)
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 5: Iterative Refinement (`createIterativeRefiner`)
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 6: Architecture & Composition
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-1.5'
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-3-pro-preview'
702
+ defaultModel: '~google/gemini-pro-latest'
638
703
  });
639
704
  ```
640
705