@onmars/lunar-agent-claude 0.7.0 → 0.9.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onmars/lunar-agent-claude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"LICENSE"
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@onmars/lunar-core": "^0.
|
|
15
|
+
"@onmars/lunar-core": "^0.9.0"
|
|
16
16
|
},
|
|
17
17
|
"description": "Claude CLI agent adapter for Lunar",
|
|
18
18
|
"author": "onMars Tech",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # ClaudeCompactionBridge — Functional Specification
|
|
3
|
+
*
|
|
4
|
+
* Phase 1.5 bridge between Anthropic's Compaction API (and Claude Code
|
|
5
|
+
* PreCompact hook) and the agnostic FlushHook that lives in
|
|
6
|
+
* @onmars/lunar-core.
|
|
7
|
+
*
|
|
8
|
+
* ## onStreamEvent(event)
|
|
9
|
+
* Pattern-matches Anthropic streaming events. When a
|
|
10
|
+
* `content_block_start` event with `content_block.type === 'compaction'`
|
|
11
|
+
* is seen, emits `{ kind: 'compaction-incoming', source:
|
|
12
|
+
* 'anthropic-stream', model? }` on the wired FlushHook. Other event
|
|
13
|
+
* types (including `message_start`, `content_block_delta`, unrelated
|
|
14
|
+
* blocks) are ignored. `message_start` events are sniffed for the
|
|
15
|
+
* active model so subsequent compaction signals can be annotated.
|
|
16
|
+
*
|
|
17
|
+
* ## handlePreCompactHook(payload)
|
|
18
|
+
* Public entry point for Claude Code's PreCompact hook. Emits
|
|
19
|
+
* `{ kind: 'compaction-incoming', source: 'claude-code-hook', model? }`
|
|
20
|
+
* and returns exit code 0 — the native compaction must never be
|
|
21
|
+
* blocked. Errors from the FlushHook are swallowed (logged) and still
|
|
22
|
+
* resolve to 0.
|
|
23
|
+
*
|
|
24
|
+
* ## shutdown()
|
|
25
|
+
* Makes the bridge inert. Subsequent onStreamEvent/handlePreCompactHook
|
|
26
|
+
* calls are no-ops.
|
|
27
|
+
*
|
|
28
|
+
* ## Defensive: missing FlushHook
|
|
29
|
+
* When constructed with `undefined`, every method is a silent no-op.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, test } from 'bun:test'
|
|
32
|
+
import type { CurationEngine, CurationResult, PressureSignal } from '@onmars/lunar-core'
|
|
33
|
+
import { FlushHook } from '@onmars/lunar-core'
|
|
34
|
+
import {
|
|
35
|
+
type AnthropicStreamEvent,
|
|
36
|
+
ClaudeCompactionBridge,
|
|
37
|
+
type PreCompactPayload,
|
|
38
|
+
} from '../lib/compaction-bridge'
|
|
39
|
+
|
|
40
|
+
// ─── helpers ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Minimal CurationEngine stub that records signals it receives. */
|
|
43
|
+
function makeEngineStub(result?: CurationResult) {
|
|
44
|
+
const signals: PressureSignal[] = []
|
|
45
|
+
const stub = {
|
|
46
|
+
curate: async (ctx: { signal: PressureSignal }) => {
|
|
47
|
+
signals.push(ctx.signal)
|
|
48
|
+
return (
|
|
49
|
+
result ?? {
|
|
50
|
+
strategy: 'delegate' as const,
|
|
51
|
+
savedFacts: [],
|
|
52
|
+
skipped: [],
|
|
53
|
+
instructionToInject: '[SYSTEM FLUSH] test',
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
signals,
|
|
58
|
+
} as unknown as CurationEngine & { signals: PressureSignal[] }
|
|
59
|
+
return stub
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFlushHook(): {
|
|
63
|
+
hook: FlushHook
|
|
64
|
+
signals: PressureSignal[]
|
|
65
|
+
engine: CurationEngine & { signals: PressureSignal[] }
|
|
66
|
+
} {
|
|
67
|
+
const engine = makeEngineStub()
|
|
68
|
+
const hook = new FlushHook({ curationEngine: engine, cooldownMs: 0, threshold: 0.7 })
|
|
69
|
+
// Buffer a message so the signal does not get skipped as 'empty-buffer'.
|
|
70
|
+
hook.pushMessage({ role: 'user', content: 'hello' })
|
|
71
|
+
return { hook, signals: engine.signals, engine }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wait a tick for fire-and-forget onSignal to flush through. */
|
|
75
|
+
async function flushMicrotasks(ms = 10): Promise<void> {
|
|
76
|
+
await new Promise<void>((r) => setTimeout(r, ms))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── tests ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('ClaudeCompactionBridge — onStreamEvent', () => {
|
|
82
|
+
test('emits compaction-incoming when content_block_start.type is compaction', async () => {
|
|
83
|
+
const { hook, signals } = makeFlushHook()
|
|
84
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
85
|
+
|
|
86
|
+
// Observe the model from a prior message_start so the signal can
|
|
87
|
+
// annotate it.
|
|
88
|
+
bridge.onStreamEvent({
|
|
89
|
+
type: 'message_start',
|
|
90
|
+
message: { model: 'claude-opus-4-7' },
|
|
91
|
+
} satisfies AnthropicStreamEvent)
|
|
92
|
+
|
|
93
|
+
bridge.onStreamEvent({
|
|
94
|
+
type: 'content_block_start',
|
|
95
|
+
content_block: { type: 'compaction' },
|
|
96
|
+
} satisfies AnthropicStreamEvent)
|
|
97
|
+
|
|
98
|
+
await flushMicrotasks()
|
|
99
|
+
|
|
100
|
+
expect(signals).toHaveLength(1)
|
|
101
|
+
const [signal] = signals
|
|
102
|
+
expect(signal.kind).toBe('compaction-incoming')
|
|
103
|
+
if (signal.kind === 'compaction-incoming') {
|
|
104
|
+
expect(signal.source).toBe('anthropic-stream')
|
|
105
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('emits signal with undefined model when no message_start was seen', async () => {
|
|
110
|
+
const { hook, signals } = makeFlushHook()
|
|
111
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
112
|
+
|
|
113
|
+
bridge.onStreamEvent({
|
|
114
|
+
type: 'content_block_start',
|
|
115
|
+
content_block: { type: 'compaction' },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await flushMicrotasks()
|
|
119
|
+
|
|
120
|
+
expect(signals).toHaveLength(1)
|
|
121
|
+
const [signal] = signals
|
|
122
|
+
if (signal.kind === 'compaction-incoming') {
|
|
123
|
+
expect(signal.model).toBeUndefined()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('ignores unrelated content_block_start types', async () => {
|
|
128
|
+
const { hook, signals } = makeFlushHook()
|
|
129
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
130
|
+
|
|
131
|
+
bridge.onStreamEvent({
|
|
132
|
+
type: 'content_block_start',
|
|
133
|
+
content_block: { type: 'text' },
|
|
134
|
+
})
|
|
135
|
+
bridge.onStreamEvent({
|
|
136
|
+
type: 'content_block_start',
|
|
137
|
+
content_block: { type: 'tool_use' },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await flushMicrotasks()
|
|
141
|
+
|
|
142
|
+
expect(signals).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('ignores unrelated event types', async () => {
|
|
146
|
+
const { hook, signals } = makeFlushHook()
|
|
147
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
148
|
+
|
|
149
|
+
bridge.onStreamEvent({ type: 'message_delta' })
|
|
150
|
+
bridge.onStreamEvent({ type: 'message_stop' })
|
|
151
|
+
bridge.onStreamEvent({ type: 'content_block_delta', content_block: { type: 'compaction' } })
|
|
152
|
+
bridge.onStreamEvent({ type: 'ping' })
|
|
153
|
+
|
|
154
|
+
await flushMicrotasks()
|
|
155
|
+
|
|
156
|
+
expect(signals).toHaveLength(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('ignores malformed events gracefully', async () => {
|
|
160
|
+
const { hook, signals } = makeFlushHook()
|
|
161
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
162
|
+
|
|
163
|
+
// None of these should throw; none should emit.
|
|
164
|
+
bridge.onStreamEvent({ type: 'content_block_start' })
|
|
165
|
+
bridge.onStreamEvent({ type: 'content_block_start', content_block: { type: '' } })
|
|
166
|
+
bridge.onStreamEvent({ type: '' })
|
|
167
|
+
|
|
168
|
+
await flushMicrotasks()
|
|
169
|
+
|
|
170
|
+
expect(signals).toHaveLength(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('tracks model across multiple message_start events', async () => {
|
|
174
|
+
const { hook, signals } = makeFlushHook()
|
|
175
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
176
|
+
|
|
177
|
+
bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-sonnet-4-6' } })
|
|
178
|
+
bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-opus-4-7' } })
|
|
179
|
+
|
|
180
|
+
bridge.onStreamEvent({
|
|
181
|
+
type: 'content_block_start',
|
|
182
|
+
content_block: { type: 'compaction' },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await flushMicrotasks()
|
|
186
|
+
|
|
187
|
+
expect(signals).toHaveLength(1)
|
|
188
|
+
const [signal] = signals
|
|
189
|
+
if (signal.kind === 'compaction-incoming') {
|
|
190
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('ClaudeCompactionBridge — handlePreCompactHook', () => {
|
|
196
|
+
test('emits compaction-incoming with claude-code-hook source and returns 0', async () => {
|
|
197
|
+
const { hook, signals } = makeFlushHook()
|
|
198
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
199
|
+
|
|
200
|
+
const code = await bridge.handlePreCompactHook({
|
|
201
|
+
model: 'claude-opus-4-7',
|
|
202
|
+
session_id: 'sess-123',
|
|
203
|
+
} satisfies PreCompactPayload)
|
|
204
|
+
|
|
205
|
+
expect(code).toBe(0)
|
|
206
|
+
expect(signals).toHaveLength(1)
|
|
207
|
+
const [signal] = signals
|
|
208
|
+
expect(signal.kind).toBe('compaction-incoming')
|
|
209
|
+
if (signal.kind === 'compaction-incoming') {
|
|
210
|
+
expect(signal.source).toBe('claude-code-hook')
|
|
211
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('accepts payload without model', async () => {
|
|
216
|
+
const { hook, signals } = makeFlushHook()
|
|
217
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
218
|
+
|
|
219
|
+
const code = await bridge.handlePreCompactHook({})
|
|
220
|
+
expect(code).toBe(0)
|
|
221
|
+
expect(signals).toHaveLength(1)
|
|
222
|
+
const [signal] = signals
|
|
223
|
+
if (signal.kind === 'compaction-incoming') {
|
|
224
|
+
expect(signal.model).toBeUndefined()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('returns 0 even when the underlying flush throws', async () => {
|
|
229
|
+
const { hook } = makeFlushHook()
|
|
230
|
+
// Monkey-patch onSignal to throw — handlePreCompactHook must still
|
|
231
|
+
// return 0 so it never blocks the native compaction.
|
|
232
|
+
const original = hook.onSignal.bind(hook)
|
|
233
|
+
hook.onSignal = async () => {
|
|
234
|
+
throw new Error('boom')
|
|
235
|
+
}
|
|
236
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
237
|
+
|
|
238
|
+
const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
|
|
239
|
+
expect(code).toBe(0)
|
|
240
|
+
|
|
241
|
+
// Restore to avoid cross-test contamination.
|
|
242
|
+
hook.onSignal = original
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('ClaudeCompactionBridge — shutdown', () => {
|
|
247
|
+
test('onStreamEvent is a no-op after shutdown', async () => {
|
|
248
|
+
const { hook, signals } = makeFlushHook()
|
|
249
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
250
|
+
|
|
251
|
+
bridge.shutdown()
|
|
252
|
+
expect(bridge.isShutdown).toBe(true)
|
|
253
|
+
|
|
254
|
+
bridge.onStreamEvent({
|
|
255
|
+
type: 'content_block_start',
|
|
256
|
+
content_block: { type: 'compaction' },
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await flushMicrotasks()
|
|
260
|
+
expect(signals).toHaveLength(0)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('handlePreCompactHook returns 0 and emits nothing after shutdown', async () => {
|
|
264
|
+
const { hook, signals } = makeFlushHook()
|
|
265
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
266
|
+
bridge.shutdown()
|
|
267
|
+
|
|
268
|
+
const code = await bridge.handlePreCompactHook({ model: 'x' })
|
|
269
|
+
expect(code).toBe(0)
|
|
270
|
+
expect(signals).toHaveLength(0)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('ClaudeCompactionBridge — defensive (no FlushHook)', () => {
|
|
275
|
+
test('onStreamEvent is a no-op when constructed without a FlushHook', () => {
|
|
276
|
+
const bridge = new ClaudeCompactionBridge(undefined)
|
|
277
|
+
|
|
278
|
+
expect(() =>
|
|
279
|
+
bridge.onStreamEvent({
|
|
280
|
+
type: 'content_block_start',
|
|
281
|
+
content_block: { type: 'compaction' },
|
|
282
|
+
}),
|
|
283
|
+
).not.toThrow()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('handlePreCompactHook returns 0 when constructed without a FlushHook', async () => {
|
|
287
|
+
const bridge = new ClaudeCompactionBridge(undefined)
|
|
288
|
+
const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
|
|
289
|
+
expect(code).toBe(0)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ClaudeTokenCounter } from '../lib/token-counter-claude'
|
|
3
|
+
|
|
4
|
+
// ─── mock fetch helper ─────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface FakeCall {
|
|
7
|
+
url: string
|
|
8
|
+
init: RequestInit
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeFetch(responses: Array<{ status?: number; body: unknown }>) {
|
|
12
|
+
const calls: FakeCall[] = []
|
|
13
|
+
let idx = 0
|
|
14
|
+
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
|
15
|
+
calls.push({ url: String(input), init: init ?? {} })
|
|
16
|
+
const resp = responses[Math.min(idx, responses.length - 1)]
|
|
17
|
+
idx++
|
|
18
|
+
return new Response(JSON.stringify(resp.body), {
|
|
19
|
+
status: resp.status ?? 200,
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
})
|
|
22
|
+
}) as unknown as typeof fetch
|
|
23
|
+
return { fetchImpl, calls }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── tests ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe('ClaudeTokenCounter', () => {
|
|
29
|
+
test('count returns input_tokens from API response', async () => {
|
|
30
|
+
const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 12345 } }])
|
|
31
|
+
|
|
32
|
+
const counter = new ClaudeTokenCounter({
|
|
33
|
+
apiKey: 'sk-test',
|
|
34
|
+
model: 'claude-opus-4-7',
|
|
35
|
+
fetchImpl,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const n = await counter.count([{ role: 'user', content: 'hello world' }])
|
|
39
|
+
expect(n).toBe(12345)
|
|
40
|
+
expect(calls).toHaveLength(1)
|
|
41
|
+
|
|
42
|
+
// Request shape
|
|
43
|
+
const init = calls[0].init
|
|
44
|
+
expect(init.method).toBe('POST')
|
|
45
|
+
const headers = init.headers as Record<string, string>
|
|
46
|
+
expect(headers['x-api-key']).toBe('sk-test')
|
|
47
|
+
expect(headers['anthropic-version']).toBe('2023-06-01')
|
|
48
|
+
expect(headers['content-type']).toBe('application/json')
|
|
49
|
+
|
|
50
|
+
const body = JSON.parse(init.body as string)
|
|
51
|
+
expect(body.model).toBe('claude-opus-4-7')
|
|
52
|
+
expect(body.messages).toHaveLength(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('cache hit avoids second fetch for identical messages', async () => {
|
|
56
|
+
const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 100 } }])
|
|
57
|
+
|
|
58
|
+
const counter = new ClaudeTokenCounter({
|
|
59
|
+
apiKey: 'sk-test',
|
|
60
|
+
model: 'claude-sonnet-4-6',
|
|
61
|
+
fetchImpl,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const msgs = [{ role: 'user', content: 'hi' }]
|
|
65
|
+
const a = await counter.count(msgs)
|
|
66
|
+
const b = await counter.count(msgs)
|
|
67
|
+
|
|
68
|
+
expect(a).toBe(100)
|
|
69
|
+
expect(b).toBe(100)
|
|
70
|
+
expect(calls).toHaveLength(1)
|
|
71
|
+
expect(counter.cacheSize()).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('different messages trigger new API call', async () => {
|
|
75
|
+
const { fetchImpl, calls } = makeFetch([
|
|
76
|
+
{ body: { input_tokens: 10 } },
|
|
77
|
+
{ body: { input_tokens: 20 } },
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const counter = new ClaudeTokenCounter({
|
|
81
|
+
apiKey: 'sk-test',
|
|
82
|
+
model: 'claude-haiku-4-5',
|
|
83
|
+
fetchImpl,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(await counter.count([{ role: 'user', content: 'first' }])).toBe(10)
|
|
87
|
+
expect(await counter.count([{ role: 'user', content: 'second' }])).toBe(20)
|
|
88
|
+
expect(calls).toHaveLength(2)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('LRU evicts oldest after 50 unique entries', async () => {
|
|
92
|
+
// Each call returns a fresh token count. We just care about cache bookkeeping.
|
|
93
|
+
const { fetchImpl } = makeFetch([{ body: { input_tokens: 1 } }])
|
|
94
|
+
|
|
95
|
+
const counter = new ClaudeTokenCounter({
|
|
96
|
+
apiKey: 'sk-test',
|
|
97
|
+
model: 'claude-opus-4-7',
|
|
98
|
+
fetchImpl,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < 60; i++) {
|
|
102
|
+
await counter.count([{ role: 'user', content: `msg-${i}` }])
|
|
103
|
+
}
|
|
104
|
+
expect(counter.cacheSize()).toBe(50)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('windowSize returns 200K for standard models', () => {
|
|
108
|
+
const a = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-opus-4-7' })
|
|
109
|
+
expect(a.windowSize()).toBe(200_000)
|
|
110
|
+
|
|
111
|
+
const b = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-sonnet-4-6' })
|
|
112
|
+
expect(b.windowSize()).toBe(200_000)
|
|
113
|
+
|
|
114
|
+
const c = new ClaudeTokenCounter({ apiKey: 'x', model: 'claude-haiku-4-5' })
|
|
115
|
+
expect(c.windowSize()).toBe(200_000)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('windowSize returns 1M when model ends with [1m]', () => {
|
|
119
|
+
const counter = new ClaudeTokenCounter({
|
|
120
|
+
apiKey: 'x',
|
|
121
|
+
model: 'claude-opus-4-7[1m]',
|
|
122
|
+
})
|
|
123
|
+
expect(counter.windowSize()).toBe(1_000_000)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('aliases resolve: opus → claude-opus-4-7', () => {
|
|
127
|
+
const counter = new ClaudeTokenCounter({ apiKey: 'x', model: 'opus' })
|
|
128
|
+
expect(counter.windowSize()).toBe(200_000)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('aliases + [1m]: sonnet[1m] → 1M window', () => {
|
|
132
|
+
const counter = new ClaudeTokenCounter({ apiKey: 'x', model: 'sonnet[1m]' })
|
|
133
|
+
expect(counter.windowSize()).toBe(1_000_000)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('disabled mode (no apiKey) returns 0 without calling fetch', async () => {
|
|
137
|
+
let callCount = 0
|
|
138
|
+
const fetchImpl = (async () => {
|
|
139
|
+
callCount++
|
|
140
|
+
return new Response('{}', { status: 200 })
|
|
141
|
+
}) as unknown as typeof fetch
|
|
142
|
+
|
|
143
|
+
const counter = new ClaudeTokenCounter({
|
|
144
|
+
model: 'claude-opus-4-7',
|
|
145
|
+
fetchImpl,
|
|
146
|
+
})
|
|
147
|
+
expect(counter.isDisabled()).toBe(true)
|
|
148
|
+
|
|
149
|
+
const n = await counter.count([{ role: 'user', content: 'hi' }])
|
|
150
|
+
expect(n).toBe(0)
|
|
151
|
+
expect(callCount).toBe(0)
|
|
152
|
+
expect(counter.windowSize()).toBe(200_000)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('empty messages returns 0 without fetching', async () => {
|
|
156
|
+
let callCount = 0
|
|
157
|
+
const fetchImpl = (async () => {
|
|
158
|
+
callCount++
|
|
159
|
+
return new Response(JSON.stringify({ input_tokens: 999 }), { status: 200 })
|
|
160
|
+
}) as unknown as typeof fetch
|
|
161
|
+
|
|
162
|
+
const counter = new ClaudeTokenCounter({
|
|
163
|
+
apiKey: 'sk-test',
|
|
164
|
+
model: 'claude-opus-4-7',
|
|
165
|
+
fetchImpl,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(await counter.count([])).toBe(0)
|
|
169
|
+
expect(callCount).toBe(0)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('non-OK response returns 0 without crashing', async () => {
|
|
173
|
+
const { fetchImpl } = makeFetch([{ status: 500, body: { error: 'server down' } }])
|
|
174
|
+
const counter = new ClaudeTokenCounter({
|
|
175
|
+
apiKey: 'sk-test',
|
|
176
|
+
model: 'claude-opus-4-7',
|
|
177
|
+
fetchImpl,
|
|
178
|
+
})
|
|
179
|
+
const n = await counter.count([{ role: 'user', content: 'hi' }])
|
|
180
|
+
expect(n).toBe(0)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('fetch throw is swallowed into 0', async () => {
|
|
184
|
+
const fetchImpl = (async () => {
|
|
185
|
+
throw new Error('network failure')
|
|
186
|
+
}) as unknown as typeof fetch
|
|
187
|
+
|
|
188
|
+
const counter = new ClaudeTokenCounter({
|
|
189
|
+
apiKey: 'sk-test',
|
|
190
|
+
model: 'claude-opus-4-7',
|
|
191
|
+
fetchImpl,
|
|
192
|
+
})
|
|
193
|
+
const n = await counter.count([{ role: 'user', content: 'hi' }])
|
|
194
|
+
expect(n).toBe(0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('context1m flag adds anthropic-beta header', async () => {
|
|
198
|
+
const { fetchImpl, calls } = makeFetch([{ body: { input_tokens: 7 } }])
|
|
199
|
+
const counter = new ClaudeTokenCounter({
|
|
200
|
+
apiKey: 'sk-test',
|
|
201
|
+
model: 'claude-opus-4-7[1m]',
|
|
202
|
+
fetchImpl,
|
|
203
|
+
})
|
|
204
|
+
await counter.count([{ role: 'user', content: 'hi' }])
|
|
205
|
+
const headers = calls[0].init.headers as Record<string, string>
|
|
206
|
+
expect(headers['anthropic-beta']).toBe('context-1m-2025-08-07')
|
|
207
|
+
// Model sent to API is the alias-resolved, suffix-stripped version
|
|
208
|
+
const body = JSON.parse(calls[0].init.body as string)
|
|
209
|
+
expect(body.model).toBe('claude-opus-4-7')
|
|
210
|
+
})
|
|
211
|
+
})
|
package/src/adapter.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentInput, AgentUsage } from '@onmars/lunar-co
|
|
|
2
2
|
import { log } from '@onmars/lunar-core'
|
|
3
3
|
import type { SecurityConfig } from '@onmars/lunar-core/lib/config-loader'
|
|
4
4
|
import { buildSafeEnv } from '@onmars/lunar-core/lib/security'
|
|
5
|
+
import type { ClaudeCompactionBridge } from './lib/compaction-bridge'
|
|
5
6
|
|
|
6
7
|
export interface ClaudeAgentOptions {
|
|
7
8
|
/** Agent ID for routing — defaults to 'claude' */
|
|
@@ -100,11 +101,29 @@ export class ClaudeAgent implements Agent {
|
|
|
100
101
|
|
|
101
102
|
private activeProcess: ReturnType<typeof Bun.spawn> | null = null
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Optional Claude-specific compaction bridge. When set, every parsed
|
|
106
|
+
* CLI stream event is forwarded so the bridge can emit
|
|
107
|
+
* `compaction-incoming` pressure signals. The bridge remains fully
|
|
108
|
+
* opt-in — if unset (or the feature is disabled upstream), the adapter
|
|
109
|
+
* behaves exactly as before.
|
|
110
|
+
*/
|
|
111
|
+
private compactionBridge: ClaudeCompactionBridge | undefined
|
|
112
|
+
|
|
103
113
|
constructor(private options: ClaudeAgentOptions) {
|
|
104
114
|
this.id = options.id ?? 'claude'
|
|
105
115
|
this.name = options.name ?? 'Claude Code (CLI)'
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Inject a compaction bridge after construction. The bridge is
|
|
120
|
+
* optional and installed post-hoc from `start.ts` because the
|
|
121
|
+
* FlushHook it wraps is built later in the boot sequence.
|
|
122
|
+
*/
|
|
123
|
+
setCompactionBridge(bridge: ClaudeCompactionBridge | undefined): void {
|
|
124
|
+
this.compactionBridge = bridge
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
async init(): Promise<void> {
|
|
109
128
|
const binary = this.options.binaryPath ?? 'claude'
|
|
110
129
|
|
|
@@ -389,6 +408,26 @@ export class ClaudeAgent implements Agent {
|
|
|
389
408
|
|
|
390
409
|
try {
|
|
391
410
|
const msg: ClaudeJsonMessage = JSON.parse(trimmed)
|
|
411
|
+
|
|
412
|
+
// Forward raw stream event to the compaction bridge (if any)
|
|
413
|
+
// BEFORE we transform into AgentEvents. The bridge does its
|
|
414
|
+
// own structural matching and ignores unrelated messages.
|
|
415
|
+
if (this.compactionBridge) {
|
|
416
|
+
try {
|
|
417
|
+
this.compactionBridge.onStreamEvent(
|
|
418
|
+
msg as unknown as import('./lib/compaction-bridge').AnthropicStreamEvent,
|
|
419
|
+
)
|
|
420
|
+
} catch (bridgeErr) {
|
|
421
|
+
// Bridge failures must never break the stream loop.
|
|
422
|
+
log.debug(
|
|
423
|
+
{
|
|
424
|
+
err: bridgeErr instanceof Error ? bridgeErr.message : String(bridgeErr),
|
|
425
|
+
},
|
|
426
|
+
'Compaction bridge onStreamEvent threw (ignored)',
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
392
431
|
for (const evt of this.processMessage(msg)) {
|
|
393
432
|
if (evt.type === 'text') hasAssistantText = true
|
|
394
433
|
yield evt
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export type { ClaudeAgentOptions } from './adapter'
|
|
2
2
|
export { CLAUDE_MODEL_ALIASES, ClaudeAgent, resolveModelAlias } from './adapter'
|
|
3
|
+
export type {
|
|
4
|
+
AnthropicStreamEvent,
|
|
5
|
+
PreCompactPayload,
|
|
6
|
+
} from './lib/compaction-bridge'
|
|
7
|
+
export { ClaudeCompactionBridge } from './lib/compaction-bridge'
|
|
8
|
+
export type { ClaudeTokenCounterOptions } from './lib/token-counter-claude'
|
|
9
|
+
export { ClaudeTokenCounter } from './lib/token-counter-claude'
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeCompactionBridge — Claude-specific translator between Anthropic's
|
|
3
|
+
* Compaction API (and Claude Code's PreCompact hook) and the agnostic
|
|
4
|
+
* `FlushHook` that lives in @onmars/lunar-core.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1.5 bridge. Responsibilities:
|
|
7
|
+
*
|
|
8
|
+
* • onStreamEvent(event) — inspect a Claude stream event. If we
|
|
9
|
+
* detect a `content_block_start` of
|
|
10
|
+
* type `compaction` (Anthropic's
|
|
11
|
+
* Compaction API, beta header
|
|
12
|
+
* `compact-2026-01-12` with
|
|
13
|
+
* `pause_after_compaction: true`), emit
|
|
14
|
+
* a `compaction-incoming` PressureSignal
|
|
15
|
+
* with source = 'anthropic-stream'.
|
|
16
|
+
*
|
|
17
|
+
* • handlePreCompactHook(p) — public entry point for Claude Code's
|
|
18
|
+
* PreCompact hook. Claude Code can be
|
|
19
|
+
* configured (via settings.json's
|
|
20
|
+
* hooks.PreCompact) to shell out to a
|
|
21
|
+
* local endpoint that ultimately calls
|
|
22
|
+
* this method. Emits the same signal
|
|
23
|
+
* with source = 'claude-code-hook'.
|
|
24
|
+
* Returns exit code 0 so the native
|
|
25
|
+
* compact proceeds unblocked.
|
|
26
|
+
*
|
|
27
|
+
* • shutdown() — mark the bridge inert. After shutdown,
|
|
28
|
+
* further events/hooks are ignored.
|
|
29
|
+
*
|
|
30
|
+
* Design constraints:
|
|
31
|
+
*
|
|
32
|
+
* • Zero runtime dependency on @anthropic-ai/sdk. We accept a minimal
|
|
33
|
+
* structural `AnthropicStreamEvent` shape so integrators are free to
|
|
34
|
+
* feed events from any source (SDK, CLI stream-json, mock tests).
|
|
35
|
+
* • No @onmars/lunar-core types leak Anthropic-specific fields upward.
|
|
36
|
+
* This file is the only place that knows about Anthropic's
|
|
37
|
+
* compaction block shape.
|
|
38
|
+
* • Opt-in. If no FlushHook is provided, every method is a no-op.
|
|
39
|
+
* Callers should simply skip constructing the bridge when the
|
|
40
|
+
* feature is disabled.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { FlushHook } from '@onmars/lunar-core'
|
|
44
|
+
import { log } from '@onmars/lunar-core'
|
|
45
|
+
|
|
46
|
+
// ════════════════════════════════════════════════════════════
|
|
47
|
+
// Public types — structural, no SDK import required
|
|
48
|
+
// ════════════════════════════════════════════════════════════
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minimal structural shape of an Anthropic streaming event we need to
|
|
52
|
+
* recognise compaction. Matches the public Messages API streaming
|
|
53
|
+
* contract (see Anthropic docs). Only the fields we actually inspect
|
|
54
|
+
* are declared; integrators may feed richer events and we'll ignore
|
|
55
|
+
* the rest.
|
|
56
|
+
*
|
|
57
|
+
* Compaction detection uses the `content_block_start` event whose
|
|
58
|
+
* `content_block.type === 'compaction'` (Anthropic Compaction API, beta
|
|
59
|
+
* header `compact-2026-01-12`).
|
|
60
|
+
*/
|
|
61
|
+
export interface AnthropicStreamEvent {
|
|
62
|
+
type: string
|
|
63
|
+
/** Present on content_block_start / content_block_delta events. */
|
|
64
|
+
content_block?: {
|
|
65
|
+
type: string
|
|
66
|
+
[key: string]: unknown
|
|
67
|
+
}
|
|
68
|
+
/** Present on message_start — we use it to annotate the signal. */
|
|
69
|
+
message?: {
|
|
70
|
+
model?: string
|
|
71
|
+
[key: string]: unknown
|
|
72
|
+
}
|
|
73
|
+
[key: string]: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Payload shape for Claude Code's PreCompact hook. The upstream CLI
|
|
78
|
+
* currently writes a JSON object on stdin to hook commands; we accept
|
|
79
|
+
* whatever it provides and only read what we need.
|
|
80
|
+
*/
|
|
81
|
+
export interface PreCompactPayload {
|
|
82
|
+
/** The model about to compact, if reported. */
|
|
83
|
+
model?: string
|
|
84
|
+
/** Optional session identifier for log context. */
|
|
85
|
+
session_id?: string
|
|
86
|
+
/** Allow arbitrary additional fields without type loss. */
|
|
87
|
+
[key: string]: unknown
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ════════════════════════════════════════════════════════════
|
|
91
|
+
// Implementation
|
|
92
|
+
// ════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
export class ClaudeCompactionBridge {
|
|
95
|
+
private shutdownRequested = false
|
|
96
|
+
|
|
97
|
+
/** Last model seen on a message_start — lets us tag stream signals. */
|
|
98
|
+
private lastKnownModel: string | undefined
|
|
99
|
+
|
|
100
|
+
constructor(private readonly flushHook: FlushHook | undefined) {}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Feed a single Anthropic stream event to the bridge. The method is
|
|
104
|
+
* intentionally synchronous and fire-and-forget on the FlushHook side:
|
|
105
|
+
* we trigger the signal but do not await the flush (callers must not
|
|
106
|
+
* block the streaming loop).
|
|
107
|
+
*/
|
|
108
|
+
onStreamEvent(event: AnthropicStreamEvent): void {
|
|
109
|
+
if (this.shutdownRequested) return
|
|
110
|
+
if (!this.flushHook) return
|
|
111
|
+
if (!event || typeof event !== 'object') return
|
|
112
|
+
|
|
113
|
+
// Track model from message_start so we can annotate downstream
|
|
114
|
+
// signals with the active model id.
|
|
115
|
+
if (event.type === 'message_start' && event.message?.model) {
|
|
116
|
+
this.lastKnownModel = event.message.model
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.type !== 'content_block_start') return
|
|
121
|
+
if (!event.content_block || event.content_block.type !== 'compaction') return
|
|
122
|
+
|
|
123
|
+
const model = this.lastKnownModel
|
|
124
|
+
log.info(
|
|
125
|
+
{ source: 'anthropic-stream', model },
|
|
126
|
+
'Claude Compaction bridge: compaction block detected in stream',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Fire and forget — the FlushHook is already async-safe internally
|
|
130
|
+
// and will coalesce concurrent signals.
|
|
131
|
+
void this.flushHook.onSignal({
|
|
132
|
+
kind: 'compaction-incoming',
|
|
133
|
+
source: 'anthropic-stream',
|
|
134
|
+
model,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Public entry point for Claude Code's PreCompact hook. Intended to
|
|
140
|
+
* be invoked by whatever process bridges the CLI hook payload into
|
|
141
|
+
* the Lunar runtime (future: a lightweight HTTP endpoint). Always
|
|
142
|
+
* returns 0 so the native compaction proceeds unblocked — we are a
|
|
143
|
+
* last-chance persister, not a gatekeeper.
|
|
144
|
+
*/
|
|
145
|
+
async handlePreCompactHook(payload: PreCompactPayload): Promise<number> {
|
|
146
|
+
if (this.shutdownRequested) return 0
|
|
147
|
+
if (!this.flushHook) return 0
|
|
148
|
+
|
|
149
|
+
const model = typeof payload?.model === 'string' ? payload.model : undefined
|
|
150
|
+
log.info(
|
|
151
|
+
{ source: 'claude-code-hook', model, sessionId: payload?.session_id },
|
|
152
|
+
'Claude Compaction bridge: PreCompact hook received',
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await this.flushHook.onSignal({
|
|
157
|
+
kind: 'compaction-incoming',
|
|
158
|
+
source: 'claude-code-hook',
|
|
159
|
+
model,
|
|
160
|
+
})
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Never block the native compaction on our side — log and swallow.
|
|
163
|
+
log.warn(
|
|
164
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
165
|
+
'Claude Compaction bridge: PreCompact flush failed (continuing)',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Mark the bridge inert. Subsequent events/hooks are no-ops. */
|
|
173
|
+
shutdown(): void {
|
|
174
|
+
this.shutdownRequested = true
|
|
175
|
+
this.lastKnownModel = undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Observability helper — tests and logs only. */
|
|
179
|
+
get isShutdown(): boolean {
|
|
180
|
+
return this.shutdownRequested
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeTokenCounter — concrete TokenCounter implementation backed by
|
|
3
|
+
* Anthropic's Token Count API (https://docs.anthropic.com/en/api/messages-count-tokens).
|
|
4
|
+
*
|
|
5
|
+
* This lives in agent-claude to keep the core package provider-agnostic.
|
|
6
|
+
* A tiny in-memory LRU cache (50 entries) keeps the same message bundle
|
|
7
|
+
* from re-hitting the API when the Router checks the ratio repeatedly.
|
|
8
|
+
*
|
|
9
|
+
* If the constructor doesn't receive an API key, the counter operates in
|
|
10
|
+
* disabled mode: `count()` returns 0 and the FlushHook will simply never
|
|
11
|
+
* see a token-threshold signal above zero.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { log } from '@onmars/lunar-core'
|
|
16
|
+
import type { CountableMessage, TokenCounter } from '@onmars/lunar-core/lib/token-counter'
|
|
17
|
+
import { resolveModelAlias } from '../adapter'
|
|
18
|
+
|
|
19
|
+
// ════════════════════════════════════════════════════════════
|
|
20
|
+
// Model → context window mapping (static; single source of truth here)
|
|
21
|
+
// ════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
const MODEL_WINDOW: Readonly<Record<string, number>> = Object.freeze({
|
|
24
|
+
'claude-opus-4-7': 200_000,
|
|
25
|
+
'claude-opus-4-6': 200_000,
|
|
26
|
+
'claude-sonnet-4-6': 200_000,
|
|
27
|
+
'claude-sonnet-4-5': 200_000,
|
|
28
|
+
'claude-haiku-4-5': 200_000,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const ONE_MILLION = 1_000_000
|
|
32
|
+
const DEFAULT_WINDOW = 200_000
|
|
33
|
+
|
|
34
|
+
// ════════════════════════════════════════════════════════════
|
|
35
|
+
// Tiny LRU (insertion-ordered Map trick — no dep)
|
|
36
|
+
// ════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
class TinyLRU<V> {
|
|
39
|
+
private readonly max: number
|
|
40
|
+
private readonly store = new Map<string, V>()
|
|
41
|
+
|
|
42
|
+
constructor(max: number) {
|
|
43
|
+
this.max = max
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(key: string): V | undefined {
|
|
47
|
+
const v = this.store.get(key)
|
|
48
|
+
if (v !== undefined) {
|
|
49
|
+
// refresh recency
|
|
50
|
+
this.store.delete(key)
|
|
51
|
+
this.store.set(key, v)
|
|
52
|
+
}
|
|
53
|
+
return v
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
set(key: string, value: V): void {
|
|
57
|
+
if (this.store.has(key)) {
|
|
58
|
+
this.store.delete(key)
|
|
59
|
+
} else if (this.store.size >= this.max) {
|
|
60
|
+
// evict oldest
|
|
61
|
+
const oldest = this.store.keys().next().value as string | undefined
|
|
62
|
+
if (oldest !== undefined) this.store.delete(oldest)
|
|
63
|
+
}
|
|
64
|
+
this.store.set(key, value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get size(): number {
|
|
68
|
+
return this.store.size
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ════════════════════════════════════════════════════════════
|
|
73
|
+
// Public options + types
|
|
74
|
+
// ════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
export interface ClaudeTokenCounterOptions {
|
|
77
|
+
/** Anthropic API key. When absent, the counter runs in disabled mode. */
|
|
78
|
+
apiKey?: string
|
|
79
|
+
/** Model ID (aliases resolved; accepts "[1m]" suffix for 1M context). */
|
|
80
|
+
model: string
|
|
81
|
+
/** Override the endpoint (tests). */
|
|
82
|
+
endpoint?: string
|
|
83
|
+
/** Override fetch (tests). */
|
|
84
|
+
fetchImpl?: typeof fetch
|
|
85
|
+
/** Anthropic API version header. Defaults to '2023-06-01'. */
|
|
86
|
+
apiVersion?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface CountTokensResponse {
|
|
90
|
+
input_tokens: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ════════════════════════════════════════════════════════════
|
|
94
|
+
// Implementation
|
|
95
|
+
// ════════════════════════════════════════════════════════════
|
|
96
|
+
|
|
97
|
+
export class ClaudeTokenCounter implements TokenCounter {
|
|
98
|
+
private readonly apiKey?: string
|
|
99
|
+
private readonly model: string
|
|
100
|
+
private readonly effectiveModel: string
|
|
101
|
+
private readonly endpoint: string
|
|
102
|
+
private readonly fetchImpl: typeof fetch
|
|
103
|
+
private readonly apiVersion: string
|
|
104
|
+
private readonly context1m: boolean
|
|
105
|
+
private readonly cache = new TinyLRU<number>(50)
|
|
106
|
+
private readonly disabled: boolean
|
|
107
|
+
|
|
108
|
+
constructor(options: ClaudeTokenCounterOptions) {
|
|
109
|
+
this.apiKey = options.apiKey
|
|
110
|
+
this.model = options.model
|
|
111
|
+
this.context1m = options.model.endsWith('[1m]')
|
|
112
|
+
const baseModel = this.context1m ? options.model.slice(0, -'[1m]'.length) : options.model
|
|
113
|
+
this.effectiveModel = resolveModelAlias(baseModel)
|
|
114
|
+
this.endpoint = options.endpoint ?? 'https://api.anthropic.com/v1/messages/count_tokens'
|
|
115
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
116
|
+
this.apiVersion = options.apiVersion ?? '2023-06-01'
|
|
117
|
+
this.disabled = !this.apiKey
|
|
118
|
+
|
|
119
|
+
if (this.disabled) {
|
|
120
|
+
log.warn(
|
|
121
|
+
{ model: this.effectiveModel },
|
|
122
|
+
'ClaudeTokenCounter: no ANTHROPIC_API_KEY — token-threshold signals disabled',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether this counter is disabled (no API key). */
|
|
128
|
+
isDisabled(): boolean {
|
|
129
|
+
return this.disabled
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async count(messages: CountableMessage[]): Promise<number> {
|
|
133
|
+
if (this.disabled || messages.length === 0) return 0
|
|
134
|
+
|
|
135
|
+
const key = this.cacheKey(messages)
|
|
136
|
+
const cached = this.cache.get(key)
|
|
137
|
+
if (cached !== undefined) return cached
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const headers: Record<string, string> = {
|
|
141
|
+
'content-type': 'application/json',
|
|
142
|
+
'x-api-key': this.apiKey!,
|
|
143
|
+
'anthropic-version': this.apiVersion,
|
|
144
|
+
}
|
|
145
|
+
if (this.context1m) {
|
|
146
|
+
headers['anthropic-beta'] = 'context-1m-2025-08-07'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const body = JSON.stringify({
|
|
150
|
+
model: this.effectiveModel,
|
|
151
|
+
messages: normaliseForApi(messages),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const res = await this.fetchImpl(this.endpoint, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers,
|
|
157
|
+
body,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
log.warn(
|
|
162
|
+
{ status: res.status, model: this.effectiveModel },
|
|
163
|
+
'ClaudeTokenCounter: count_tokens API returned non-OK — treating as 0',
|
|
164
|
+
)
|
|
165
|
+
return 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const data = (await res.json()) as CountTokensResponse
|
|
169
|
+
const tokens = typeof data?.input_tokens === 'number' ? data.input_tokens : 0
|
|
170
|
+
this.cache.set(key, tokens)
|
|
171
|
+
return tokens
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.warn(
|
|
174
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
175
|
+
'ClaudeTokenCounter: count_tokens call failed — treating as 0',
|
|
176
|
+
)
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
windowSize(): number {
|
|
182
|
+
const base = MODEL_WINDOW[this.effectiveModel] ?? DEFAULT_WINDOW
|
|
183
|
+
return this.context1m ? ONE_MILLION : base
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Exposed for tests. */
|
|
187
|
+
cacheSize(): number {
|
|
188
|
+
return this.cache.size
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private cacheKey(messages: CountableMessage[]): string {
|
|
192
|
+
const serialised = JSON.stringify(messages)
|
|
193
|
+
return createHash('sha1')
|
|
194
|
+
.update(
|
|
195
|
+
this.effectiveModel + '\u0000' + (this.context1m ? '1m' : 'std') + '\u0000' + serialised,
|
|
196
|
+
)
|
|
197
|
+
.digest('hex')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ════════════════════════════════════════════════════════════
|
|
202
|
+
// Helpers
|
|
203
|
+
// ════════════════════════════════════════════════════════════
|
|
204
|
+
|
|
205
|
+
function normaliseForApi(messages: CountableMessage[]): Array<{ role: string; content: unknown }> {
|
|
206
|
+
// Anthropic accepts string or block-array content; we just pass through.
|
|
207
|
+
// If content is null/undefined, stringify to empty.
|
|
208
|
+
return messages.map((m) => ({
|
|
209
|
+
role: m.role,
|
|
210
|
+
content: m.content == null ? '' : m.content,
|
|
211
|
+
}))
|
|
212
|
+
}
|