@owlmeans/llm 0.1.15 → 0.1.16-rc.0

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.
Files changed (99) hide show
  1. package/agent-meta/instructions/llm-prompt-caching.instructions.md +100 -0
  2. package/agent-meta/instructions/llm.instructions.md +13 -3
  3. package/agent-meta/manifest.json +16 -2
  4. package/agent-meta/skills/llm/SKILL.md +54 -4
  5. package/agent-meta/skills/llm-prompt-caching/SKILL.md +135 -0
  6. package/build/consts.d.ts +36 -3
  7. package/build/consts.d.ts.map +1 -1
  8. package/build/consts.js +37 -4
  9. package/build/consts.js.map +1 -1
  10. package/build/execution/service.d.ts.map +1 -1
  11. package/build/execution/service.js +8 -3
  12. package/build/execution/service.js.map +1 -1
  13. package/build/execution/types.d.ts +20 -1
  14. package/build/execution/types.d.ts.map +1 -1
  15. package/build/execution/utils.d.ts +12 -1
  16. package/build/execution/utils.d.ts.map +1 -1
  17. package/build/execution/utils.js +22 -0
  18. package/build/execution/utils.js.map +1 -1
  19. package/build/helpers/cache.d.ts +17 -0
  20. package/build/helpers/cache.d.ts.map +1 -0
  21. package/build/helpers/cache.js +22 -0
  22. package/build/helpers/cache.js.map +1 -0
  23. package/build/helpers/index.d.ts +1 -0
  24. package/build/helpers/index.d.ts.map +1 -1
  25. package/build/helpers/index.js +1 -0
  26. package/build/helpers/index.js.map +1 -1
  27. package/build/helpers/spectate.d.ts.map +1 -1
  28. package/build/helpers/spectate.js +7 -0
  29. package/build/helpers/spectate.js.map +1 -1
  30. package/build/index.d.ts +1 -0
  31. package/build/index.d.ts.map +1 -1
  32. package/build/index.js +1 -0
  33. package/build/index.js.map +1 -1
  34. package/build/model.d.ts +1 -1
  35. package/build/model.d.ts.map +1 -1
  36. package/build/model.js +90 -16
  37. package/build/model.js.map +1 -1
  38. package/build/plugins/anthropic.d.ts.map +1 -1
  39. package/build/plugins/anthropic.js +136 -21
  40. package/build/plugins/anthropic.js.map +1 -1
  41. package/build/plugins/openai.d.ts +9 -0
  42. package/build/plugins/openai.d.ts.map +1 -1
  43. package/build/plugins/openai.js +23 -1
  44. package/build/plugins/openai.js.map +1 -1
  45. package/build/plugins/types.d.ts +38 -5
  46. package/build/plugins/types.d.ts.map +1 -1
  47. package/build/prompt/index.d.ts +5 -0
  48. package/build/prompt/index.d.ts.map +1 -0
  49. package/build/prompt/index.js +4 -0
  50. package/build/prompt/index.js.map +1 -0
  51. package/build/prompt/plugins.d.ts +24 -0
  52. package/build/prompt/plugins.d.ts.map +1 -0
  53. package/build/prompt/plugins.js +64 -0
  54. package/build/prompt/plugins.js.map +1 -0
  55. package/build/prompt/render.d.ts +28 -0
  56. package/build/prompt/render.d.ts.map +1 -0
  57. package/build/prompt/render.js +39 -0
  58. package/build/prompt/render.js.map +1 -0
  59. package/build/prompt/service.d.ts +16 -0
  60. package/build/prompt/service.d.ts.map +1 -0
  61. package/build/prompt/service.js +145 -0
  62. package/build/prompt/service.js.map +1 -0
  63. package/build/prompt/types.d.ts +101 -0
  64. package/build/prompt/types.d.ts.map +1 -0
  65. package/build/prompt/types.js +2 -0
  66. package/build/prompt/types.js.map +1 -0
  67. package/build/service.d.ts.map +1 -1
  68. package/build/service.js +3 -0
  69. package/build/service.js.map +1 -1
  70. package/build/types.d.ts +53 -3
  71. package/build/types.d.ts.map +1 -1
  72. package/build/utils/prompt.d.ts +14 -0
  73. package/build/utils/prompt.d.ts.map +1 -1
  74. package/build/utils/prompt.js +32 -0
  75. package/build/utils/prompt.js.map +1 -1
  76. package/package.json +13 -6
  77. package/src/consts.ts +43 -5
  78. package/src/execution/service.ts +9 -3
  79. package/src/execution/types.ts +21 -2
  80. package/src/execution/utils.ts +28 -1
  81. package/src/helpers/cache.ts +33 -0
  82. package/src/helpers/index.ts +1 -0
  83. package/src/helpers/spectate.ts +10 -0
  84. package/src/index.ts +1 -0
  85. package/src/model.ts +119 -16
  86. package/src/plugins/anthropic.ts +159 -20
  87. package/src/plugins/openai.ts +26 -1
  88. package/src/plugins/types.ts +42 -5
  89. package/src/prompt/index.ts +5 -0
  90. package/src/prompt/plugins.ts +69 -0
  91. package/src/prompt/render.ts +48 -0
  92. package/src/prompt/service.ts +194 -0
  93. package/src/prompt/types.ts +114 -0
  94. package/src/service.ts +3 -0
  95. package/src/types.ts +54 -3
  96. package/src/utils/prompt.ts +33 -0
  97. package/tests/execution.spec.ts +42 -0
  98. package/tests/plugins.spec.ts +279 -14
  99. package/tests/prompt.spec.ts +194 -0
