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 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. If a crashed process leaves `accounts.lock`,
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;
@@ -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
- export const usageURL = 'https://chatgpt.com/settings/usage'
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
- /** @param {{ authorize: (url: string) => any, accountId?: string, timeoutMs?: number }} settings */
148
- const login = async ({ authorize, accountId, timeoutMs = 300000 }) => {
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', 'Mohdel')
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
- // 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.0",
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.0"
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
@@ -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 Mohdel in ChatGPT Settings: ${usageURL}`)
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
- 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