@swarmclawai/swarmclaw 1.9.37 → 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 CHANGED
@@ -84,7 +84,7 @@ Available for macOS (Apple Silicon & Intel), Windows, and Linux (AppImage + .deb
84
84
  The release workflow supports Developer ID signing and notarization when Apple
85
85
  credentials are configured. If a macOS build is still ad-hoc signed, first
86
86
  launch may need one manual approval:
87
- - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common when the dmg was quarantined by Safari), strip the quarantine attribute and relaunch:
87
+ - **macOS:** signed/notarized releases publish both `.dmg` and `.zip`; unsigned fallback releases publish `.zip` only to avoid the damaged unsigned DMG path. Right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common when a downloaded app was quarantined by Safari), strip the quarantine attribute and relaunch:
88
88
  ```bash
89
89
  xattr -dr com.apple.quarantine /Applications/SwarmClaw.app
90
90
  ```
@@ -151,6 +151,15 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.38 Highlights
155
+
156
+ PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
157
+
158
+ - **TokenMix provider.** Added TokenMix as a built-in OpenAI-compatible provider with setup metadata, starter-agent defaults, and provider health checks.
159
+ - **OpenRouter context meters.** Chat context status now uses cached OpenRouter model metadata when available so routed model context windows display accurately.
160
+ - **macOS unsigned artifact fallback.** Desktop releases publish zip-only macOS artifacts when signing/notarization inputs are missing, avoiding the unsigned DMG damaged-app path.
161
+ - **Regression coverage.** Added targeted tests for TokenMix setup, OpenRouter context metadata caching, and macOS target selection.
162
+
154
163
  ## v1.9.37 Highlights
155
164
 
156
165
  Theme and memory-pressure release for lighter UI preferences and leaner chat history storage.
@@ -479,6 +488,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
479
488
 
480
489
  ## Releases
481
490
 
491
+ ### v1.9.38 Highlights
492
+
493
+ PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
494
+
495
+ - **TokenMix provider.** Added TokenMix as a built-in OpenAI-compatible provider with setup metadata, starter-agent defaults, and provider health checks.
496
+ - **OpenRouter context meters.** Chat context status now uses cached OpenRouter model metadata when available so routed model context windows display accurately.
497
+ - **macOS unsigned artifact fallback.** Desktop releases publish zip-only macOS artifacts when signing/notarization inputs are missing, avoiding the unsigned DMG damaged-app path.
498
+ - **Regression coverage.** Added targeted tests for TokenMix setup, OpenRouter context metadata caching, and macOS target selection.
499
+
482
500
  ### v1.9.37 Highlights
483
501
 
484
502
  Theme and memory-pressure release for lighter UI preferences and leaner chat history storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.37",
3
+ "version": "1.9.38",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import { getSession } from '@/lib/server/sessions/session-repository'
3
3
  import { getMessages } from '@/lib/server/messages/message-repository'
4
4
  import { getContextStatus } from '@/lib/server/context-manager'
5
5
  import { notFound } from '@/lib/server/collection-helpers'
6
+ import { ensureOpenRouterModelContextCache } from '@/lib/server/openrouter-model-context'
6
7
 
7
8
  const SYSTEM_PROMPT_TOKEN_ESTIMATE = 2000
8
9
 
@@ -11,6 +12,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
11
12
  const session = getSession(id)
12
13
  if (!session) return notFound()
13
14
  const messages = getMessages(id)
