mohdel 3.4.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
@@ -1,6 +1,6 @@
1
1
  # Mohdel
2
2
 
3
- Self-hosted LLM gateway and SDK for Node — think LiteLLM, for the JS world. One `answer()` call for 14 providers or local inference; swap models by changing one string; get real per-call USD cost back on every result, with OpenTelemetry built in and process isolation when you need it. Your keys, your infra, no SaaS proxy in the path.
3
+ Self-hosted LLM gateway and SDK for Node — think LiteLLM, for the JS world. One `answer()` call for 15 providers or local inference; swap models by changing one string; get real per-call USD cost back on every result, with OpenTelemetry built in and process isolation when you need it. Your keys, your infra, no SaaS proxy in the path.
4
4
 
5
5
  ```bash
6
6
  npm install -g mohdel
@@ -20,7 +20,35 @@ curate novita` writes complete, priced entries on its own, and setup counts the
20
20
  models that cost nothing and offers to add all of them in one keystroke. A
21
21
  working catalog without a pricing page or a brief.
22
22
 
23
- Providers: Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, Cerebras, Fireworks, DeepSeek, Qwen Cloud, Xiaomi, Meta Model API, OpenRouter, Novita. Node 22+, ES modules.
23
+ Providers: Anthropic, OpenAI, ChatGPT, Gemini, Mistral, Groq, xAI, Cerebras, Fireworks, DeepSeek, Qwen Cloud, Xiaomi, Meta Model API, OpenRouter, Novita. Node 22+, ES modules.
24
+
25
+ ### Using a ChatGPT subscription
26
+
27
+ ```bash
28
+ mo chatgpt login # open the printed URL in your browser
29
+ mo chatgpt models # account-specific models, discovered from OpenAI
30
+ mo model curate chatgpt # choose models to add to the local catalog
31
+ mo ask chatgpt/<model-slug> "Hello"
32
+ ```
33
+
34
+ `chatgpt/` uses OAuth and an eligible ChatGPT plan. It always streams through
35
+ the public Responses API with `store: false`. `openai/` continues using API
36
+ billing. Token counts are reported for both; ChatGPT's `cost: 0` means no
37
+ per-call API charge is calculated, not unlimited or free plan usage. Manage
38
+ allowance and credits with `mo chatgpt usage`.
39
+
40
+ Use `mo chatgpt list`, `select <account-id>`, `login --new`, and
41
+ `logout [account-id]` to manage accounts. Credentials live in Mohdel's user
42
+ data directory (`~/.local/share/mohdel/chatgpt/` on Linux), with owner-only
43
+ files and serialized token refresh. They are separate from the catalog.
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`,
46
+ confirm its recorded PID has exited before removing that lock.
47
+
48
+ This OpenAI flow is intended for open-source and personal local tools;
49
+ paid or remotely hosted offerings require OpenAI's private-app acceptance.
50
+ See [OpenAI's guide](https://developers.openai.com/cookbook/articles/sign-in-with-chatgpt)
51
+ and [library/gate integration](INTEGRATION.md#chatgpt-plan-access).
24
52
 
25
53
  Mohdel runs the inference layer of production stacks, among them [docAnalyzer](https://docanalyzer.ai), a document analysis and chat platform serving hundreds of thousands of users.
26
54
 
@@ -29,7 +57,7 @@ Mohdel runs the inference layer of production stacks, among them [docAnalyzer](h
29
57
  - **Real numbers on every call.** Token counts and per-call USD cost computed from your own pricing catalog (`curated.json`) — not estimates, not provider-specific shapes. Bill tenants, alert on spend, reconcile invoices. Your own catalog means your negotiated rates and your own tags, and it is not a spreadsheet you maintain: `mo model instructions` hands the provider's docs page to your coding agent, which drafts the entries for you to review. See [docs/CATALOG.md](docs/CATALOG.md).
30
58
  - **One interface across providers.** Same `answer()` call, same event stream, same `{ status, output, inputTokens, outputTokens, cost }` result. Switching from `anthropic/claude-sonnet-5-5` to `openai/gpt-5.4-mini` is one string change — adapter differences stay inside mohdel.
31
59
  - **Self-hosted, no vendor in the path.** API keys live in `~/.config/mohdel/`. Mohdel calls provider APIs directly; nothing routes through a third party, nothing marks up your tokens, no extra hop of availability risk.
32
- - **Nothing to compromise.** No network listener, no credential store, no tool execution. Mohdel runs a model call and returns the result; it cannot read a file, run a command, or hand back a key. See [Attack surface](#attack-surface).
60
+ - **Inference without tool execution.** Mohdel returns model output and tool calls for your harness to handle. Optional ChatGPT sign-in uses a temporary loopback listener and a protected local credential store. See [Attack surface](#attack-surface).
33
61
  - **Observability without instrumentation.** OpenTelemetry spans, trace-linked logs, and OTLP metrics over one endpoint. Set `OTEL_EXPORTER_OTLP_ENDPOINT`; everything else is wired.
34
62
  - **Fully typed.** Declarations are generated from the source's own JSDoc and ship with the package — `CallEnvelope`, `Event`, `AnswerResult` and `MohdelError` are the frozen wire contract, typed as such. No `@types` package, no separate TypeScript build to keep in sync.
35
63
  - **Two integration paths, same API.** In-process factory for CLI tools, scripts, single-process services. Optional `thin-gate` subprocess for fault isolation, cross-process quota, and any-language HTTP callers — no code change to switch.
@@ -118,12 +146,15 @@ reading them. Mohdel is built so neither is present.
118
146
  prompt-injected response cannot make mohdel read a file, run a shell, or make
119
147
  a call of its own. Tool execution belongs to the caller, in the caller's
120
148
  process.
121
- - **No network listener.** `thin-gate` binds **unix sockets**, not TCP, for
149
+ - **Gate listeners use unix sockets.** `thin-gate` binds **unix sockets**, not TCP, for
122
150
  both its data and admin planes, and chmods them `0600` — the default umask
123
- would otherwise leave them world-connectable. There is no port to reach.
124
- - **No credential store.** The provider key rides on each call envelope and
125
- goes straight to the SDK client. Mohdel never accumulates a pool of tenant
126
- keys, because it never holds one.
151
+ would otherwise leave them world-connectable. ChatGPT sign-in separately
152
+ starts a temporary HTTP callback listener on `127.0.0.1` with PKCE and state validation.
153
+ - **Session credentials come from the caller.** The provider credential rides
154
+ on each envelope and goes to the SDK. Optional ChatGPT sign-in saves OAuth
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.
127
158
  - **The session subprocess starts from an empty environment.** It is given
128
159
  back only what the runtime reads — `PATH`, proxy and TLS settings, mohdel's
129
160
  own dials, `OTEL_*`. Every `*_API_SK`, cloud credential and database URL the
@@ -467,6 +498,7 @@ What each provider supports through mohdel's unified interface:
467
498
  |----------|-----------|-------|--------|-------|----------|-------|
468
499
  | Anthropic | Yes | Yes | Yes | No | Yes (adaptive / budget) | `identifier` → `metadata.user_id` |
469
500
  | OpenAI | Yes | Yes | Yes | No | Yes (o-series) | GPT-5 verbosity via `outputStyle` |
501
+ | ChatGPT | Yes | Yes | Yes | No | Per model catalog | OAuth; uses an eligible ChatGPT plan; `mo chatgpt --help` |
470
502
  | Gemini | Yes | Yes | Yes | Yes | Yes (`thinkingLevel` / `thinkingBudget`) | Auto-uploads large videos; content-hashed cache |
471
503
  | Cerebras | Yes | Yes | Yes | No | Yes (`reasoning_effort` or zai `disable_reasoning`) | Shared chat-completions path |
472
504
  | Groq | Yes | Yes | Yes | No | No | Shared chat-completions path |
@@ -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
+ })
@@ -0,0 +1,230 @@
1
+ import { randomBytes, createHash } from 'node:crypto'
2
+ import { createServer } from 'node:http'
3
+ import { createRemoteJWKSet, jwtVerify } from 'jose'
4
+ import { defaultDirectory, readStore, withStore } from './store.js'
5
+ import { discoverModels, visibleModels } from './models.js'
6
+
7
+ const ISSUER = 'https://auth.openai.com'
8
+ const RESOURCE = 'https://api.openai.com/v1'
9
+ const TOKEN = `${ISSUER}/api/accounts/oauth/token`
10
+ const PERMISSION = 'chatgpt.tokens.use.direct'
11
+ const SCOPE = `openid profile email offline_access resource.invoke ${PERMISSION}`
12
+ const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
13
+ const random = () => randomBytes(32).toString('base64url')
14
+ const REFRESH_MARGIN = 30000
15
+ export const usageURL = 'https://chatgpt.com/settings/usage'
16
+
17
+ async function jsonRequest (fetcher, url, options = {}) {
18
+ let response
19
+ try {
20
+ response = await fetcher(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(30000) })
21
+ } catch {
22
+ throw new Error('ChatGPT connection failed. Try again.')
23
+ }
24
+ if (!response.ok) {
25
+ const err = new Error(`ChatGPT request failed (${response.status}).${response.status === 401 || response.status === 400 ? ' Run mo chatgpt login to reconnect.' : ''}`)
26
+ Object.assign(err, { status: response.status })
27
+ throw err
28
+ }
29
+ return response.json()
30
+ }
31
+
32
+ async function verifyIdentity (token, clientId, nonce) {
33
+ const { payload } = await jwtVerify(token, jwks, {
34
+ issuer: ISSUER,
35
+ audience: clientId,
36
+ requiredClaims: ['sub', 'exp', 'iat'],
37
+ clockTolerance: 5
38
+ })
39
+ if (payload.nonce !== nonce || typeof payload.sub !== 'string' || !payload.sub) {
40
+ throw new Error('ChatGPT identity validation failed')
41
+ }
42
+ return payload
43
+ }
44
+
45
+ function credentials (tokens, previous = {}) {
46
+ if (typeof tokens.access_token !== 'string' || !tokens.access_token ||
47
+ typeof tokens.refresh_token !== 'string' || !tokens.refresh_token ||
48
+ !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 ||
49
+ tokens.token_type?.toLowerCase() !== 'bearer') {
50
+ throw new Error('ChatGPT returned incomplete credentials; reconnect with mo chatgpt login')
51
+ }
52
+ return {
53
+ ...previous,
54
+ access_token: tokens.access_token,
55
+ refresh_token: tokens.refresh_token,
56
+ id_token: tokens.id_token ?? previous.id_token,
57
+ scopes: typeof tokens.scope === 'string' ? tokens.scope.split(/\s+/) : previous.scopes ?? [],
58
+ expires_at: Date.now() + tokens.expires_in * 1000
59
+ }
60
+ }
61
+
62
+ const summary = (id, account, active) => ({
63
+ id,
64
+ email: account.email ?? null,
65
+ active: id === active,
66
+ connected: !!account.access_token,
67
+ planUsage: !!account.access_token && account.scopes?.includes(PERMISSION) === true
68
+ })
69
+
70
+ /**
71
+ * OAuth credentials belong to this installation, independently of the public model catalog.
72
+ * `authorize` receives a sensitive browser URL; open it without recording it in logs.
73
+ * @param {{ directory?: string, fetch?: typeof fetch, verifyIdentity?: Function }} [options]
74
+ */
75
+ export function createChatGPT (options = {}) {
76
+ const directory = options.directory ?? defaultDirectory()
77
+ const fetcher = options.fetch ?? globalThis.fetch
78
+ const verify = options.verifyIdentity ?? verifyIdentity
79
+ const tokenRequest = params => jsonRequest(fetcher, TOKEN, {
80
+ method: 'POST',
81
+ body: new URLSearchParams({ ...params, resource: RESOURCE })
82
+ })
83
+
84
+ const accounts = async () => {
85
+ const store = await readStore(directory)
86
+ return Object.entries(store.accounts).map(([id, account]) => summary(id, account, store.active))
87
+ }
88
+
89
+ /** @param {string} id */
90
+ const select = async id => withStore(directory, async (store, save) => {
91
+ if (!store.accounts[id]?.access_token) throw new Error('ChatGPT account is not connected; run mo chatgpt login')
92
+ store.active = id
93
+ await save(store)
94
+ })
95
+
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
+ */
100
+ const access = async (id) => withStore(directory, async (store, save) => {
101
+ const account = store.accounts[id ?? store.active]
102
+ if (!account?.access_token) throw new Error('ChatGPT is not connected; run mo chatgpt login')
103
+ if (!account.scopes?.includes(PERMISSION)) throw new Error('ChatGPT plan usage is not enabled; run mo chatgpt login and grant plan usage')
104
+ if (account.expires_at <= Date.now() + REFRESH_MARGIN) {
105
+ const tokens = await tokenRequest({ grant_type: 'refresh_token', client_id: account.client_id, refresh_token: account.refresh_token })
106
+ Object.assign(account, credentials(tokens, account))
107
+ await save(store)
108
+ if (!account.scopes.includes(PERMISSION)) throw new Error('ChatGPT plan usage permission was removed; reconnect with mo chatgpt login')
109
+ }
110
+ return { accessToken: account.access_token, accountId: account.client_id, refreshAt: account.expires_at - REFRESH_MARGIN }
111
+ })
112
+
113
+ /** @param {string} [id] @returns {Promise<Array<{id: string, model: string, label: string}>>} */
114
+ const models = async (id) => {
115
+ const { accessToken } = await access(id)
116
+ return visibleModels(await discoverModels(accessToken, { fetch: fetcher }))
117
+ }
118
+
119
+ /** @param {string} [id] @returns {Promise<{revoked: boolean}>} */
120
+ const logout = async (id) => withStore(directory, async (store, save) => {
121
+ id ??= store.active
122
+ const account = store.accounts[id]
123
+ if (!account) throw new Error('Unknown ChatGPT account')
124
+ let revoked = !account.refresh_token
125
+ try {
126
+ if (account.refresh_token) {
127
+ const discovery = await jsonRequest(fetcher, `${ISSUER}/.well-known/openid-configuration`)
128
+ const endpoint = new URL(discovery.revocation_endpoint)
129
+ if (endpoint.origin !== ISSUER) throw new Error('Invalid revocation endpoint')
130
+ const response = await fetcher(endpoint, {
131
+ method: 'POST',
132
+ redirect: 'error',
133
+ signal: AbortSignal.timeout(30000),
134
+ body: new URLSearchParams({ token: account.refresh_token, token_type_hint: 'refresh_token', client_id: account.client_id })
135
+ })
136
+ revoked = response.status === 200
137
+ }
138
+ } catch {
139
+ revoked = false
140
+ }
141
+ for (const key of ['access_token', 'refresh_token', 'id_token', 'expires_at', 'scopes']) delete account[key]
142
+ if (store.active === id) store.active = null
143
+ await save(store)
144
+ return { revoked }
145
+ })
146
+
147
+ /** @param {{ authorize: (url: string) => any, accountId?: string, timeoutMs?: number }} settings */
148
+ const login = async ({ authorize, accountId, timeoutMs = 300000 }) => {
149
+ const initial = await withStore(directory, async (store, save) => {
150
+ if (accountId && !store.accounts[accountId]) throw new Error('Unknown ChatGPT account')
151
+ await save(store)
152
+ return { host: store.host, account: store.accounts[accountId] }
153
+ })
154
+ const state = random()
155
+ const nonce = random()
156
+ const verifier = random()
157
+ let consume
158
+ let fail
159
+ const callback = new Promise((resolve, reject) => { consume = resolve; fail = reject })
160
+ // A callback may reject while the system browser is still opening.
161
+ callback.catch(() => {})
162
+ let used = false
163
+ const server = createServer((req, res) => {
164
+ const url = new URL(req.url, 'http://127.0.0.1')
165
+ res.setHeader('Cache-Control', 'no-store')
166
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
167
+ if (req.method !== 'GET' || url.pathname !== '/auth/callback') { res.writeHead(404); res.end(); return }
168
+ if (used || url.searchParams.get('state') !== state) { res.writeHead(400); res.end('Invalid sign-in state.'); return }
169
+ used = true
170
+ if (url.searchParams.has('error')) {
171
+ res.end('Sign-in was not completed. Return to Mohdel.')
172
+ fail(new Error('ChatGPT authorization was not granted'))
173
+ return
174
+ }
175
+ res.end('Return to Mohdel to check whether sign-in completed.')
176
+ consume(url.searchParams)
177
+ })
178
+ await new Promise((resolve, reject) => {
179
+ server.once('error', reject)
180
+ server.listen(0, '127.0.0.1', () => resolve(undefined))
181
+ })
182
+ const redirect = `http://127.0.0.1:${server.address().port}/auth/callback`
183
+ const url = new URL(`${ISSUER}/api/accounts/authorize`)
184
+ url.search = new URLSearchParams({
185
+ client_id: initial.account?.client_id ?? 'dynamic_agent_client',
186
+ ext_agent_host_id: initial.host,
187
+ response_type: 'code',
188
+ redirect_uri: redirect,
189
+ scope: SCOPE,
190
+ resource: RESOURCE,
191
+ state,
192
+ nonce,
193
+ code_challenge_method: 'S256',
194
+ code_challenge: createHash('sha256').update(verifier).digest('base64url')
195
+ }).toString()
196
+ if (!initial.account) url.searchParams.set('agent_name_hint', 'Mohdel')
197
+ if (initial.account?.id_token) url.searchParams.set('id_token_hint', initial.account.id_token)
198
+ const timer = setTimeout(() => fail(new Error('ChatGPT sign-in timed out')), timeoutMs)
199
+ try {
200
+ Promise.resolve(authorize(url.toString())).catch(fail)
201
+ const params = await callback
202
+ const clientId = params.get('client_id') ?? initial.account?.client_id
203
+ if (!clientId || clientId === 'dynamic_agent_client' || (accountId && clientId !== accountId) || !params.get('code')) {
204
+ throw new Error('ChatGPT returned an invalid registration')
205
+ }
206
+ // Keep the issued ID even if code exchange fails; it must not be registered again.
207
+ await withStore(directory, async (store, save) => {
208
+ store.accounts[clientId] ??= { client_id: clientId }
209
+ await save(store)
210
+ })
211
+ const tokens = await tokenRequest({ grant_type: 'authorization_code', client_id: clientId, code: params.get('code'), code_verifier: verifier, redirect_uri: redirect })
212
+ const identity = await verify(tokens.id_token, clientId, nonce)
213
+ return await withStore(directory, async (store, save) => {
214
+ const previous = store.accounts[clientId]
215
+ 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 }
217
+ store.accounts[clientId] = account
218
+ store.active = clientId
219
+ await save(store)
220
+ return summary(clientId, account, store.active)
221
+ })
222
+ } finally {
223
+ clearTimeout(timer)
224
+ server.closeAllConnections()
225
+ await new Promise(resolve => server.close(resolve))
226
+ }
227
+ }
228
+
229
+ return { accounts, select, access, models, login, logout }
230
+ }
@@ -0,0 +1,14 @@
1
+ /** @param {string} accessToken @param {{fetch?: typeof fetch, signal?: AbortSignal}} [options] */
2
+ export async function discoverModels (accessToken, options = {}) {
3
+ const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000)
4
+ const response = await (options.fetch ?? fetch)('https://api.openai.com/v1/models', {
5
+ headers: { Authorization: `Bearer ${accessToken}` }, redirect: 'error', signal
6
+ })
7
+ if (!response.ok) throw Object.assign(new Error('ChatGPT model discovery failed'), { status: response.status })
8
+ const body = await response.json()
9
+ if (!Array.isArray(body.models)) throw new Error('ChatGPT returned an invalid model list')
10
+ return body.models.filter(m => typeof m.slug === 'string' && m.slug)
11
+ }
12
+
13
+ export const visibleModels = models => models.filter(m => m.visibility === 'list')
14
+ .map(m => ({ id: `chatgpt/${m.slug}`, model: m.slug, label: m.display_name || m.slug }))
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { join } from 'node:path'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import envPaths from 'env-paths'
6
+
7
+ export const defaultDirectory = () => join(envPaths('mohdel', { suffix: null }).data, 'chatgpt')
8
+
9
+ export async function readStore (directory) {
10
+ const path = join(directory, 'accounts.json')
11
+ let text
12
+ try {
13
+ text = await readFile(path, 'utf8')
14
+ } catch (err) {
15
+ if (err.code === 'ENOENT') return { host: `urn:uuid:${randomUUID()}`, active: null, accounts: {} }
16
+ throw err
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
+ }
24
+ }
25
+
26
+ export async function writeStore (directory, store) {
27
+ const temporary = join(directory, `${randomUUID()}.tmp`)
28
+ try {
29
+ await writeFile(temporary, JSON.stringify(store), { mode: 0o600, flag: 'wx' })
30
+ await rename(temporary, join(directory, 'accounts.json'))
31
+ } finally {
32
+ await unlink(temporary).catch(err => { if (err.code !== 'ENOENT') throw err })
33
+ }
34
+ }
35
+
36
+ // A filesystem lock also serializes refresh-token rotation between session processes.
37
+ // Never steal a lock on a timer: a slow token exchange may still own it.
38
+ export async function withStore (directory, action) {
39
+ await mkdir(directory, { recursive: true, mode: 0o700 })
40
+ const lock = join(directory, 'accounts.lock')
41
+ const until = Date.now() + 35000
42
+ while (true) {
43
+ try {
44
+ await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 })
45
+ break
46
+ } catch (err) {
47
+ if (err.code !== 'EEXIST') throw err
48
+ if (Date.now() >= until) throw new Error(`ChatGPT credentials are locked: ${lock}. If its owner has exited, remove this lock and retry.`)
49
+ await delay(100)
50
+ }
51
+ }
52
+ try {
53
+ return await action(await readStore(directory), store => writeStore(directory, store))
54
+ } finally {
55
+ await unlink(lock)
56
+ }
57
+ }
@@ -112,6 +112,8 @@ function resolveTier (price, tokens) {
112
112
  * @returns {number}
113
113
  */
114
114
  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
115
117
  return computeCost(specFor(envelope), usage)
116
118
  }
