mohdel 3.5.0 → 3.6.1

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;
@@ -152,8 +155,9 @@ reading them. Mohdel is built so neither is present.
152
155
  starts a temporary HTTP callback listener on `127.0.0.1` with PKCE and state validation.
153
156
  - **Session credentials come from the caller.** The provider credential rides
154
157
  on each envelope and goes to the SDK. Optional ChatGPT sign-in saves OAuth
155
- credentials in the caller's user-data directory; gate workers do not load
156
- that store or select a local account.
158
+ credentials in the caller's user-data directory. The standalone gate and its
159
+ sessions never read that store. Only an embedder that opts into `ChatGptAuth`
160
+ reads it, through mohdel's helper.
157
161
  - **The session subprocess starts from an empty environment.** It is given
158
162
  back only what the runtime reads — `PATH`, proxy and TLS settings, mohdel's
159
163
  own dials, `OTEL_*`. Every `*_API_SK`, cloud credential and database URL the
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Access-token helper for a supervisor that cannot run the OAuth refresh
4
+ * itself (thin-gate's `ChatGptAuth`). Invoked as
5
+ * `node <path-to-this-file> access [--account <id>]`.
6
+ *
7
+ * stdout: `{"accessToken": "…", "refreshAt": <epoch ms>}`, exit 0.
8
+ * stderr: the error message, exit 1. The token never reaches stderr.
9
+ *
10
+ * @module chatgpt/bin
11
+ */
12
+
13
+ import { createChatGPT } from './index.js'
14
+
15
+ const USAGE = 'usage: node bin.js access [--account <id>]'
16
+
17
+ async function main (args) {
18
+ const [verb, flag, id, ...rest] = args
19
+ if (verb !== 'access' || rest.length || (flag !== undefined && (flag !== '--account' || !id))) {
20
+ throw new Error(USAGE)
21
+ }
22
+ const { accessToken, refreshAt } = await createChatGPT().access(id)
23
+ process.stdout.write(JSON.stringify({ accessToken, refreshAt }))
24
+ }
25
+
26
+ main(process.argv.slice(2)).catch(err => {
27
+ process.stderr.write(err.message)
28
+ process.exitCode = 1
29
+ })
@@ -1,5 +1,6 @@
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'
@@ -11,6 +12,9 @@ const PERMISSION = 'chatgpt.tokens.use.direct'
11
12
  const SCOPE = `openid profile email offline_access resource.invoke ${PERMISSION}`
12
13
  const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
13
14
  const random = () => randomBytes(32).toString('base64url')
15
+ const REFRESH_MARGIN = 30000
16
+ // Every registration made before names were stored was sent this hint.
17
+ const UNNAMED_REGISTRATION = 'Mohdel'
14
18
  export const usageURL = 'https://chatgpt.com/settings/usage'
15
19
 
16
20
  async function jsonRequest (fetcher, url, options = {}) {
@@ -60,6 +64,7 @@ function credentials (tokens, previous = {}) {
60
64
 
61
65
  const summary = (id, account, active) => ({
62
66
  id,
67
+ name: account.name ?? UNNAMED_REGISTRATION,
63
68
  email: account.email ?? null,
64
69
  active: id === active,
65
70
  connected: !!account.access_token,
@@ -92,18 +97,21 @@ export function createChatGPT (options = {}) {
92
97
  await save(store)
93
98
  })
94
99
 
95
- /** @param {string} [id] @returns {Promise<{accessToken: string, accountId: string}>} */
100
+ /**
101
+ * `refreshAt` is when this token stops being handed out; a caller may reuse it until then.
102
+ * @param {string} [id] @returns {Promise<{accessToken: string, accountId: string, refreshAt: number}>}
103
+ */
96
104
  const access = async (id) => withStore(directory, async (store, save) => {
97
105
  const account = store.accounts[id ?? store.active]
98
106
  if (!account?.access_token) throw new Error('ChatGPT is not connected; run mo chatgpt login')
99
107
  if (!account.scopes?.includes(PERMISSION)) throw new Error('ChatGPT plan usage is not enabled; run mo chatgpt login and grant plan usage')
100
- if (account.expires_at <= Date.now() + 30000) {
108
+ if (account.expires_at <= Date.now() + REFRESH_MARGIN) {
101
109
  const tokens = await tokenRequest({ grant_type: 'refresh_token', client_id: account.client_id, refresh_token: account.refresh_token })
102
110
  Object.assign(account, credentials(tokens, account))
103
111
  await save(store)
104
112
  if (!account.scopes.includes(PERMISSION)) throw new Error('ChatGPT plan usage permission was removed; reconnect with mo chatgpt login')
105
113
  }
106
- return { accessToken: account.access_token, accountId: account.client_id }
114
+ return { accessToken: account.access_token, accountId: account.client_id, refreshAt: account.expires_at - REFRESH_MARGIN }
107
115
  })
108
116
 
109
117
  /** @param {string} [id] @returns {Promise<Array<{id: string, model: string, label: string}>>} */
@@ -112,7 +120,7 @@ export function createChatGPT (options = {}) {
112
120
  return visibleModels(await discoverModels(accessToken, { fetch: fetcher }))
113
121
  }
114
122
 
115
- /** @param {string} [id] @returns {Promise<{revoked: boolean}>} */
123
+ /** @param {string} [id] @returns {Promise<{revoked: boolean, name: string}>} */
116
124
  const logout = async (id) => withStore(directory, async (store, save) => {
117
125
  id ??= store.active
118
126
  const account = store.accounts[id]
@@ -137,16 +145,25 @@ export function createChatGPT (options = {}) {
137
145
  for (const key of ['access_token', 'refresh_token', 'id_token', 'expires_at', 'scopes']) delete account[key]
138
146
  if (store.active === id) store.active = null
139
147
  await save(store)
140
- return { revoked }
148
+ return { revoked, name: account.name ?? UNNAMED_REGISTRATION }
141
149
  })
142
150
 
143
- /** @param {{ authorize: (url: string) => any, accountId?: string, timeoutMs?: number }} settings */
144
- const login = async ({ authorize, accountId, timeoutMs = 300000 }) => {
151
+ /**
152
+ * `name` labels a new registration on the ChatGPT side (default `Mohdel (<hostname>)`);
153
+ * an existing registration keeps the name it was created with.
154
+ * @param {{ authorize: (url: string) => any, accountId?: string, name?: string, timeoutMs?: number }} settings
155
+ */
156
+ const login = async ({ authorize, accountId, name, timeoutMs = 300000 }) => {
157
+ if (name !== undefined && (typeof name !== 'string' || !name.trim())) throw new Error('ChatGPT registration name must not be empty')
145
158
  const initial = await withStore(directory, async (store, save) => {
146
159
  if (accountId && !store.accounts[accountId]) throw new Error('Unknown ChatGPT account')
160
+ if (name !== undefined && store.accounts[accountId]) {
161
+ throw new Error('A ChatGPT registration keeps the name it was created with; start a new registration to use another name')
162
+ }
147
163
  await save(store)
148
164
  return { host: store.host, account: store.accounts[accountId] }
149
165
  })
166
+ const label = name ?? `Mohdel (${hostname()})`
150
167
  const state = random()
151
168
  const nonce = random()
152
169
  const verifier = random()
@@ -189,7 +206,7 @@ export function createChatGPT (options = {}) {
189
206
  code_challenge_method: 'S256',
190
207
  code_challenge: createHash('sha256').update(verifier).digest('base64url')
191
208
  }).toString()
192
- if (!initial.account) url.searchParams.set('agent_name_hint', 'Mohdel')
209
+ if (!initial.account) url.searchParams.set('agent_name_hint', label)
193
210
  if (initial.account?.id_token) url.searchParams.set('id_token_hint', initial.account.id_token)
194
211
  const timer = setTimeout(() => fail(new Error('ChatGPT sign-in timed out')), timeoutMs)
195
212
  try {
@@ -201,7 +218,7 @@ export function createChatGPT (options = {}) {
201
218
  }
202
219
  // Keep the issued ID even if code exchange fails; it must not be registered again.
203
220
  await withStore(directory, async (store, save) => {
204
- store.accounts[clientId] ??= { client_id: clientId }
221
+ store.accounts[clientId] ??= { client_id: clientId, name: label }
205
222
  await save(store)
206
223
  })
207
224
  const tokens = await tokenRequest({ grant_type: 'authorization_code', client_id: clientId, code: params.get('code'), code_verifier: verifier, redirect_uri: redirect })
@@ -209,7 +226,7 @@ export function createChatGPT (options = {}) {
209
226
  return await withStore(directory, async (store, save) => {
210
227
  const previous = store.accounts[clientId]
211
228
  if (previous.subject && previous.subject !== identity.sub) throw new Error('ChatGPT returned a different account identity')
212
- const account = { ...credentials(tokens), client_id: clientId, subject: identity.sub, email: identity.email ?? null }
229
+ const account = { ...credentials(tokens), client_id: clientId, name: previous.name, subject: identity.sub, email: identity.email ?? null }
213
230
  store.accounts[clientId] = account
214
231
  store.active = clientId
215
232
  await save(store)
@@ -7,12 +7,20 @@ import envPaths from 'env-paths'
7
7
  export const defaultDirectory = () => join(envPaths('mohdel', { suffix: null }).data, 'chatgpt')
8
8
 
9
9
  export async function readStore (directory) {
10
+ const path = join(directory, 'accounts.json')
11
+ let text
10
12
  try {
11
- return JSON.parse(await readFile(join(directory, 'accounts.json'), 'utf8'))
13
+ text = await readFile(path, 'utf8')
12
14
  } catch (err) {
13
15
  if (err.code === 'ENOENT') return { host: `urn:uuid:${randomUUID()}`, active: null, accounts: {} }
14
16
  throw err
15
17
  }
18
+ try {
19
+ return JSON.parse(text)
20
+ } catch {
21
+ // V8's parse error quotes the input, which here holds tokens.
22
+ throw new Error(`ChatGPT credentials are not valid JSON: ${path}`)
23
+ }
16
24
  }
17
25
 
18
26
  export async function writeStore (directory, store) {
@@ -7,6 +7,19 @@ import { getSpec } from './_catalog.js'
7
7
  import { discoverModels } from '../../chatgpt/models.js'
8
8
  import { abortedDone } from './_aborted.js'
9
9
 
10
+ // A token belongs to one account, so its model list only changes with the token.
11
+ const discovered = new Map()
12
+ const DISCOVERED_TOKENS = 8
13
+
14
+ async function accountModels (key, deps) {
15
+ const cached = discovered.get(key)
16
+ if (cached) return cached
17
+ const models = await discoverModels(key, deps)
18
+ discovered.set(key, models)
19
+ if (discovered.size > DISCOVERED_TOKENS) discovered.delete(discovered.keys().next().value)
20
+ return models
21
+ }
22
+
10
23
  /**
11
24
  * Uses only the caller's OAuth access token, including behind the gate.
12
25
  * Session subprocesses never read the host's saved ChatGPT account.
@@ -18,7 +31,7 @@ export async function * chatgpt (envelope, deps = {}) {
18
31
  const start = String(process.hrtime.bigint())
19
32
  const model = getSpec(catalogKey(envelope.model))?.model ?? bareOf(catalogKey(envelope.model))
20
33
  try {
21
- const models = await discoverModels(key, deps)
34
+ const models = await accountModels(key, deps)
22
35
  if (!models.some(m => m.slug === model)) {
23
36
  yield { type: 'error', error: { type: 'INVALID_REQUEST', severity: 'error', message: 'This model is not available to the selected ChatGPT account.', retryable: false } }
24
37
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -46,6 +46,10 @@
46
46
  "types": "./js/chatgpt/index.d.ts",
47
47
  "default": "./js/chatgpt/index.js"
48
48
  },
49
+ "./chatgpt/bin": {
50
+ "types": "./js/chatgpt/bin.d.ts",
51
+ "default": "./js/chatgpt/bin.js"
52
+ },
49
53
  ".": {
50
54
  "types": "./src/lib/index.d.ts",
51
55
  "default": "./src/lib/index.js"
@@ -140,7 +144,7 @@
140
144
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
141
145
  "@opentelemetry/sdk-node": "^0.222.0",
142
146
  "chalk": "^6.0.1",
143
- "mohdel-thin-gate-linux-x64-gnu": "3.5.0"
147
+ "mohdel-thin-gate-linux-x64-gnu": "3.6.1"
144
148
  },
145
149
  "dependencies": {
146
150
  "@anthropic-ai/sdk": "^0.129.0",
@@ -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