15
+ await ensureOpenRouterModelContextCache(session.provider as string)
14
16
  const status = getContextStatus(
15
17
  messages,
16
18
  SYSTEM_PROMPT_TOKEN_ESTIMATE,
@@ -66,3 +66,62 @@ test('GET /api/chats/[id]/context-status returns token usage summary', () => {
66
66
  assert.ok(['ok', 'warning', 'critical'].includes(output.strategy))
67
67
  assert.equal(output.missingStatus, 404)
68
68
  })
69
+
70
+ test('GET /api/chats/[id]/context-status uses OpenRouter model metadata context window', () => {
71
+ const output = runWithTempDataDir<{
72
+ status: number
73
+ contextWindow: number
74
+ }>(`
75
+ const fs = await import('node:fs')
76
+ const path = await import('node:path')
77
+ const cachePath = path.join(process.env.DATA_DIR, 'openrouter-model-context.json')
78
+ fs.writeFileSync(cachePath, JSON.stringify({
79
+ loadedAt: Date.now(),
80
+ models: { 'minimax/minimax-m3': 524288 },
81
+ }))
82
+
83
+ globalThis.fetch = async () => {
84
+ throw new Error('route should use seeded OpenRouter model metadata cache')
85
+ }
86
+
87
+ const storageMod = await import('./src/lib/server/storage')
88
+ const repoMod = await import('@/lib/server/messages/message-repository')
89
+ const routeMod = await import('./src/app/api/chats/[id]/context-status/route')
90
+ const storage = storageMod.default || storageMod
91
+ const repo = repoMod.default || repoMod
92
+ const route = routeMod.default || routeMod
93
+
94
+ const now = Date.now()
95
+ storage.saveSessions({
96
+ sess_ctx_openrouter: {
97
+ id: 'sess_ctx_openrouter',
98
+ name: 'OpenRouter context status test',
99
+ cwd: process.env.WORKSPACE_DIR,
100
+ user: 'tester',
101
+ provider: 'openrouter',
102
+ model: 'minimax/minimax-m3',
103
+ claudeSessionId: null,
104
+ messages: [],
105
+ createdAt: now,
106
+ lastActiveAt: now,
107
+ },
108
+ })
109
+
110
+ repo.appendMessage('sess_ctx_openrouter', { role: 'user', text: 'hello world', time: now })
111
+
112
+ const response = await route.GET(
113
+ new Request('http://local/api/chats/sess_ctx_openrouter/context-status'),
114
+ { params: Promise.resolve({ id: 'sess_ctx_openrouter' }) },
115
+ )
116
+ const payload = await response.json()
117
+
118
+ console.log(JSON.stringify({
119
+ status: response.status,
120
+ contextWindow: payload.contextWindow,
121
+ }))
122
+ `, { prefix: 'swarmclaw-context-status-route-' })
123
+
124
+ assert.equal(output.status, 200)
125
+ assert.equal(output.contextWindow, 524_288)
126
+ assert.notEqual(output.contextWindow, 8_192)
127
+ })
@@ -161,3 +161,15 @@ test('POST returns provider diagnostics with normalized LM Studio targets and re
161
161
  globalThis.fetch = originalFetch
162
162
  }
163
163
  })
164
+
165
+ test('POST rejects TokenMix setup checks without an API key', async () => {
166
+ const res = await POST(new Request('http://localhost/api/setup/check-provider', {
167
+ method: 'POST',
168
+ body: JSON.stringify({ provider: 'tokenmix' }),
169
+ }))
170
+ const payload = await res.json()
171
+
172
+ assert.equal(res.status, 200)
173
+ assert.equal(payload.ok, false)
174
+ assert.equal(payload.message, 'TokenMix API key is required.')
175
+ })
@@ -501,6 +501,12 @@ export async function POST(req: Request) {
501
501
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
502
502
  return NextResponse.json(result)
503
503
  }
504
+ case 'tokenmix': {
505
+ if (!apiKey) return NextResponse.json({ ok: false, message: 'TokenMix API key is required.' })
506
+ const info = OPENAI_COMPATIBLE_DEFAULTS.tokenmix
507
+ const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
508
+ return NextResponse.json(result)
509
+ }
504
510
  case 'anthropic': {
505
511
  if (!apiKey) return NextResponse.json({ ok: false, message: 'Anthropic API key is required.' })
506
512
  const result = await checkAnthropic(apiKey, endpoint, model)
@@ -128,6 +128,29 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
128
128
  },
129
129
  },
130
130
  },
