Skip to content

Commit 27529ed

Browse files
ndemiancclaude
andcommitted
refactor(ai): make cache-token splitting testable; guard empty cache blocks
- openaiCompat: extract splitOutCachedTokens() (pure, exported) and treat prompt_tokens/completion_tokens == 0 correctly (!= null, not truthy) - anthropic: withRollingCacheBreakpoint skips messages with no usable content before marking a cache breakpoint, and is now exported - add test/promptCaching.test.js covering both Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 523f45b commit 27529ed

3 files changed

Lines changed: 157 additions & 7 deletions

File tree

extensions/levelcode-ai/providers/anthropic.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,19 @@ function withRollingCacheBreakpoint(messages) {
169169
const m = arr[i];
170170
if (!m) { continue; }
171171
let content = m.content;
172+
let hasUsableContent = false;
172173
if (typeof content === 'string') {
173174
if (!content) { continue; }
175+
hasUsableContent = true;
174176
content = [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }];
175177
} else if (Array.isArray(content) && content.length) {
178+
// Require at least one non-empty block to count this message as cacheable.
179+
hasUsableContent = content.some((b) => {
180+
if (!b) { return false; }
181+
if (b.type === 'text') { return !!(b.text || '').trim(); }
182+
return b.type === 'tool_result' || b.type === 'tool_use' || !!b.content;
183+
});
184+
if (!hasUsableContent) { continue; }
176185
content = content.slice();
177186
content[content.length - 1] = { ...content[content.length - 1], cache_control: { type: 'ephemeral' } };
178187
} else {
@@ -244,4 +253,4 @@ async function streamClaudeAgentTurn(opts) {
244253
return { content: content, stop_reason: stopReason, usage: usage, malformed: malformed };
245254
}
246255

247-
module.exports = { streamClaude, completeClaude, claudeAgentTurn, streamClaudeAgentTurn, finalizeAgentBlocks };
256+
module.exports = { streamClaude, completeClaude, claudeAgentTurn, streamClaudeAgentTurn, finalizeAgentBlocks, withRollingCacheBreakpoint };

extensions/levelcode-ai/providers/openaiCompat.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ async function completeOpenAI(opts) {
135135
* @param {{baseURL:string, apiKey?:string, headers?:object}} opts
136136
* @returns {Promise<string[]>}
137137
*/
138+
/**
139+
* [LevelCode] Split OpenAI-style cached_tokens out of prompt_tokens so the agent's meter
140+
* shows fresh input + cache_read exactly as Anthropic does. cached_tokens is included in
141+
* prompt_tokens, so input_tokens = prompt_tokens - cached_tokens. Pure — unit-tested.
142+
* @param {{input_tokens:number, output_tokens:number, cache_read_input_tokens:number}} usage
143+
* @param {{usage:{prompt_tokens?:number, completion_tokens?:number, prompt_tokens_details?:{cached_tokens?:number}}}} ev
144+
*/
145+
function splitOutCachedTokens(usage, ev) {
146+
const det = ev.usage.prompt_tokens_details || {};
147+
const cached = det.cached_tokens || 0;
148+
if (ev.usage.prompt_tokens != null) { usage.input_tokens = Math.max(0, ev.usage.prompt_tokens - cached); }
149+
if (cached) { usage.cache_read_input_tokens = cached; }
150+
if (ev.usage.completion_tokens != null) { usage.output_tokens = ev.usage.completion_tokens; }
151+
}
152+
138153
async function listOpenAIModels(opts) {
139154
try {
140155
const ac = new AbortController();
@@ -201,11 +216,7 @@ async function streamOpenAIAgentTurn(opts) {
201216
// via OpenRouter, the LevelCode Cloud gateway) report cache hits under prompt_tokens_details.
202217
// cached_tokens, and it is INCLUDED in prompt_tokens — so split it out (fresh = prompt - cached)
203218
// to mirror Anthropic's disjoint fields and keep the context meter's input+cache_read total exact.
204-
const det = ev.usage.prompt_tokens_details || {};
205-
const cached = det.cached_tokens || 0;
206-
if (ev.usage.prompt_tokens) { usage.input_tokens = Math.max(0, ev.usage.prompt_tokens - cached); }
207-
if (cached) { usage.cache_read_input_tokens = cached; }
208-
if (ev.usage.completion_tokens) { usage.output_tokens = ev.usage.completion_tokens; }
219+
splitOutCachedTokens(usage, ev);
209220
}
210221
// [LevelCode] The Cloud gateway's final credits frame, emitted just before [DONE]: what THIS turn
211222
// cost and what's left, in retail micro-$ (the same basis as GET /account/models). Namespaced and
@@ -229,4 +240,4 @@ async function streamOpenAIAgentTurn(opts) {
229240
return { content, stop_reason: stopReason, usage, malformed };
230241
}
231242

232-
module.exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily };
243+
module.exports = { streamOpenAI, completeOpenAI, listOpenAIModels, streamOpenAIAgentTurn, buildChatBody, deltaFromEvent, isReasoningModel, isAnthropicFamily, splitOutCachedTokens };
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Regression tests for prompt caching (P4/P4.5):
3+
* - openaiCompat.splitOutCachedTokens splits OpenAI-style cached_tokens out of prompt_tokens.
4+
* - anthropic.withRollingCacheBreakpoint places breakpoints on system + last message.
5+
* - The two-breakpoint agent pattern survives a round-trip through translate.js on Anthropic-family.
6+
*
7+
* run: node test/promptCaching.test.js
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
const assert = require('assert');
13+
const A = require('../providers/anthropic');
14+
const O = require('../providers/openaiCompat');
15+
const T = require('../providers/translate');
16+
17+
let n = 0;
18+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
19+
20+
// ---- Anthropic: the native two-breakpoint pattern ----
21+
22+
test('withRollingCacheBreakpoint: marks only the last non-empty message/block', () => {
23+
const msgs = [
24+
{ role: 'user', content: 'goal' },
25+
{ role: 'assistant', content: 'ok' },
26+
{ role: 'user', content: 'result' }
27+
];
28+
const out = A.withRollingCacheBreakpoint(msgs);
29+
// first two unchanged
30+
assert.deepStrictEqual(out[0], msgs[0]);
31+
assert.deepStrictEqual(out[1], msgs[1]);
32+
// last message's last block has cache_control
33+
const last = out[2];
34+
assert.ok(Array.isArray(last.content));
35+
assert.deepStrictEqual(last.content[0].cache_control, { type: 'ephemeral' });
36+
});
37+
38+
test('withRollingCacheBreakpoint: skips empty trailing messages and lands on the prior real one', () => {
39+
const msgs = [
40+
{ role: 'user', content: 'goal' },
41+
{ role: 'assistant', content: '' }, // empty string → skipped
42+
{ role: 'assistant', content: [{ type: 'text', text: '' }] } // empty block → skipped
43+
];
44+
const out = A.withRollingCacheBreakpoint(msgs);
45+
// Empty trailing messages are skipped: the breakpoint lands on msgs[0].
46+
assert.strictEqual(out.length, 3);
47+
assert.deepStrictEqual(out[0].content, [{ type: 'text', text: 'goal', cache_control: { type: 'ephemeral' } }]);
48+
assert.strictEqual(out[1].content, '');
49+
assert.deepStrictEqual(out[2].content, [{ type: 'text', text: '' }]);
50+
});
51+
52+
test('withRollingCacheBreakpoint: handles block-array content on the last message', () => {
53+
const msgs = [
54+
{ role: 'user', content: [
55+
{ type: 'text', text: 'step 1' },
56+
{ type: 'tool_result', tool_use_id: 'a', content: 'ok' }
57+
] }
58+
];
59+
const out = A.withRollingCacheBreakpoint(msgs);
60+
const arr = out[0].content;
61+
assert.strictEqual(arr.length, 2);
62+
assert.ok(!('cache_control' in arr[0]));
63+
assert.deepStrictEqual(arr[1].cache_control, { type: 'ephemeral' });
64+
});
65+
66+
// ---- OpenAI-compatible: split cached_tokens out of prompt_tokens ----
67+
68+
test('splitOutCachedTokens: subtracts cached from prompt, records cache_read, preserves output', () => {
69+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
70+
const ev = { usage: { prompt_tokens: 10339, completion_tokens: 60, prompt_tokens_details: { cached_tokens: 10318 } } };
71+
O.splitOutCachedTokens(usage, ev);
72+
assert.strictEqual(usage.input_tokens, 21); // fresh = prompt - cached
73+
assert.strictEqual(usage.cache_read_input_tokens, 10318);
74+
assert.strictEqual(usage.output_tokens, 60);
75+
});
76+
77+
test('splitOutCachedTokens: leaves values untouched when no cached_tokens detail', () => {
78+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
79+
const ev = { usage: { prompt_tokens: 100, completion_tokens: 12 } };
80+
O.splitOutCachedTokens(usage, ev);
81+
assert.strictEqual(usage.input_tokens, 100);
82+
assert.strictEqual(usage.cache_read_input_tokens, 0);
83+
assert.strictEqual(usage.output_tokens, 12);
84+
});
85+
86+
test('splitOutCachedTokens: zero cached_tokens does not subtract', () => {
87+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
88+
const ev = { usage: { prompt_tokens: 2500, completion_tokens: 30, prompt_tokens_details: { cached_tokens: 0 } } };
89+
O.splitOutCachedTokens(usage, ev);
90+
assert.strictEqual(usage.input_tokens, 2500);
91+
assert.strictEqual(usage.cache_read_input_tokens, 0);
92+
});
93+
94+
// ---- Anthropic-family gating for OpenRouter explicit caching ----
95+
96+
test('isAnthropicFamily: identifies Claude upstreams on OpenRouter, IDs only Anthropic routes', () => {
97+
for (const id of ['claude-opus-4-8', 'anthropic/claude-sonnet-4-6', 'claude-sonnet-4-6']) {
98+
assert.strictEqual(O.isAnthropicFamily(id), true, id);
99+
}
100+
for (const id of ['openai/gpt-4o', 'deepseek/deepseek-chat', 'gpt-4o', 'moonshotai/kimi-k2.7-code', '']) {
101+
assert.strictEqual(O.isAnthropicFamily(id), false, id);
102+
}
103+
});
104+
105+
// ---- End-to-end: Anthropic-family OpenRouter message shape carries cache_control ----
106+
107+
test('toOpenAIMessages(cache:true) yields the two-breakpoint OpenRouter shape', () => {
108+
const out = T.toOpenAIMessages('SYS', [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }], { cache: true });
109+
assert.strictEqual(out[0].role, 'system');
110+
assert.ok(Array.isArray(out[0].content));
111+
assert.deepStrictEqual(out[0].content[0].cache_control, { type: 'ephemeral' });
112+
const last = out[out.length - 1];
113+
assert.strictEqual(last.role, 'assistant');
114+
assert.ok(Array.isArray(last.content));
115+
assert.deepStrictEqual(last.content[last.content.length - 1].cache_control, { type: 'ephemeral' });
116+
});
117+
118+
test('toOpenAIMessages(cache:false) keeps legacy string shape for non-Claude upstreams', () => {
119+
const out = T.toOpenAIMessages('SYS', [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }], { cache: false });
120+
assert.strictEqual(out[0].content, 'SYS');
121+
assert.strictEqual(out[out.length - 1].content, 'hi');
122+
});
123+
124+
test('cache write is gated: only Anthropic-family should produce cache_control blocks', () => {
125+
const model = 'openai/gpt-4o';
126+
const out = T.toOpenAIMessages('SYS', [{ role: 'user', content: 'hello' }], { cache: O.isAnthropicFamily(model) });
127+
assert.strictEqual(out[0].content, 'SYS'); // cache:false → no block form, no cache_control
128+
});
129+
130+
console.log('\npromptCaching: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)