mohdel 0.124.0 → 1.0.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.
Files changed (45) hide show
  1. package/README.md +155 -30
  2. package/config/curated.schema.json +32 -10
  3. package/js/client/call.js +76 -19
  4. package/js/client/gate-binary.js +5 -0
  5. package/js/client/index.js +1 -0
  6. package/js/core/envelope.js +5 -1
  7. package/js/factory/bridge.js +2 -2
  8. package/js/session/adapters/_cancelled.js +0 -6
  9. package/js/session/adapters/_chat_completions.js +25 -0
  10. package/js/session/adapters/_output_cap.js +30 -0
  11. package/js/session/adapters/_registry.js +44 -0
  12. package/js/session/adapters/anthropic.js +8 -5
  13. package/js/session/adapters/gemini.js +4 -0
  14. package/js/session/adapters/openai.js +2 -1
  15. package/js/session/run.js +14 -4
  16. package/js/session/run_image.js +12 -3
  17. package/package.json +49 -19
  18. package/src/cli/aliases.js +20 -0
  19. package/src/cli/ask.js +58 -12
  20. package/src/cli/backup.js +2 -1
  21. package/src/cli/check.js +15 -86
  22. package/src/cli/complete.js +130 -0
  23. package/src/cli/default.js +34 -13
  24. package/src/cli/doctor.js +33 -14
  25. package/src/cli/entry.js +173 -0
  26. package/src/cli/index.js +77 -66
  27. package/src/cli/instructions.js +349 -0
  28. package/src/cli/local.js +14 -0
  29. package/src/cli/model.js +184 -37
  30. package/src/cli/onboard.js +186 -121
  31. package/src/cli/rank.js +2 -1
  32. package/src/cli/ratelimit.js +3 -3
  33. package/src/cli/tag.js +2 -0
  34. package/src/lib/assistants.js +93 -0
  35. package/src/lib/catalog/openrouter.js +6 -1
  36. package/src/lib/catalog-review.js +195 -0
  37. package/src/lib/common.js +14 -1
  38. package/src/lib/creators.js +35 -0
  39. package/src/lib/index.js +17 -3
  40. package/src/lib/local-conventions.js +120 -0
  41. package/src/lib/provider-info.js +98 -0
  42. package/src/lib/providers.js +69 -14
  43. package/src/lib/schema.js +15 -3
  44. package/src/lib/select.js +125 -67
  45. package/js/session/adapters/image/index.js +0 -40
package/src/lib/select.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as clack from '@clack/prompts'
2
2
  import providers from './providers.js'
