@startsimpli/llm 0.4.23 → 0.4.24
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/package.json +1 -1
- package/src/__tests__/openai-provider.test.ts +38 -0
- package/src/__tests__/usage-tally.test.ts +22 -0
- package/src/index.ts +2 -0
- package/src/providers/openai.ts +34 -3
- package/src/types.ts +11 -0
- package/src/utils/usage-tally.ts +56 -0
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ interface Sent {
|
|
|
33
33
|
max_tokens?: number;
|
|
34
34
|
max_completion_tokens?: number;
|
|
35
35
|
reasoning_effort?: string;
|
|
36
|
+
prompt_cache_key?: string;
|
|
37
|
+
prompt_cache_retention?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const OK_BODY = {
|
|
@@ -280,3 +282,39 @@ describe('images', () => {
|
|
|
280
282
|
expect(plain.messages[1]!.content).toBe('plain');
|
|
281
283
|
});
|
|
282
284
|
});
|
|
285
|
+
|
|
286
|
+
describe('prompt caching', () => {
|
|
287
|
+
// Measured against api.openai.com 2026-10-15 (gpt-6-astra, gpt-6-luna): both
|
|
288
|
+
// parameters accepted; a repeat of a 2,020-token prompt came back with
|
|
289
|
+
// prompt_tokens_details.cached_tokens 2,017.
|
|
290
|
+
it('sends the cache key and retention when asked, and nothing when not', async () => {
|
|
291
|
+
const p = provider('gpt-6-astra');
|
|
292
|
+
await p.generate('u', 's', { cacheKey: 'crochet-decompose', cacheRetention: '24h' });
|
|
293
|
+
await p.generate('u', 's');
|
|
294
|
+
expect(sent[0]!.prompt_cache_key).toBe('crochet-decompose');
|
|
295
|
+
expect(sent[0]!.prompt_cache_retention).toBe('24h');
|
|
296
|
+
expect(sent[1]!.prompt_cache_key).toBeUndefined();
|
|
297
|
+
expect(sent[1]!.prompt_cache_retention).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('reports cached prompt tokens', async () => {
|
|
301
|
+
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
302
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
303
|
+
return { ok: true, status: 200, json: async () => ({ ...OK_BODY, usage: { prompt_tokens: 2020, completion_tokens: 4, total_tokens: 2024, prompt_tokens_details: { cached_tokens: 2017 } } }) } as unknown as Response;
|
|
304
|
+
});
|
|
305
|
+
const res = await provider('gpt-6-astra').generate('u', 's', { cacheKey: 'k' });
|
|
306
|
+
expect(res.usage.cachedTokens).toBe(2017);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('drops the cache parameters for a model that rejects them, and remembers', async () => {
|
|
310
|
+
const p = provider('some-old-model');
|
|
311
|
+
reject({ code: 'unknown_parameter', param: 'prompt_cache_retention', message: "Unrecognized request argument supplied: prompt_cache_retention" });
|
|
312
|
+
const res = await p.generate('u', 's', { cacheKey: 'k', cacheRetention: '24h' });
|
|
313
|
+
expect(res.content).toBe('{"ok":true}');
|
|
314
|
+
expect(sent[1]!.prompt_cache_retention).toBeUndefined();
|
|
315
|
+
expect(sent[1]!.prompt_cache_key).toBeUndefined();
|
|
316
|
+
await p.generate('u', 's', { cacheKey: 'k', cacheRetention: '24h' });
|
|
317
|
+
expect(sent).toHaveLength(3);
|
|
318
|
+
expect(sent[2]!.prompt_cache_retention).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { newUsageTally, tallyingService } from '../utils/usage-tally';
|
|
3
|
+
|
|
4
|
+
describe('tallyingService', () => {
|
|
5
|
+
it('adds up every call per model, cached tokens included, and passes other members through', async () => {
|
|
6
|
+
const svc = {
|
|
7
|
+
name: 'svc',
|
|
8
|
+
async generateChatResponse(_u: string, _s: string, opts?: { model?: string }) {
|
|
9
|
+
return { content: '{}', model: opts?.model ?? 'a', usage: { promptTokens: 100, completionTokens: 40, totalTokens: 140, cachedTokens: 60 } };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
const tally = newUsageTally();
|
|
13
|
+
const counted = tallyingService(svc, tally);
|
|
14
|
+
await counted.generateChatResponse('u', 's', { model: 'astra' });
|
|
15
|
+
await counted.generateChatResponse('u', 's', { model: 'astra' });
|
|
16
|
+
await counted.generateChatResponse('u', 's', { model: 'luna' });
|
|
17
|
+
expect(counted.name).toBe('svc');
|
|
18
|
+
expect(tally).toMatchObject({ calls: 3, promptTokens: 300, completionTokens: 120, cachedTokens: 180 });
|
|
19
|
+
expect(tally.byModel.astra).toEqual({ calls: 2, promptTokens: 200, completionTokens: 80, cachedTokens: 120 });
|
|
20
|
+
expect(tally.byModel.luna.calls).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type { MockBehavior } from './providers/mock';
|
|
|
36
36
|
// Utilities
|
|
37
37
|
export { extractJson } from './utils/json-extraction'
|
|
38
38
|
export { llmErrorToHttpStatus } from './utils/http-status';
|
|
39
|
+
export { newUsageTally, addUsage, tallyingService } from './utils/usage-tally';
|
|
40
|
+
export type { UsageTally, UsageCounts } from './utils/usage-tally';
|
|
39
41
|
export {
|
|
40
42
|
buildSchemaErrorCorrectionPrompt,
|
|
41
43
|
buildParseErrorCorrectionPrompt,
|
package/src/providers/openai.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
NativeToolResponse,
|
|
20
20
|
ReasoningEffort,
|
|
21
21
|
LLMImage,
|
|
22
|
+
TokenUsage,
|
|
22
23
|
} from '../types';
|
|
23
24
|
import { LLM_DEFAULTS } from '../types';
|
|
24
25
|
import { LLMProviderError } from '../errors';
|
|
@@ -51,6 +52,8 @@ interface OpenAIRequest {
|
|
|
51
52
|
/** Reasoning models only; dropped automatically for models that reject it. */
|
|
52
53
|
reasoning_effort?: ReasoningEffort;
|
|
53
54
|
response_format?: { type: 'json_object' | 'text' };
|
|
55
|
+
prompt_cache_key?: string;
|
|
56
|
+
prompt_cache_retention?: 'in_memory' | '24h';
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
@@ -85,6 +88,23 @@ interface ModelQuirks {
|
|
|
85
88
|
efforts?: ReasoningEffort[];
|
|
86
89
|
/** Largest completion-token cap this model accepts. */
|
|
87
90
|
maxOutput?: number;
|
|
91
|
+
/** The model rejected prompt-cache parameters; send none. */
|
|
92
|
+
noPromptCache?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Prompt-cache parameters for a request, unless this model has rejected them. */
|
|
96
|
+
function cacheParams(q: ModelQuirks, options?: GenerateOptions): Pick<OpenAIRequest, 'prompt_cache_key' | 'prompt_cache_retention'> {
|
|
97
|
+
if (q.noPromptCache) return {};
|
|
98
|
+
return {
|
|
99
|
+
...(options?.cacheKey ? { prompt_cache_key: options.cacheKey } : {}),
|
|
100
|
+
...(options?.cacheRetention ? { prompt_cache_retention: options.cacheRetention } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Cached prompt tokens from an OpenAI usage block, when it reports them. */
|
|
105
|
+
function cachedTokens(usage: { prompt_tokens_details?: { cached_tokens?: number } } | undefined): { cachedTokens?: number } {
|
|
106
|
+
const n = usage?.prompt_tokens_details?.cached_tokens;
|
|
107
|
+
return typeof n === 'number' ? { cachedTokens: n } : {};
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
const EFFORT_ORDER: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
@@ -103,6 +123,11 @@ function nearestEffort(want: ReasoningEffort, supported: ReasoningEffort[]): Rea
|
|
|
103
123
|
function learnFromError(q: ModelQuirks, err: OpenAIErrorResponse['error'] | undefined, sent: { effort?: ReasoningEffort; maxTokens: number }): boolean {
|
|
104
124
|
if (!err?.message) return false;
|
|
105
125
|
const msg = err.message;
|
|
126
|
+
if (err.param === 'prompt_cache_key' || err.param === 'prompt_cache_retention' || /prompt_cache_(key|retention)/.test(msg)) {
|
|
127
|
+
if (q.noPromptCache) return false;
|
|
128
|
+
q.noPromptCache = true;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
106
131
|
if (err.param === 'temperature' || /'temperature'/.test(msg)) {
|
|
107
132
|
if (q.noTemperature) return false;
|
|
108
133
|
q.noTemperature = true;
|
|
@@ -183,6 +208,7 @@ interface OpenAIResponse {
|
|
|
183
208
|
prompt_tokens: number;
|
|
184
209
|
completion_tokens: number;
|
|
185
210
|
total_tokens: number;
|
|
211
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
186
212
|
};
|
|
187
213
|
}
|
|
188
214
|
|
|
@@ -216,7 +242,7 @@ interface OpenAIToolsResponse {
|
|
|
216
242
|
message: { role: string; content: string | null; tool_calls?: OpenAIToolCall[] };
|
|
217
243
|
finish_reason: string;
|
|
218
244
|
}>;
|
|
219
|
-
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
245
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };
|
|
220
246
|
}
|
|
221
247
|
|
|
222
248
|
const OPENAI_MODELS = [
|
|
@@ -351,13 +377,14 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
351
377
|
const response = await this.postAdaptivelyRaw(model, (q) => ({
|
|
352
378
|
...base,
|
|
353
379
|
...samplingParams(q, temperature, maxTokens, effort),
|
|
380
|
+
...cacheParams(q, options),
|
|
354
381
|
}), { effort, maxTokens }, controller.signal);
|
|
355
382
|
|
|
356
383
|
let content = '';
|
|
357
384
|
let reportedModel = model;
|
|
358
385
|
let finishReason: string | undefined;
|
|
359
386
|
let responseId: string | undefined;
|
|
360
|
-
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
387
|
+
const usage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
361
388
|
const reader = response.body!.getReader();
|
|
362
389
|
const decoder = new TextDecoder();
|
|
363
390
|
let buffer = '';
|
|
@@ -376,7 +403,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
376
403
|
id?: string;
|
|
377
404
|
model?: string;
|
|
378
405
|
choices?: Array<{ delta?: { content?: string | null }; finish_reason?: string | null }>;
|
|
379
|
-
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
406
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };
|
|
380
407
|
};
|
|
381
408
|
if (chunk.id) responseId = chunk.id;
|
|
382
409
|
if (chunk.model) reportedModel = chunk.model;
|
|
@@ -391,6 +418,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
391
418
|
usage.promptTokens = chunk.usage.prompt_tokens;
|
|
392
419
|
usage.completionTokens = chunk.usage.completion_tokens;
|
|
393
420
|
usage.totalTokens = chunk.usage.total_tokens;
|
|
421
|
+
Object.assign(usage, cachedTokens(chunk.usage));
|
|
394
422
|
}
|
|
395
423
|
}
|
|
396
424
|
}
|
|
@@ -450,6 +478,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
450
478
|
const data = await this.postAdaptively<OpenAIResponse>(model, (q) => ({
|
|
451
479
|
...base,
|
|
452
480
|
...samplingParams(q, temperature, maxTokens, effort),
|
|
481
|
+
...cacheParams(q, options),
|
|
453
482
|
}), { effort, maxTokens }, controller.signal);
|
|
454
483
|
|
|
455
484
|
clearTimeout(timeoutId);
|
|
@@ -470,6 +499,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
470
499
|
promptTokens: data.usage.prompt_tokens,
|
|
471
500
|
completionTokens: data.usage.completion_tokens,
|
|
472
501
|
totalTokens: data.usage.total_tokens,
|
|
502
|
+
...cachedTokens(data.usage),
|
|
473
503
|
},
|
|
474
504
|
model: data.model,
|
|
475
505
|
provider: this.name,
|
|
@@ -568,6 +598,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
568
598
|
const data = await this.postAdaptively<OpenAIToolsResponse>(model, (q) => ({
|
|
569
599
|
...base,
|
|
570
600
|
...samplingParams(q, temperature, maxTokens, effort),
|
|
601
|
+
...cacheParams(q, options),
|
|
571
602
|
}), { effort, maxTokens }, controller.signal);
|
|
572
603
|
clearTimeout(timeoutId);
|
|
573
604
|
const choice = data.choices?.[0];
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface TokenUsage {
|
|
|
7
7
|
promptTokens: number;
|
|
8
8
|
completionTokens: number;
|
|
9
9
|
totalTokens: number;
|
|
10
|
+
/** Of promptTokens, how many were served from the provider's prompt cache (billed at the cached rate). */
|
|
11
|
+
cachedTokens?: number;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -56,6 +58,15 @@ export interface GenerateOptions {
|
|
|
56
58
|
* a model look at a rendering of what it designed).
|
|
57
59
|
*/
|
|
58
60
|
images?: LLMImage[];
|
|
61
|
+
/**
|
|
62
|
+
* Prompt caching (OpenAI). Requests that share a long identical opening
|
|
63
|
+
* (instructions first, per-request data last) are billed at the cached rate
|
|
64
|
+
* when they reach the same cache: `cacheKey` routes them there, and
|
|
65
|
+
* `cacheRetention: '24h'` keeps the cache for a day instead of minutes, which
|
|
66
|
+
* is what makes it pay off for low-traffic features.
|
|
67
|
+
*/
|
|
68
|
+
cacheKey?: string;
|
|
69
|
+
cacheRetention?: 'in_memory' | '24h';
|
|
59
70
|
/** Arbitrary extra data the caller wants to thread through (e.g. domain preferences) */
|
|
60
71
|
extra?: Record<string, unknown>;
|
|
61
72
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token usage added up across every call a feature makes, per model, so what a
|
|
3
|
+
* request cost can be recorded and priced (a crochet design makes 3-7 calls on
|
|
4
|
+
* two models). Wrap any service whose methods resolve to `{ model, usage }`.
|
|
5
|
+
*/
|
|
6
|
+
import type { TokenUsage } from '../types';
|
|
7
|
+
|
|
8
|
+
export interface UsageCounts {
|
|
9
|
+
calls: number;
|
|
10
|
+
promptTokens: number;
|
|
11
|
+
completionTokens: number;
|
|
12
|
+
/** Of promptTokens, how many were served from the prompt cache. */
|
|
13
|
+
cachedTokens: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UsageTally extends UsageCounts {
|
|
17
|
+
byModel: Record<string, UsageCounts>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const zero = (): UsageCounts => ({ calls: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0 });
|
|
21
|
+
|
|
22
|
+
export function newUsageTally(): UsageTally {
|
|
23
|
+
return { ...zero(), byModel: {} };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Add one response's usage to the tally. Returns the response unchanged. */
|
|
27
|
+
export function addUsage<R extends { model?: string; usage?: TokenUsage }>(tally: UsageTally, response: R): R {
|
|
28
|
+
const u = response.usage;
|
|
29
|
+
const m = (tally.byModel[response.model ?? 'unknown'] ??= zero());
|
|
30
|
+
for (const c of [tally, m]) {
|
|
31
|
+
c.calls++;
|
|
32
|
+
c.promptTokens += u?.promptTokens ?? 0;
|
|
33
|
+
c.completionTokens += u?.completionTokens ?? 0;
|
|
34
|
+
c.cachedTokens += u?.cachedTokens ?? 0;
|
|
35
|
+
}
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The same service, with every call's usage added to `tally`. Methods in
|
|
41
|
+
* `methods` that return a promise of a response are counted; everything else
|
|
42
|
+
* passes through untouched.
|
|
43
|
+
*/
|
|
44
|
+
export function tallyingService<S extends object>(
|
|
45
|
+
service: S,
|
|
46
|
+
tally: UsageTally,
|
|
47
|
+
methods: readonly string[] = ['generateChatResponse', 'generateChatResponseStream', 'generate'],
|
|
48
|
+
): S {
|
|
49
|
+
return new Proxy(service, {
|
|
50
|
+
get(target, prop, receiver) {
|
|
51
|
+
const value = Reflect.get(target, prop, receiver);
|
|
52
|
+
if (typeof prop !== 'string' || !methods.includes(prop) || typeof value !== 'function') return value;
|
|
53
|
+
return (...args: unknown[]) => (value as (...a: unknown[]) => Promise<{ model?: string; usage?: TokenUsage }>).apply(target, args).then((r) => addUsage(tally, r));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|