mohdel 3.6.0 → 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.
- package/README.md +4 -1
- package/js/chatgpt/index.js +22 -8
- package/js/session/adapters/_pricing.js +31 -2
- package/js/session/adapters/embedding/cohere.js +2 -2
- package/js/session/adapters/embedding/gemini.js +2 -2
- package/js/session/adapters/embedding/openai_compatible.js +2 -2
- package/js/session/adapters/transcription/openai_compatible.js +2 -2
- package/package.json +2 -2
- package/src/cli/ask.js +7 -3
- package/src/cli/chatgpt.js +14 -6
- package/src/cli/index.js +7 -0
- package/src/cli/model.js +6 -3
- package/src/lib/catalog-review.js +3 -0
- package/src/lib/providers.js +35 -0
package/README.md
CHANGED
|
@@ -42,7 +42,10 @@ Use `mo chatgpt list`, `select <account-id>`, `login --new`, and
|
|
|
42
42
|
data directory (`~/.local/share/mohdel/chatgpt/` on Linux), with owner-only
|
|
43
43
|
files and serialized token refresh. They are separate from the catalog.
|
|
44
44
|
`mo chatgpt login <account-id>` reuses a saved registration after sign-out
|
|
45
|
-
or a failed code exchange.
|
|
45
|
+
or a failed code exchange. ChatGPT lists each registration by name: a new one
|
|
46
|
+
is `Mohdel (<hostname>)`, or the label from `mo chatgpt login --name <label>`
|
|
47
|
+
if you'd rather not send the hostname. The name is fixed when the registration
|
|
48
|
+
is made, and `mo chatgpt list` shows it next to the account id. If a crashed process leaves `accounts.lock`,
|
|
46
49
|
confirm its recorded PID has exited before removing that lock.
|
|
47
50
|
|
|
48
51
|
This OpenAI flow is intended for open-source and personal local tools;
|
package/js/chatgpt/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomBytes, createHash } from 'node:crypto'
|
|
2
2
|
import { createServer } from 'node:http'
|
|
3
|
+
import { hostname } from 'node:os'
|
|
3
4
|
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
4
5
|
import { defaultDirectory, readStore, withStore } from './store.js'
|
|
5
6
|
import { discoverModels, visibleModels } from './models.js'
|
|
7
|
+
import providers from '../../src/lib/providers.js'
|
|
6
8
|
|
|
7
9
|
const ISSUER = 'https://auth.openai.com'
|
|
8
10
|
const RESOURCE = 'https://api.openai.com/v1'
|
|
@@ -12,7 +14,9 @@ const SCOPE = `openid profile email offline_access resource.invoke ${PERMISSION}
|
|
|
12
14
|
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
|
|
13
15
|
const random = () => randomBytes(32).toString('base64url')
|
|
14
16
|
const REFRESH_MARGIN = 30000
|
|
15
|
-
|
|
17
|
+
// Every registration made before names were stored was sent this hint.
|
|
18
|
+
const UNNAMED_REGISTRATION = 'Mohdel'
|
|
19
|
+
export const usageURL = providers.chatgpt.billing.usage
|
|
16
20
|
|
|
17
21
|
async function jsonRequest (fetcher, url, options = {}) {
|
|
18
22
|
let response
|
|
@@ -61,6 +65,7 @@ function credentials (tokens, previous = {}) {
|
|
|
61
65
|
|
|
62
66
|
const summary = (id, account, active) => ({
|
|
63
67
|
id,
|
|
68
|
+
name: account.name ?? UNNAMED_REGISTRATION,
|
|
64
69
|
email: account.email ?? null,
|
|
65
70
|
active: id === active,
|
|
66
71
|
connected: !!account.access_token,
|
|
@@ -116,7 +121,7 @@ export function createChatGPT (options = {}) {
|
|
|
116
121
|
return visibleModels(await discoverModels(accessToken, { fetch: fetcher }))
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
/** @param {string} [id] @returns {Promise<{revoked: boolean}>} */
|
|
124
|
+
/** @param {string} [id] @returns {Promise<{revoked: boolean, name: string}>} */
|
|
120
125
|
const logout = async (id) => withStore(directory, async (store, save) => {
|
|
121
126
|
id ??= store.active
|
|
122
127
|
const account = store.accounts[id]
|
|
@@ -141,16 +146,25 @@ export function createChatGPT (options = {}) {
|
|
|
141
146
|
for (const key of ['access_token', 'refresh_token', 'id_token', 'expires_at', 'scopes']) delete account[key]
|
|
142
147
|
if (store.active === id) store.active = null
|
|
143
148
|
await save(store)
|
|
144
|
-
return { revoked }
|
|
149
|
+
return { revoked, name: account.name ?? UNNAMED_REGISTRATION }
|
|
145
150
|
})
|
|
146
151
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
152
|
+
/**
|
|
153
|
+
* `name` labels a new registration on the ChatGPT side (default `Mohdel (<hostname>)`);
|
|
154
|
+
* an existing registration keeps the name it was created with.
|
|
155
|
+
* @param {{ authorize: (url: string) => any, accountId?: string, name?: string, timeoutMs?: number }} settings
|
|
156
|
+
*/
|
|
157
|
+
const login = async ({ authorize, accountId, name, timeoutMs = 300000 }) => {
|
|
158
|
+
if (name !== undefined && (typeof name !== 'string' || !name.trim())) throw new Error('ChatGPT registration name must not be empty')
|
|
149
159
|
const initial = await withStore(directory, async (store, save) => {
|
|
150
160
|
if (accountId && !store.accounts[accountId]) throw new Error('Unknown ChatGPT account')
|
|
161
|
+
if (name !== undefined && store.accounts[accountId]) {
|
|
162
|
+
throw new Error('A ChatGPT registration keeps the name it was created with; start a new registration to use another name')
|
|
163
|
+
}
|
|
151
164
|
await save(store)
|
|
152
165
|
return { host: store.host, account: store.accounts[accountId] }
|
|
153
166
|
})
|
|
167
|
+
const label = name ?? `Mohdel (${hostname()})`
|
|
154
168
|
const state = random()
|
|
155
169
|
const nonce = random()
|
|
156
170
|
const verifier = random()
|
|
@@ -193,7 +207,7 @@ export function createChatGPT (options = {}) {
|
|
|
193
207
|
code_challenge_method: 'S256',
|
|
194
208
|
code_challenge: createHash('sha256').update(verifier).digest('base64url')
|
|
195
209
|
}).toString()
|
|
196
|
-
if (!initial.account) url.searchParams.set('agent_name_hint',
|
|
210
|
+
if (!initial.account) url.searchParams.set('agent_name_hint', label)
|
|
197
211
|
if (initial.account?.id_token) url.searchParams.set('id_token_hint', initial.account.id_token)
|
|
198
212
|
const timer = setTimeout(() => fail(new Error('ChatGPT sign-in timed out')), timeoutMs)
|
|
199
213
|
try {
|
|
@@ -205,7 +219,7 @@ export function createChatGPT (options = {}) {
|
|
|
205
219
|
}
|
|
206
220
|
// Keep the issued ID even if code exchange fails; it must not be registered again.
|
|
207
221
|
await withStore(directory, async (store, save) => {
|
|
208
|
-
store.accounts[clientId] ??= { client_id: clientId }
|
|
222
|
+
store.accounts[clientId] ??= { client_id: clientId, name: label }
|
|
209
223
|
await save(store)
|
|
210
224
|
})
|
|
211
225
|
const tokens = await tokenRequest({ grant_type: 'authorization_code', client_id: clientId, code: params.get('code'), code_verifier: verifier, redirect_uri: redirect })
|
|
@@ -213,7 +227,7 @@ export function createChatGPT (options = {}) {
|
|
|
213
227
|
return await withStore(directory, async (store, save) => {
|
|
214
228
|
const previous = store.accounts[clientId]
|
|
215
229
|
if (previous.subject && previous.subject !== identity.sub) throw new Error('ChatGPT returned a different account identity')
|
|
216
|
-
const account = { ...credentials(tokens), client_id: clientId, subject: identity.sub, email: identity.email ?? null }
|
|
230
|
+
const account = { ...credentials(tokens), client_id: clientId, name: previous.name, subject: identity.sub, email: identity.email ?? null }
|
|
217
231
|
store.accounts[clientId] = account
|
|
218
232
|
store.active = clientId
|
|
219
233
|
await save(store)
|
|
@@ -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
|
-
|
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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 {
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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/chatgpt.js
CHANGED
|
@@ -3,18 +3,23 @@ import { parseJsonFlag, printAvailableFields, jsonOutput } from './json-output.j
|
|
|
3
3
|
|
|
4
4
|
export async function runChatGPT (args) {
|
|
5
5
|
const output = parseJsonFlag(args)
|
|
6
|
+
const nameAt = args.indexOf('--name')
|
|
7
|
+
const name = nameAt === -1 ? undefined : args.splice(nameAt, 2)[1]
|
|
6
8
|
const [verb, id] = args
|
|
7
9
|
const auth = createChatGPT()
|
|
8
10
|
try {
|
|
11
|
+
if (nameAt !== -1 && (verb !== 'login' || !name || name.startsWith('--'))) {
|
|
12
|
+
throw new Error('Usage: mo chatgpt login --name <label>')
|
|
13
|
+
}
|
|
9
14
|
if (verb === 'list' || verb === 'models') {
|
|
10
|
-
const fields = verb === 'list' ? ['id', 'email', 'active', 'connected', 'planUsage'] : ['id', 'model', 'label']
|
|
15
|
+
const fields = verb === 'list' ? ['id', 'name', 'email', 'active', 'connected', 'planUsage'] : ['id', 'model', 'label']
|
|
11
16
|
if (output.json && !output.fields) { printAvailableFields(fields); return }
|
|
12
17
|
const items = verb === 'list' ? await auth.accounts() : await auth.models(id)
|
|
13
18
|
if (output.json) jsonOutput(items, output.fields)
|
|
14
19
|
else {
|
|
15
20
|
for (const item of items) {
|
|
16
21
|
console.log(verb === 'list'
|
|
17
|
-
? `${item.active ? '*' : ' '} ${item.id} ${item.email ?? ''} ${item.planUsage ? 'Using ChatGPT plan' : item.connected ? 'Plan usage not enabled' : 'Signed out'}`
|
|
22
|
+
? `${item.active ? '*' : ' '} ${item.id} ${item.name} ${item.email ?? ''} ${item.planUsage ? 'Using ChatGPT plan' : item.connected ? 'Plan usage not enabled' : 'Signed out'}`
|
|
18
23
|
: `${item.id} ${item.label}`)
|
|
19
24
|
}
|
|
20
25
|
}
|
|
@@ -23,7 +28,8 @@ export async function runChatGPT (args) {
|
|
|
23
28
|
if (verb === 'login') {
|
|
24
29
|
const active = (await auth.accounts()).find(a => a.active)
|
|
25
30
|
const account = await auth.login({
|
|
26
|
-
accountId: id === '--new' ? undefined : id ?? active?.id,
|
|
31
|
+
accountId: id === '--new' || (name && !id) ? undefined : id ?? active?.id,
|
|
32
|
+
name,
|
|
27
33
|
authorize: value => {
|
|
28
34
|
// Retained ID-token hints must never be printed to a terminal or log.
|
|
29
35
|
const url = new URL(value)
|
|
@@ -31,22 +37,24 @@ export async function runChatGPT (args) {
|
|
|
31
37
|
console.log(`Continue with ChatGPT — open this URL in your browser:\n${url}`)
|
|
32
38
|
}
|
|
33
39
|
})
|
|
34
|
-
console.log(`Connected: ${account.id} ${account.email ?? ''}`)
|
|
40
|
+
console.log(`Connected: ${account.id} ${account.name} ${account.email ?? ''}`)
|
|
35
41
|
console.log(account.planUsage ? 'Using ChatGPT plan. Discover models: mo chatgpt models' : 'Plan usage not enabled. Run mo chatgpt login to grant access.')
|
|
36
42
|
console.log(`Manage usage: ${usageURL}`)
|
|
37
43
|
} else if (verb === 'select' && id) {
|
|
38
44
|
await auth.select(id)
|
|
39
45
|
console.log(`Selected ChatGPT account: ${id}`)
|
|
40
46
|
} else if (verb === 'logout') {
|
|
41
|
-
const { revoked } = await auth.logout(id)
|
|
47
|
+
const { revoked, name: registration } = await auth.logout(id)
|
|
42
48
|
console.log('Signed out locally.')
|
|
43
|
-
if (!revoked) console.log(`Remote revocation was not confirmed. Disconnect
|
|
49
|
+
if (!revoked) console.log(`Remote revocation was not confirmed. Disconnect "${registration}" in ChatGPT Settings: ${usageURL}`)
|
|
44
50
|
} else if (verb === 'usage') {
|
|
45
51
|
console.log(usageURL)
|
|
46
52
|
} else {
|
|
47
53
|
console.log(`mo chatgpt — use your eligible ChatGPT plan
|
|
48
54
|
|
|
49
55
|
login [account-id|--new] Continue with ChatGPT in your browser
|
|
56
|
+
login --name <label> Start a new registration, named as ChatGPT shows it
|
|
57
|
+
(a new registration defaults to Mohdel (<hostname>))
|
|
50
58
|
list [--json fields] Saved accounts and the active account
|
|
51
59
|
select <account-id> Choose a saved account
|
|
52
60
|
logout [account-id] Revoke and clear its local credentials
|
package/src/cli/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Aliases: ls → model list, rl → ratelimit
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
12
13
|
import { detectAssistants, launchLines } from '../lib/assistants.js'
|
|
13
14
|
import { getConfig } from '../lib/common.js'
|
|
14
15
|
import { ALIASES } from './aliases.js'
|
|
@@ -34,6 +35,11 @@ if (command === 'completion') {
|
|
|
34
35
|
process.exit(0)
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
if (command === '--version') {
|
|
39
|
+
console.log(createRequire(import.meta.url)('../../package.json').version)
|
|
40
|
+
process.exit(0)
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
if (command === '-h' || command === '--help') {
|
|
38
44
|
const assistant = (await getConfig()).assistant || null
|
|
39
45
|
const { default: PROVIDER_INFO } = await import('../lib/provider-info.js')
|
|
@@ -92,6 +98,7 @@ Commands:
|
|
|
92
98
|
default [model] Set the model "mo ask" uses by default
|
|
93
99
|
doctor Check that your install is wired up
|
|
94
100
|
completion bash Shell completion — source <(mo completion bash)
|
|
101
|
+
--version Print the installed mohdel version
|
|
95
102
|
|
|
96
103
|
Catalog work with a coding agent:
|
|
97
104
|
Prices, context limits and cache rates are in no provider API — they live on
|
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
|
-
|
|
735
|
-
|
|
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
|
}
|
package/src/lib/providers.js
CHANGED
|
@@ -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
|