@onmars/lunar-agent-claude 0.7.0 → 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.
|
|
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.
|
|
15
|
+
"@onmars/lunar-core": "^0.8.0"
|
|
16
16
|
},
|
|
17
17
|
"description": "Claude CLI agent adapter for Lunar",
|
|
18
18
|
"author": "onMars Tech",
|
|
@@ -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
|
+
}
|