mohdel 0.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/config/benchmarks.json +39 -0
- package/js/client/call.js +75 -0
- package/js/client/call_image.js +82 -0
- package/js/client/gate-binary.js +72 -0
- package/js/client/index.js +16 -0
- package/js/client/ndjson.js +29 -0
- package/js/client/transport.js +48 -0
- package/js/core/envelope.js +141 -0
- package/js/core/errors.js +75 -0
- package/js/core/events.js +96 -0
- package/js/core/image.js +58 -0
- package/js/core/index.js +10 -0
- package/js/core/status.js +48 -0
- package/js/factory/bridge.js +372 -0
- package/js/session/_cooldown.js +114 -0
- package/js/session/_logger.js +138 -0
- package/js/session/_rate_limiter.js +77 -0
- package/js/session/_tracing.js +58 -0
- package/js/session/adapters/_cancelled.js +44 -0
- package/js/session/adapters/_catalog.js +58 -0
- package/js/session/adapters/_chat_completions.js +439 -0
- package/js/session/adapters/_errors.js +85 -0
- package/js/session/adapters/_images.js +60 -0
- package/js/session/adapters/_lazy_json_cache.js +76 -0
- package/js/session/adapters/_pricing.js +67 -0
- package/js/session/adapters/_providers.js +60 -0
- package/js/session/adapters/_tools.js +185 -0
- package/js/session/adapters/_videos.js +283 -0
- package/js/session/adapters/anthropic.js +397 -0
- package/js/session/adapters/cerebras.js +28 -0
- package/js/session/adapters/deepseek.js +32 -0
- package/js/session/adapters/echo.js +51 -0
- package/js/session/adapters/fake.js +262 -0
- package/js/session/adapters/fireworks.js +46 -0
- package/js/session/adapters/gemini.js +381 -0
- package/js/session/adapters/groq.js +23 -0
- package/js/session/adapters/image/fake.js +55 -0
- package/js/session/adapters/image/index.js +40 -0
- package/js/session/adapters/image/novita.js +135 -0
- package/js/session/adapters/image/openai.js +50 -0
- package/js/session/adapters/index.js +53 -0
- package/js/session/adapters/mistral.js +31 -0
- package/js/session/adapters/novita.js +29 -0
- package/js/session/adapters/openai.js +381 -0
- package/js/session/adapters/openrouter.js +66 -0
- package/js/session/adapters/xai.js +27 -0
- package/js/session/bin.js +54 -0
- package/js/session/driver.js +160 -0
- package/js/session/index.js +18 -0
- package/js/session/run.js +393 -0
- package/js/session/run_image.js +61 -0
- package/package.json +107 -0
- package/src/cli/ask.js +160 -0
- package/src/cli/backup.js +107 -0
- package/src/cli/bench.js +262 -0
- package/src/cli/check.js +123 -0
- package/src/cli/colored-logger.js +67 -0
- package/src/cli/colors.js +13 -0
- package/src/cli/default.js +39 -0
- package/src/cli/index.js +150 -0
- package/src/cli/json-output.js +60 -0
- package/src/cli/model.js +571 -0
- package/src/cli/onboard.js +232 -0
- package/src/cli/rank.js +176 -0
- package/src/cli/ratelimit.js +160 -0
- package/src/cli/tag.js +105 -0
- package/src/lib/assets/alibaba.svg +1 -0
- package/src/lib/assets/anthropic.svg +5 -0
- package/src/lib/assets/deepseek.svg +1 -0
- package/src/lib/assets/gemini.svg +1 -0
- package/src/lib/assets/google.svg +2 -0
- package/src/lib/assets/kwaipilot.svg +1 -0
- package/src/lib/assets/meta.svg +1 -0
- package/src/lib/assets/minimax.svg +9 -0
- package/src/lib/assets/moonshotai.svg +4 -0
- package/src/lib/assets/openai.svg +5 -0
- package/src/lib/assets/xai.svg +1 -0
- package/src/lib/assets/xiaomi.svg +2 -0
- package/src/lib/assets/zai.svg +219 -0
- package/src/lib/benchmark-score.js +215 -0
- package/src/lib/benchmark-truth.js +68 -0
- package/src/lib/cache.js +76 -0
- package/src/lib/common.js +208 -0
- package/src/lib/cooldown.js +63 -0
- package/src/lib/creators.js +71 -0
- package/src/lib/curated-cache.js +146 -0
- package/src/lib/errors.js +126 -0
- package/src/lib/index.js +726 -0
- package/src/lib/logger.js +29 -0
- package/src/lib/providers.js +87 -0
- package/src/lib/rank.js +390 -0
- package/src/lib/rate-limiter.js +50 -0
- package/src/lib/schema.js +150 -0
- package/src/lib/select.js +474 -0
- package/src/lib/tracing.js +62 -0
- package/src/lib/utils.js +85 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { readFile, writeFile, mkdir, copyFile, stat } from 'fs/promises'
|
|
4
|
+
import envPaths from 'env-paths'
|
|
5
|
+
import { validate, stripComputed } from './schema.js'
|
|
6
|
+
import { silent } from './logger.js'
|
|
7
|
+
|
|
8
|
+
// Module-level logger — set by mohdel factory via setLogger().
|
|
9
|
+
// Defaults to silent so file-load operations don't spam console when imported standalone.
|
|
10
|
+
let moduleLogger = silent
|
|
11
|
+
export const setLogger = (logger) => { moduleLogger = logger || silent }
|
|
12
|
+
|
|
13
|
+
export const CONFIG_DIR = envPaths('mohdel', { suffix: null }).config
|
|
14
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'default.json')
|
|
15
|
+
export const CURATED_PATH = join(CONFIG_DIR, 'curated.json')
|
|
16
|
+
export const EXCLUDED_PATH = join(CONFIG_DIR, 'excluded.json')
|
|
17
|
+
export const PROVIDERS_CONFIG_PATH = join(CONFIG_DIR, 'providers.json')
|
|
18
|
+
export const ENV_PATH = join(CONFIG_DIR, 'environment')
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CURATED = {}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_EXCLUDED = {}
|
|
23
|
+
|
|
24
|
+
export const loadEnvFile = (envPath) => {
|
|
25
|
+
try {
|
|
26
|
+
process.loadEnvFile(envPath)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code !== 'ENOENT') {
|
|
29
|
+
throw err
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const loadDefaultEnv = () => {
|
|
35
|
+
loadEnvFile(ENV_PATH)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const getAPIKey = (envVarName) => {
|
|
39
|
+
if (process.env[envVarName]) {
|
|
40
|
+
return process.env[envVarName]
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sortObjectKeys = (obj) => {
|
|
46
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj
|
|
47
|
+
|
|
48
|
+
return Object.keys(obj)
|
|
49
|
+
.sort()
|
|
50
|
+
.reduce((sorted, key) => {
|
|
51
|
+
sorted[key] = obj[key]
|
|
52
|
+
return sorted
|
|
53
|
+
}, {})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3-slot backup rotation: .prev (every save), .daily (first save of the day), .weekly (first save of the week)
|
|
57
|
+
const getWeek = (ms) => {
|
|
58
|
+
const d = new Date(ms)
|
|
59
|
+
return `${d.getFullYear()}-W${String(Math.ceil((d.getDate() + new Date(d.getFullYear(), d.getMonth(), 1).getDay()) / 7)).padStart(2, '0')}-${d.getMonth()}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rotateBackup = async (filePath) => {
|
|
63
|
+
if (!existsSync(filePath)) return
|
|
64
|
+
|
|
65
|
+
const prev = filePath + '.prev'
|
|
66
|
+
const daily = filePath + '.daily'
|
|
67
|
+
const weekly = filePath + '.weekly'
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Always: current → .prev
|
|
71
|
+
await copyFile(filePath, prev)
|
|
72
|
+
|
|
73
|
+
// First save of new day: .prev → .daily
|
|
74
|
+
const prevMtime = (await stat(prev)).mtimeMs
|
|
75
|
+
const dailyMtime = existsSync(daily) ? (await stat(daily)).mtimeMs : 0
|
|
76
|
+
if (new Date(prevMtime).toDateString() !== new Date(dailyMtime).toDateString()) {
|
|
77
|
+
await copyFile(prev, daily)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// First save of new week: .daily → .weekly
|
|
81
|
+
if (existsSync(daily)) {
|
|
82
|
+
const dMtime = (await stat(daily)).mtimeMs
|
|
83
|
+
const wMtime = existsSync(weekly) ? (await stat(weekly)).mtimeMs : 0
|
|
84
|
+
if (getWeek(dMtime) !== getWeek(wMtime)) {
|
|
85
|
+
await copyFile(daily, weekly)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
moduleLogger.warn(`[mohdel:common] backup rotation failed: ${err.message}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const BACKUP_SLOTS = ['prev', 'daily', 'weekly']
|
|
94
|
+
|
|
95
|
+
const createFileOperation = (filePath, defaultValue = {}, operationType) => {
|
|
96
|
+
const loadHandler = async () => {
|
|
97
|
+
let loadedData
|
|
98
|
+
try {
|
|
99
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
100
|
+
await mkdir(CONFIG_DIR, { recursive: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!existsSync(filePath)) {
|
|
104
|
+
if (defaultValue && Object.keys(defaultValue).length > 0) {
|
|
105
|
+
await writeFile(filePath, JSON.stringify(defaultValue, null, 2))
|
|
106
|
+
loadedData = JSON.parse(JSON.stringify(defaultValue))
|
|
107
|
+
} else {
|
|
108
|
+
loadedData = {}
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
const fileContent = await readFile(filePath, 'utf8')
|
|
112
|
+
loadedData = JSON.parse(fileContent)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof loadedData === 'object' && loadedData !== null && !Array.isArray(loadedData)) {
|
|
116
|
+
const processedData = {}
|
|
117
|
+
for (const [key, entryValue] of Object.entries(loadedData)) {
|
|
118
|
+
if (typeof entryValue !== 'object' || entryValue === null || Array.isArray(entryValue)) {
|
|
119
|
+
processedData[key] = entryValue
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const entry = { ...entryValue }
|
|
123
|
+
const upstreamIds = []
|
|
124
|
+
if (entry.model) {
|
|
125
|
+
upstreamIds.push(entry.model)
|
|
126
|
+
}
|
|
127
|
+
if (entry.aliases && Array.isArray(entry.aliases) && entry.aliases.length > 0) {
|
|
128
|
+
upstreamIds.push(...entry.aliases)
|
|
129
|
+
}
|
|
130
|
+
processedData[key] = { ...entry, upstreamIds }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (operationType === 'curated models') {
|
|
134
|
+
for (const [curatedKey, entry] of Object.entries(processedData)) {
|
|
135
|
+
const issues = validate(entry, curatedKey)
|
|
136
|
+
for (const issue of issues) {
|
|
137
|
+
moduleLogger.warn(`[mohdel:schema] ${curatedKey}: ${issue.field} — ${issue.message}`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return processedData
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return loadedData
|
|
146
|
+
} catch (err) {
|
|
147
|
+
moduleLogger.warn(`[mohdel:common] failed to load ${operationType}: ${err.message}`)
|
|
148
|
+
return JSON.parse(JSON.stringify(defaultValue || {}))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const saveHandler = async (data) => {
|
|
153
|
+
try {
|
|
154
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
155
|
+
await mkdir(CONFIG_DIR, { recursive: true })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await rotateBackup(filePath)
|
|
159
|
+
|
|
160
|
+
let dataToSave = data
|
|
161
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
162
|
+
const cleanedData = {}
|
|
163
|
+
for (const [key, entry] of Object.entries(data)) {
|
|
164
|
+
cleanedData[key] = (typeof entry === 'object' && entry !== null && !Array.isArray(entry))
|
|
165
|
+
? stripComputed(entry)
|
|
166
|
+
: entry
|
|
167
|
+
}
|
|
168
|
+
dataToSave = cleanedData
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sortedData = sortObjectKeys(dataToSave)
|
|
172
|
+
await writeFile(filePath, JSON.stringify(sortedData, null, 2))
|
|
173
|
+
return true
|
|
174
|
+
} catch (err) {
|
|
175
|
+
moduleLogger.error(`[mohdel:common] failed to save ${operationType}: ${err.message}`)
|
|
176
|
+
if (operationType !== 'configuration') {
|
|
177
|
+
throw new Error(`Failed to save ${operationType}: ${err.message}`)
|
|
178
|
+
}
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { load: loadHandler, save: saveHandler }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const configOps = createFileOperation(CONFIG_PATH, {}, 'configuration')
|
|
187
|
+
const curatedOps = createFileOperation(CURATED_PATH, DEFAULT_CURATED, 'curated models')
|
|
188
|
+
const excludedOps = createFileOperation(EXCLUDED_PATH, DEFAULT_EXCLUDED, 'excluded models')
|
|
189
|
+
const providersConfigOps = createFileOperation(PROVIDERS_CONFIG_PATH, {}, 'provider config')
|
|
190
|
+
|
|
191
|
+
export const getConfig = configOps.load
|
|
192
|
+
export const saveConfig = configOps.save
|
|
193
|
+
export const getCuratedModels = curatedOps.load
|
|
194
|
+
export const saveCuratedModels = curatedOps.save
|
|
195
|
+
export const getExcludedModels = excludedOps.load
|
|
196
|
+
export const saveExcludedModels = excludedOps.save
|
|
197
|
+
export const getProvidersConfig = providersConfigOps.load
|
|
198
|
+
export const saveProvidersConfig = providersConfigOps.save
|
|
199
|
+
|
|
200
|
+
export const getDefaultModelId = async () => {
|
|
201
|
+
const config = await getConfig()
|
|
202
|
+
|
|
203
|
+
if (config.defaultModel) {
|
|
204
|
+
return config.defaultModel
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error('No default model configured. Run \'mo default\' to set up a default model.')
|
|
208
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Severity, MohdelError } from './errors.js'
|
|
2
|
+
import { silent } from './logger.js'
|
|
3
|
+
|
|
4
|
+
const createCooldownTracker = (threshold = 3, durationMs = 60000, { logger = silent } = {}) => {
|
|
5
|
+
// key → { failCount, until, reason }
|
|
6
|
+
const entries = new Map()
|
|
7
|
+
|
|
8
|
+
const check = (key) => {
|
|
9
|
+
const entry = entries.get(key)
|
|
10
|
+
if (!entry || !entry.until) return null
|
|
11
|
+
if (Date.now() >= entry.until) {
|
|
12
|
+
entries.delete(key)
|
|
13
|
+
logger.debug({ key }, '[mohdel:cooldown] expired')
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
return entry
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const recordFailure = (key, { immediate = false, cause } = {}) => {
|
|
20
|
+
const entry = entries.get(key) || { failCount: 0, until: null, reason: null }
|
|
21
|
+
entry.failCount++
|
|
22
|
+
if (cause) entry.lastCause = cause
|
|
23
|
+
const shouldCooldown = immediate || entry.failCount >= threshold
|
|
24
|
+
if (shouldCooldown) {
|
|
25
|
+
entry.until = Date.now() + durationMs
|
|
26
|
+
entry.reason = immediate ? 'auth' : 'consecutive_failures'
|
|
27
|
+
entries.set(key, entry)
|
|
28
|
+
logger.info({
|
|
29
|
+
key,
|
|
30
|
+
reason: entry.reason,
|
|
31
|
+
failCount: entry.failCount,
|
|
32
|
+
durationMs,
|
|
33
|
+
triggeredBy: entry.lastCause || null
|
|
34
|
+
}, '[mohdel:cooldown] activated')
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
entries.set(key, entry)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reset = (key) => {
|
|
42
|
+
const had = entries.has(key)
|
|
43
|
+
entries.delete(key)
|
|
44
|
+
if (had) logger.debug({ key }, '[mohdel:cooldown] reset')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const throwIfCoolingDown = (key, span) => {
|
|
48
|
+
const entry = check(key)
|
|
49
|
+
if (!entry) return
|
|
50
|
+
const secsLeft = Math.ceil((entry.until - Date.now()) / 1000)
|
|
51
|
+
logger.trace({ key, secsLeft }, '[mohdel:cooldown] fast-fail')
|
|
52
|
+
throw new MohdelError('PROVIDER_COOLDOWN', {
|
|
53
|
+
severity: Severity.WARN,
|
|
54
|
+
retryable: true,
|
|
55
|
+
detail: `Provider ${key} is in cooldown for ${secsLeft}s after ${entry.failCount} consecutive failures (${entry.reason}).`,
|
|
56
|
+
context: { provider: key, failCount: entry.failCount, reason: entry.reason, cooldownUntil: entry.until }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { check, recordFailure, reset, throwIfCoolingDown }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default createCooldownTracker
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const creators = {
|
|
2
|
+
alibaba: {
|
|
3
|
+
label: 'Alibaba',
|
|
4
|
+
logo: 'alibaba.svg',
|
|
5
|
+
description: 'Alibaba Cloud’s Qwen models target large-scale enterprise scenarios with strong multilingual and commerce-focused capabilities.'
|
|
6
|
+
},
|
|
7
|
+
bfl: {
|
|
8
|
+
label: 'Black Forest Labs',
|
|
9
|
+
logo: 'bfl.svg',
|
|
10
|
+
description: 'Black Forest Labs builds the Flux family of image generation models, delivering fast, high-quality text-to-image synthesis.'
|
|
11
|
+
},
|
|
12
|
+
anthropic: {
|
|
13
|
+
label: 'Anthropic',
|
|
14
|
+
logo: 'anthropic.svg',
|
|
15
|
+
description: 'Anthropic builds Claude models that emphasize safe reasoning, tool use, and reliable outputs for production assistants.'
|
|
16
|
+
},
|
|
17
|
+
deepseek: {
|
|
18
|
+
label: 'DeepSeek',
|
|
19
|
+
logo: 'deepseek.svg',
|
|
20
|
+
description: 'DeepSeek offers fast, cost-efficient foundation models optimized for coding, chat, and multilingual reasoning.'
|
|
21
|
+
},
|
|
22
|
+
google: {
|
|
23
|
+
label: 'Google',
|
|
24
|
+
logo: 'gemini.svg',
|
|
25
|
+
description: 'Google’s Gemini family blends multimodal understanding, coding assistance, and long-context reasoning from Google DeepMind research.'
|
|
26
|
+
},
|
|
27
|
+
kwaipilot: {
|
|
28
|
+
label: 'Kwaipilot',
|
|
29
|
+
logo: 'kwaipilot.svg',
|
|
30
|
+
description: 'Kuaishou\'s KwaiPilot team builds KAT-Coder, a MoE coding model with strong agentic and multi-step reasoning for software engineering tasks.'
|
|
31
|
+
},
|
|
32
|
+
meta: {
|
|
33
|
+
label: 'Meta',
|
|
34
|
+
logo: 'meta.svg',
|
|
35
|
+
description: 'Meta stewards the Llama ecosystem with open, widely adoptable models for chat, coding, and research.'
|
|
36
|
+
},
|
|
37
|
+
minimax: {
|
|
38
|
+
label: 'Minimax',
|
|
39
|
+
logo: 'minimax.svg',
|
|
40
|
+
description: 'Minimax offers versatile Chinese-first chat and coding models tuned for fast, cost-aware assistants and enterprise integrations.'
|
|
41
|
+
},
|
|
42
|
+
moonshotai: {
|
|
43
|
+
label: 'Moonshot AI',
|
|
44
|
+
logo: 'moonshotai.svg',
|
|
45
|
+
description: 'Moonshot AI ships fluent, Chinese-first assistants and lean models tuned for consumer chat and business workflows.'
|
|
46
|
+
},
|
|
47
|
+
openai: {
|
|
48
|
+
label: 'OpenAI',
|
|
49
|
+
logo: 'openai.svg',
|
|
50
|
+
description: 'OpenAI’s GPT and o-series models focus on broad tool-use, reasoning quality, and multimodal support across developer platforms.'
|
|
51
|
+
},
|
|
52
|
+
xai: {
|
|
53
|
+
label: 'xAI',
|
|
54
|
+
logo: 'xai.svg',
|
|
55
|
+
description: 'xAI develops Grok with real-time, web-aware chat and coding behavior aimed at terse, fast responses.'
|
|
56
|
+
},
|
|
57
|
+
xiaomi: {
|
|
58
|
+
label: 'Xiaomi',
|
|
59
|
+
logo: 'xiaomi.svg',
|
|
60
|
+
description: 'Xiaomi develops MiMo, a high-efficiency MoE reasoning model optimized for agentic coding and tool use at low inference cost.'
|
|
61
|
+
},
|
|
62
|
+
zai: {
|
|
63
|
+
label: 'Z AI',
|
|
64
|
+
logo: 'zai.svg',
|
|
65
|
+
description: 'Z AI delivers streamlined assistants with lightweight models oriented toward pragmatic productivity use cases.'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Object.freeze(creators)
|
|
70
|
+
|
|
71
|
+
export default creators
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getCuratedModels, saveCuratedModels } from './common.js'
|
|
2
|
+
|
|
3
|
+
let curatedCache = null
|
|
4
|
+
let aliasMapCache = null
|
|
5
|
+
|
|
6
|
+
export const getMohdelModel = curatedKey => {
|
|
7
|
+
const [provider, ...modelParts] = curatedKey.split('/')
|
|
8
|
+
return { provider, model: modelParts.join('/') }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BASE_NAME_RE = /^([^-\d]+-\d+(?:-\d+)*(?:-[a-z]+)?)/
|
|
12
|
+
|
|
13
|
+
const buildAliasMap = (curatedModels) => {
|
|
14
|
+
const aliasMap = new Map()
|
|
15
|
+
const modelCountByName = new Map()
|
|
16
|
+
const parsed = []
|
|
17
|
+
|
|
18
|
+
// Pass 1: count names and cache parsed results
|
|
19
|
+
for (const fullModelId in curatedModels) {
|
|
20
|
+
const { provider, model: modelName } = getMohdelModel(fullModelId)
|
|
21
|
+
const baseMatch = modelName.match(BASE_NAME_RE)
|
|
22
|
+
const baseName = baseMatch?.[1] || null
|
|
23
|
+
|
|
24
|
+
if (baseName) modelCountByName.set(baseName, (modelCountByName.get(baseName) || 0) + 1)
|
|
25
|
+
modelCountByName.set(modelName, (modelCountByName.get(modelName) || 0) + 1)
|
|
26
|
+
parsed.push({ fullModelId, provider, modelName, baseName, entry: curatedModels[fullModelId] })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pass 2: build aliases + explicit aliases
|
|
30
|
+
for (const { fullModelId, provider, modelName, baseName, entry } of parsed) {
|
|
31
|
+
if (modelCountByName.get(modelName) === 1) aliasMap.set(modelName, fullModelId)
|
|
32
|
+
|
|
33
|
+
if (baseName) {
|
|
34
|
+
if (modelCountByName.get(baseName) === 1) aliasMap.set(baseName, fullModelId)
|
|
35
|
+
aliasMap.set(`${provider}/${baseName}`, fullModelId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(entry.aliases)) {
|
|
39
|
+
for (const alias of entry.aliases) {
|
|
40
|
+
if (!aliasMap.has(alias)) aliasMap.set(alias, fullModelId)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return aliasMap
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const expandModelAliasInternal = (modelId) => {
|
|
49
|
+
if (curatedCache[modelId]) return modelId
|
|
50
|
+
if (aliasMapCache.has(modelId)) {
|
|
51
|
+
return aliasMapCache.get(modelId)
|
|
52
|
+
}
|
|
53
|
+
return modelId
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ensureCuratedCache = async () => {
|
|
57
|
+
if (!curatedCache) {
|
|
58
|
+
curatedCache = await getCuratedModels()
|
|
59
|
+
aliasMapCache = buildAliasMap(curatedCache)
|
|
60
|
+
}
|
|
61
|
+
return curatedCache
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rebuildAliasMap = () => {
|
|
65
|
+
aliasMapCache = buildAliasMap(curatedCache || {})
|
|
66
|
+
return aliasMapCache
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const loadCuratedCache = ensureCuratedCache
|
|
70
|
+
|
|
71
|
+
export const getCuratedCacheSnapshot = () => curatedCache
|
|
72
|
+
|
|
73
|
+
export const getAliasMapSnapshot = () => aliasMapCache
|
|
74
|
+
|
|
75
|
+
// Find close matches for a model ID that wasn't found.
|
|
76
|
+
// Returns array of { id, label } sorted by relevance (max 5).
|
|
77
|
+
export const suggestModels = (query, maxResults = 5) => {
|
|
78
|
+
if (!curatedCache) return []
|
|
79
|
+
const q = query.toLowerCase()
|
|
80
|
+
const scored = []
|
|
81
|
+
|
|
82
|
+
for (const fullId of Object.keys(curatedCache)) {
|
|
83
|
+
if (curatedCache[fullId].deprecated) continue
|
|
84
|
+
const entry = curatedCache[fullId]
|
|
85
|
+
const label = (entry.label || '').toLowerCase()
|
|
86
|
+
const id = fullId.toLowerCase()
|
|
87
|
+
const model = (entry.model || '').toLowerCase()
|
|
88
|
+
|
|
89
|
+
// Score by how well the query matches
|
|
90
|
+
let score = 0
|
|
91
|
+
if (id.includes(q)) score = 3
|
|
92
|
+
else if (model.includes(q)) score = 2.5
|
|
93
|
+
else if (label.includes(q)) score = 2
|
|
94
|
+
else {
|
|
95
|
+
// Fuzzy: check if all query segments appear somewhere
|
|
96
|
+
const terms = q.split(/[\s/\-_]+/).filter(Boolean)
|
|
97
|
+
const haystack = `${id} ${label} ${model}`
|
|
98
|
+
const matched = terms.filter(t => haystack.includes(t))
|
|
99
|
+
if (matched.length === terms.length) score = 1.5
|
|
100
|
+
else if (matched.length > 0) score = matched.length / terms.length
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (score > 0) scored.push({ id: fullId, label: entry.label || fullId, score })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
107
|
+
return scored.slice(0, maxResults)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const expandModelAliasSync = (modelId) => {
|
|
111
|
+
if (!curatedCache || !aliasMapCache) {
|
|
112
|
+
throw new Error('Curated cache has not been loaded yet')
|
|
113
|
+
}
|
|
114
|
+
return expandModelAliasInternal(modelId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const expandModelAlias = async (modelId) => {
|
|
118
|
+
await ensureCuratedCache()
|
|
119
|
+
return expandModelAliasInternal(modelId)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const persistCuratedCache = async () => {
|
|
123
|
+
if (!curatedCache) {
|
|
124
|
+
await ensureCuratedCache()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
rebuildAliasMap()
|
|
128
|
+
await saveCuratedModels(curatedCache)
|
|
129
|
+
return curatedCache
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const overwriteCuratedCache = async (nextCache) => {
|
|
133
|
+
curatedCache = nextCache || {}
|
|
134
|
+
return persistCuratedCache()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const reloadCuratedCache = async () => {
|
|
138
|
+
curatedCache = null
|
|
139
|
+
aliasMapCache = null
|
|
140
|
+
return ensureCuratedCache()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const clearCuratedCache = () => {
|
|
144
|
+
curatedCache = null
|
|
145
|
+
aliasMapCache = null
|
|
146
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export const Severity = Object.freeze({
|
|
2
|
+
TRACE: Symbol('TRACE'),
|
|
3
|
+
DEBUG: Symbol('DEBUG'),
|
|
4
|
+
INFO: Symbol('INFO'),
|
|
5
|
+
WARN: Symbol('WARN'),
|
|
6
|
+
ERROR: Symbol('ERROR'),
|
|
7
|
+
FATAL: Symbol('FATAL')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export const getSeverityNumber = (severitySymbol) => {
|
|
11
|
+
switch (severitySymbol) {
|
|
12
|
+
case Severity.TRACE:
|
|
13
|
+
return 1
|
|
14
|
+
case Severity.DEBUG:
|
|
15
|
+
return 5
|
|
16
|
+
case Severity.INFO:
|
|
17
|
+
return 9
|
|
18
|
+
case Severity.WARN:
|
|
19
|
+
return 13
|
|
20
|
+
case Severity.ERROR:
|
|
21
|
+
return 17
|
|
22
|
+
case Severity.FATAL:
|
|
23
|
+
return 21
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`[mohdel] unknown severity symbol: ${String(severitySymbol)}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// NOTE used to mock upstream Error
|
|
30
|
+
export class APIError extends Error {
|
|
31
|
+
constructor (message, status = 500) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'APIError'
|
|
34
|
+
this.status = status
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// usually Severity at least info
|
|
39
|
+
export class MohdelError extends Error {
|
|
40
|
+
constructor (
|
|
41
|
+
message,
|
|
42
|
+
{ cause, severity, detail, context, component = 'inference', retryable = false, silent = false } = {}
|
|
43
|
+
) {
|
|
44
|
+
super(message, { cause })
|
|
45
|
+
this.name = 'MohdelError'
|
|
46
|
+
this.severity = severity
|
|
47
|
+
this.detail = detail
|
|
48
|
+
this.context = context
|
|
49
|
+
this.component = component
|
|
50
|
+
this.retryable = retryable
|
|
51
|
+
this.silent = silent
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Convert any error to the serialized transport shape. Duck-types on
|
|
56
|
+
// `detail` to distinguish typed errors (MohdelError) from plain Error.
|
|
57
|
+
export const toTransportError = (err, span) => {
|
|
58
|
+
const isTyped = err.detail !== undefined
|
|
59
|
+
return {
|
|
60
|
+
message: isTyped ? err.message : 'UNEXPECTED_ERROR',
|
|
61
|
+
detail: isTyped ? err.detail : 'An unexpected error occurred',
|
|
62
|
+
trace: span?.spanContext()?.traceId,
|
|
63
|
+
component: err.component || undefined,
|
|
64
|
+
context: err.context || undefined,
|
|
65
|
+
retryable: err.retryable ?? false,
|
|
66
|
+
silent: err.silent ?? false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const retryableWarn = (err, detail) => {
|
|
71
|
+
return {
|
|
72
|
+
message: 'PROVIDER_OVERLOADED',
|
|
73
|
+
severity: Severity.WARN,
|
|
74
|
+
retryable: true,
|
|
75
|
+
detail,
|
|
76
|
+
cause: err
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export const reportRetryable = (err, provider, detail) => {
|
|
80
|
+
detail ||= `**An unexpected error occurred**: ${provider}'s API failed to respond. Try again or switch to a different model. If the issue persists, please contact support and provide the Trace ID.`
|
|
81
|
+
return {
|
|
82
|
+
message: 'PROVIDER_RETRYABLE_ERROR',
|
|
83
|
+
severity: Severity.ERROR,
|
|
84
|
+
detail,
|
|
85
|
+
retryable: true,
|
|
86
|
+
cause: err
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const reportDefault = (err, provider) => {
|
|
91
|
+
return {
|
|
92
|
+
message: 'PROVIDER_ERROR',
|
|
93
|
+
severity: Severity.ERROR,
|
|
94
|
+
detail: `**An unexpected error occurred**: ${provider}'s API failed to respond. Try switching to a different model or please contact support and provide the Trace ID.`,
|
|
95
|
+
retryable: isConnectionError(err),
|
|
96
|
+
cause: err
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const reportContextOverflow = (err, provider) => {
|
|
101
|
+
return {
|
|
102
|
+
message: 'CONTEXT_OVERFLOW',
|
|
103
|
+
severity: Severity.WARN,
|
|
104
|
+
detail: `The prompt exceeds ${provider}'s context limit. Reduce input or switch to a larger context model.`,
|
|
105
|
+
retryable: false,
|
|
106
|
+
cause: err
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isContextOverflowMessage (errMessage) {
|
|
111
|
+
const msg = (errMessage || '').toLowerCase()
|
|
112
|
+
if (msg.includes('context_length') || msg.includes('context length')) return true
|
|
113
|
+
if (msg.includes('token limit') || msg.includes('too long') || msg.includes('too many tokens')) return true
|
|
114
|
+
if (msg.includes('maximum context') || msg.includes('prompt is too long')) return true
|
|
115
|
+
if (msg.includes('max_tokens') && msg.includes('exceed')) return true
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isConnectionError (err) {
|
|
120
|
+
const code = err?.code || err?.cause?.code
|
|
121
|
+
if (code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' ||
|
|
122
|
+
code === 'EPIPE' || code === 'ENOTFOUND' || code === 'UND_ERR_CONNECT_TIMEOUT') return true
|
|
123
|
+
const msg = (err?.message || '').toLowerCase()
|
|
124
|
+
if (msg.includes('fetch failed') || msg.includes('socket hang up') || msg.includes('network')) return true
|
|
125
|
+
return false
|
|
126
|
+
}
|