@onmars/lunar-agent-claude 0.6.2 → 0.8.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.6.2",
3
+ "version": "0.8.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.6.0"
15
+ "@onmars/lunar-core": "^0.8.0"
16
16
  },
17
17
  "description": "Claude CLI agent adapter for Lunar",
18
18
  "author": "onMars Tech",
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * ## resolveModelAlias() — Model name resolution
29
29
  * Maps short aliases to full Claude model IDs.
30
- * 'opus' → 'claude-opus-4-6', 'sonnet' → 'claude-sonnet-4-6', etc.
30
+ * 'opus' → 'claude-opus-4-7', 'sonnet' → 'claude-sonnet-4-6', etc.
31
31
  * Unknown names pass through unchanged.
32
32
  */
33
33
  import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
@@ -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/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export type { ClaudeAgentOptions } from './adapter'
2
2
  export { CLAUDE_MODEL_ALIASES, ClaudeAgent, resolveModelAlias } from './adapter'
3
+ export type { ClaudeTokenCounterOptions } from './lib/token-counter-claude'
4
+ export { ClaudeTokenCounter } from './lib/token-counter-claude'
@@ -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
+ }