@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.
Files changed (37) hide show
  1. package/README.md +53 -1
  2. package/package.json +3 -3
  3. package/src/app/api/chats/[id]/context-status/route.ts +2 -0
  4. package/src/app/api/chats/context-status-route.test.ts +59 -0
  5. package/src/app/api/openclaw/history/route.ts +11 -6
  6. package/src/app/api/preview-server/route.ts +20 -12
  7. package/src/app/api/search/route.test.ts +63 -0
  8. package/src/app/api/search/route.ts +3 -2
  9. package/src/app/api/settings/route.ts +5 -1
  10. package/src/app/api/settings/settings-route.test.ts +38 -0
  11. package/src/app/api/setup/check-provider/route.test.ts +12 -0
  12. package/src/app/api/setup/check-provider/route.ts +6 -0
  13. package/src/app/api/usage/live/route.ts +2 -2
  14. package/src/app/globals.css +158 -0
  15. package/src/app/layout.tsx +12 -9
  16. package/src/app/protocols/builder/[templateId]/page.tsx +5 -5
  17. package/src/components/layout/dashboard-shell.tsx +9 -0
  18. package/src/components/protocols/builder/protocol-builder-canvas.tsx +106 -15
  19. package/src/components/providers/theme-provider.tsx +16 -0
  20. package/src/features/protocols/builder/hooks/use-template-sync.ts +5 -0
  21. package/src/features/protocols/builder/protocol-builder-store.ts +4 -4
  22. package/src/features/protocols/builder/utils/builder-template-access.test.ts +30 -0
  23. package/src/features/protocols/builder/utils/builder-template-access.ts +5 -0
  24. package/src/lib/providers/index.ts +23 -0
  25. package/src/lib/server/context-manager.ts +4 -0
  26. package/src/lib/server/messages/message-repository.test.ts +122 -0
  27. package/src/lib/server/messages/message-repository.ts +67 -11
  28. package/src/lib/server/openrouter-model-context.test.ts +205 -0
  29. package/src/lib/server/openrouter-model-context.ts +169 -0
  30. package/src/lib/server/provider-health.ts +1 -0
  31. package/src/lib/server/runtime/devserver-launch.ts +7 -4
  32. package/src/lib/setup-defaults.test.ts +10 -1
  33. package/src/lib/setup-defaults.ts +20 -0
  34. package/src/lib/theme-mode.ts +5 -0
  35. package/src/types/app-settings.ts +2 -0
  36. package/src/types/provider.ts +1 -1
  37. 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 on the blob (keep messages intact for backward compat)
174
+ // Compute metadata before compacting deprecated blob storage.
143
175
  const lastMsg = messages[messages.length - 1]
144
- let lastAssistantAt: number | null = null
145
- for (let i = messages.length - 1; i >= 0; i--) {
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(): { migrated: number; skipped: number; total: number } {
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
- if (rowCount(row.id) > 0) {
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) => fs.existsSync(path.join(dir, 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')