@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.
- package/agent-meta/instructions/llm-prompt-caching.instructions.md +100 -0
- package/agent-meta/instructions/llm.instructions.md +13 -3
- package/agent-meta/manifest.json +16 -2
- package/agent-meta/skills/llm/SKILL.md +54 -4
- package/agent-meta/skills/llm-prompt-caching/SKILL.md +135 -0
- package/build/consts.d.ts +36 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +37 -4
- package/build/consts.js.map +1 -1
- package/build/execution/service.d.ts.map +1 -1
- package/build/execution/service.js +8 -3
- package/build/execution/service.js.map +1 -1
- package/build/execution/types.d.ts +20 -1
- package/build/execution/types.d.ts.map +1 -1
- package/build/execution/utils.d.ts +12 -1
- package/build/execution/utils.d.ts.map +1 -1
- package/build/execution/utils.js +22 -0
- package/build/execution/utils.js.map +1 -1
- package/build/helpers/cache.d.ts +17 -0
- package/build/helpers/cache.d.ts.map +1 -0
- package/build/helpers/cache.js +22 -0
- package/build/helpers/cache.js.map +1 -0
- package/build/helpers/index.d.ts +1 -0
- package/build/helpers/index.d.ts.map +1 -1
- package/build/helpers/index.js +1 -0
- package/build/helpers/index.js.map +1 -1
- package/build/helpers/spectate.d.ts.map +1 -1
- package/build/helpers/spectate.js +7 -0
- package/build/helpers/spectate.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/model.d.ts +1 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +90 -16
- package/build/model.js.map +1 -1
- package/build/plugins/anthropic.d.ts.map +1 -1
- package/build/plugins/anthropic.js +136 -21
- package/build/plugins/anthropic.js.map +1 -1
- package/build/plugins/openai.d.ts +9 -0
- package/build/plugins/openai.d.ts.map +1 -1
- package/build/plugins/openai.js +23 -1
- package/build/plugins/openai.js.map +1 -1
- package/build/plugins/types.d.ts +38 -5
- package/build/plugins/types.d.ts.map +1 -1
- package/build/prompt/index.d.ts +5 -0
- package/build/prompt/index.d.ts.map +1 -0
- package/build/prompt/index.js +4 -0
- package/build/prompt/index.js.map +1 -0
- package/build/prompt/plugins.d.ts +24 -0
- package/build/prompt/plugins.d.ts.map +1 -0
- package/build/prompt/plugins.js +64 -0
- package/build/prompt/plugins.js.map +1 -0
- package/build/prompt/render.d.ts +28 -0
- package/build/prompt/render.d.ts.map +1 -0
- package/build/prompt/render.js +39 -0
- package/build/prompt/render.js.map +1 -0
- package/build/prompt/service.d.ts +16 -0
- package/build/prompt/service.d.ts.map +1 -0
- package/build/prompt/service.js +145 -0
- package/build/prompt/service.js.map +1 -0
- package/build/prompt/types.d.ts +101 -0
- package/build/prompt/types.d.ts.map +1 -0
- package/build/prompt/types.js +2 -0
- package/build/prompt/types.js.map +1 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +3 -0
- package/build/service.js.map +1 -1
- package/build/types.d.ts +53 -3
- package/build/types.d.ts.map +1 -1
- package/build/utils/prompt.d.ts +14 -0
- package/build/utils/prompt.d.ts.map +1 -1
- package/build/utils/prompt.js +32 -0
- package/build/utils/prompt.js.map +1 -1
- package/package.json +13 -6
- package/src/consts.ts +43 -5
- package/src/execution/service.ts +9 -3
- package/src/execution/types.ts +21 -2
- package/src/execution/utils.ts +28 -1
- package/src/helpers/cache.ts +33 -0
- package/src/helpers/index.ts +1 -0
- package/src/helpers/spectate.ts +10 -0
- package/src/index.ts +1 -0
- package/src/model.ts +119 -16
- package/src/plugins/anthropic.ts +159 -20
- package/src/plugins/openai.ts +26 -1
- package/src/plugins/types.ts +42 -5
- package/src/prompt/index.ts +5 -0
- package/src/prompt/plugins.ts +69 -0
- package/src/prompt/render.ts +48 -0
- package/src/prompt/service.ts +194 -0
- package/src/prompt/types.ts +114 -0
- package/src/service.ts +3 -0
- package/src/types.ts +54 -3
- package/src/utils/prompt.ts +33 -0
- package/tests/execution.spec.ts +42 -0
- package/tests/plugins.spec.ts +279 -14
- 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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
/**
|
package/src/utils/prompt.ts
CHANGED
|
@@ -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
|
package/tests/execution.spec.ts
CHANGED
|
@@ -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()
|
package/tests/plugins.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
172
|
-
{ role: 'user' as const, content: '
|
|
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:
|
|
176
|
-
expect(msgs[0]!.content).
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
expect(
|
|
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)
|