mohdel 3.5.0 → 3.6.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
@@ -152,8 +152,9 @@ reading them. Mohdel is built so neither is present.
152
152
  starts a temporary HTTP callback listener on `127.0.0.1` with PKCE and state validation.
153
153
  - **Session credentials come from the caller.** The provider credential rides
154
154
  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.
155
+ credentials in the caller's user-data directory. The standalone gate and its
156
+ sessions never read that store. Only an embedder that opts into `ChatGptAuth`
157
+ reads it, through mohdel's helper.
157
158
  - **The session subprocess starts from an empty environment.** It is given
158
159
  back only what the runtime reads — `PATH`, proxy and TLS settings, mohdel's
159
160
  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
+ })
@@ -11,6 +11,7 @@ const PERMISSION = 'chatgpt.tokens.use.direct'
11
11
  const SCOPE = `openid profile email offline_access resource.invoke ${PERMISSION}`
12
12
  const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
13
13
  const random = () => randomBytes(32).toString('base64url')
14
+ const REFRESH_MARGIN = 30000
14
15
  export const usageURL = 'https://chatgpt.com/settings/usage'
15
16
 
16
17
  async function jsonRequest (fetcher, url, options = {}) {
@@ -92,18 +93,21 @@ export function createChatGPT (options = {}) {
92
93
  await save(store)
93
94
  })
94
95
 
95
- /** @param {string} [id] @returns {Promise<{accessToken: string, accountId: string}>} */
96
+ /**
97
+ * `refreshAt` is when this token stops being handed out; a caller may reuse it until then.
98
+ * @param {string} [id] @returns {Promise<{accessToken: string, accountId: string, refreshAt: number}>}
99
+ */
96
100
  const access = async (id) => withStore(directory, async (store, save) => {
97
101
  const account = store.accounts[id ?? store.active]
98
102
  if (!account?.access_token) throw new Error('ChatGPT is not connected; run mo chatgpt login')
99
103
  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) {
104
+ if (account.expires_at <= Date.now() + REFRESH_MARGIN) {
101
105
  const tokens = await tokenRequest({ grant_type: 'refresh_token', client_id: account.client_id, refresh_token: account.refresh_token })
102
106
  Object.assign(account, credentials(tokens, account))
103
107
  await save(store)
104
108
  if (!account.scopes.includes(PERMISSION)) throw new Error('ChatGPT plan usage permission was removed; reconnect with mo chatgpt login')
105
109
  }
106
- return { accessToken: account.access_token, accountId: account.client_id }
110
+ return { accessToken: account.access_token, accountId: account.client_id, refreshAt: account.expires_at - REFRESH_MARGIN }
107
111
  })
108
112
 
109
113
  /** @param {string} [id] @returns {Promise<Array<{id: string, model: string, label: string}>>} */
@@ -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.0",
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.0"
144
148
  },
145
149
  "dependencies": {
146
150
  "@anthropic-ai/sdk": "^0.129.0",