@cjhyy/code-shell-core 0.9.3 → 0.9.4
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/dist/engine/engine.d.ts +8 -0
- package/dist/engine/engine.js +16 -12
- package/dist/engine/model-facade.d.ts +3 -0
- package/dist/engine/model-facade.js +2 -0
- package/dist/engine/run-tooling.js +8 -8
- package/dist/engine/streaming-tool-queue.d.ts +4 -1
- package/dist/engine/streaming-tool-queue.js +17 -2
- package/dist/engine/turn-loop.d.ts +14 -5
- package/dist/engine/turn-loop.js +92 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/llm/prompt-cache.d.ts +48 -0
- package/dist/llm/prompt-cache.js +100 -0
- package/dist/llm/providers/anthropic.d.ts +3 -0
- package/dist/llm/providers/anthropic.js +77 -53
- package/dist/llm/providers/openai.d.ts +7 -27
- package/dist/llm/providers/openai.js +120 -68
- package/dist/llm/types.d.ts +3 -0
- package/dist/panel-apps/manifest.d.ts +12 -12
- package/dist/profile/types.d.ts +26 -26
- package/dist/session/session-manager.js +30 -1
- package/dist/tool-system/context.d.ts +9 -4
- package/dist/tool-system/external-tool-exposure.js +11 -10
- package/package.json +2 -1
|
@@ -9,6 +9,7 @@ import { countTokens } from "../token-counter.js";
|
|
|
9
9
|
import { capabilitiesFor } from "../capabilities/index.js";
|
|
10
10
|
import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
|
|
11
11
|
import { stripVisionFromHistory } from "../strip-vision.js";
|
|
12
|
+
import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
|
|
12
13
|
/**
|
|
13
14
|
* Anthropic's `max_tokens` is required, so unlike OpenAI we can't omit it when
|
|
14
15
|
* the model's ceiling is unknown. Use a conservative floor in that rare case
|
|
@@ -75,12 +76,22 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
75
76
|
}
|
|
76
77
|
return this._capability;
|
|
77
78
|
}
|
|
79
|
+
promptCachePolicy(request) {
|
|
80
|
+
return resolvePromptCachePolicy({
|
|
81
|
+
provider: this.provider,
|
|
82
|
+
providerKind: this.config.providerKind,
|
|
83
|
+
model: this.model,
|
|
84
|
+
request,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
78
87
|
getPromptCacheConfigIdentity() {
|
|
88
|
+
const cachePolicy = this.promptCachePolicy();
|
|
79
89
|
return {
|
|
80
90
|
...super.getPromptCacheConfigIdentity(),
|
|
81
|
-
cacheStrategy:
|
|
82
|
-
cacheLayoutVersion:
|
|
83
|
-
|
|
91
|
+
cacheStrategy: cachePolicy.strategy,
|
|
92
|
+
cacheLayoutVersion: cachePolicy.layoutVersion,
|
|
93
|
+
cacheBreakpoints: cachePolicy.breakpoints,
|
|
94
|
+
breakpointCount: cachePolicy.breakpoints.length,
|
|
84
95
|
reasoningShape: this.capability.reasoning,
|
|
85
96
|
};
|
|
86
97
|
}
|
|
@@ -133,8 +144,9 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
133
144
|
}
|
|
134
145
|
async createMessage(options) {
|
|
135
146
|
return this.withRetry(async (requestSignal) => {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
147
|
+
const cachePolicy = this.promptCachePolicy(options.promptCache);
|
|
148
|
+
const messages = this.buildMessages(options.messages, options.promptCache, cachePolicy);
|
|
149
|
+
const tools = options.tools ? this.convertTools(options.tools, cachePolicy) : undefined;
|
|
138
150
|
// One span per outbound LLM request. Begin emits debug-level so the
|
|
139
151
|
// info log isn't spammed during normal operation; end emits info with
|
|
140
152
|
// duration_ms + usage so `--debug=llm` already gives a quick latency
|
|
@@ -146,6 +158,7 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
146
158
|
stream: !!(options.stream && options.onChunk),
|
|
147
159
|
messageCount: messages.length,
|
|
148
160
|
toolCount: tools?.length ?? 0,
|
|
161
|
+
cacheStrategy: cachePolicy.strategy,
|
|
149
162
|
});
|
|
150
163
|
try {
|
|
151
164
|
const response = options.stream && options.onChunk
|
|
@@ -176,13 +189,7 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
176
189
|
const response = await this.client.messages.create({
|
|
177
190
|
model: this.model,
|
|
178
191
|
max_tokens: maxTokens,
|
|
179
|
-
system:
|
|
180
|
-
{
|
|
181
|
-
type: "text",
|
|
182
|
-
text: options.systemPrompt,
|
|
183
|
-
cache_control: { type: "ephemeral" },
|
|
184
|
-
},
|
|
185
|
-
],
|
|
192
|
+
system: this.buildSystem(options.systemPrompt, this.promptCachePolicy(options.promptCache)),
|
|
186
193
|
messages,
|
|
187
194
|
...(tools?.length ? { tools } : {}),
|
|
188
195
|
...(thinking ? { thinking } : {}),
|
|
@@ -207,13 +214,7 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
207
214
|
const stream = this.client.messages.stream({
|
|
208
215
|
model: this.model,
|
|
209
216
|
max_tokens: maxTokens,
|
|
210
|
-
system:
|
|
211
|
-
{
|
|
212
|
-
type: "text",
|
|
213
|
-
text: options.systemPrompt,
|
|
214
|
-
cache_control: { type: "ephemeral" },
|
|
215
|
-
},
|
|
216
|
-
],
|
|
217
|
+
system: this.buildSystem(options.systemPrompt, this.promptCachePolicy(options.promptCache)),
|
|
217
218
|
messages,
|
|
218
219
|
...(tools?.length ? { tools } : {}),
|
|
219
220
|
...(thinking ? { thinking } : {}),
|
|
@@ -316,10 +317,27 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
316
317
|
stopReason: response.stop_reason ?? undefined,
|
|
317
318
|
};
|
|
318
319
|
}
|
|
319
|
-
|
|
320
|
+
buildSystem(systemPrompt, policy) {
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
type: "text",
|
|
324
|
+
text: systemPrompt,
|
|
325
|
+
...(policy.breakpoints.includes("system")
|
|
326
|
+
? { cache_control: { type: "ephemeral" } }
|
|
327
|
+
: {}),
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
buildMessages(messages, promptCache, policy) {
|
|
320
332
|
messages = stripVisionFromHistory(messages, this.capability.supportsVision);
|
|
321
333
|
const result = [];
|
|
322
|
-
|
|
334
|
+
const stablePrefixMessageCount = Math.max(0, Math.min(messages.length, promptCache?.stablePrefixMessageCount ?? messages.length));
|
|
335
|
+
let stablePrefixEndMessage;
|
|
336
|
+
for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
|
|
337
|
+
if (sourceIndex === stablePrefixMessageCount) {
|
|
338
|
+
stablePrefixEndMessage = result[result.length - 1];
|
|
339
|
+
}
|
|
340
|
+
const msg = messages[sourceIndex];
|
|
323
341
|
if (msg.role === "system")
|
|
324
342
|
continue;
|
|
325
343
|
const role = msg.role === "tool" ? "user" : msg.role;
|
|
@@ -390,40 +408,45 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
390
408
|
}
|
|
391
409
|
}
|
|
392
410
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
lastMsg.content = [
|
|
406
|
-
{
|
|
407
|
-
type: "text",
|
|
408
|
-
text: lastMsg.content,
|
|
409
|
-
cache_control: { type: "ephemeral" },
|
|
410
|
-
},
|
|
411
|
-
];
|
|
412
|
-
}
|
|
413
|
-
else {
|
|
414
|
-
const lastBlock = lastMsg.content[lastMsg.content.length - 1];
|
|
415
|
-
// Skip thinking/redacted_thinking blocks: they reject cache_control and
|
|
416
|
-
// marking them would 400. buildMessages never emits them today (thinking
|
|
417
|
-
// is a top-level request field, not history content), but guard anyway
|
|
418
|
-
// so a future block type can't silently break the request.
|
|
419
|
-
if (lastBlock && lastBlock.type !== "thinking" && lastBlock.type !== "redacted_thinking") {
|
|
420
|
-
lastBlock.cache_control = { type: "ephemeral" };
|
|
421
|
-
}
|
|
422
|
-
}
|
|
411
|
+
stablePrefixEndMessage ??= result[result.length - 1];
|
|
412
|
+
const stablePrefixEndIndex = stablePrefixEndMessage
|
|
413
|
+
? result.indexOf(stablePrefixEndMessage)
|
|
414
|
+
: undefined;
|
|
415
|
+
const breakpointIndexes = uniquePromptCacheBreakpointIndexes([
|
|
416
|
+
policy.breakpoints.includes("stable-history") && stablePrefixEndIndex !== undefined
|
|
417
|
+
? stablePrefixEndIndex
|
|
418
|
+
: undefined,
|
|
419
|
+
policy.breakpoints.includes("rolling-history") ? result.length - 1 : undefined,
|
|
420
|
+
]);
|
|
421
|
+
for (const index of breakpointIndexes) {
|
|
422
|
+
this.markAnthropicCacheBreakpoint(result[index]);
|
|
423
423
|
}
|
|
424
424
|
return result;
|
|
425
425
|
}
|
|
426
|
-
|
|
426
|
+
markAnthropicCacheBreakpoint(message) {
|
|
427
|
+
if (!message)
|
|
428
|
+
return;
|
|
429
|
+
if (typeof message.content === "string") {
|
|
430
|
+
message.content = [
|
|
431
|
+
{
|
|
432
|
+
type: "text",
|
|
433
|
+
text: message.content,
|
|
434
|
+
cache_control: { type: "ephemeral" },
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
for (let index = message.content.length - 1; index >= 0; index--) {
|
|
440
|
+
const block = message.content[index];
|
|
441
|
+
// Thinking blocks reject cache_control. Walk backward so a preceding
|
|
442
|
+
// cacheable text/tool block can still anchor the prefix.
|
|
443
|
+
if (block.type === "thinking" || block.type === "redacted_thinking")
|
|
444
|
+
continue;
|
|
445
|
+
block.cache_control = { type: "ephemeral" };
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
convertTools(tools, policy) {
|
|
427
450
|
const converted = tools.map((t) => ({
|
|
428
451
|
name: t.name,
|
|
429
452
|
description: t.description,
|
|
@@ -436,8 +459,9 @@ export class AnthropicClient extends LLMClientBase {
|
|
|
436
459
|
// not per-tool); a second marker here would waste a scarce cache_control
|
|
437
460
|
// slot (max 4) and risk KV eviction. See docs/todo/prompt-cache-optimization.md.
|
|
438
461
|
const last = converted[converted.length - 1];
|
|
439
|
-
if (last)
|
|
462
|
+
if (last && policy.breakpoints.includes("tools")) {
|
|
440
463
|
last.cache_control = { type: "ephemeral" };
|
|
464
|
+
}
|
|
441
465
|
return converted;
|
|
442
466
|
}
|
|
443
467
|
handleApiError(err) {
|
|
@@ -52,6 +52,9 @@ export declare class OpenAIClient extends LLMClientBase {
|
|
|
52
52
|
private readonly dangerouslyAllowBrowser;
|
|
53
53
|
private _forceMaxCompletionTokens;
|
|
54
54
|
private _dropReasoningEffort;
|
|
55
|
+
/** Compatibility fallbacks for OpenAI-compatible gateways that lag the API. */
|
|
56
|
+
private _disableExplicitPromptCache;
|
|
57
|
+
private _disablePromptCacheKey;
|
|
55
58
|
constructor(config: LLMConfig, defaults?: ClientDefaults, runtimeOptions?: {
|
|
56
59
|
dangerouslyAllowBrowser?: boolean;
|
|
57
60
|
});
|
|
@@ -66,20 +69,7 @@ export declare class OpenAIClient extends LLMClientBase {
|
|
|
66
69
|
*/
|
|
67
70
|
private _capability;
|
|
68
71
|
private get capability();
|
|
69
|
-
|
|
70
|
-
* True when this client routes an Anthropic-family model through OpenRouter's
|
|
71
|
-
* OpenAI-compatible endpoint. Anthropic caching is EXPLICIT — nothing is
|
|
72
|
-
* cached unless the request carries `cache_control` breakpoints (verified live
|
|
73
|
-
* 2026-07-02: plain requests to anthropic/claude-opus-4.7-fast via OpenRouter
|
|
74
|
-
* report cached_tokens 0 on every repeat; a single system-block breakpoint
|
|
75
|
-
* turns the whole stable prefix — tools + system — into a cache hit, ~89%
|
|
76
|
-
* cheaper on the follow-up). OpenAI and other OpenRouter models cache
|
|
77
|
-
* automatically, so they must NOT get breakpoints. The slug arrives resolved
|
|
78
|
-
* (e.g. "anthropic/claude-opus-4.7-fast") or as the router alias
|
|
79
|
-
* ("~anthropic/claude-opus-latest") — both start with an optional "~" then
|
|
80
|
-
* "anthropic/".
|
|
81
|
-
*/
|
|
82
|
-
private get isOpenRouterAnthropic();
|
|
72
|
+
private promptCachePolicy;
|
|
83
73
|
getPromptCacheConfigIdentity(): Readonly<Record<string, unknown>>;
|
|
84
74
|
createMessage(options: CreateMessageOptions): Promise<LLMResponse>;
|
|
85
75
|
/**
|
|
@@ -93,20 +83,10 @@ export declare class OpenAIClient extends LLMClientBase {
|
|
|
93
83
|
private processChoice;
|
|
94
84
|
private buildMessages;
|
|
95
85
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* 1. System block — the stable prefix. Anthropic sees tools BEFORE the
|
|
99
|
-
* system prompt, so one marker on the system block caches tools too
|
|
100
|
-
* (verified live: system-only marker cached 3511/3952 prompt tokens
|
|
101
|
-
* including tool defs).
|
|
102
|
-
* 2. Last message — one rolling breakpoint so the growing conversation
|
|
103
|
-
* history becomes a cached prefix. Not scrolled: as history grows the
|
|
104
|
-
* "last message" naturally advances and its tail is the next write.
|
|
105
|
-
* A string `content` is lifted to a single-element `[{type:"text",...}]`
|
|
106
|
-
* array so it can carry `cache_control`; OpenRouter accepts this OpenAI
|
|
107
|
-
* multimodal wire form for text.
|
|
86
|
+
* Translate semantic prefix boundaries to the active wire format. Both
|
|
87
|
+
* formats annotate content blocks without reordering messages.
|
|
108
88
|
*/
|
|
109
|
-
private
|
|
89
|
+
private applyPromptCacheBreakpoints;
|
|
110
90
|
private convertTools;
|
|
111
91
|
private handleApiError;
|
|
112
92
|
}
|
|
@@ -16,7 +16,8 @@ import { capabilitiesFor } from "../capabilities/index.js";
|
|
|
16
16
|
import { clampMaxTokens } from "../clamp-max-tokens.js";
|
|
17
17
|
import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
|
|
18
18
|
import { stripVisionFromHistory } from "../strip-vision.js";
|
|
19
|
-
import {
|
|
19
|
+
import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
|
|
20
|
+
import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError } from "../stream-watchdog.js";
|
|
20
21
|
/**
|
|
21
22
|
* Extract prompt-cache counts from an OpenAI-compatible usage object.
|
|
22
23
|
*
|
|
@@ -74,9 +75,7 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
|
|
|
74
75
|
// An explicit idleTimeoutMs always activates the watchdog. Otherwise follow
|
|
75
76
|
// disableWatchdog (per-call override) if set, else the env default.
|
|
76
77
|
const watchdogActive = opts.idleTimeoutMs !== undefined ||
|
|
77
|
-
(opts.disableWatchdog === undefined
|
|
78
|
-
? STREAM_WATCHDOG_CONFIG.enabled
|
|
79
|
-
: !opts.disableWatchdog);
|
|
78
|
+
(opts.disableWatchdog === undefined ? STREAM_WATCHDOG_CONFIG.enabled : !opts.disableWatchdog);
|
|
80
79
|
const idleTimeoutMs = opts.idleTimeoutMs ?? STREAM_WATCHDOG_CONFIG.idleTimeoutMs;
|
|
81
80
|
let text = "";
|
|
82
81
|
// Fast path: watchdog disabled AND caller did not override → no overhead.
|
|
@@ -224,6 +223,9 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
224
223
|
// succeed. Omitting the field just means "model default reasoning", which is
|
|
225
224
|
// fine for our background/aux calls.
|
|
226
225
|
_dropReasoningEffort = false;
|
|
226
|
+
/** Compatibility fallbacks for OpenAI-compatible gateways that lag the API. */
|
|
227
|
+
_disableExplicitPromptCache = false;
|
|
228
|
+
_disablePromptCacheKey = false;
|
|
227
229
|
constructor(config, defaults, runtimeOptions = {}) {
|
|
228
230
|
super(config, defaults);
|
|
229
231
|
this.dangerouslyAllowBrowser = runtimeOptions.dangerouslyAllowBrowser === true;
|
|
@@ -264,30 +266,26 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
264
266
|
}
|
|
265
267
|
return this._capability;
|
|
266
268
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
* "anthropic/".
|
|
279
|
-
*/
|
|
280
|
-
get isOpenRouterAnthropic() {
|
|
281
|
-
return this.config.providerKind === "openrouter" && /^~?anthropic\//.test(this.model);
|
|
269
|
+
promptCachePolicy(request) {
|
|
270
|
+
const policy = resolvePromptCachePolicy({
|
|
271
|
+
provider: this.provider,
|
|
272
|
+
providerKind: this.config.providerKind,
|
|
273
|
+
model: this.model,
|
|
274
|
+
request,
|
|
275
|
+
explicitDisabled: this._disableExplicitPromptCache,
|
|
276
|
+
});
|
|
277
|
+
return this._disablePromptCacheKey && policy.cacheKey
|
|
278
|
+
? { ...policy, cacheKey: undefined }
|
|
279
|
+
: policy;
|
|
282
280
|
}
|
|
283
281
|
getPromptCacheConfigIdentity() {
|
|
284
282
|
const capability = this.capability;
|
|
283
|
+
const cachePolicy = this.promptCachePolicy();
|
|
285
284
|
return {
|
|
286
285
|
...super.getPromptCacheConfigIdentity(),
|
|
287
|
-
cacheStrategy:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
cacheLayoutVersion: this.isOpenRouterAnthropic ? "system-history-v1" : "automatic-v1",
|
|
286
|
+
cacheStrategy: cachePolicy.strategy,
|
|
287
|
+
cacheLayoutVersion: cachePolicy.layoutVersion,
|
|
288
|
+
cacheBreakpoints: cachePolicy.breakpoints,
|
|
291
289
|
tokenLimitField: this._forceMaxCompletionTokens
|
|
292
290
|
? "max_completion_tokens"
|
|
293
291
|
: capability.tokenLimitField,
|
|
@@ -295,6 +293,8 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
295
293
|
rejectedParams: [...capability.rejectedParams].sort(),
|
|
296
294
|
forceMaxCompletionTokens: this._forceMaxCompletionTokens,
|
|
297
295
|
dropReasoningEffort: this._dropReasoningEffort,
|
|
296
|
+
disableExplicitPromptCache: this._disableExplicitPromptCache,
|
|
297
|
+
disablePromptCacheKey: this._disablePromptCacheKey,
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
async createMessage(options) {
|
|
@@ -305,7 +305,7 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
305
305
|
// Per-call reasoning wins; otherwise fall back to provider default
|
|
306
306
|
// (settings.providers[].reasoning, threaded through LLMConfig).
|
|
307
307
|
const reasoning = options.reasoning ?? this.config.reasoning;
|
|
308
|
-
const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning);
|
|
308
|
+
const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning, options.promptCache);
|
|
309
309
|
const tools = options.tools?.length ? this.convertTools(options.tools) : undefined;
|
|
310
310
|
const span = logger.span("llm.request", {
|
|
311
311
|
cat: "llm",
|
|
@@ -314,6 +314,7 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
314
314
|
stream: !!(options.stream && options.onChunk),
|
|
315
315
|
messageCount: messages.length,
|
|
316
316
|
toolCount: tools?.length ?? 0,
|
|
317
|
+
cacheStrategy: this.promptCachePolicy(options.promptCache).strategy,
|
|
317
318
|
});
|
|
318
319
|
try {
|
|
319
320
|
const response = options.stream && options.onChunk
|
|
@@ -341,6 +342,7 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
341
342
|
*/
|
|
342
343
|
buildRequestBody(options, messages, tools, reasoning, stream) {
|
|
343
344
|
const cap = this.capability;
|
|
345
|
+
const cachePolicy = this.promptCachePolicy(options.promptCache);
|
|
344
346
|
// Clamp to the model's known output ceiling so a stale catalog value
|
|
345
347
|
// (e.g. 384000 inherited after a hot model switch) can't 400 a
|
|
346
348
|
// smaller-cap model. No known cap → send the value as-is.
|
|
@@ -449,10 +451,10 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
449
451
|
// requested summary level to that object. For the bare `reasoning_effort`
|
|
450
452
|
// shape there's no summary field on chat-completions, so we skip it rather
|
|
451
453
|
// than send an unknown top-level param.
|
|
452
|
-
if (this.config.reasoningSummary &&
|
|
454
|
+
if (this.config.reasoningSummary &&
|
|
455
|
+
reasoningBody.reasoning &&
|
|
453
456
|
typeof reasoningBody.reasoning === "object") {
|
|
454
|
-
reasoningBody.reasoning.summary =
|
|
455
|
-
this.config.reasoningSummary;
|
|
457
|
+
reasoningBody.reasoning.summary = this.config.reasoningSummary;
|
|
456
458
|
}
|
|
457
459
|
// Catalog-driven passthrough params (temperature/top_p/thinking etc, already
|
|
458
460
|
// wire-mapped from the connection's paramValues by applyParams). Filter each
|
|
@@ -489,6 +491,12 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
489
491
|
...paramBody,
|
|
490
492
|
// service_tier (TODO 7.2): passed through verbatim when configured.
|
|
491
493
|
...(this.config.serviceTier ? { service_tier: this.config.serviceTier } : {}),
|
|
494
|
+
...(options.promptCache && cachePolicy.cacheKey
|
|
495
|
+
? { prompt_cache_key: cachePolicy.cacheKey }
|
|
496
|
+
: {}),
|
|
497
|
+
...(options.promptCache && cachePolicy.promptCacheOptions
|
|
498
|
+
? { prompt_cache_options: cachePolicy.promptCacheOptions }
|
|
499
|
+
: {}),
|
|
492
500
|
...(tools ? { tools } : {}),
|
|
493
501
|
...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
|
|
494
502
|
};
|
|
@@ -740,7 +748,7 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
740
748
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
741
749
|
};
|
|
742
750
|
}
|
|
743
|
-
buildMessages(systemPrompt, messages, reasoning) {
|
|
751
|
+
buildMessages(systemPrompt, messages, reasoning, promptCache) {
|
|
744
752
|
const result = [{ role: "system", content: systemPrompt }];
|
|
745
753
|
// Drop historical image blocks when the active model can't accept vision.
|
|
746
754
|
// Engine.run only gates *new* attachments; an image left in history from
|
|
@@ -748,6 +756,8 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
748
756
|
// below and 400s ("unknown variant `image_url`") after a model switch.
|
|
749
757
|
// Identity-preserving on the common path (vision models / no images).
|
|
750
758
|
messages = stripVisionFromHistory(messages, this.capability.supportsVision);
|
|
759
|
+
const stablePrefixMessageCount = Math.max(0, Math.min(messages.length, promptCache?.stablePrefixMessageCount ?? messages.length));
|
|
760
|
+
let stablePrefixEndMessage = stablePrefixMessageCount === 0 ? result[0] : undefined;
|
|
751
761
|
// Reasoning-content echo-back contract — driven by capability:
|
|
752
762
|
// "when-tools" : backfill an empty placeholder if the prior assistant
|
|
753
763
|
// turn doesn't carry one (DeepSeek V4 + tools 400s
|
|
@@ -760,11 +770,13 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
760
770
|
const cap = this.capability;
|
|
761
771
|
const hasTools = messages.some((m) => Array.isArray(m.content) &&
|
|
762
772
|
m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
|
|
763
|
-
const needsReasoningBackfill = reasoning?.mode !== "off" &&
|
|
764
|
-
cap.echoReasoning === "when-tools" &&
|
|
765
|
-
hasTools;
|
|
773
|
+
const needsReasoningBackfill = reasoning?.mode !== "off" && cap.echoReasoning === "when-tools" && hasTools;
|
|
766
774
|
const stripReasoning = cap.echoReasoning === "never";
|
|
767
|
-
for (
|
|
775
|
+
for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
|
|
776
|
+
if (sourceIndex === stablePrefixMessageCount) {
|
|
777
|
+
stablePrefixEndMessage = result[result.length - 1];
|
|
778
|
+
}
|
|
779
|
+
const msg = messages[sourceIndex];
|
|
768
780
|
if (msg.role === "system")
|
|
769
781
|
continue;
|
|
770
782
|
if (msg.role === "assistant") {
|
|
@@ -948,55 +960,64 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
948
960
|
}
|
|
949
961
|
}
|
|
950
962
|
}
|
|
963
|
+
stablePrefixEndMessage ??= result[result.length - 1];
|
|
951
964
|
const normalized = normalizeOpenAIToolMessagePairs(result);
|
|
952
|
-
|
|
953
|
-
|
|
965
|
+
const stablePrefixEndIndex = stablePrefixEndMessage
|
|
966
|
+
? normalized.indexOf(stablePrefixEndMessage)
|
|
967
|
+
: undefined;
|
|
968
|
+
const cachePolicy = this.promptCachePolicy(promptCache);
|
|
969
|
+
if (promptCache || cachePolicy.strategy === "anthropic-explicit") {
|
|
970
|
+
this.applyPromptCacheBreakpoints(normalized, cachePolicy, stablePrefixEndIndex !== undefined && stablePrefixEndIndex >= 0
|
|
971
|
+
? stablePrefixEndIndex
|
|
972
|
+
: undefined);
|
|
954
973
|
}
|
|
955
974
|
return normalized;
|
|
956
975
|
}
|
|
957
976
|
/**
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* 1. System block — the stable prefix. Anthropic sees tools BEFORE the
|
|
961
|
-
* system prompt, so one marker on the system block caches tools too
|
|
962
|
-
* (verified live: system-only marker cached 3511/3952 prompt tokens
|
|
963
|
-
* including tool defs).
|
|
964
|
-
* 2. Last message — one rolling breakpoint so the growing conversation
|
|
965
|
-
* history becomes a cached prefix. Not scrolled: as history grows the
|
|
966
|
-
* "last message" naturally advances and its tail is the next write.
|
|
967
|
-
* A string `content` is lifted to a single-element `[{type:"text",...}]`
|
|
968
|
-
* array so it can carry `cache_control`; OpenRouter accepts this OpenAI
|
|
969
|
-
* multimodal wire form for text.
|
|
977
|
+
* Translate semantic prefix boundaries to the active wire format. Both
|
|
978
|
+
* formats annotate content blocks without reordering messages.
|
|
970
979
|
*/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
980
|
+
applyPromptCacheBreakpoints(messages, policy, stablePrefixEndIndex) {
|
|
981
|
+
if (policy.strategy !== "anthropic-explicit" && policy.strategy !== "openai-explicit") {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const markedMessages = new Set();
|
|
985
|
+
const mark = (index) => {
|
|
986
|
+
let cursor = Math.min(index, messages.length - 1);
|
|
987
|
+
let m;
|
|
988
|
+
while (cursor >= 0) {
|
|
989
|
+
const candidate = messages[cursor];
|
|
990
|
+
if ((typeof candidate.content === "string" && candidate.content.length > 0) ||
|
|
991
|
+
(Array.isArray(candidate.content) && candidate.content.length > 0)) {
|
|
992
|
+
m = candidate;
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
cursor--;
|
|
996
|
+
}
|
|
997
|
+
if (!m || markedMessages.has(m))
|
|
974
998
|
return;
|
|
999
|
+
markedMessages.add(m);
|
|
1000
|
+
const marker = policy.strategy === "anthropic-explicit"
|
|
1001
|
+
? { cache_control: { type: "ephemeral" } }
|
|
1002
|
+
: { prompt_cache_breakpoint: { mode: "explicit" } };
|
|
975
1003
|
// Lift a plain-string content to a text-block array so it can carry the
|
|
976
|
-
// marker. Non-text content
|
|
977
|
-
// array of parts — mark the last part instead.
|
|
1004
|
+
// marker. Non-text content already uses an array of parts.
|
|
978
1005
|
if (typeof m.content === "string") {
|
|
979
|
-
m.content = [
|
|
980
|
-
{ type: "text", text: m.content, cache_control: { type: "ephemeral" } },
|
|
981
|
-
];
|
|
1006
|
+
m.content = [{ type: "text", text: m.content, ...marker }];
|
|
982
1007
|
return;
|
|
983
1008
|
}
|
|
984
1009
|
if (Array.isArray(m.content) && m.content.length > 0) {
|
|
985
|
-
// cache_control is an Anthropic-via-OpenRouter extension field, not in
|
|
986
|
-
// the OpenAI content-part union — attach through `unknown`.
|
|
987
1010
|
const last = m.content[m.content.length - 1];
|
|
988
|
-
last
|
|
1011
|
+
Object.assign(last, marker);
|
|
989
1012
|
}
|
|
990
1013
|
};
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
if (last && last !== sys)
|
|
999
|
-
mark(last);
|
|
1014
|
+
const requested = uniquePromptCacheBreakpointIndexes([
|
|
1015
|
+
policy.breakpoints.includes("system") ? 0 : undefined,
|
|
1016
|
+
policy.breakpoints.includes("stable-history") ? stablePrefixEndIndex : undefined,
|
|
1017
|
+
policy.breakpoints.includes("rolling-history") ? messages.length - 1 : undefined,
|
|
1018
|
+
]);
|
|
1019
|
+
for (const index of requested)
|
|
1020
|
+
mark(index);
|
|
1000
1021
|
}
|
|
1001
1022
|
convertTools(tools) {
|
|
1002
1023
|
return tools.map((t) => ({
|
|
@@ -1031,6 +1052,33 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
1031
1052
|
// the corrected body — fixing the call that triggered it, not just the
|
|
1032
1053
|
// next one.
|
|
1033
1054
|
let selfCorrected = false;
|
|
1055
|
+
// New GPT-5.6 cache fields may reach an OpenAI-compatible gateway before
|
|
1056
|
+
// that gateway supports them. Downgrade sticky-per-client and retry once:
|
|
1057
|
+
// first explicit -> implicit, then omit the affinity key only if that is
|
|
1058
|
+
// also rejected. Native OpenAI keeps the optimized path.
|
|
1059
|
+
if (err.status === 400 && msg.includes("prompt_cache")) {
|
|
1060
|
+
if ((msg.includes("prompt_cache_options") || msg.includes("prompt_cache_breakpoint")) &&
|
|
1061
|
+
!this._disableExplicitPromptCache) {
|
|
1062
|
+
this._disableExplicitPromptCache = true;
|
|
1063
|
+
selfCorrected = true;
|
|
1064
|
+
logger.warn("llm.prompt_cache_explicit_unsupported", {
|
|
1065
|
+
cat: "llm",
|
|
1066
|
+
provider: this.provider,
|
|
1067
|
+
providerKind: this.config.providerKind,
|
|
1068
|
+
model: this.model,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (msg.includes("prompt_cache_key") && !this._disablePromptCacheKey) {
|
|
1072
|
+
this._disablePromptCacheKey = true;
|
|
1073
|
+
selfCorrected = true;
|
|
1074
|
+
logger.warn("llm.prompt_cache_key_unsupported", {
|
|
1075
|
+
cat: "llm",
|
|
1076
|
+
provider: this.provider,
|
|
1077
|
+
providerKind: this.config.providerKind,
|
|
1078
|
+
model: this.model,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1034
1082
|
// o-series / gpt-5+ reject `max_tokens` and demand
|
|
1035
1083
|
// `max_completion_tokens`. The id-based regex catches the common
|
|
1036
1084
|
// cases; this is the belt-and-suspenders path for ids that ship
|
|
@@ -1095,8 +1143,12 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
1095
1143
|
function deepMergeInto(dst, src) {
|
|
1096
1144
|
for (const [k, v] of Object.entries(src)) {
|
|
1097
1145
|
const cur = dst[k];
|
|
1098
|
-
if (v &&
|
|
1099
|
-
|
|
1146
|
+
if (v &&
|
|
1147
|
+
typeof v === "object" &&
|
|
1148
|
+
!Array.isArray(v) &&
|
|
1149
|
+
cur &&
|
|
1150
|
+
typeof cur === "object" &&
|
|
1151
|
+
!Array.isArray(cur)) {
|
|
1100
1152
|
deepMergeInto(cur, v);
|
|
1101
1153
|
}
|
|
1102
1154
|
else {
|
package/dist/llm/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* LLM-specific internal types.
|
|
3
3
|
*/
|
|
4
4
|
import type { Message, ToolDefinition, LLMStreamChunk, TokenUsage } from "../types.js";
|
|
5
|
+
import type { PromptCacheRequestContext } from "./prompt-cache.js";
|
|
5
6
|
export interface CreateMessageOptions {
|
|
6
7
|
systemPrompt: string;
|
|
7
8
|
messages: Message[];
|
|
@@ -33,6 +34,8 @@ export interface CreateMessageOptions {
|
|
|
33
34
|
* the client's capability layer.
|
|
34
35
|
*/
|
|
35
36
|
reasoning?: import("./reasoning-setting.js").ReasoningSetting;
|
|
37
|
+
/** Provider-neutral prompt-cache context supplied by the model facade. */
|
|
38
|
+
promptCache?: PromptCacheRequestContext;
|
|
36
39
|
}
|
|
37
40
|
export interface LLMUsageTracker {
|
|
38
41
|
records: TokenUsage[];
|