@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 +19 -1
- package/package.json +1 -1
- 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/setup/check-provider/route.test.ts +12 -0
- package/src/app/api/setup/check-provider/route.ts +6 -0
- package/src/lib/providers/index.ts +23 -0
- package/src/lib/server/context-manager.ts +4 -0
- 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/setup-defaults.test.ts +10 -1
- package/src/lib/setup-defaults.ts +20 -0
- package/src/types/provider.ts +1 -1
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:**
|
|
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.
|
|
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.',
|
package/src/types/provider.ts
CHANGED
|
@@ -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 {
|