3
+ import creators, { creatorFromModelId } from './creators.js'
3
4
  import {
4
5
  getAPIKey,
5
6
  getCuratedModels,
@@ -17,40 +18,21 @@ import { silent } from './logger.js'
17
18
  loadEnvFile('.env')
18
19
  loadDefaultEnv()
19
20
 
20
- export const initializeAPIs = async () => {
21
- const api = {}
22
- const providersWithKeys = []
23
-
24
- for (const [name, config] of Object.entries(providers)) {
25
- try {
26
- if (config.catalog === false || !config.apiKeyEnv) {
27
- continue
28
- }
29
-
30
- // Get API key using the common.js functionality
31
- const apiKey = getAPIKey(config.apiKeyEnv)
32
-
33
- if (!apiKey) {
34
- console.warn(`Warning: No API key found for ${name} (env var: ${config.apiKeyEnv})`)
35
- continue
36
- }
37
-
38
- // Create configuration
39
- const sdkConfig = config.createConfiguration(apiKey)
40
-
41
- const sdkPath = `./catalog/${config.sdk}.js`
42
- const { default: API } = await import(sdkPath)
43
-
44
- api[name] = API({ ...sdkConfig, baseURL: config.baseURL }, {}, silent)
45
- providersWithKeys.push(name)
46
- } catch (err) {
47
- console.error(`Error initializing provider ${name} api:`, err.message)
48
- }
49
- }
50
-
51
- return { api, providersWithKeys }
21
+ // One provider's catalog client, for callers that don't need all of them.
22
+ export const providerApi = async (name) => {
23
+ const config = providers[name]
24
+ if (!config || config.catalog === false || !config.apiKeyEnv) return null
25
+ const apiKey = getAPIKey(config.apiKeyEnv)
26
+ if (!apiKey) return null
27
+ const { default: API } = await import(`./catalog/${config.sdk}.js`)
28
+ return API({ ...config.createConfiguration(apiKey), baseURL: config.baseURL }, {}, silent)
52
29
  }
53
30
 
31
+ export const providersWithKeys = () =>
32
+ Object.entries(providers)
33
+ .filter(([, c]) => c.catalog !== false && c.apiKeyEnv && getAPIKey(c.apiKeyEnv))
34
+ .map(([name]) => name)
35
+
54
36
  const getModelDetails = async (providerName, modelId, api) => {
55
37
  try {
56
38
  if (!api.getModelInfo) {
@@ -183,33 +165,45 @@ const isModelTrackedInCollection = (collection, providerName, modelId) => {
183
165
  return false
184
166
  }
185
167
 
168
+ // Which creators a provider serves is a fact of the catalog, not a property of
169
+ // the provider: a router's line-up changes week to week. Offer the ones this
170
+ // provider already serves first, then every creator mohdel has a label for.
171
+ const creatorOptions = async (providerName, guess) => {
172
+ const catalog = await getCuratedModels()
173
+ const served = new Set()
174
+ for (const [key, spec] of catalogEntries(catalog)) {
175
+ if (spec.deprecated || !spec.creator) continue
176
+ if (key.split('/')[0] === providerName) served.add(spec.creator)
177
+ }
178
+ served.delete(guess)
179
+ const rest = Object.keys(creators).filter(c => c !== guess && !served.has(c)).sort()
180
+ return [
181
+ ...(guess ? [{ value: guess, label: guess, hint: 'matches the model id' }] : []),
182
+ ...[...served].sort().map(c => ({ value: c, label: c, hint: `already served by ${providerName}` })),
183
+ ...rest.map(c => ({ value: c, label: c })),
184
+ { value: '__other', label: 'Other… (enter a name)' }
185
+ ]
186
+ }
187
+
186
188
  export const promptMissingFields = async (entry, curatedKey) => {
187
189
  if (!entry.creator) {
188
- const [providerName] = curatedKey.split('/')
189
- const providerConfig = providers[providerName]
190
- const creatorsList = providerConfig?.creators || []
191
-
192
- if (creatorsList.length === 1) {
193
- entry.creator = creatorsList[0]
194
- } else {
195
- const creatorVal = await clack.select({
196
- message: `Creator for ${curatedKey}:`,
197
- options: [
198
- ...creatorsList.map(c => ({ value: c, label: c })),
199
- { value: '__other', label: 'Other… (enter a name)' }
200
- ]
190
+ const [providerName, ...bare] = curatedKey.split('/')
191
+ const guess = creatorFromModelId(bare.join('/'))
192
+ const creatorVal = await clack.select({
193
+ message: `Creator for ${curatedKey}:`,
194
+ initialValue: guess || undefined,
195
+ options: await creatorOptions(providerName, guess)
196
+ })
197
+ if (clack.isCancel(creatorVal)) return entry
198
+ if (creatorVal === '__other') {
199
+ const custom = await clack.text({
200
+ message: `New creator name for ${curatedKey}:`,
201
+ validate: (v) => v?.trim() ? undefined : 'Creator is required'
201
202
  })
202
- if (clack.isCancel(creatorVal)) return entry
203
- if (creatorVal === '__other') {
204
- const custom = await clack.text({
205
- message: `New creator name for ${curatedKey}:`,
206
- validate: (v) => v?.trim() ? undefined : 'Creator is required'
207
- })
208
- if (clack.isCancel(custom)) return entry
209
- entry.creator = custom.trim()
210
- } else {
211
- entry.creator = creatorVal
212
- }
203
+ if (clack.isCancel(custom)) return entry
204
+ entry.creator = custom.trim()
205
+ } else {
206
+ entry.creator = creatorVal
213
207
  }
214
208
  }
215
209
 
@@ -268,6 +262,38 @@ const filterUncurated = (models, providerName, curated, excluded) => {
268
262
  })
269
263
  }
270
264
 
265
+ // A model list only carries prices when the provider publishes them
266
+ // (`pricesFromApi`). Where it does, the ones that cost nothing are the whole
267
+ // no-agent story, and a free-text search over hundreds of ids is no way to
268
+ // find them.
269
+ export const freeModels = (models) =>
270
+ models.filter(m => m.inputPrice === 0 && m.outputPrice === 0)
271
+
272
+ const buildEntry = async (providerName, model, providerInstance) => {
273
+ const info = await getModelDetails(providerName, model.id, providerInstance)
274
+ const entry = stripUnknown({
275
+ provider: providerName,
276
+ sdk: providers[providerName].sdk,
277
+ model: model.id,
278
+ label: model.label || model.id,
279
+ ...(info || {})
280
+ })
281
+ // Upstream lists name the provider, never the creator. An OpenRouter id is
282
+ // `<vendor>/<model>`, so the vendor segment is the answer when the creator
283
+ // table does not recognize the name.
284
+ if (!entry.creator) entry.creator = creatorFromModelId(model.id) || model.id.split('/')[0]
285
+ return entry
286
+ }
287
+
288
+ export const addModels = async (providerName, providerInstance, models) => {
289
+ const curated = await getCuratedModels()
290
+ for (const model of models) {
291
+ curated[`${providerName}/${model.id}`] = await buildEntry(providerName, model, providerInstance)
292
+ }
293
+ await saveCuratedModels(curated)
294
+ return models.length
295
+ }
296
+
271
297
  const searchModels = (models, query) => {
272
298
  const q = query.toLowerCase()
273
299
  const terms = q.split(/\s+/).filter(Boolean)
@@ -278,12 +304,32 @@ const searchModels = (models, query) => {
278
304
  }
279
305
 
280
306
  const processModelsSearchMode = async (providerName, providerInstance, allModels) => {
281
- const curated = await getCuratedModels()
307
+ let curated = await getCuratedModels()
282
308
  const excluded = await getExcludedModels()
283
- const uncurated = filterUncurated(allModels, providerName, curated, excluded)
309
+ let uncurated = filterUncurated(allModels, providerName, curated, excluded)
284
310
 
285
311
  clack.log.info(`${providerName}: ${allModels.length} models upstream, ${uncurated.length} uncurated`)
286
312
 
313
+ const free = freeModels(uncurated)
314
+ if (free.length) {
315
+ const chosen = await clack.multiselect({
316
+ message: `${free.length} model${free.length > 1 ? 's' : ''} cost${free.length > 1 ? '' : 's'} nothing. Add them?`,
317
+ options: free.map(m => ({ value: m.id, label: m.id, hint: m.label !== m.id ? m.label : undefined })),
318
+ initialValues: free.map(m => m.id),
319
+ maxItems: 12,
320
+ required: false
321
+ })
322
+ if (!clack.isCancel(chosen) && chosen.length) {
323
+ const picked = free.filter(m => chosen.includes(m.id))
324
+ const s = clack.spinner()
325
+ s.start(`Adding ${picked.length} model${picked.length > 1 ? 's' : ''}...`)
326
+ await addModels(providerName, providerInstance, picked)
327
+ s.stop(`Added ${picked.length} model${picked.length > 1 ? 's' : ''}, priced at $0`)
328
+ curated = await getCuratedModels()
329
+ uncurated = filterUncurated(allModels, providerName, curated, excluded)
330
+ }
331
+ }
332
+
287
333
  while (true) {
288
334
  const query = await clack.text({
289
335
  message: `Search ${providerName} models (or "done" to finish):`,
@@ -451,27 +497,35 @@ const processModelsSelectMode = async (providerName, providerInstance, allModels
451
497
  export const processModels = async (providerName, providerInstance) => {
452
498
  if (!providerInstance.listModels) {
453
499
  console.log(`Provider ${providerName} does not support listModels`)
454
- return
500
+ return false
455
501
  }
456
502
 
457
- try {
458
- const s = clack.spinner()
459
- s.start(`Fetching model list from ${providerName}...`)
503
+ // The spinner is started outside the try so a failed fetch can stop it. It
504
+ // used to be created inside, and any error left it spinning forever.
505
+ const s = clack.spinner()
506
+ let spinning = true
507
+ const stop = (message) => {
508
+ if (!spinning) return
509
+ spinning = false
510
+ s.stop(message)
511
+ }
512
+ s.start(`Fetching model list from ${providerName}...`)
460
513
 
514
+ try {
461
515
  const models = await providerInstance.listModels()
462
516
  if (!Array.isArray(models)) {
463
- s.stop(`${providerName} returned an invalid model list`)
464
- return
517
+ stop(`${providerName} returned an invalid model list`)
518
+ return false
465
519
  }
466
520
 
467
521
  const curated = await getCuratedModels()
468
522
  const excluded = await getExcludedModels()
469
523
  const uncurated = filterUncurated(models, providerName, curated, excluded)
470
- s.stop(`${providerName}: ${models.length} models upstream, ${uncurated.length} uncurated`)
524
+ stop(`${providerName}: ${models.length} models upstream, ${uncurated.length} uncurated`)
471
525
 
472
526
  if (!uncurated.length) {
473
527
  clack.log.info(`Nothing new to curate. To add a model not in the upstream catalog:\n mo model add ${providerName}/<model-id>`)
474
- return
528
+ return true
475
529
  }
476
530
 
477
531
  if (uncurated.length > SEARCH_MODE_THRESHOLD) {
@@ -479,7 +533,11 @@ export const processModels = async (providerName, providerInstance) => {
479
533
  } else {
480
534
  await processModelsSelectMode(providerName, providerInstance, models)
481
535
  }
536
+ return true
482
537
  } catch (err) {
483
- console.error(`Error processing models for ${providerName}:`, err.message)
538
+ stop(`${providerName}: could not fetch the model list`)
539
+ console.error(err.message)
540
+ console.error(`Check the key with "mo doctor", or reset it with "mo provider setup ${providerName}".`)
541
+ return false
484
542
  }
485
543
  }
@@ -1,40 +0,0 @@
1
- /**
2
- * Image-adapter registry. Mirrors session/adapters/index.js but
3
- * scoped to image-generation providers.
4
- *
5
- * @module session/adapters/image
6
- */
7
-
8
- import { openaiImage } from './openai.js'
9
- import { novitaImage } from './novita.js'
10
- import { fakeImage } from './fake.js'
11
-
12
- const IMAGE_ADAPTERS = {
13
- openai: openaiImage,
14
- novita: novitaImage,
15
- fake: fakeImage
16
- }
17
-
18
- /**
19
- * @param {string} provider
20
- * @returns {(
21
- * env: import('#core/image.js').ImageEnvelope,
22
- * deps?: any
23
- * ) => Promise<import('#core/image.js').ImageResult>}
24
- */
25
- export function getImageAdapter (provider) {
26
- const adapter = IMAGE_ADAPTERS[provider]
27
- if (!adapter) throw new Error(`no image adapter for provider: ${provider}`)
28
- return adapter
29
- }
30
-
31
- /**
32
- * Whether the provider has an image adapter registered. Used by
33
- * `run.js` to distinguish "wrong call path" (image-only provider
34
- * invoked via answer) from "truly unknown provider".
35
- *
36
- * @param {string} provider
37
- */
38
- export function isImageProvider (provider) {
39
- return Object.prototype.hasOwnProperty.call(IMAGE_ADAPTERS, provider)
40
- }