131
+ tokenmix: {
132
+ id: 'tokenmix',
133
+ name: 'TokenMix',
134
+ models: [
135
+ 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5',
136
+ 'gpt-5.4', 'gpt-5.4-mini',
137
+ 'gemini-2.5-pro', 'gemini-2.5-flash',
138
+ 'deepseek-chat', 'deepseek-reasoner',
139
+ 'qwen-max',
140
+ ],
141
+ requiresApiKey: true,
142
+ requiresEndpoint: false,
143
+ defaultEndpoint: 'https://api.tokenmix.ai/v1',
144
+ handler: {
145
+ streamChat: (opts) => {
146
+ const patchedSession = {
147
+ ...opts.session,
148
+ apiEndpoint: opts.session.apiEndpoint || 'https://api.tokenmix.ai/v1',
149
+ }
150
+ return streamOpenAiChat({ ...opts, session: patchedSession })
151
+ },
152
+ },
153
+ },
131
154
  anthropic: {
132
155
  id: 'anthropic',
133
156
  name: 'Anthropic',
@@ -2,6 +2,7 @@ import type { Message, Session } from '@/types'
2
2
  import { getMemoryDb } from '@/lib/server/memory/memory-db'
3
3
  import { extractFactsFromMessages, ensureRunContext, pruneRunContext } from '@/lib/server/run-context'
4
4
  import { getSession, saveSession } from '@/lib/server/sessions/session-repository'
5
+ import { getCachedOpenRouterContextWindow } from '@/lib/server/openrouter-model-context'
5
6
 
6
7
  import { repairTranscriptConsistency } from './transcript-repair'
7
8
 
@@ -97,6 +98,9 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
97
98
 
98
99
  /** Get context window size for a model, falling back to provider default */
99
100
  export function getContextWindowSize(provider: string, model: string): number {
101
+ const openRouterContext = getCachedOpenRouterContextWindow(provider, model)
102
+ if (openRouterContext) return openRouterContext
103
+
100
104
  return PROVIDER_CONTEXT_WINDOWS[model]
101
105
  || PROVIDER_DEFAULT_WINDOWS[provider]
102
106
  || 8_192
@@ -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' },
@@ -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')
@@ -86,6 +86,19 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
86
86
  icon: 'R',
87
87
  modelLibraryUrl: 'https://openrouter.ai/models',
88
88
  },
89
+ {
90
+ id: 'tokenmix',
91
+ name: 'TokenMix',
92
+ description: 'One OpenAI-compatible API relay for Claude, OpenAI, Gemini, DeepSeek, Qwen, and other hosted models.',
93
+ requiresKey: true,
94
+ supportsEndpoint: false,
95
+ defaultEndpoint: 'https://api.tokenmix.ai/v1',
96
+ keyUrl: 'https://tokenmix.ai',
97
+ keyLabel: 'tokenmix.ai',
98
+ badge: 'Catalog',
99
+ icon: 'T',
100
+ modelLibraryUrl: 'https://tokenmix.ai/models',
101
+ },
89
102
  {
90
103
  id: 'openclaw',
91
104
  name: 'OpenClaw',
@@ -781,6 +794,13 @@ export const DEFAULT_AGENTS = {
781
794
  model: 'anthropic/claude-sonnet-4.6',
782
795
  tools: STARTER_AGENT_TOOLS,
783
796
  },
797
+ tokenmix: {
798
+ name: 'TokenMix Agent',
799
+ description: 'A helpful assistant powered through TokenMix.',
800
+ systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
801
+ model: 'claude-sonnet-4-6',
802
+ tools: STARTER_AGENT_TOOLS,
803
+ },
784
804
  google: {
785
805
  name: 'Gemini',
786
806
  description: 'A helpful Gemini-powered assistant.',
@@ -1,4 +1,4 @@
1
- export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'opencode-web' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'aider-cli' | 'amp-cli' | 'augment-cli' | 'adal-cli' | 'bob-cli' | 'cline-cli' | 'codebuddy-cli' | 'command-code-cli' | 'continue-cli' | 'cortex-cli' | 'crush-cli' | 'deepagents-cli' | 'firebender-cli' | 'iflow-cli' | 'junie-cli' | 'kilo-code-cli' | 'kimi-cli' | 'kode-cli' | 'mcpjam-cli' | 'mistral-vibe-cli' | 'mux-cli' | 'neovate-cli' | 'openhands-cli' | 'pochi-cli' | 'qoder-cli' | 'replit-cli' | 'roo-code-cli' | 'trae-cn-cli' | 'warp-cli' | 'windsurf-cli' | 'zencoder-cli' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'lmstudio' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
1
+ export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'opencode-web' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'aider-cli' | 'amp-cli' | 'augment-cli' | 'adal-cli' | 'bob-cli' | 'cline-cli' | 'codebuddy-cli' | 'command-code-cli' | 'continue-cli' | 'cortex-cli' | 'crush-cli' | 'deepagents-cli' | 'firebender-cli' | 'iflow-cli' | 'junie-cli' | 'kilo-code-cli' | 'kimi-cli' | 'kode-cli' | 'mcpjam-cli' | 'mistral-vibe-cli' | 'mux-cli' | 'neovate-cli' | 'openhands-cli' | 'pochi-cli' | 'qoder-cli' | 'replit-cli' | 'roo-code-cli' | 'trae-cn-cli' | 'warp-cli' | 'windsurf-cli' | 'zencoder-cli' | 'openai' | 'openrouter' | 'tokenmix' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'lmstudio' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
2
2
  export type ProviderId = ProviderType | (string & {})
3
3
 
4
4
  export interface ProviderInfo {