mohdel 0.116.1 → 0.117.1

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.
@@ -113,6 +113,11 @@
113
113
  * @typedef {object} MessagePart
114
114
  * @property {('text'|'reasoning')} type
115
115
  * @property {string} text
116
+ * @property {('5m'|'1h')} [cache]
117
+ * Prompt-cache marker. On system parts: a breakpoint at this block.
118
+ * On non-system parts: opts the whole conversation into prefix
119
+ * caching (adapter places the breakpoints). Providers with
120
+ * automatic caching ignore it.
116
121
  */
117
122
 
118
123
  /**
@@ -90,7 +90,7 @@ export async function * anthropic (envelope, deps = {}) {
90
90
  const start = String(process.hrtime.bigint())
91
91
  let first = null
92
92
 
93
- const { system, conversation } = splitPrompt(envelope.prompt)
93
+ const { system, conversation, conversationCacheTtl } = splitPrompt(envelope.prompt)
94
94
 
95
95
  // Attach images to the last user message before building the request.
96
96
  if (envelope.images?.length) {
@@ -105,7 +105,7 @@ export async function * anthropic (envelope, deps = {}) {
105
105
  }
106
106
  }
107
107
 
108
- const request = buildRequest(envelope, conversation, system)
108
+ const request = buildRequest(envelope, conversation, system, conversationCacheTtl)
109
109
 
110
110
  // F53: accumulate via array + join to avoid per-delta V8 cons-string
111
111
  // churn. Materialized at each exit point.
@@ -303,8 +303,11 @@ function safeParseJson (s) {
303
303
  * @param {string | Array<{type: string, text: string, cache_control?: object}>} system
304
304
  * Either a flat string (legacy) or an array of typed blocks with optional
305
305
  * `cache_control` for prompt caching. The Anthropic SDK accepts both shapes.
306
+ * @param {'5m'|'1h'|null} [conversationCacheTtl]
307
+ * When set, place conversation-prefix breakpoints (see
308
+ * `applyCacheBreakpoints`).
306
309
  */
307
- function buildRequest (envelope, conversation, system) {
310
+ function buildRequest (envelope, conversation, system, conversationCacheTtl = null) {
308
311
  const spec = getSpec(catalogKey(envelope.model))
309
312
  const outputTokenLimit = spec?.outputTokenLimit
310
313
 
@@ -347,22 +350,106 @@ function buildRequest (envelope, conversation, system) {
347
350
  }
348
351
  }
349
352
 
353
+ applyCacheBreakpoints(request, conversationCacheTtl)
354
+
350
355
  return request
351
356
  }
352
357
 
358
+ /** Anthropic caps `cache_control` breakpoints at 4 per request. */
359
+ const MAX_CACHE_BREAKPOINTS = 4
360
+
361
+ /**
362
+ * Conversation breakpoints sit at stable block positions (indices
363
+ * ≡ 15 mod 16) so that consecutive requests share entry positions
364
+ * even when one request appends more blocks than Anthropic's
365
+ * 20-block cache lookback covers.
366
+ */
367
+ const MILESTONE_INTERVAL = 16
368
+
369
+ /** @param {'5m'|'1h'} ttl */
370
+ function cacheControlFor (ttl) {
371
+ return ttl === '1h'
372
+ ? { type: 'ephemeral', ttl: '1h' }
373
+ : { type: 'ephemeral' }
374
+ }
375
+
376
+ /**
377
+ * A `cache: '5m'|'1h'` marker on any non-system message part opts the
378
+ * conversation into prefix caching. Over-cap system breakpoints are
379
+ * dropped middle-first: the first one is the most stable read fallback
380
+ * (volatile blocks like session memory sit later), the last covers the
381
+ * full system prompt. Dropping a breakpoint never changes prompt
382
+ * content — only cache eligibility.
383
+ *
384
+ * @param {{system?: any, messages: Array<{role: string, content: any}>}} request
385
+ * @param {'5m'|'1h'|null} conversationTtl
386
+ */
387
+ function applyCacheBreakpoints (request, conversationTtl) {
388
+ let messageBreakpoints = 0
389
+ if (conversationTtl && request.messages.length) {
390
+ messageBreakpoints = placeConversationBreakpoints(request.messages, conversationTtl)
391
+ }
392
+ if (Array.isArray(request.system)) {
393
+ const marked = request.system.filter(b => b.cache_control)
394
+ const budget = MAX_CACHE_BREAKPOINTS - messageBreakpoints
395
+ while (marked.length > budget && marked.length > 0) {
396
+ const drop = marked.length > 1 ? marked.splice(1, 1)[0] : marked.shift()
397
+ delete drop.cache_control
398
+ }
399
+ }
400
+ }
401
+
402
+ /**
403
+ * @param {Array<{role: string, content: any}>} messages
404
+ * @param {'5m'|'1h'} ttl
405
+ * @returns {number} breakpoints placed
406
+ */
407
+ function placeConversationBreakpoints (messages, ttl) {
408
+ const blockCount = messages.reduce(
409
+ (n, m) => n + (Array.isArray(m.content) ? m.content.length : 1), 0)
410
+ if (blockCount === 0) return 0
411
+ const trailing = blockCount - 1
412
+ const targets = [trailing]
413
+ const milestone = MILESTONE_INTERVAL * Math.floor(trailing / MILESTONE_INTERVAL) - 1
414
+ if (milestone >= 0 && milestone !== trailing) targets.unshift(milestone)
415
+ let placed = 0
416
+ let idx = 0
417
+ for (const m of messages) {
418
+ const len = Array.isArray(m.content) ? m.content.length : 1
419
+ for (const t of targets) {
420
+ if (t >= idx && t < idx + len) {
421
+ if (!Array.isArray(m.content)) {
422
+ m.content = [{ type: 'text', text: m.content }]
423
+ }
424
+ m.content[t - idx].cache_control = cacheControlFor(ttl)
425
+ placed++
426
+ }
427
+ }
428
+ idx += len
429
+ }
430
+ return placed
431
+ }
432
+
353
433
  /** @param {string | import('#core/envelope.js').Message[]} prompt */