@@ -0,0 +1,114 @@
1
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
2
+ import type { MessageFieldWithRole } from '@langchain/core/messages'
3
+ import type { InitializedService } from '@owlmeans/context'
4
+ import type {
5
+ CacheTtl, FileProviderRef, LlmPurpose, PromptBlock, PromptPolicy, SkillDefinition,
6
+ } from '@owlmeans/llm-common'
7
+ import type { LlmPlugin, LlmSystemBlock } from '../plugins/types.js'
8
+
9
+ /**
10
+ * Everything that shapes one composed system prompt. The serializable part
11
+ * ({@link PromptPolicy}) travels on the execution state; the rest is per-call.
12
+ */
13
+ export interface PromptInput extends PromptPolicy {
14
+ /** Skills supplied verbatim, without registering them first. */
15
+ inline?: SkillDefinition[]
16
+ /**
17
+ * Raw text for the volatile `Context` block — a caller's own system message, carried
18
+ * through unchanged. Never part of the cached prefix.
19
+ */
20
+ context?: string[]
21
+ /**
22
+ * Skill aliases requested for THIS call only. Rendered into `Context`, not `Skills`:
23
+ * a set that changes per call must not sit inside the region other calls cache.
24
+ */
25
+ callSkills?: string[]
26
+ }
27
+
28
+ export interface PromptComposeParams {
29
+ /** The ORIGINAL (unrefined) model the composed prompt will be sent to. */
30
+ model: BaseChatModel
31
+ /** Provider plugin governing the call — supplies the cache-marker strategy. */
32
+ provider?: LlmPlugin
33
+ purpose?: LlmPurpose
34
+ action?: string
35
+ /** Total breakpoint budget for the request. */
36
+ cacheMax?: number
37
+ /** File access a plugin may use to resolve knowledge from disk. */
38
+ files?: FileProviderRef
39
+ }
40
+
41
+ /** What a prompt plugin sees and may contribute to. */
42
+ export interface PromptContext extends PromptComposeParams {
43
+ input: PromptInput
44
+ /** The call's messages. Read them for detection; do NOT mutate. */
45
+ messages: readonly MessageFieldWithRole[]
46
+ /** Append a rendered chunk to a block. Empty text is ignored. */
47
+ add: (block: PromptBlock, text: string) => void
48
+ /** Resolve skill aliases through the registry, following `requires`. */
49
+ resolve: (aliases: readonly string[]) => SkillDefinition[]
50
+ }
51
+
52
+ /**
53
+ * A unit of system-prompt composition, registered on the {@link PromptService} at context
54
+ * composition time. This is the seam that makes the LLM layer pluggable: the package
55
+ * ships the role and skills plugins, an application adds its own.
56
+ *
57
+ * A plugin MUST be deterministic — same input, same bytes. Anything it contributes to a
58
+ * cached block and cannot reproduce exactly invalidates the prefix for every call that
59
+ * shares it.
60
+ */
61
+ export interface LlmPromptPlugin {
62
+ alias: string
63
+ /** Lower runs first. The built-ins occupy 0 (role), 10 (skills) and 90 (context). */
64
+ order?: number
65
+ /** Contribute static content, before anything has looked at the messages. */
66
+ compose?: (ctx: PromptContext) => void | Promise<void>
67
+ /** Contribute content derived from the messages (detection, lookup, fetch). */
68
+ inspect?: (ctx: PromptContext) => void | Promise<void>
69
+ }
70
+
71
+ export interface PromptResult {
72
+ /** The composed system message, or `null` when nothing was contributed. */
73
+ system: MessageFieldWithRole | null
74
+ /** Breakpoints the system prompt consumed; subtract from the message budget. */
75
+ breakpoints: number
76
+ /** The rendered blocks in emission order — exposed for tests and diagnostics. */
77
+ blocks: LlmSystemBlock[]
78
+ }
79
+
80
+ export interface PromptServiceOptions {
81
+ /** Skills registered up front; equivalent to calling `register` after construction. */
82
+ skills?: SkillDefinition[]
83
+ /** Plugins registered up front, in addition to the built-ins. */
84
+ plugins?: LlmPromptPlugin[]
85
+ /** Default for `PromptPolicy.cacheSystem`. Defaults to `true`. */
86
+ cacheSystem?: boolean
87
+ /** Default for `PromptPolicy.cacheTtl`. */
88
+ cacheTtl?: CacheTtl
89
+ }
90
+
91
+ /**
92
+ * Registry of reusable skills plus the plugin chain that turns a role and a set of skill
93
+ * aliases into a cache-friendly system prompt.
94
+ */
95
+ export interface PromptService extends InitializedService {
96
+ /** Register a composition plugin. Runs in `order` then registration order. */
97
+ use: (plugin: LlmPromptPlugin) => void
98
+ /** Register (or replace, by alias) reusable skills. */
99
+ register: (...skills: SkillDefinition[]) => void
100
+ has: (alias: string) => boolean
101
+ /** Resolve aliases to definitions, pulling in `requires` transitively. Unknown aliases are skipped. */
102
+ resolve: (aliases: readonly string[]) => SkillDefinition[]
103
+ /** Every registered skill, in deterministic order. */
104
+ skills: () => SkillDefinition[]
105
+ compose: (
106
+ input: PromptInput,
107
+ messages: readonly MessageFieldWithRole[],
108
+ params: PromptComposeParams,
109
+ ) => Promise<PromptResult>
110
+ }
111
+
112
+ export interface WithPromptService {
113
+ prompts: () => PromptService
114
+ }
package/src/service.ts CHANGED
@@ -51,6 +51,9 @@ export const llmServiceApi = (options: LlmServiceOptions, self: () => LlmService
51
51
  throw new LlmMissconfiguredError(alias)
52
52
  }
