@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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,
@@ -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
+ }