117
119
 
@@ -13,6 +13,7 @@
13
13
  export const ADAPTER_NAMES = Object.freeze([
14
14
  'anthropic',
15
15
  'cerebras',
16
+ 'chatgpt',
16
17
  'deepseek',
17
18
  'echo',
18
19
  'fake',
@@ -0,0 +1,56 @@
1
+ import OpenAI from 'openai'
2
+ import { openai } from './openai.js'
3
+ import { streamingDispatcher } from './_dispatcher.js'
4
+ import { classifyProviderError } from './_errors.js'
5
+ import { catalogKey, bareOf } from '#core/model-id.js'
6
+ import { getSpec } from './_catalog.js'
7
+ import { discoverModels } from '../../chatgpt/models.js'
8
+ import { abortedDone } from './_aborted.js'
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
+
23
+ /**
24
+ * Uses only the caller's OAuth access token, including behind the gate.
25
+ * Session subprocesses never read the host's saved ChatGPT account.
26
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
27
+ * @param {{client?: any, fetch?: typeof fetch, signal?: AbortSignal, log?: any, span?: any}} [deps]
28
+ */
29
+ export async function * chatgpt (envelope, deps = {}) {
30
+ const key = envelope.auth?.key
31
+ const start = String(process.hrtime.bigint())
32
+ const model = getSpec(catalogKey(envelope.model))?.model ?? bareOf(catalogKey(envelope.model))
33
+ try {
34
+ const models = await accountModels(key, deps)
35
+ if (!models.some(m => m.slug === model)) {
36
+ yield { type: 'error', error: { type: 'INVALID_REQUEST', severity: 'error', message: 'This model is not available to the selected ChatGPT account.', retryable: false } }
37
+ return
38
+ }
39
+ } catch (err) {
40
+ if (deps.signal?.aborted) {
41
+ yield abortedDone(start, null, envelope, '', 0, 0)
42
+ return
43
+ }
44
+ yield { type: 'error', error: classifyProviderError(err, key, { provider: 'chatgpt' }) }
45
+ return
46
+ }
47
+ const client = deps.client ?? new OpenAI({
48
+ apiKey: key,
49
+ baseURL: 'https://api.openai.com/v1',
50
+ maxRetries: 0,
51
+ fetchOptions: { dispatcher: streamingDispatcher() }
52
+ })
53
+ // SDK errors can carry request details; never put OAuth credentials in logs.
54
+ const log = deps.log ? { warn: (_data, message) => deps.log.warn({ provider: 'chatgpt' }, message) } : undefined
55
+ yield * openai(envelope, { ...deps, client, log })
56
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { anthropic } from './anthropic.js'
15
15
  import { cerebras } from './cerebras.js'
16
+ import { chatgpt } from './chatgpt.js'
16
17
  import { deepseek } from './deepseek.js'
17
18
  import { echo } from './echo.js'
18
19
  import { fake } from './fake.js'
@@ -32,6 +33,7 @@ import { xiaomi } from './xiaomi.js'
32
33
  export const adapters = Object.freeze({
33
34
  anthropic,
34
35
  cerebras,
36
+ chatgpt,
35
37
  deepseek,
36
38
  echo,
37
39
  fake,
@@ -70,7 +70,7 @@ export async function * openai (envelope, deps = {}) {
70
70
  }
71
71
 
72
72
  const { instructions, input } = splitPrompt(envelope.prompt, imageParts, {
73
- toolResultImages: providerOf(envelope.model) === 'openai'
73
+ toolResultImages: ['openai', 'chatgpt'].includes(providerOf(envelope.model))
74
74
  })
75
75
  if (imageInputs.length) injectImageParts(input, imageInputs)
76
76
 
@@ -84,6 +84,7 @@ export async function * openai (envelope, deps = {}) {
84
84
  let thinkingTokens = 0
85
85
  let cachedInputTokens = 0
86
86
  let cacheWriteTokens = 0
87
+ let terminalResponse = false
87
88
  let status = STATUS_COMPLETED
88
89
  /** @type {string | undefined} */
89
90
  let warning
@@ -136,6 +137,7 @@ export async function * openai (envelope, deps = {}) {
136
137
  break
137
138
 
138
139
  case 'response.completed':
140
+ terminalResponse = true
139
141
  servedTier = event.response?.service_tier ?? servedTier
140
142
  if (event.response?.usage) {
141
143
  inputTokens = event.response.usage.input_tokens ?? 0
@@ -150,6 +152,7 @@ export async function * openai (envelope, deps = {}) {
150
152
  break
151
153
 
152
154
  case 'response.incomplete':
155
+ terminalResponse = true
153
156
  servedTier = event.response?.service_tier ?? servedTier
154
157
  status = STATUS_INCOMPLETE
155
158
  if (event.response?.incomplete_details?.reason === 'max_output_tokens') {
@@ -164,6 +167,13 @@ export async function * openai (envelope, deps = {}) {
164
167
  }
165
168
  break
166
169
 
170
+ case 'response.failed':
171
+ if (providerOf(envelope.model) === 'chatgpt') {
172
+ yield { type: 'error', error: classifyProviderError(new Error('ChatGPT response failed'), envelope.auth?.key, { provider: 'chatgpt' }) }
173
+ return
174
+ }
175
+ break
176
+
167
177
  default:
168
178
  break
169
179
  }
@@ -184,6 +194,10 @@ export async function * openai (envelope, deps = {}) {
184
194
  }
185
195
 
186
196
  const end = String(process.hrtime.bigint())
197
+ if (providerOf(envelope.model) === 'chatgpt' && !terminalResponse) {
198
+ yield { type: 'error', error: classifyProviderError(new Error('ChatGPT stream ended before completion'), envelope.auth?.key, { provider: 'chatgpt' }) }
199
+ return
200
+ }
187
201
  // OpenAI Responses reports `output_tokens` INCLUDING reasoning
188
202
  // tokens. The `AnswerResult` contract separates them into
189
203
  // `outputTokens` (message-only) and `thinkingTokens`, so subtract
@@ -350,7 +364,7 @@ function buildRequest (envelope, input, instructions) {
350
364
  // Per-user identifier — openai uses `safety_identifier`; other
351
365
  // Responses-API providers (xai) use the legacy `user` field.
352
366
  if (envelope.identifier) {
353
- if (provider === 'openai') {
367
+ if (provider === 'openai' || provider === 'chatgpt') {
354
368
  request.safety_identifier = envelope.identifier
355
369
  request.prompt_cache_key = envelope.identifier
356
370
  } else {
@@ -362,6 +376,10 @@ function buildRequest (envelope, input, instructions) {
362
376
 
363
377
  // Meta's Responses API stores every prompt and response unless told not to.
364
378
  if (provider === 'meta') request.store = false
379
+ if (provider === 'chatgpt') {
380
+ request.store = false
381
+ request.stream = true
382
+ }
365
383
 
366
384
  return request
367
385
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
7
7
  "email": "clb@toort.net"
8
8
  },
9
- "description": "Self-hosted LLM gateway and SDK for Node — a LiteLLM-style unified API for 14 providers (Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, DeepSeek, OpenRouter, …) with per-call USD cost tracking, streaming, tool calls, vision, speech-to-text, and built-in OpenTelemetry. Run in-process, or behind the process-isolated thin-gate for fault containment.",
9
+ "description": "Self-hosted LLM gateway and SDK for Node — a LiteLLM-style unified API for 15 providers (Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, DeepSeek, OpenRouter, …) with per-call USD cost tracking, streaming, tool calls, vision, speech-to-text, and built-in OpenTelemetry. Run in-process, or behind the process-isolated thin-gate for fault containment.",
10
10
  "type": "module",
11
11
  "keywords": [
12
12
  "llm",
@@ -42,6 +42,14 @@
42
42
  },
43
43
  "main": "src/lib/index.js",
44
44
  "exports": {
45
+ "./chatgpt": {
46
+ "types": "./js/chatgpt/index.d.ts",
47
+ "default": "./js/chatgpt/index.js"
48
+ },
49
+ "./chatgpt/bin": {
50
+ "types": "./js/chatgpt/bin.d.ts",
51
+ "default": "./js/chatgpt/bin.js"
52
+ },
45
53
  ".": {
46
54
  "types": "./src/lib/index.d.ts",
47
55
  "default": "./src/lib/index.js"
@@ -136,7 +144,7 @@
136
144
  "@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
137
145
  "@opentelemetry/sdk-node": "^0.222.0",
138
146
  "chalk": "^6.0.1",
139
- "mohdel-thin-gate-linux-x64-gnu": "3.4.0"
147
+ "mohdel-thin-gate-linux-x64-gnu": "3.6.0"
140
148
  },
141
149
  "dependencies": {
142
150
  "@anthropic-ai/sdk": "^0.129.0",
@@ -146,7 +154,8 @@
146
154
  "@opentelemetry/api": "^1.9.1",
147
155
  "env-paths": "^4.0.0",
148
156
  "groq-sdk": "^1.6.0",
149
- "openai": "^7.23.0",
157
+ "jose": "^6.2.12",
158
+ "openai": "^7.25.0",
150
159
  "undici": "^7.29.0"
151
160
  },
152
161
  "lint-staged": {
package/src/cli/ask.js CHANGED
@@ -42,10 +42,15 @@ export const hintsForError = (err, modelId) => {
42
42
  }
43
43
 
44
44
  if (/API key not found/i.test(both) || /AUTH_INVALID/i.test(err?.type || '') || /401|unauthorized|invalid api key/i.test(both)) {
45
- if (provider) hints.push(`→ run: mo setup ${provider}`)
45
+ if (provider === 'chatgpt') hints.push('→ run: mo chatgpt login')
46
+ else if (provider) hints.push(`→ run: mo setup ${provider}`)
46
47
  else hints.push('→ run: mo # interactive provider/key setup')
47
48
  }
48
49
 
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')
52
+ }
53
+
49
54
  if (/deprecated/i.test(both) && /replacement/i.test(both)) {
50
55
  hints.push('→ run: mo check # find broken deprecation links in curated.json')
51
56
  }
@@ -218,7 +223,8 @@ Examples:
218
223
  if (tokens.inputTokens) summary.push(`${tokens.inputTokens} in`)
219
224
  if (tokens.outputTokens) summary.push(`${tokens.outputTokens} out`)
220
225
  if (tokens.thinkingTokens) summary.push(`${tokens.thinkingTokens} think`)
221
- if (tokens.cost != null) summary.push(`$${tokens.cost.toFixed(4)}`)
226
+ if (model.id.startsWith('chatgpt/')) summary.push('Using ChatGPT plan — manage usage: https://chatgpt.com/settings/usage')
227
+ else if (tokens.cost != null) summary.push(`$${tokens.cost.toFixed(4)}`)
222
228
  if (tokens.speed) {
223
229
  const served = tokens.servedSpeed
224
230
  summary.push(served === tokens.speed
@@ -0,0 +1,65 @@
1
+ import { createChatGPT, usageURL } from '../../js/chatgpt/index.js'
2
+ import { parseJsonFlag, printAvailableFields, jsonOutput } from './json-output.js'
3
+
4
+ export async function runChatGPT (args) {
5
+ const output = parseJsonFlag(args)
6
+ const [verb, id] = args
7
+ const auth = createChatGPT()
8
+ try {
9
+ if (verb === 'list' || verb === 'models') {
10
+ const fields = verb === 'list' ? ['id', 'email', 'active', 'connected', 'planUsage'] : ['id', 'model', 'label']
11
+ if (output.json && !output.fields) { printAvailableFields(fields); return }
12
+ const items = verb === 'list' ? await auth.accounts() : await auth.models(id)
13
+ if (output.json) jsonOutput(items, output.fields)
14
+ else {
15
+ for (const item of items) {
16
+ console.log(verb === 'list'
17
+ ? `${item.active ? '*' : ' '} ${item.id} ${item.email ?? ''} ${item.planUsage ? 'Using ChatGPT plan' : item.connected ? 'Plan usage not enabled' : 'Signed out'}`
18
+ : `${item.id} ${item.label}`)
19
+ }
20
+ }
21
+ return
22
+ }
23
+ if (verb === 'login') {
24
+ const active = (await auth.accounts()).find(a => a.active)
25
+ const account = await auth.login({
26
+ accountId: id === '--new' ? undefined : id ?? active?.id,
27
+ authorize: value => {
28
+ // Retained ID-token hints must never be printed to a terminal or log.
29
+ const url = new URL(value)
30
+ url.searchParams.delete('id_token_hint')
31
+ console.log(`Continue with ChatGPT — open this URL in your browser:\n${url}`)
32
+ }
33
+ })
34
+ console.log(`Connected: ${account.id} ${account.email ?? ''}`)
35
+ console.log(account.planUsage ? 'Using ChatGPT plan. Discover models: mo chatgpt models' : 'Plan usage not enabled. Run mo chatgpt login to grant access.')
36
+ console.log(`Manage usage: ${usageURL}`)
37
+ } else if (verb === 'select' && id) {
38
+ await auth.select(id)
39
+ console.log(`Selected ChatGPT account: ${id}`)
40
+ } else if (verb === 'logout') {
41
+ const { revoked } = await auth.logout(id)
42
+ console.log('Signed out locally.')
43
+ if (!revoked) console.log(`Remote revocation was not confirmed. Disconnect Mohdel in ChatGPT Settings: ${usageURL}`)
44
+ } else if (verb === 'usage') {
45
+ console.log(usageURL)
46
+ } else {
47
+ console.log(`mo chatgpt — use your eligible ChatGPT plan
48
+
49
+ login [account-id|--new] Continue with ChatGPT in your browser
50
+ list [--json fields] Saved accounts and the active account
51
+ select <account-id> Choose a saved account
52
+ logout [account-id] Revoke and clear its local credentials
53
+ models [--json fields] Discover the selected account's models
54
+ usage Open this link to manage plan usage
55
+
56
+ After signing in, add models with: mo model curate chatgpt
57
+ Then call them with: mo ask chatgpt/<model-slug> "your prompt"
58
+ Usage consumes your ChatGPT allowance. Reported cost is API USD only.`)
59
+ if (verb && !['--help', '-h'].includes(verb)) process.exitCode = 1
60
+ }
61
+ } catch (err) {
62
+ console.error(err.message)
63
+ process.exitCode = 1
64
+ }
65
+ }
package/src/cli/index.js CHANGED
@@ -49,6 +49,7 @@ Run a model, see what the call cost, and curate the catalog those prices
49
49
  come from. Your keys, your infra, no SaaS in the path.
50
50
 
51
51
  Commands:
52
+ chatgpt login|list|select|logout|models Connect and use your ChatGPT plan
52
53
  model list [--sort price|context|name] List all curated models (mo ls, mo models)
53
54
  model search <term> Filter models by name/label (mo search)
54
55
  model stats Catalog summary (mo stats)
@@ -136,7 +137,10 @@ const alias = ALIASES[command]
136
137
  const resolved = alias ? alias.noun : command
137
138
  const resolvedArgs = alias ? [...alias.inject, ...args] : args
138
139
 
139
- if (resolved === 'default') {
140
+ if (resolved === 'chatgpt') {
141
+ const { runChatGPT } = await import('./chatgpt.js')
142
+ await runChatGPT(resolvedArgs)
143
+ } else if (resolved === 'default') {
140
144
  const { runDefault } = await import('./default.js')
141
145
  await runDefault(resolvedArgs)
142
146
  } else if (resolved === 'doctor') {
package/src/cli/model.js CHANGED
@@ -431,7 +431,7 @@ Requires an API key for the chosen provider — run 'mo' to configure one.`)
431
431
  const { providerApi, providersWithKeys, processModels } = await import('../lib/select.js')
432
432
  const withKeys = providersWithKeys()
433
433
 
434
- if (!withKeys.length) {
434
+ if (!withKeys.length && !arg1) {
435
435
  console.error(err('No providers with API keys configured. Run "mo" to set up.'))
436
436
  process.exit(1)
437
437
  }
@@ -731,6 +731,10 @@ 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.')
736
+ return
737
+ }
734
738
  const def = providerDefs[providerName]
735
739
  if (def?.pricesFromApi) {
736
740
  console.log(`\n${meta(`${providerName} publishes prices in its model list — the entries are complete.`)}`)
@@ -0,0 +1,20 @@
1
+ import { createChatGPT } from '../../../js/chatgpt/index.js'
2
+ import { discoverModels, visibleModels } from '../../../js/chatgpt/models.js'
3
+
4
+ export default (configuration = {}) => {
5
+ const auth = createChatGPT()
6
+ let models
7
+ const load = async () => {
8
+ models ??= configuration.apiKey
9
+ ? visibleModels(await discoverModels(configuration.apiKey))
10
+ : await auth.models()
11
+ return models
12
+ }
13
+ return {
14
+ listModels: async () => (await load()).map(m => ({ id: m.model, label: m.label })),
15
+ getModelInfo: async id => {
16
+ const model = (await load()).find(m => m.model === id)
17
+ return model ? { model: id, label: model.label, creator: 'openai', inputPrice: 0, outputPrice: 0 } : null
18
+ }
19
+ }
20
+ }
package/src/lib/index.js CHANGED
@@ -587,7 +587,11 @@ const createModelProxy = (resolvedModelId, modelSpec, handlers, aliasOutputEffor
587
587
  // factory-owned trackers are threaded in so
588
588
  // `setProviderRateLimit` / factory-scoped cooldown settings
589
589
  // remain the source of truth for this factory instance.
590
- const { configuration: defaultConfiguration } = await getRuntime()
590
+ const defaultConfiguration = providers[modelSpec.provider].refreshConfiguration
591
+ ? externalConfigurations?.[modelSpec.provider] ?? (sdkOptions.configuration
592
+ ? undefined
593
+ : await resolveProviderConfiguration(providers[modelSpec.provider], modelSpec.provider))
594
+ : (await getRuntime()).configuration
591
595
  const effectiveConfiguration = sdkOptions.configuration || defaultConfiguration
592
596
  delete sdkOptions.configuration
593
597
 
@@ -31,6 +31,21 @@ const providers = {
31
31
  contextSemantics: 'shared',
32
32
  outputCapStrategy: 'accept'
33
33
  },
34
+ chatgpt: {
35
+ sdk: 'openai',
36
+ catalogClient: 'chatgpt',
37
+ refreshConfiguration: true,
38
+ resolveConfiguration: async () => {
39
+ const { createChatGPT } = await import('../../js/chatgpt/index.js')
40
+ const { accessToken } = await createChatGPT().access()
41
+ return { apiKey: accessToken }
42
+ },
43
+ references: {
44
+ models: 'https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference'
45
+ },
46
+ contextSemantics: 'shared',
47
+ outputCapStrategy: 'accept'
48
+ },
34
49
  deepseek: {
35
50
  sdk: 'openai',
36
51
  api: 'chatCompletions',
package/src/lib/select.js CHANGED
@@ -21,6 +21,10 @@ loadDefaultEnv()
21
21
  // One provider's catalog client, for callers that don't need all of them.
22
22
  export const providerApi = async (name) => {
23
23
  const config = providers[name]
24
+ if (config?.catalogClient === 'chatgpt') {
25
+ const { default: API } = await import('./catalog/chatgpt.js')
26
+ return API()
27
+ }
24
28
  if (!config || config.catalog === false || !config.apiKeyEnv) return null
25
29
  const apiKey = getAPIKey(config.apiKeyEnv)
26
30
  if (!apiKey) return null