53
53
  const config: ModelConfig = { ...baseConfig, ...override }
54
+ // The service-wide idle deadline is a floor, not an override: a preset that states its
55
+ // own `streamTimeout` knows something specific about that model and keeps it.
56
+ config.streamTimeout ??= options.streamTimeout
54
57
  const preset: Partial<ModelConfig> = config.preset != null
55
58
  ? { ...(models.find(m => m.alias === config.preset) ?? {}) }
56
59
  : {}
package/src/types.ts CHANGED
@@ -4,8 +4,10 @@ import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/cor
4
4
  import type { JSONSchemaType } from 'ajv'
5
5
  import type { InitializedService } from '@owlmeans/context'
6
6
  import type {
7
- LlmPurpose, ModelProvider, NullCapture, SpectatorArgument, SpectatorEntryLogged,
7
+ FileProviderRef, LlmPurpose, ModelProvider, NullCapture, SpectatorArgument,
8
+ SpectatorEntryLogged,
8
9
  } from '@owlmeans/llm-common'
10
+ import type { PromptInput, PromptService } from './prompt/types.js'
9
11
 
10
12
  export type MaybeArray<T> = T | T[]
11
13
 
@@ -47,6 +49,20 @@ export interface LlmLogging {
47
49
  export interface LlmModelOptions extends LlmLogging {
48
50
  model: BaseChatModel
49
51
  retries?: number
52
+ /**
53
+ * Role and skills for every call this model makes. Composed into a cacheable system
54
+ * prompt by {@link PromptService}; ignored when no `prompts` resolver is supplied.
55
+ * Usually just `exec.prompt` from the helper execution.
56
+ */
57
+ prompt?: PromptInput
58
+ /**
59
+ * Late-bound resolver for the prompt service — a function, like `Execution.models`, so
60
+ * the service can be swapped or cloned. Without it the model behaves exactly as before
61
+ * this layer existed: the caller's messages are sent untouched.
62
+ */
63
+ prompts?: () => PromptService
64
+ /** File access offered to prompt plugins that resolve knowledge from disk. */
65
+ files?: FileProviderRef
50
66
  }
51
67
 
52
68
  export type ModelMessage = BaseMessage | MessageFieldWithRole
@@ -56,10 +72,23 @@ export type ModelInput = MaybeArray<ModelInputItem>
56
72
  export interface LlmCallOptions {
57
73
  /** Short name of the operation — used as the LangChain run name and in spectator entries. */
58
74
  action: string
59
- /** Ask the provider to cache the prompt prefix (no-op for providers without prompt caching). */
75
+ /**
76
+ * Cache the leading MESSAGES too. The composed system prompt is cached by default and
77
+ * independently of this flag — this one is about the conversation prefix, which is only
78
+ * worth caching when the same leading messages recur across calls.
79
+ */
60
80
  useCache?: boolean
61
- /** How many leading messages to mark as cacheable (capped by the provider's own limit). */
81
+ /**
82
+ * How many leading messages form the stable prefix. One breakpoint is placed at its
83
+ * end (not one per message), capped by whatever the system prompt left unspent.
84
+ */
62
85
  cacheMax?: number
86
+ /**
87
+ * Skill aliases for THIS call only. Rendered into the volatile `Context` block, so they
88
+ * never disturb the cached region — declare a skill on the execution instead when it
89
+ * should be part of the shared prefix.
90
+ */
91
+ skills?: string[]
63
92
  }
64
93
 
65
94
  export interface LlmAskOptions extends LlmCallOptions {
@@ -183,11 +212,33 @@ export interface ModelConfig {
183
212
  * rotating providers mid-call would flip the structured-output format.
184
213
  */
185
214
  fallback?: Partial<ModelConfig>
215
+ /**
216
+ * Per-model override of {@link MIN_CACHEABLE_TOKENS} — the shortest prefix worth a
217
+ * cache breakpoint. Anthropic's own minimum is model-dependent and NOT monotonic
218
+ * across generations (512 on the newest, 1024 on most, 4096 on a few older ones), so a
219
+ * preset that pins an old model should raise this rather than pay for markers that
220
+ * silently never cache.
221
+ */
222
+ cacheMinTokens?: number
223
+ /**
224
+ * Cache-routing key for providers whose prompt cache is automatic (OpenAI's
225
+ * `prompt_cache_key`): requests sharing a key are routed to the same backend, which
226
+ * raises the hit rate for a shared prefix. Must be STABLE and low-cardinality — one
227
+ * value per role, never per user or per request. Defaults to the config alias.
228
+ */
229
+ cacheKey?: string
186
230
  }
187
231
 
188
232
  export interface LlmServiceOptions {
189
233
  /** The full config list; resolved by `alias` on every `getModel` call. */
190
234
  models: () => ModelConfig[]
235
+ /**
236
+ * Idle deadline (ms) applied to every model this service builds, unless the model's own
237
+ * config overrides it. This is the knob an application sets where it composes its
238
+ * context — one place to tune how long the whole deployment waits on a silent provider,
239
+ * without touching a preset. Falls back to {@link MODEL_STREAM_TIMEOUT_MS}.
240
+ */
241
+ streamTimeout?: number
191
242
  }
192
243
 
193
244
  /**
@@ -23,6 +23,39 @@ const appendDirective = (msgs: MessageFieldWithRole[], text: string): void => {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Drop every `cache_control` marker from the messages.
28
+ *
29
+ * Markers are placed in-place, on the caller's own message objects — and a caller that
30
+ * carries its message array across calls (the coder's growing conversation, a fix loop
31
+ * re-sending the same sources) hands them back still marked. The provider counts markers
32
+ * per REQUEST, not per message: Anthropic rejects the fifth outright with
33
+ * `400 A maximum of 4 blocks with cache_control may be provided. Found 5.`, and since a
34
+ * 400 is fatal it burns the entire retry budget before surfacing.
35
+ *
36
+ * So the pipeline always starts from a clean slate and re-places its own markers, which
37
+ * makes the per-request count a function of THIS call alone.
38
+ */
39
+ export const stripCacheMarkers = (msgs: MessageFieldWithRole[]): void => {
40
+ for (const msg of msgs) {
41
+ if (!Array.isArray(msg.content)) {
42
+ continue
43
+ }
44
+ let found = false
45
+ const blocks = msg.content.map(part => {
46
+ if (typeof part === 'object' && part !== null && 'cache_control' in part) {
47
+ found = true
48
+ const { cache_control: _dropped, ...rest } = part as Record<string, unknown>
49
+ return rest
50
+ }
51
+ return part
52
+ })
53
+ if (found) {
54
+ msg.content = blocks as unknown as typeof msg.content
55
+ }
56
+ }
57
+ }
58
+
26
59
  /**
27
60
  * Ensure the word "json" appears somewhere in the prompt. Several providers refuse or
28
61
  * silently ignore a JSON mode unless it does; when it is missing the JSON instruction is
@@ -180,6 +180,48 @@ describe('@owlmeans/llm — snapshot and restore', () => {
180
180
  })
181
181
  })
182
182
 
183
+ describe('@owlmeans/llm — prompt policy accumulation', () => {
184
+ const withPrompt = () => service.root({
185
+ models: makeService(),
186
+ policy: { effort: DEFAULT_EFFORT },
187
+ purpose: { type: 'spec' },
188
+ prompt: { role: 'project role', skills: ['base'] },
189
+ })
190
+
191
+ // Skills accumulate as work narrows — that is how a helper ends up knowing everything
192
+ // the project, the task and its own role declared, without any of them repeating it.
193
+ test('skills accumulate down the chain while the deepest role wins', () => {
194
+ const task = service.forTask(withPrompt(), { prompt: { skills: ['task'] } })
195
+ expect(task.prompt?.skills).toEqual(['base', 'task'])
196
+ expect(task.prompt?.role).toBe('project role')
197
+
198
+ const helper = service.forHelper(task, {
199
+ role: Role.Analyst, prompt: { role: 'helper role', skills: ['helper'] },
200
+ })
201
+ expect(helper.prompt?.skills).toEqual(['base', 'task', 'helper'])
202
+ expect(helper.prompt?.role).toBe('helper role')
203
+ })
204
+
205
+ // A skill declared twice must not render twice, or the composed prefix differs from the
206
+ // one a single declaration would have produced.
207
+ test('a repeated skill is unioned, not duplicated', () => {
208
+ const task = service.forTask(withPrompt(), { prompt: { skills: ['base', 'task'] } })
209
+ expect(task.prompt?.skills).toEqual(['base', 'task'])
210
+ })
211
+
212
+ test('levels that declare nothing inherit the policy untouched', () => {
213
+ const helper = service.forHelper(service.forTask(withPrompt(), {}), { role: Role.Analyst })
214
+ expect(helper.prompt).toEqual({ role: 'project role', skills: ['base'] })
215
+ })
216
+
217
+ // It is part of ExecutionState, so a resumed run rebuilds the same system prompt.
218
+ test('the policy survives a snapshot/restore round trip', () => {
219
+ const helper = service.forHelper(withPrompt(), { role: Role.Analyst })
220
+ const restored = service.restore(service.snapshot(helper), { models: makeService() })
221
+ expect(restored.prompt).toEqual({ role: 'project role', skills: ['base'] })
222
+ })
223
+ })
224
+
183
225
  describe('@owlmeans/llm — resilience plugin seam', () => {
184
226
  test('checkpoint is a no-op until a plugin is registered', async () => {
185
227
  await expect(service.checkpoint(root, 'key')).resolves.toBeUndefined()
@@ -2,13 +2,14 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { ChatAnthropic } from '@langchain/anthropic'
3
3
  import { ChatOpenAI } from '@langchain/openai'
4
4
  import { BadRequestError } from '@anthropic-ai/sdk'
5
- import { ModelProvider, StructuredMode } from '@owlmeans/llm-common'
5
+ import { ModelProvider, PromptBlock, StructuredMode } from '@owlmeans/llm-common'
6
6
  import {
7
7
  anthropicPlugin, compatiblePlugin, makeLlmService, openAiPlugin, pluginFor, pluginOf,
8
8
  registerLlmPlugin, resolvePlugin,
9
9
  } from '@owlmeans/llm'
10
10
  import type { LlmPlugin, ModelConfig } from '@owlmeans/llm'
11
11
  import { offlineConfigs, Role } from './context.js'
12
+ import { stripCacheMarkers } from '../src/utils/prompt.js'
12
13
 
13
14
  const build = (plugin: LlmPlugin, config: Partial<ModelConfig> = {}) =>
14
15
  plugin.build({
@@ -164,26 +165,251 @@ describe('@owlmeans/llm — retry escalation behaviour', () => {
164
165
  })
165
166
  })
166
167
 
167
- describe('@owlmeans/llm — prompt caching', () => {
168
- test('anthropic marks up to the requested number of leading messages', () => {
168
+ describe('@owlmeans/llm — message prompt caching', () => {
169
+ /** `cacheMinTokens: 1` puts the minimum at 4 characters so fixtures stay readable. */
170
+ const cheap = () => build(anthropicPlugin, { model: 'claude-haiku-4-5-20251001', cacheMinTokens: 1 })
171
+
172
+ // The budget is four breakpoints for the WHOLE request, and the composed system prompt
173
+ // has first claim on it — so the messages get one marker at the end of their stable
174
+ // prefix, not one marker each.
175
+ test('anthropic places a single breakpoint at the end of the stable prefix', () => {
176
+ const msgs = [
177
+ { role: 'system' as const, content: 'aaaa' },
178
+ { role: 'user' as const, content: 'bbbb' },
179
+ { role: 'user' as const, content: 'cccc' },
180
+ ]
181
+ expect(anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 2 })).toBe(true)
182
+ expect(msgs[0]!.content).toBe('aaaa')
183
+ expect(msgs[1]!.content).toEqual([{ type: 'text', text: 'bbbb', cache_control: { type: 'ephemeral' } }])
184
+ expect(msgs[2]!.content).toBe('cccc')
185
+ })
186
+
187
+ // The last message is the per-call payload (and `ensureJsonMention` / `applyNoThink`
188
+ // append to it) — caching it would write a fresh entry every call and read none.
189
+ test('the final message is never part of the cached prefix', () => {
190
+ const msgs = [
191
+ { role: 'system' as const, content: 'aaaa' },
192
+ { role: 'user' as const, content: 'bbbb' },
193
+ ]
194
+ expect(anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 9 })).toBe(true)
195
+ expect(msgs[0]!.content).toEqual([{ type: 'text', text: 'aaaa', cache_control: { type: 'ephemeral' } }])
196
+ expect(msgs[1]!.content).toBe('bbbb')
197
+
198
+ const single = [{ role: 'user' as const, content: 'aaaa' }]
199
+ expect(anthropicPlugin.patchCache?.(single, { model: cheap(), useCache: true, cacheMax: 4 })).toBe(false)
200
+ expect(single[0]!.content).toBe('aaaa')
201
+ })
202
+
203
+ test('a marker is appended to block content rather than replacing it, and is idempotent', () => {
204
+ const msgs = [
205
+ { role: 'system' as const, content: [{ type: 'text', text: 'aa' }, { type: 'text', text: 'bb' }] },
206
+ { role: 'user' as const, content: 'cccc' },
207
+ ]
208
+ expect(anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 1 })).toBe(true)
209
+ expect(msgs[0]!.content).toEqual([
210
+ { type: 'text', text: 'aa' },
211
+ { type: 'text', text: 'bb', cache_control: { type: 'ephemeral' } },
212
+ ])
213
+
214
+ const before = JSON.stringify(msgs[0]!.content)
215
+ expect(anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 1 })).toBe(true)
216
+ expect(JSON.stringify(msgs[0]!.content)).toBe(before)
217
+ })
218
+
219
+ // A prefix under the provider's own minimum is silently never cached, so a marker there
220
+ // buys nothing and costs one of the four breakpoints.
221
+ test('a prefix below the cacheable minimum is left unmarked', () => {
169
222
  const model = build(anthropicPlugin, { model: 'claude-haiku-4-5-20251001' })
170
223
  const msgs = [
171
- { role: 'system' as const, content: 'a' },
172
- { role: 'user' as const, content: 'b' },
173
- { role: 'user' as const, content: 'c' },
224
+ { role: 'system' as const, content: 'short' },
225
+ { role: 'user' as const, content: 'also short' },
174
226
  ]
175
- expect(anthropicPlugin.patchCache?.(msgs, { model, useCache: true, cacheMax: 2 })).toBe(true)
176
- expect(msgs[0]!.content).toEqual([{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }])
177
- expect(msgs[1]!.content).toEqual([{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }])
178
- expect(msgs[2]!.content).toBe('c')
227
+ expect(anthropicPlugin.patchCache?.(msgs, { model, useCache: true, cacheMax: 4 })).toBe(false)
228
+ expect(msgs[0]!.content).toBe('short')
229
+ })
230
+
231
+ test('the message budget yields to whatever the system prompt already spent', () => {
232
+ const msgs = [
233
+ { role: 'system' as const, content: 'aaaa' },
234
+ { role: 'user' as const, content: 'bbbb' },
235
+ ]
236
+ expect(anthropicPlugin.patchCache?.(
237
+ msgs, { model: cheap(), useCache: true, cacheMax: 4, reserved: 4 }
238
+ )).toBe(false)
239
+ expect(msgs[0]!.content).toBe('aaaa')
179
240
  })
