mohdel 3.6.1 → 3.7.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.
@@ -4,6 +4,7 @@ import { hostname } from 'node:os'
4
4
  import { createRemoteJWKSet, jwtVerify } from 'jose'
5
5
  import { defaultDirectory, readStore, withStore } from './store.js'
6
6
  import { discoverModels, visibleModels } from './models.js'
7
+ import providers from '../../src/lib/providers.js'
7
8
 
8
9
  const ISSUER = 'https://auth.openai.com'
9
10
  const RESOURCE = 'https://api.openai.com/v1'
@@ -15,7 +16,7 @@ const random = () => randomBytes(32).toString('base64url')
15
16
  const REFRESH_MARGIN = 30000
16
17
  // Every registration made before names were stored was sent this hint.
17
18
  const UNNAMED_REGISTRATION = 'Mohdel'
18
- export const usageURL = 'https://chatgpt.com/settings/usage'
19
+ export const usageURL = providers.chatgpt.billing.usage
19
20
 
20
21
  async function jsonRequest (fetcher, url, options = {}) {
21
22
  let response
@@ -10,6 +10,16 @@
10
10
  */
11
11
 
12
12
  import { setCatalog, specFor } from './_catalog.js'
13
+ import { providerOf } from '#core/model-id.js'
14
+ import providers from '../../../src/lib/providers.js'
15
+
16
+ // `plan` and `capacity` calls have no per-call API invoice, so `cost`, which is
17
+ // API USD, is 0 whatever the catalog prices. `fake` and `echo` are adapters,
18
+ // not providers: they carry no declaration and price from the catalog.
19
+ function unmetered (model) {
20
+ const def = providers[providerOf(model)]
21
+ return def !== undefined && def.billing.kind !== 'metered'
22
+ }
13
23
 
14
24
  /**
15
25
  * Pure cost computation from spec + usage.
@@ -112,11 +122,30 @@ function resolveTier (price, tokens) {
112
122
  * @returns {number}
113
123
  */
114
124
  export function costFor (envelope, usage) {
115
- // Plan usage has no per-call API invoice; it still consumes the plan allowance.
116
- if (envelope.model.startsWith('chatgpt/')) return 0
125
+ if (unmetered(envelope.model)) return 0
117
126
  return computeCost(specFor(envelope), usage)
118
127
  }
119
128
 
129
+ /**
130
+ * @param {{model: string}} envelope
131
+ * @param {any} spec
132
+ * @param {{durationSeconds?: number | null, inputTokens?: number, outputTokens?: number}} usage
133
+ * @returns {number}
134
+ */
135
+ export function transcriptionCostFor (envelope, spec, usage) {
136
+ return unmetered(envelope.model) ? 0 : computeTranscriptionCost(spec, usage)
137
+ }
138
+
139
+ /**
140
+ * @param {{model: string}} envelope
141
+ * @param {any} spec
142
+ * @param {{inputTokens?: number}} usage
143
+ * @returns {number}
144
+ */
145
+ export function embeddingCostFor (envelope, spec, usage) {
146
+ return unmetered(envelope.model) ? 0 : computeEmbeddingCost(spec, usage)
147
+ }
148
+
120
149
  /**
121
150
  * Cost of a transcription call.
122
151
  *
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { getSpec } from '../_catalog.js'
14
14
  import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
15
- import { computeEmbeddingCost } from '../_pricing.js'
15
+ import { embeddingCostFor } from '../_pricing.js'
16
16
  import { catalogKey, bareOf } from '#core/model-id.js'
17
17
  import { checkBatch, checkDimensions, resolveInputType, widthOf } from './_shared.js'
18
18
 
@@ -85,7 +85,7 @@ export async function cohereEmbedding (envelope, deps = {}) {
85
85
  dimensions: widthOf(vectors),
86
86
  inputType,
87
87
  inputTokens,
88
- cost: computeEmbeddingCost(spec, { inputTokens }),
88
+ cost: embeddingCostFor(envelope, spec, { inputTokens }),
89
89
  timestamps: { start, first: end, end }
90
90
  }
91
91
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { getSpec } from '../_catalog.js'
15
15
  import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
16
- import { computeEmbeddingCost } from '../_pricing.js'
16
+ import { embeddingCostFor } from '../_pricing.js'
17
17
  import { catalogKey, bareOf } from '#core/model-id.js'
18
18
  import { checkBatch, checkDimensions, resolveInputType, widthOf } from './_shared.js'
19
19
 
@@ -86,7 +86,7 @@ export async function geminiEmbedding (envelope, deps = {}) {
86
86
  dimensions: widthOf(vectors),
87
87
  inputType: taskType,
88
88
  inputTokens,
89
- cost: computeEmbeddingCost(spec, { inputTokens }),
89
+ cost: embeddingCostFor(envelope, spec, { inputTokens }),
90
90
  timestamps: { start, first: end, end }
91
91
  }
92
92
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { getSpec } from '../_catalog.js'
13
13
  import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
14
- import { computeEmbeddingCost } from '../_pricing.js'
14
+ import { embeddingCostFor } from '../_pricing.js'
15
15
  import { catalogKey, bareOf } from '#core/model-id.js'
16
16
  import { checkBatch, checkDimensions, resolveInputType, widthOf } from './_shared.js'
17
17
 
@@ -85,7 +85,7 @@ export function createEmbeddingAdapter ({ baseURL, dimensionsField = 'dimensions
85
85
  dimensions: widthOf(vectors),
86
86
  inputType,
87
87
  inputTokens,
88
- cost: computeEmbeddingCost(spec, { inputTokens }),
88
+ cost: embeddingCostFor(envelope, spec, { inputTokens }),
89
89
  timestamps: { start, first: end, end }
90
90
  }
91
91
  }
@@ -23,7 +23,7 @@ import { basename } from 'node:path'
23
23
  import { getSpec } from '../_catalog.js'
24
24
  import { classifyProviderError, fromHttpStatus, typedError } from '../_errors.js'
25
25
  import { dataUriPayload, isTrustedMedia, mediaScheme, readLocalMedia } from '../_media.js'
26
- import { computeTranscriptionCost } from '../_pricing.js'
26
+ import { transcriptionCostFor } from '../_pricing.js'
27
27
  import { catalogKey, bareOf } from '#core/model-id.js'
28
28
 
29
29
  /**
@@ -67,7 +67,7 @@ export function createTranscriptionAdapter ({ baseURL, responseFormat }) {
67
67
  const body = await res.json()
68
68
  const durationSeconds = extractDuration(body)
69
69
  const tokens = extractTokens(body)
70
- const cost = computeTranscriptionCost(spec, { durationSeconds, ...tokens })
70
+ const cost = transcriptionCostFor(envelope, spec, { durationSeconds, ...tokens })
71
71
 
72
72
  const end = String(process.hrtime.bigint())
73
73
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -144,7 +144,7 @@
144
144
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
145
145
  "@opentelemetry/sdk-node": "^0.222.0",
146
146
  "chalk": "^6.0.1",
147
- "mohdel-thin-gate-linux-x64-gnu": "3.6.1"
147
+ "mohdel-thin-gate-linux-x64-gnu": "3.7.0"
148
148
  },
149
149
  "dependencies": {
150
150
  "@anthropic-ai/sdk": "^0.129.0",
package/src/cli/ask.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import mohdel, { silent } from '../lib/index.js'
2
2
  import { getConfig, loadDefaultEnv } from '../lib/common.js'
3
+ import providerDefs, { billingOf } from '../lib/providers.js'
3
4
 
4
5
  const noop = () => {}
5
6
 
@@ -47,8 +48,10 @@ export const hintsForError = (err, modelId) => {
47
48
  else hints.push('→ run: mo # interactive provider/key setup')
48
49
  }
49
50
 
50
- if (provider === 'chatgpt' && /RATE_LIMIT|QUOTA_EXHAUSTED|429|usage limit/i.test(`${err?.type || ''} ${both}`)) {
51
- hints.push('→ manage ChatGPT plan usage: https://chatgpt.com/settings/usage')
51
+ // The id is what the user typed, so its provider may not exist.
52
+ const usage = providerDefs[provider]?.billing.usage
53
+ if (usage && /RATE_LIMIT|QUOTA_EXHAUSTED|429|usage limit/i.test(`${err?.type || ''} ${both}`)) {
54
+ hints.push(`→ manage ${providerDefs[provider].billing.label} usage: ${usage}`)
52
55
  }
53
56
 
54
57
  if (/deprecated/i.test(both) && /replacement/i.test(both)) {
@@ -223,7 +226,8 @@ Examples:
223
226
  if (tokens.inputTokens) summary.push(`${tokens.inputTokens} in`)
224
227
  if (tokens.outputTokens) summary.push(`${tokens.outputTokens} out`)
225
228
  if (tokens.thinkingTokens) summary.push(`${tokens.thinkingTokens} think`)
226
- if (model.id.startsWith('chatgpt/')) summary.push('Using ChatGPT plan — manage usage: https://chatgpt.com/settings/usage')
229
+ const billing = billingOf(model.id)
230
+ if (billing.kind !== 'metered') summary.push(`not metered · ${billing.label}${billing.usage ? ` — manage usage: ${billing.usage}` : ''}`)
227
231
  else if (tokens.cost != null) summary.push(`$${tokens.cost.toFixed(4)}`)
228
232
  if (tokens.speed) {
229
233
  const served = tokens.servedSpeed
package/src/cli/model.js CHANGED
@@ -731,11 +731,11 @@ Alibaba's Qwen. Routing follows the provider — see "mo provider --help".`)
731
731
 
732
732
  // The provider API returns ids, never prices — curated entries land unpriced.
733
733
  function printCurateNext (providerName) {
734
- if (providerName === 'chatgpt') {
735
- console.log('ChatGPT plan models are ready. Use mo ask chatgpt/<model-slug> "your prompt". Plan usage is not API billing.')
734
+ const def = providerDefs[providerName]
735
+ if (def.billing.kind !== 'metered') {
736
+ console.log(`${providerName} models are ready. Use mo ask ${providerName}/<model-id> "your prompt". Calls are not metered: they draw on your ${def.billing.label}.`)
736
737
  return
737
738
  }
738
- const def = providerDefs[providerName]
739
739
  if (def?.pricesFromApi) {
740
740
  console.log(`\n${meta(`${providerName} publishes prices in its model list — the entries are complete.`)}`)
741
741
  console.log(`${meta('Check them:')} mo ls ${meta('│')} mo check`)
@@ -779,6 +779,9 @@ const SINGLE_DIMENSION_PRICES = [
779
779
  ]
780
780
 
781
781
  function formatPrice (info) {
782
+ // An entry naming an unknown provider is `mo check`'s to report; it lists with its prices.
783
+ const billing = providerDefs[info.provider]?.billing
784
+ if (billing && billing.kind !== 'metered') return meta(`not metered · ${billing.label}`)
782
785
  const inp = resolvePrice(info.inputPrice)
783
786
  const out = resolvePrice(info.outputPrice)
784
787
  if (inp || out) return price(`$${inp}`) + meta('/') + price(`$${out}`)
@@ -73,6 +73,9 @@ export const reviewEntry = (key, spec, catalog, { strict = false, local = null }
73
73
  if (spec.provider && spec.provider !== keyProvider) {
74
74
  errors.push(`${key}: spec.provider '${spec.provider}' doesn't match key prefix '${keyProvider}'`)
75
75
  }
76
+ if (providerConfig?.billing.kind === 'capacity' && Object.keys(spec).some(k => k.endsWith('Price'))) {
77
+ warnings.push(`${key}: prices are never charged — ${providerConfig.billing.label} calls report cost 0`)
78
+ }
76
79
  if (providerConfig && spec.sdk && spec.sdk !== providerConfig.sdk) {
77
80
  errors.push(`${key}: spec.sdk '${spec.sdk}' doesn't match provider sdk '${providerConfig.sdk}'`)
78
81
  }
@@ -1,3 +1,5 @@
1
+ import { providerOf } from '#core/model-id.js'
2
+
1
3
  const LOCAL_API_KEY_ENV = 'MOHDEL_LOCAL_API_SK'
2
4
 
3
5
  // `contextSemantics` and `outputCapStrategy` are published facts about a
@@ -6,8 +8,13 @@ const LOCAL_API_KEY_ENV = 'MOHDEL_LOCAL_API_SK'
6
8
  // own provider requests does not have to rediscover the behaviour one 400 at a
7
9
  // time. Entries may override `outputCapStrategy` per model. See
8
10
  // ARCHITECTURE.md > "The output budget is capped to the model's ceiling".
11
+ // `billing` is how a provider's calls are paid for. `metered`: API money,
12
+ // reported as `cost`. `plan`: a share of a subscription allowance, seen at
13
+ // `usage`. `capacity`: hardware run or rented at a flat rate. `cost` is 0 for
14
+ // the last two.
9
15
  const providers = {
10
16
  anthropic: {
17
+ billing: { kind: 'metered' },
11
18
  sdk: 'anthropic',
12
19
  apiKeyEnv: 'ANTHROPIC_API_SK',
13
20
  createConfiguration: apiKey => ({ apiKey }),
@@ -20,6 +27,7 @@ const providers = {
20
27
  outputCapStrategy: 'error'
21
28
  },
22
29
  cerebras: {
30
+ billing: { kind: 'metered' },
23
31
  sdk: 'cerebras',
24
32
  apiKeyEnv: 'CEREBRAS_API_SK',
25
33
  createConfiguration: apiKey => ({ apiKey }),
@@ -32,6 +40,7 @@ const providers = {
32
40
  outputCapStrategy: 'accept'
33
41
  },
34
42
  chatgpt: {
43
+ billing: { kind: 'plan', label: 'ChatGPT plan', usage: 'https://chatgpt.com/settings/usage' },
35
44
  sdk: 'openai',
36
45
  catalogClient: 'chatgpt',
37
46
  refreshConfiguration: true,
@@ -47,6 +56,7 @@ const providers = {
47
56
  outputCapStrategy: 'accept'
48
57
  },
49
58
  deepseek: {
59
+ billing: { kind: 'metered' },
50
60
  sdk: 'openai',
51
61
  api: 'chatCompletions',
52
62
  apiKeyEnv: 'DEEPSEEK_API_SK',
@@ -61,6 +71,7 @@ const providers = {
61
71
  outputCapStrategy: 'accept'
62
72
  },
63
73
  fireworks: {
74
+ billing: { kind: 'metered' },
64
75
  sdk: 'fireworks',
65
76
  apiKeyEnv: 'FIREWORKS_API_SK',
66
77
  baseURL: 'https://api.fireworks.ai/inference/v1',
@@ -74,6 +85,7 @@ const providers = {
74
85
  outputCapStrategy: 'accept'
75
86
  },
76
87
  gemini: {
88
+ billing: { kind: 'metered' },
77
89
  sdk: 'gemini',
78
90
  apiKeyEnv: 'GEMINI_API_SK',
79
91
  createConfiguration: apiKey => ({ apiKey }),
@@ -86,6 +98,7 @@ const providers = {
86
98
  outputCapStrategy: 'accept'
87
99
  },
88
100
  groq: {
101
+ billing: { kind: 'metered' },
89
102
  sdk: 'groq',
90
103
  apiKeyEnv: 'GROQ_API_SK',
91
104
  createConfiguration: apiKey => ({ apiKey }),
@@ -96,6 +109,7 @@ const providers = {
96
109
  }
97
110
  },
98
111
  local: {
112
+ billing: { kind: 'capacity', label: 'local server' },
99
113
  sdk: 'openai',
100
114
  api: 'chatCompletions',
101
115
  catalog: false,
@@ -105,6 +119,7 @@ const providers = {
105
119
  outputCapStrategy: 'accept'
106
120
  },
107
121
  meta: {
122
+ billing: { kind: 'metered' },
108
123
  sdk: 'openai',
109
124
  apiKeyEnv: 'META_API_SK',
110
125
  baseURL: 'https://api.meta.ai/v1',
@@ -117,6 +132,7 @@ const providers = {
117
132
  outputCapStrategy: 'accept'
118
133
  },
119
134
  cohere: {
135
+ billing: { kind: 'metered' },
120
136
  sdk: 'cohere',
121
137
  api: 'embeddings',
122
138
  apiKeyEnv: 'COHERE_API_SK',
@@ -129,6 +145,7 @@ const providers = {
129
145
  }
130
146
  },
131
147
  mistral: {
148
+ billing: { kind: 'metered' },
132
149
  sdk: 'openai',
133
150
  api: 'chatCompletions',
134
151
  apiKeyEnv: 'MISTRAL_API_SK',
@@ -141,6 +158,7 @@ const providers = {
141
158
  }
142
159
  },
143
160
  novita: {
161
+ billing: { kind: 'metered' },
144
162
  sdk: 'openai',
145
163
  api: 'chatCompletions',
146
164
  imageHandler: 'novita',
@@ -157,6 +175,7 @@ const providers = {
157
175
  outputCapStrategy: 'error'
158
176
  },
159
177
  openai: {
178
+ billing: { kind: 'metered' },
160
179
  sdk: 'openai',
161
180
  apiKeyEnv: 'OPENAI_API_SK',
162
181
  createConfiguration: apiKey => ({ apiKey }),
@@ -169,6 +188,7 @@ const providers = {
169
188
  outputCapStrategy: 'accept'
170
189
  },
171
190
  openrouter: {
191
+ billing: { kind: 'metered' },
172
192
  sdk: 'openrouter',
173
193
  apiKeyEnv: 'OPENROUTER_API_SK',
174
194
  baseURL: 'https://openrouter.ai/api/v1',
@@ -184,6 +204,7 @@ const providers = {
184
204
  }
185
205
  },
186
206
  qwen: {
207
+ billing: { kind: 'metered' },
187
208
  sdk: 'openai',
188
209
  api: 'chatCompletions',
189
210
  apiKeyEnv: 'QWEN_API_SK',
@@ -198,6 +219,7 @@ const providers = {
198
219
  outputCapStrategy: 'accept'
199
220
  },
200
221
  xai: {
222
+ billing: { kind: 'metered' },
201
223
  sdk: 'openai',
202
224
  apiKeyEnv: 'XAI_API_SK',
203
225
  baseURL: 'https://api.x.ai/v1',
@@ -211,6 +233,7 @@ const providers = {
211
233
  outputCapStrategy: 'accept'
212
234
  },
213
235
  xiaomi: {
236
+ billing: { kind: 'metered' },
214
237
  sdk: 'openai',
215
238
  api: 'chatCompletions',
216
239
  apiKeyEnv: 'XIAOMI_API_SK',
@@ -223,4 +246,16 @@ const providers = {
223
246
 
224
247
  Object.freeze(providers)
225
248
 
249
+ /**
250
+ * How calls to `modelId`'s provider are paid for.
251
+ * @param {string} modelId
252
+ * @returns {{kind: 'metered' | 'plan' | 'capacity', label?: string, usage?: string}}
253
+ */
254
+ export function billingOf (modelId) {
255
+ const provider = providerOf(modelId)
256
+ const def = providers[provider]
257
+ if (!def) throw new Error(`Unknown provider '${provider}' in model id '${modelId}'`)
258
+ return { ...def.billing }
259
+ }
260
+
226
261
  export default providers