@swarmclawai/swarmclaw 1.9.35 → 1.9.38
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/README.md +53 -1
- package/package.json +3 -3
- package/src/app/api/chats/[id]/context-status/route.ts +2 -0
- package/src/app/api/chats/context-status-route.test.ts +59 -0
- package/src/app/api/openclaw/history/route.ts +11 -6
- package/src/app/api/preview-server/route.ts +20 -12
- package/src/app/api/search/route.test.ts +63 -0
- package/src/app/api/search/route.ts +3 -2
- package/src/app/api/settings/route.ts +5 -1
- package/src/app/api/settings/settings-route.test.ts +38 -0
- package/src/app/api/setup/check-provider/route.test.ts +12 -0
- package/src/app/api/setup/check-provider/route.ts +6 -0
- package/src/app/api/usage/live/route.ts +2 -2
- package/src/app/globals.css +158 -0
- package/src/app/layout.tsx +12 -9
- package/src/app/protocols/builder/[templateId]/page.tsx +5 -5
- package/src/components/layout/dashboard-shell.tsx +9 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +106 -15
- package/src/components/providers/theme-provider.tsx +16 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +5 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +4 -4
- package/src/features/protocols/builder/utils/builder-template-access.test.ts +30 -0
- package/src/features/protocols/builder/utils/builder-template-access.ts +5 -0
- package/src/lib/providers/index.ts +23 -0
- package/src/lib/server/context-manager.ts +4 -0
- package/src/lib/server/messages/message-repository.test.ts +122 -0
- package/src/lib/server/messages/message-repository.ts +67 -11
- package/src/lib/server/openrouter-model-context.test.ts +205 -0
- package/src/lib/server/openrouter-model-context.ts +169 -0
- package/src/lib/server/provider-health.ts +1 -0
- package/src/lib/server/runtime/devserver-launch.ts +7 -4
- package/src/lib/setup-defaults.test.ts +10 -1
- package/src/lib/setup-defaults.ts +20 -0
- package/src/lib/theme-mode.ts +5 -0
- package/src/types/app-settings.ts +2 -0
- package/src/types/provider.ts +1 -1
- package/src/views/settings/section-theme.tsx +41 -1
|
@@ -68,3 +68,125 @@ test('appendMessage notifies both generic and per-session message topics', () =>
|
|
|
68
68
|
assert.deepEqual(output.genericTopics, ['messages'])
|
|
69
69
|
assert.deepEqual(output.sessionTopics, ['messages:sess-notify'])
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
test('lazy migration compacts legacy session message blobs after table persistence', () => {
|
|
73
|
+
const output = runWithTempDataDir<{
|
|
74
|
+
returnedTexts: string[]
|
|
75
|
+
secondReadTexts: string[]
|
|
76
|
+
blobMessageCount: number
|
|
77
|
+
messageCount: number
|
|
78
|
+
lastMessageText: string | null
|
|
79
|
+
}>(`
|
|
80
|
+
const storageMod = await import('@/lib/server/storage')
|
|
81
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
82
|
+
const storage = storageMod.default || storageMod
|
|
83
|
+
const repo = repoMod.default || repoMod
|
|
84
|
+
|
|
85
|
+
storage.saveSessions({
|
|
86
|
+
'sess-legacy-blob': {
|
|
87
|
+
id: 'sess-legacy-blob',
|
|
88
|
+
name: 'Legacy blob session',
|
|
89
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
90
|
+
user: 'tester',
|
|
91
|
+
provider: 'openai',
|
|
92
|
+
model: 'gpt-5',
|
|
93
|
+
claudeSessionId: null,
|
|
94
|
+
codexThreadId: null,
|
|
95
|
+
opencodeSessionId: null,
|
|
96
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
97
|
+
messages: [
|
|
98
|
+
{ role: 'user', text: 'first legacy prompt', time: 1 },
|
|
99
|
+
{ role: 'assistant', text: 'first legacy reply', time: 2 },
|
|
100
|
+
{ role: 'user', text: 'second legacy prompt', time: 3 },
|
|
101
|
+
],
|
|
102
|
+
createdAt: Date.now(),
|
|
103
|
+
lastActiveAt: Date.now(),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const returned = repo.getMessages('sess-legacy-blob')
|
|
108
|
+
const secondRead = repo.getMessages('sess-legacy-blob')
|
|
109
|
+
const stored = storage.loadSessions()['sess-legacy-blob']
|
|
110
|
+
|
|
111
|
+
console.log(JSON.stringify({
|
|
112
|
+
returnedTexts: returned.map((message) => message.text),
|
|
113
|
+
secondReadTexts: secondRead.map((message) => message.text),
|
|
114
|
+
blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
|
|
115
|
+
messageCount: stored.messageCount,
|
|
116
|
+
lastMessageText: stored.lastMessageSummary?.text || null,
|
|
117
|
+
}))
|
|
118
|
+
`, { prefix: 'swarmclaw-message-repo-compact-' })
|
|
119
|
+
|
|
120
|
+
assert.deepEqual(output.returnedTexts, [
|
|
121
|
+
'first legacy prompt',
|
|
122
|
+
'first legacy reply',
|
|
123
|
+
'second legacy prompt',
|
|
124
|
+
])
|
|
125
|
+
assert.deepEqual(output.secondReadTexts, output.returnedTexts)
|
|
126
|
+
assert.equal(output.blobMessageCount, 0)
|
|
127
|
+
assert.equal(output.messageCount, 3)
|
|
128
|
+
assert.equal(output.lastMessageText, 'second legacy prompt')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('bulk migration reports compaction for table-backed legacy blobs', () => {
|
|
132
|
+
const output = runWithTempDataDir<{
|
|
133
|
+
result: {
|
|
134
|
+
migrated: number
|
|
135
|
+
compacted: number
|
|
136
|
+
skipped: number
|
|
137
|
+
total: number
|
|
138
|
+
}
|
|
139
|
+
blobMessageCount: number
|
|
140
|
+
messageCount: number
|
|
141
|
+
}>(`
|
|
142
|
+
const storageMod = await import('@/lib/server/storage')
|
|
143
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
144
|
+
const storage = storageMod.default || storageMod
|
|
145
|
+
const repo = repoMod.default || repoMod
|
|
146
|
+
|
|
147
|
+
const messages = [
|
|
148
|
+
{ role: 'user', text: 'stale blob prompt', time: 10 },
|
|
149
|
+
{ role: 'assistant', text: 'stale blob reply', time: 20 },
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
storage.saveSessions({
|
|
153
|
+
'sess-table-backed-blob': {
|
|
154
|
+
id: 'sess-table-backed-blob',
|
|
155
|
+
name: 'Table backed blob session',
|
|
156
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
157
|
+
user: 'tester',
|
|
158
|
+
provider: 'openai',
|
|
159
|
+
model: 'gpt-5',
|
|
160
|
+
claudeSessionId: null,
|
|
161
|
+
codexThreadId: null,
|
|
162
|
+
opencodeSessionId: null,
|
|
163
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
164
|
+
messages,
|
|
165
|
+
createdAt: Date.now(),
|
|
166
|
+
lastActiveAt: Date.now(),
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const db = storage.getDb()
|
|
171
|
+
const insert = db.prepare('INSERT INTO session_messages (session_id, seq, data) VALUES (?, ?, ?)')
|
|
172
|
+
messages.forEach((message, index) => {
|
|
173
|
+
insert.run('sess-table-backed-blob', index, JSON.stringify(message))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const result = repo.migrateAllSessions()
|
|
177
|
+
const stored = storage.loadSessions()['sess-table-backed-blob']
|
|
178
|
+
|
|
179
|
+
console.log(JSON.stringify({
|
|
180
|
+
result,
|
|
181
|
+
blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
|
|
182
|
+
messageCount: stored.messageCount,
|
|
183
|
+
}))
|
|
184
|
+
`, { prefix: 'swarmclaw-message-repo-migrate-report-' })
|
|
185
|
+
|
|
186
|
+
assert.equal(output.result.migrated, 0)
|
|
187
|
+
assert.equal(output.result.compacted, 1)
|
|
188
|
+
assert.equal(output.result.skipped, 1)
|
|
189
|
+
assert.equal(output.result.total, 1)
|
|
190
|
+
assert.equal(output.blobMessageCount, 0)
|
|
191
|
+
assert.equal(output.messageCount, 2)
|
|
192
|
+
})
|
|
@@ -86,6 +86,38 @@ function summarizeForMeta(message: Message): Message {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function getLastAssistantAt(messages: Message[]): number | null {
|
|
90
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
91
|
+
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
|
|
92
|
+
return messages[i].time
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compactDeprecatedBlobMessages(
|
|
99
|
+
sessionId: string,
|
|
100
|
+
persistedCount: number,
|
|
101
|
+
lastMsg: Message | null,
|
|
102
|
+
lastAssistantAt: number | null,
|
|
103
|
+
): boolean {
|
|
104
|
+
let compacted = false
|
|
105
|
+
patchSession(sessionId, (current) => {
|
|
106
|
+
if (!current) return null
|
|
107
|
+
const blobCount = Array.isArray(current.messages) ? current.messages.length : 0
|
|
108
|
+
if (blobCount === 0) return current
|
|
109
|
+
if (persistedCount < blobCount) return current
|
|
110
|
+
|
|
111
|
+
current.messages = []
|
|
112
|
+
current.messageCount = persistedCount
|
|
113
|
+
current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
|
|
114
|
+
if (lastAssistantAt !== null) current.lastAssistantAt = lastAssistantAt
|
|
115
|
+
compacted = true
|
|
116
|
+
return current
|
|
117
|
+
})
|
|
118
|
+
return compacted
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
// ---------------------------------------------------------------------------
|
|
90
122
|
// Session metadata sync — keeps messageCount / lastMessageSummary on the blob
|
|
91
123
|
// ---------------------------------------------------------------------------
|
|
@@ -139,18 +171,14 @@ function lazyMigrateSession(sessionId: string): Message[] | null {
|
|
|
139
171
|
ins.run(sessionId, i, JSON.stringify(messages[i]))
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
// Compute metadata
|
|
174
|
+
// Compute metadata before compacting deprecated blob storage.
|
|
143
175
|
const lastMsg = messages[messages.length - 1]
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
|
|
147
|
-
lastAssistantAt = messages[i].time
|
|
148
|
-
break
|
|
149
|
-
}
|
|
150
|
-
}
|
|
176
|
+
const lastAssistantAt = getLastAssistantAt(messages)
|
|
177
|
+
const persistedCount = rowCount(sessionId)
|
|
151
178
|
|
|
152
179
|
patchSession(sessionId, (current) => {
|
|
153
180
|
if (!current) return null
|
|
181
|
+
current.messages = persistedCount >= messages.length ? [] : current.messages
|
|
154
182
|
current.messageCount = messages.length
|
|
155
183
|
current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
|
|
156
184
|
if (lastAssistantAt !== null && typeof current.lastAssistantAt !== 'number') {
|
|
@@ -183,6 +211,7 @@ export function getMessages(sessionId: string): Message[] {
|
|
|
183
211
|
const m = parseMsg(row.data)
|
|
184
212
|
if (m) out.push(m)
|
|
185
213
|
}
|
|
214
|
+
compactDeprecatedBlobMessages(sessionId, out.length, out[out.length - 1] || null, getLastAssistantAt(out))
|
|
186
215
|
return out
|
|
187
216
|
}, { sessionId })
|
|
188
217
|
}
|
|
@@ -338,10 +367,16 @@ export function deleteSessionMessages(sessionId: string): void {
|
|
|
338
367
|
// Bulk migration (for CLI / admin endpoint)
|
|
339
368
|
// ---------------------------------------------------------------------------
|
|
340
369
|
|
|
341
|
-
export function migrateAllSessions(): {
|
|
370
|
+
export function migrateAllSessions(): {
|
|
371
|
+
migrated: number
|
|
372
|
+
compacted: number
|
|
373
|
+
skipped: number
|
|
374
|
+
total: number
|
|
375
|
+
} {
|
|
342
376
|
const db = getDb()
|
|
343
377
|
const rows = db.prepare('SELECT id, data FROM sessions').all() as Array<{ id: string; data: string }>
|
|
344
378
|
let migrated = 0
|
|
379
|
+
let compacted = 0
|
|
345
380
|
let skipped = 0
|
|
346
381
|
|
|
347
382
|
for (const row of rows) {
|
|
@@ -351,16 +386,37 @@ export function migrateAllSessions(): { migrated: number; skipped: number; total
|
|
|
351
386
|
skipped++
|
|
352
387
|
continue
|
|
353
388
|
}
|
|
354
|
-
|
|
389
|
+
|
|
390
|
+
const persistedCount = rowCount(row.id)
|
|
391
|
+
if (persistedCount > 0) {
|
|
392
|
+
const persistedRows = stmts().selectAll.all(row.id) as Array<{ data: string }>
|
|
393
|
+
const persistedMessages: Message[] = []
|
|
394
|
+
for (const persistedRow of persistedRows) {
|
|
395
|
+
const message = parseMsg(persistedRow.data)
|
|
396
|
+
if (message) persistedMessages.push(message)
|
|
397
|
+
}
|
|
398
|
+
if (compactDeprecatedBlobMessages(
|
|
399
|
+
row.id,
|
|
400
|
+
persistedCount,
|
|
401
|
+
persistedMessages[persistedMessages.length - 1] || null,
|
|
402
|
+
getLastAssistantAt(persistedMessages),
|
|
403
|
+
)) {
|
|
404
|
+
compacted++
|
|
405
|
+
}
|
|
355
406
|
skipped++
|
|
356
407
|
continue
|
|
357
408
|
}
|
|
409
|
+
|
|
358
410
|
lazyMigrateSession(row.id)
|
|
359
411
|
migrated++
|
|
412
|
+
const stored = loadSession(row.id)
|
|
413
|
+
if (!Array.isArray(stored?.messages) || stored.messages.length === 0) {
|
|
414
|
+
compacted++
|
|
415
|
+
}
|
|
360
416
|
} catch {
|
|
361
417
|
skipped++
|
|
362
418
|
}
|
|
363
419
|
}
|
|
364
420
|
|
|
365
|
-
return { migrated, skipped, total: rows.length }
|
|
421
|
+
return { migrated, compacted, skipped, total: rows.length }
|
|
366
422
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
interface OpenRouterContextResult {
|
|
7
|
+
contextWindow: number | null
|
|
8
|
+
fetchCalls?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function runOpenRouterContextScript(script: string): OpenRouterContextResult {
|
|
12
|
+
return runWithTempDataDir<OpenRouterContextResult>(script, {
|
|
13
|
+
prefix: 'swarmclaw-openrouter-context-',
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('exact OpenRouter model ID returns cached context length', () => {
|
|
18
|
+
const output = runOpenRouterContextScript(`
|
|
19
|
+
const fs = await import('node:fs')
|
|
20
|
+
const path = await import('node:path')
|
|
21
|
+
const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
|
|
22
|
+
fs.writeFileSync(cachePath, JSON.stringify({
|
|
23
|
+
loadedAt: Date.now(),
|
|
24
|
+
models: { 'minimax/minimax-m3': 524288 },
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
28
|
+
const mod = modImport.default || modImport
|
|
29
|
+
globalThis.fetch = async () => {
|
|
30
|
+
throw new Error('fetch should not run when cache is fresh')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
34
|
+
console.log(JSON.stringify({
|
|
35
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
|
|
36
|
+
}))
|
|
37
|
+
`)
|
|
38
|
+
|
|
39
|
+
assert.equal(output.contextWindow, 524_288)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('top_provider.context_length is preferred over context_length', () => {
|
|
43
|
+
const output = runOpenRouterContextScript(`
|
|
44
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
45
|
+
const mod = modImport.default || modImport
|
|
46
|
+
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
47
|
+
data: [{
|
|
48
|
+
id: 'provider/model-a',
|
|
49
|
+
context_length: 8192,
|
|
50
|
+
top_provider: { context_length: 131072 },
|
|
51
|
+
}],
|
|
52
|
+
}), { status: 200 })
|
|
53
|
+
|
|
54
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
55
|
+
console.log(JSON.stringify({
|
|
56
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'provider/model-a'),
|
|
57
|
+
}))
|
|
58
|
+
`)
|
|
59
|
+
|
|
60
|
+
assert.equal(output.contextWindow, 131_072)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('unique suffix match works for unprefixed model IDs', () => {
|
|
64
|
+
const output = runOpenRouterContextScript(`
|
|
65
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
66
|
+
const mod = modImport.default || modImport
|
|
67
|
+
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
68
|
+
data: [{ id: 'google/gemini-2.5-pro', context_length: 1048576 }],
|
|
69
|
+
}), { status: 200 })
|
|
70
|
+
|
|
71
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
72
|
+
console.log(JSON.stringify({
|
|
73
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'gemini-2.5-pro'),
|
|
74
|
+
}))
|
|
75
|
+
`)
|
|
76
|
+
|
|
77
|
+
assert.equal(output.contextWindow, 1_048_576)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('ambiguous suffix match returns null', () => {
|
|
81
|
+
const output = runOpenRouterContextScript(`
|
|
82
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
83
|
+
const mod = modImport.default || modImport
|
|
84
|
+
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
85
|
+
data: [
|
|
86
|
+
{ id: 'provider-a/shared-model', context_length: 32000 },
|
|
87
|
+
{ id: 'provider-b/shared-model', context_length: 64000 },
|
|
88
|
+
],
|
|
89
|
+
}), { status: 200 })
|
|
90
|
+
|
|
91
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
92
|
+
console.log(JSON.stringify({
|
|
93
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'shared-model'),
|
|
94
|
+
}))
|
|
95
|
+
`)
|
|
96
|
+
|
|
97
|
+
assert.equal(output.contextWindow, null)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('non-OpenRouter provider returns null', () => {
|
|
101
|
+
const output = runOpenRouterContextScript(`
|
|
102
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
103
|
+
const mod = modImport.default || modImport
|
|
104
|
+
console.log(JSON.stringify({
|
|
105
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openai', 'minimax/minimax-m3'),
|
|
106
|
+
}))
|
|
107
|
+
`)
|
|
108
|
+
|
|
109
|
+
assert.equal(output.contextWindow, null)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('failed fetch does not throw', () => {
|
|
113
|
+
const output = runOpenRouterContextScript(`
|
|
114
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
115
|
+
const mod = modImport.default || modImport
|
|
116
|
+
let fetchCalls = 0
|
|
117
|
+
globalThis.fetch = async () => {
|
|
118
|
+
fetchCalls += 1
|
|
119
|
+
throw new Error('network down')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
123
|
+
console.log(JSON.stringify({
|
|
124
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
|
|
125
|
+
fetchCalls,
|
|
126
|
+
}))
|
|
127
|
+
`)
|
|
128
|
+
|
|
129
|
+
assert.equal(output.contextWindow, null)
|
|
130
|
+
assert.equal(output.fetchCalls, 1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('timed out fetch does not throw', () => {
|
|
134
|
+
const output = runOpenRouterContextScript(`
|
|
135
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
136
|
+
const mod = modImport.default || modImport
|
|
137
|
+
let fetchCalls = 0
|
|
138
|
+
globalThis.fetch = async (_input, init) => {
|
|
139
|
+
fetchCalls += 1
|
|
140
|
+
await new Promise((_resolve, reject) => {
|
|
141
|
+
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
146
|
+
console.log(JSON.stringify({
|
|
147
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
|
|
148
|
+
fetchCalls,
|
|
149
|
+
}))
|
|
150
|
+
`)
|
|
151
|
+
|
|
152
|
+
assert.equal(output.contextWindow, null)
|
|
153
|
+
assert.equal(output.fetchCalls, 1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('stale cache is ignored', () => {
|
|
157
|
+
const output = runOpenRouterContextScript(`
|
|
158
|
+
const fs = await import('node:fs')
|
|
159
|
+
const path = await import('node:path')
|
|
160
|
+
const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
|
|
161
|
+
fs.writeFileSync(cachePath, JSON.stringify({
|
|
162
|
+
loadedAt: Date.now() - (25 * 60 * 60 * 1000),
|
|
163
|
+
models: { 'minimax/minimax-m3': 524288 },
|
|
164
|
+
}))
|
|
165
|
+
|
|
166
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
167
|
+
const mod = modImport.default || modImport
|
|
168
|
+
let fetchCalls = 0
|
|
169
|
+
globalThis.fetch = async () => {
|
|
170
|
+
fetchCalls += 1
|
|
171
|
+
throw new Error('network down')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
175
|
+
console.log(JSON.stringify({
|
|
176
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'minimax/minimax-m3'),
|
|
177
|
+
fetchCalls,
|
|
178
|
+
}))
|
|
179
|
+
`)
|
|
180
|
+
|
|
181
|
+
assert.equal(output.contextWindow, null)
|
|
182
|
+
assert.equal(output.fetchCalls, 1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('cache write failure does not throw', () => {
|
|
186
|
+
const output = runOpenRouterContextScript(`
|
|
187
|
+
const fs = await import('node:fs')
|
|
188
|
+
const path = await import('node:path')
|
|
189
|
+
const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
|
|
190
|
+
fs.mkdirSync(cachePath)
|
|
191
|
+
|
|
192
|
+
const modImport = await import('./src/lib/server/openrouter-model-context')
|
|
193
|
+
const mod = modImport.default || modImport
|
|
194
|
+
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
195
|
+
data: [{ id: 'provider/model-a', context_length: 65536 }],
|
|
196
|
+
}), { status: 200 })
|
|
197
|
+
|
|
198
|
+
await mod.ensureOpenRouterModelContextCache('openrouter')
|
|
199
|
+
console.log(JSON.stringify({
|
|
200
|
+
contextWindow: mod.getCachedOpenRouterContextWindow('openrouter', 'provider/model-a'),
|
|
201
|
+
}))
|
|
202
|
+
`)
|
|
203
|
+
|
|
204
|
+
assert.equal(output.contextWindow, 65_536)
|
|
205
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { fetchWithTimeout } from '@/lib/fetch-timeout'
|
|
5
|
+
import { DATA_DIR } from '@/lib/server/data-dir'
|
|
6
|
+
|
|
7
|
+
interface OpenRouterModelEntry {
|
|
8
|
+
id?: string
|
|
9
|
+
context_length?: number
|
|
10
|
+
top_provider?: {
|
|
11
|
+
context_length?: number
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface OpenRouterModelsResponse {
|
|
16
|
+
data?: OpenRouterModelEntry[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OpenRouterModelContextCache {
|
|
20
|
+
loadedAt: number
|
|
21
|
+
models: Record<string, number>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
|
25
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
26
|
+
const FETCH_TIMEOUT_MS = 2_000
|
|
27
|
+
const CACHE_PATH = path.join(DATA_DIR, 'openrouter-model-context.json')
|
|
28
|
+
|
|
29
|
+
let cache: OpenRouterModelContextCache | null = null
|
|
30
|
+
let loading: Promise<void> | null = null
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseModelEntry(value: unknown): OpenRouterModelEntry | null {
|
|
37
|
+
if (!isRecord(value)) return null
|
|
38
|
+
|
|
39
|
+
const entry: OpenRouterModelEntry = {}
|
|
40
|
+
if (typeof value.id === 'string') entry.id = value.id
|
|
41
|
+
if (typeof value.context_length === 'number') entry.context_length = value.context_length
|
|
42
|
+
|
|
43
|
+
if (isRecord(value.top_provider)) {
|
|
44
|
+
const topProvider: OpenRouterModelEntry['top_provider'] = {}
|
|
45
|
+
if (typeof value.top_provider.context_length === 'number') {
|
|
46
|
+
topProvider.context_length = value.top_provider.context_length
|
|
47
|
+
}
|
|
48
|
+
entry.top_provider = topProvider
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return entry
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseModelsResponse(value: unknown): OpenRouterModelsResponse {
|
|
55
|
+
if (!isRecord(value) || !Array.isArray(value.data)) return {}
|
|
56
|
+
return {
|
|
57
|
+
data: value.data
|
|
58
|
+
.map(parseModelEntry)
|
|
59
|
+
.filter((entry): entry is OpenRouterModelEntry => entry !== null),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseCache(value: unknown): OpenRouterModelContextCache | null {
|
|
64
|
+
if (!isRecord(value) || typeof value.loadedAt !== 'number' || !isRecord(value.models)) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const models: Record<string, number> = {}
|
|
69
|
+
for (const [id, contextLength] of Object.entries(value.models)) {
|
|
70
|
+
if (typeof contextLength === 'number' && Number.isFinite(contextLength) && contextLength > 0) {
|
|
71
|
+
models[id] = contextLength
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { loadedAt: value.loadedAt, models }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isFreshCache(value: OpenRouterModelContextCache | null): value is OpenRouterModelContextCache {
|
|
79
|
+
return value !== null
|
|
80
|
+
&& Number.isFinite(value.loadedAt)
|
|
81
|
+
&& Date.now() - value.loadedAt <= CACHE_TTL_MS
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readCache(): Promise<OpenRouterModelContextCache | null> {
|
|
85
|
+
try {
|
|
86
|
+
const raw = await fs.readFile(CACHE_PATH, 'utf8')
|
|
87
|
+
const parsed = parseCache(JSON.parse(raw))
|
|
88
|
+
return isFreshCache(parsed) ? parsed : null
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function writeCache(nextCache: OpenRouterModelContextCache): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
await fs.mkdir(DATA_DIR, { recursive: true })
|
|
97
|
+
await fs.writeFile(CACHE_PATH, JSON.stringify(nextCache), 'utf8')
|
|
98
|
+
} catch {
|
|
99
|
+
// Best-effort cache. Runtime behavior should not depend on disk writes.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildModelContextMap(response: OpenRouterModelsResponse): Record<string, number> {
|
|
104
|
+
const models: Record<string, number> = {}
|
|
105
|
+
for (const entry of response.data || []) {
|
|
106
|
+
if (!entry.id) continue
|
|
107
|
+
const contextLength = entry.top_provider?.context_length || entry.context_length
|
|
108
|
+
if (typeof contextLength === 'number' && Number.isFinite(contextLength) && contextLength > 0) {
|
|
109
|
+
models[entry.id] = contextLength
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return models
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchOpenRouterModels(): Promise<OpenRouterModelContextCache | null> {
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetchWithTimeout(OPENROUTER_MODELS_URL, {}, FETCH_TIMEOUT_MS)
|
|
118
|
+
if (!response.ok) return null
|
|
119
|
+
|
|
120
|
+
const payload = parseModelsResponse(await response.json())
|
|
121
|
+
return {
|
|
122
|
+
loadedAt: Date.now(),
|
|
123
|
+
models: buildModelContextMap(payload),
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadOpenRouterModelContextCache(): Promise<void> {
|
|
131
|
+
const diskCache = await readCache()
|
|
132
|
+
if (diskCache) {
|
|
133
|
+
cache = diskCache
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fetchedCache = await fetchOpenRouterModels()
|
|
138
|
+
if (!fetchedCache) return
|
|
139
|
+
|
|
140
|
+
cache = fetchedCache
|
|
141
|
+
await writeCache(fetchedCache)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getCachedOpenRouterContextWindow(provider: string, model: string): number | null {
|
|
145
|
+
if (provider !== 'openrouter' || !isFreshCache(cache)) return null
|
|
146
|
+
|
|
147
|
+
const exactMatch = cache.models[model]
|
|
148
|
+
if (exactMatch) return exactMatch
|
|
149
|
+
|
|
150
|
+
if (model.includes('/')) return null
|
|
151
|
+
|
|
152
|
+
const suffixMatches = Object.entries(cache.models)
|
|
153
|
+
.filter(([id]) => id.endsWith(`/${model}`))
|
|
154
|
+
.map(([, contextLength]) => contextLength)
|
|
155
|
+
|
|
156
|
+
return suffixMatches.length === 1 ? suffixMatches[0] : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function ensureOpenRouterModelContextCache(provider: string): Promise<void> {
|
|
160
|
+
if (provider !== 'openrouter' || isFreshCache(cache)) return
|
|
161
|
+
|
|
162
|
+
if (!loading) {
|
|
163
|
+
loading = loadOpenRouterModelContextCache().finally(() => {
|
|
164
|
+
loading = null
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await loading
|
|
169
|
+
}
|
|
@@ -261,6 +261,7 @@ async function parseErrorMessage(res: Response, fallback: string): Promise<strin
|
|
|
261
261
|
export const OPENAI_COMPATIBLE_DEFAULTS: Record<string, { name: string; defaultEndpoint: string }> = {
|
|
262
262
|
openai: { name: 'OpenAI', defaultEndpoint: 'https://api.openai.com/v1' },
|
|
263
263
|
openrouter: { name: 'OpenRouter', defaultEndpoint: 'https://openrouter.ai/api/v1' },
|
|
264
|
+
tokenmix: { name: 'TokenMix', defaultEndpoint: 'https://api.tokenmix.ai/v1' },
|
|
264
265
|
google: { name: 'Google Gemini', defaultEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
265
266
|
deepseek: { name: 'DeepSeek', defaultEndpoint: 'https://api.deepseek.com/v1' },
|
|
266
267
|
groq: { name: 'Groq', defaultEndpoint: 'https://api.groq.com/openai/v1' },
|
|
@@ -23,10 +23,10 @@ const NEXT_CONFIG_FILES = [
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
function readPackageJson(dir: string): PackageJsonLike | null {
|
|
26
|
-
const pkgPath = path.join(dir, 'package.json')
|
|
27
|
-
if (!fs.existsSync(pkgPath)) return null
|
|
26
|
+
const pkgPath = path.join(/*turbopackIgnore: true*/ dir, 'package.json')
|
|
27
|
+
if (!fs.existsSync(/*turbopackIgnore: true*/ pkgPath)) return null
|
|
28
28
|
try {
|
|
29
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
29
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(/*turbopackIgnore: true*/ pkgPath, 'utf8'))
|
|
30
30
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
31
31
|
? parsed as PackageJsonLike
|
|
32
32
|
: null
|
|
@@ -46,7 +46,10 @@ function hasNextScript(pkg: PackageJsonLike): boolean {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function hasNextConfig(dir: string): boolean {
|
|
49
|
-
return NEXT_CONFIG_FILES.some((file) =>
|
|
49
|
+
return NEXT_CONFIG_FILES.some((file) => {
|
|
50
|
+
const configPath = path.join(/*turbopackIgnore: true*/ dir, file)
|
|
51
|
+
return fs.existsSync(/*turbopackIgnore: true*/ configPath)
|
|
52
|
+
})
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function classifyPackageRoot(dir: string, pkg: PackageJsonLike): FrameworkKind {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
3
|
import { CLI_PROVIDER_METADATA } from './providers/cli-provider-metadata'
|
|
4
|
-
import { DEFAULT_AGENTS, getDefaultModelForProvider } from './setup-defaults'
|
|
4
|
+
import { DEFAULT_AGENTS, SETUP_PROVIDERS, getDefaultModelForProvider } from './setup-defaults'
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// OpenClaw default model is empty (not 'default')
|
|
@@ -33,6 +33,15 @@ test('getDefaultModelForProvider returns non-empty for openrouter', () => {
|
|
|
33
33
|
assert.ok(model, 'openrouter model should be truthy')
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
test('TokenMix has setup metadata and a default agent model', () => {
|
|
37
|
+
const provider = SETUP_PROVIDERS.find((candidate) => candidate.id === 'tokenmix')
|
|
38
|
+
assert.ok(provider, 'tokenmix should appear in setup providers')
|
|
39
|
+
assert.equal(provider.defaultEndpoint, 'https://api.tokenmix.ai/v1')
|
|
40
|
+
assert.equal(provider.supportsEndpoint, false)
|
|
41
|
+
assert.equal(provider.requiresKey, true)
|
|
42
|
+
assert.equal(getDefaultModelForProvider('tokenmix'), 'claude-sonnet-4-6')
|
|
43
|
+
})
|
|
44
|
+
|
|
36
45
|
test('getDefaultModelForProvider returns non-empty for anthropic', () => {
|
|
37
46
|
const model = getDefaultModelForProvider('anthropic')
|
|
38
47
|
assert.ok(model, 'anthropic model should be truthy')
|