180
241
 
181
242
  test('caching is a no-op when not requested, and for providers without it', () => {
182
- const model = build(anthropicPlugin, { model: 'claude-haiku-4-5-20251001' })
183
- const msgs = [{ role: 'user' as const, content: 'a' }]
184
- expect(anthropicPlugin.patchCache?.(msgs, { model, useCache: false, cacheMax: 4 })).toBe(false)
185
- expect(msgs[0]!.content).toBe('a')
243
+ const msgs = [{ role: 'user' as const, content: 'aaaa' }, { role: 'user' as const, content: 'bbbb' }]
244
+ expect(anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: false, cacheMax: 4 })).toBe(false)
245
+ expect(msgs[0]!.content).toBe('aaaa')
186
246
  expect(openAiPlugin.patchCache).toBeUndefined()
247
+ expect(openAiPlugin.patchSystem).toBeUndefined()
248
+ })
249
+ })
250
+
251
+ describe('@owlmeans/llm — system prompt caching', () => {
252
+ const model = () => build(anthropicPlugin, { model: 'claude-haiku-4-5-20251001', cacheMinTokens: 1 })
253
+ const blocks = (...pairs: Array<[PromptBlock, string]>) =>
254
+ pairs.map(([block, text]) => ({ block, text }))
255
+
256
+ const marks = (content: unknown): boolean[] =>
257
+ (content as Array<Record<string, unknown>>).map(part => part.cache_control != null)
258
+
259
+ // Role + skills is the region every call of this role shares; packages vary with the
260
+ // request. Two boundaries, so a changing package block can never invalidate the skills.
261
+ test('breakpoints land on the stability boundaries, not on every block', () => {
262
+ const render = anthropicPlugin.patchSystem?.(
263
+ blocks(
264
+ [PromptBlock.Role, 'role text'],
265
+ [PromptBlock.Skills, 'skill text'],
266
+ [PromptBlock.Packages, 'package text'],
267
+ ),
268
+ { model: model(), cacheMax: 3, ttl: '5m' },
269
+ )
270
+ expect(render?.breakpoints).toBe(2)
271
+ expect(marks(render?.content)).toEqual([false, true, true])
272
+ })
273
+
274
+ // "Fully cached by default" also has to hold for a caller that has not migrated and
275
+ // still hands over a single system message of its own.
276
+ test('a lone context block is still cached', () => {
277
+ const render = anthropicPlugin.patchSystem?.(
278
+ blocks([PromptBlock.Context, 'legacy system message']),
279
+ { model: model(), cacheMax: 3, ttl: '5m' },
280
+ )
281
+ expect(render?.breakpoints).toBe(1)
282
+ expect(marks(render?.content)).toEqual([true])
283
+ })
284
+
285
+ test('marking stops at the budget, earliest boundary first', () => {
286
+ const render = anthropicPlugin.patchSystem?.(
287
+ blocks(
288
+ [PromptBlock.Skills, 'skill text'],
289
+ [PromptBlock.Packages, 'package text'],
290
+ [PromptBlock.Context, 'context text'],
291
+ ),
292
+ { model: model(), cacheMax: 1, ttl: '5m' },
293
+ )
294
+ expect(render?.breakpoints).toBe(1)
295
+ expect(marks(render?.content)).toEqual([true, false, false])
296
+ })
297
+
298
+ // An explicit `"ttl": "5m"` is different BYTES from no ttl at all, and a prefix cached
299
+ // one way would not match the other.
300
+ test('the default ttl is omitted from the marker, and 1h is spelled out', () => {
301
+ const short = anthropicPlugin.patchSystem?.(
302
+ blocks([PromptBlock.Skills, 'skill text']), { model: model(), cacheMax: 3, ttl: '5m' }
303
+ )
304
+ expect((short?.content as Array<Record<string, unknown>>)[0]!.cache_control)
305
+ .toEqual({ type: 'ephemeral' })
306
+
307
+ const long = anthropicPlugin.patchSystem?.(
308
+ blocks([PromptBlock.Skills, 'skill text']), { model: model(), cacheMax: 3, ttl: '1h' }
309
+ )
310
+ expect((long?.content as Array<Record<string, unknown>>)[0]!.cache_control)
311
+ .toEqual({ type: 'ephemeral', ttl: '1h' })
312
+ })
313
+
314
+ // A trailing context block changes every call. Marking it would pay a cache WRITE per
315
+ // call and never read one back — a breakpoint spent to buy nothing.
316
+ test('a trailing context block is never marked when stable blocks precede it', () => {
317
+ const render = anthropicPlugin.patchSystem?.(
318
+ blocks(
319
+ [PromptBlock.Role, 'role text'],
320
+ [PromptBlock.Skills, 'skill text'],
321
+ [PromptBlock.Context, 'per-call text'],
322
+ ),
323
+ { model: model(), cacheMax: 2, ttl: '5m' },
324
+ )
325
+ expect(render?.breakpoints).toBe(1)
326
+ expect(marks(render?.content)).toEqual([false, true, false])
327
+ })
328
+
329
+ test('the packages boundary is still marked with context trailing behind it', () => {
330
+ const render = anthropicPlugin.patchSystem?.(
331
+ blocks(
332
+ [PromptBlock.Role, 'role text'],
333
+ [PromptBlock.Skills, 'skill text'],
334
+ [PromptBlock.Packages, 'package text'],
335
+ [PromptBlock.Context, 'per-call text'],
336
+ ),
337
+ { model: model(), cacheMax: 2, ttl: '5m' },
338
+ )
339
+ expect(render?.breakpoints).toBe(2)
340
+ expect(marks(render?.content)).toEqual([false, true, true, false])
341
+ })
342
+
343
+ test('a non-claude model still renders the blocks, just without markers', () => {
344
+ const render = anthropicPlugin.patchSystem?.(
345
+ blocks([PromptBlock.Role, 'role text']),
346
+ { model: build(anthropicPlugin, { model: 'some-other-model' }), cacheMax: 3, ttl: '5m' },
347
+ )
348
+ expect(render?.breakpoints).toBe(0)
349
+ expect(render?.content).toEqual([{ type: 'text', text: 'role text' }])
350
+ })
351
+ })
352
+
353
+ describe('@owlmeans/llm — the four-breakpoint request budget', () => {
354
+ const cheap = () => build(anthropicPlugin, { model: 'claude-haiku-4-5-20251001', cacheMinTokens: 1 })
355
+
356
+ const markers = (msgs: Array<{ content: unknown }>): number =>
357
+ msgs.reduce((sum, msg) => sum + (Array.isArray(msg.content)
358
+ ? (msg.content as Array<Record<string, unknown>>).filter(b => b.cache_control != null).length
359
+ : 0), 0)
360
+
361
+ // The live failure: a caller that carries its message array across calls hands back
362
+ // messages this pipeline already marked. They accumulate until Anthropic rejects the
363
+ // request with `400 A maximum of 4 blocks with cache_control may be provided. Found 5.`
364
+ test('markers left over from a previous call are cleared before new ones are placed', () => {
365
+ const msgs = [
366
+ { role: 'system' as const, content: 'aaaa' },
367
+ { role: 'user' as const, content: 'bbbb' },
368
+ { role: 'user' as const, content: 'cccc' },
369
+ ]
370
+ // Call one marks the stable prefix.
371
+ anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 2 })
372
+ expect(markers(msgs)).toBe(1)
373
+
374
+ // Call two: the caller appends a turn and re-sends the SAME objects.
375
+ msgs.push({ role: 'user' as const, content: 'dddd' })
376
+ stripCacheMarkers(msgs)
377
+ expect(markers(msgs)).toBe(0)
378
+
379
+ anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 3 })
380
+ expect(markers(msgs)).toBe(1)
381
+ })
382
+
383
+ test('the system prompt plus the message prefix never exceed the provider limit', () => {
384
+ // Worst case: every stability boundary distinct, so the system claims its full share.
385
+ const system = anthropicPlugin.patchSystem?.(
386
+ [
387
+ { block: PromptBlock.Role, text: 'role text' },
388
+ { block: PromptBlock.Skills, text: 'skill text' },
389
+ { block: PromptBlock.Packages, text: 'package text' },
390
+ { block: PromptBlock.Context, text: 'context text' },
391
+ ],
392
+ { model: cheap(), cacheMax: 3, ttl: '5m' },
393
+ )
394
+ const reserved = system?.breakpoints ?? 0
395
+ expect(reserved).toBeLessThanOrEqual(3)
396
+
397
+ const msgs = [
398
+ { role: 'system' as const, content: system?.content as never },
399
+ { role: 'user' as const, content: 'bbbb' },
400
+ { role: 'user' as const, content: 'cccc' },
401
+ ]
402
+ anthropicPlugin.patchCache?.(msgs, { model: cheap(), useCache: true, cacheMax: 4, reserved })
403
+ expect(markers(msgs)).toBeLessThanOrEqual(4)
404
+ })
405
+
406
+ test('stripping leaves the rest of a content block untouched', () => {
407
+ const msgs = [{
408
+ role: 'user' as const,
409
+ content: [{ type: 'text', text: 'keep me', cache_control: { type: 'ephemeral' } }] as never,
410
+ }]
411
+ stripCacheMarkers(msgs)
412
+ expect(msgs[0]!.content).toEqual([{ type: 'text', text: 'keep me' }] as never)
187
413
  })