354
434
  function splitPrompt (prompt) {
355
435
  if (typeof prompt === 'string') {
356
- return { system: '', conversation: [{ role: 'user', content: prompt }] }
436
+ return { system: '', conversation: [{ role: 'user', content: prompt }], conversationCacheTtl: null }
357
437
  }
358
438
  /** @type {string[]} */
359
439
  const systemParts = []
360
440
  /** @type {Array<{type: string, text: string, cache_control?: object}>} */
361
441
  const systemBlocks = []
362
442
  let hasCacheMarkers = false
443
+ /** @type {'5m'|'1h'|null} */
444
+ let conversationCacheTtl = null
363
445
  /** @type {Array<{role: string, content: any}>} */
364
446
  const conversation = []
365
447
  for (const m of prompt) {
448
+ if (m.role !== 'system' && Array.isArray(m.content)) {
449
+ for (const p of m.content) {
450
+ if (p?.cache === '5m' || p?.cache === '1h') conversationCacheTtl = p.cache
451
+ }
452
+ }
366
453
  if (m.role === 'system') {
367
454
  // Translate spore-style cache markers ({text, cache: '5m'|'1h'}) into
368
455
  // Anthropic's cache_control. Preserves the block boundary that spore
@@ -429,7 +516,7 @@ function splitPrompt (prompt) {
429
516
  const system = hasCacheMarkers
430
517
  ? systemBlocks
431
518
  : systemParts.filter(Boolean).join('\n\n')
432
- return { system, conversation }
519
+ return { system, conversation, conversationCacheTtl }
433
520
  }
434
521
 
435
522
  /** @param {string | import('#core/envelope.js').MessagePart[]} content */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.116.1",
3
+ "version": "0.117.1",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -87,16 +87,16 @@
87
87
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0",
88
88
  "@opentelemetry/sdk-node": "^0.220.0",
89
89
  "chalk": "^5.4.0",
90
- "mohdel-thin-gate-linux-x64-gnu": "0.116.1"
90
+ "mohdel-thin-gate-linux-x64-gnu": "0.117.1"
91
91
  },
92
92
  "dependencies": {
93
- "@anthropic-ai/sdk": "^0.110.0",
94
- "@cerebras/cerebras_cloud_sdk": "^1.61.1",
95
- "@google/genai": "^2.11.0",
93
+ "@anthropic-ai/sdk": "^0.112.2",
94
+ "@cerebras/cerebras_cloud_sdk": "^1.91.0",
95
+ "@google/genai": "^2.12.0",
96
96
  "@opentelemetry/api": "^1.9.1",
97
97
  "env-paths": "^4.0.0",
98
98
  "groq-sdk": "^1.3.0",
99
- "openai": "^6.46.0",
99
+ "openai": "^6.48.0",
100
100
  "undici": "^7.24.5"
101
101
  },
102
102
  "lint-staged": {