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 +7 -3
- package/js/chatgpt/bin.js +29 -0
- package/js/chatgpt/index.js +27 -10
- package/js/chatgpt/store.js +9 -1
- package/js/session/adapters/chatgpt.js +14 -1
- package/package.json +6 -2
- package/src/cli/chatgpt.js +14 -6
- package/src/cli/index.js +7 -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;
|
|
@@ -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
|
|
156
|
-
that store
|
|
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
|
+
})
|
package/js/chatgpt/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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() +
|
|
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
|
-
/**
|
|
144
|
-
|
|
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',
|
|
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)
|
package/js/chatgpt/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
147
|
+
"mohdel-thin-gate-linux-x64-gnu": "3.6.1"
|
|
144
148
|
},
|
|
145
149
|
"dependencies": {
|
|
146
150
|
"@anthropic-ai/sdk": "^0.129.0",
|
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
|