188
414
  })
189
415
 
@@ -193,6 +419,22 @@ describe('@owlmeans/llm — fatal error classification', () => {
193
419
  expect(anthropicPlugin.isFatal?.(bad)).toBe(bad)
194
420
  expect(anthropicPlugin.isFatal?.(new Error('transient'))).toBeNull()
195
421
  })
422
+
423
+ // `@langchain/anthropic` bundles its OWN nested copy of `@anthropic-ai/sdk`, so the
424
+ // error it throws is an instance of a different class than the one imported here and
425
+ // `instanceof` silently misses. That turned every fatal 400 into eight full retries.
426
+ test('a 400 from a foreign SDK copy is still fatal', () => {
427
+ const foreign = Object.assign(new Error('400 too many cache_control blocks'), { status: 400 })
428
+ expect(anthropicPlugin.isFatal?.(foreign)).toBe(foreign)
429
+ expect(openAiPlugin.isFatal?.(foreign)).toBe(foreign)
430
+ expect(compatiblePlugin.isFatal?.(foreign)).toBe(foreign)
431
+ })
432
+
433
+ test('a retryable status is not treated as fatal', () => {
434
+ const overloaded = Object.assign(new Error('529'), { status: 529 })
435
+ expect(anthropicPlugin.isFatal?.(overloaded)).toBeNull()
436
+ expect(openAiPlugin.isFatal?.(overloaded)).toBeNull()
437
+ })
196
438
  })
197
439
 
198
440
  describe('@owlmeans/llm — service', () => {
@@ -203,6 +445,29 @@ describe('@owlmeans/llm — service', () => {
203
445
  expect(service.getModel(Role.Analyst, {}, true)).not.toBe(first)
204
446
  })
205
447
 
448
+ // The knob an application sets where it composes its context: one place to bound how
449
+ // long the whole deployment waits on a silent provider.
450
+ test('a service-wide idle deadline reaches every model it builds', () => {
451
+ const service = makeLlmService(
452
+ { models: offlineConfigs, streamTimeout: 90_000 }, 'spec-llm-timeout'
453
+ )
454
+ const config = (service.getModel(Role.Analyst) as unknown as {
455
+ metadata: { config: ModelConfig }
456
+ }).metadata.config
457
+ expect(config.streamTimeout).toBe(90_000)
458
+ })
459
+
460
+ // A preset that names its own deadline knows something specific about that model.
461
+ test('a model config keeps its own deadline over the service default', () => {
462
+ const configs = (): ModelConfig[] =>
463
+ offlineConfigs().map(c => c.alias === Role.Analyst ? { ...c, streamTimeout: 12_000 } : c)
464
+ const service = makeLlmService({ models: configs, streamTimeout: 90_000 }, 'spec-llm-timeout-2')
465
+ const config = (service.getModel(Role.Analyst) as unknown as {
466
+ metadata: { config: ModelConfig }
467
+ }).metadata.config
468
+ expect(config.streamTimeout).toBe(12_000)
469
+ })
470
+
206
471
  test('an override participates in the cache key', () => {
207
472
  const service = makeLlmService({ models: offlineConfigs }, 'spec-llm-override')
208
473
  const plain = service.getModel(Role.Analyst)