mohdel 3.3.0 → 3.5.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 +39 -8
- package/js/chatgpt/index.js +226 -0
- package/js/chatgpt/models.js +14 -0
- package/js/chatgpt/store.js +49 -0
- package/js/session/adapters/_pricing.js +2 -0
- package/js/session/adapters/_registry.js +1 -0
- package/js/session/adapters/chatgpt.js +43 -0
- package/js/session/adapters/index.js +2 -0
- package/js/session/adapters/openai.js +20 -2
- package/package.json +9 -4
- package/src/cli/ask.js +8 -2
- package/src/cli/chatgpt.js +65 -0
- package/src/cli/index.js +6 -2
- package/src/cli/model.js +5 -1
- package/src/lib/catalog/chatgpt.js +20 -0
- package/src/lib/index.js +5 -1
- package/src/lib/providers.js +19 -1
- package/src/lib/select.js +4 -0
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
|
|
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
|
-
- **
|
|
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,14 @@ 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
|
-
- **
|
|
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.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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; gate workers do not load
|
|
156
|
+
that store or select a local account.
|
|
127
157
|
- **The session subprocess starts from an empty environment.** It is given
|
|
128
158
|
back only what the runtime reads — `PATH`, proxy and TLS settings, mohdel's
|
|
129
159
|
own dials, `OTEL_*`. Every `*_API_SK`, cloud credential and database URL the
|
|
@@ -467,6 +497,7 @@ What each provider supports through mohdel's unified interface:
|
|
|
467
497
|
|----------|-----------|-------|--------|-------|----------|-------|
|
|
468
498
|
| Anthropic | Yes | Yes | Yes | No | Yes (adaptive / budget) | `identifier` → `metadata.user_id` |
|
|
469
499
|
| OpenAI | Yes | Yes | Yes | No | Yes (o-series) | GPT-5 verbosity via `outputStyle` |
|
|
500
|
+
| ChatGPT | Yes | Yes | Yes | No | Per model catalog | OAuth; uses an eligible ChatGPT plan; `mo chatgpt --help` |
|
|
470
501
|
| Gemini | Yes | Yes | Yes | Yes | Yes (`thinkingLevel` / `thinkingBudget`) | Auto-uploads large videos; content-hashed cache |
|
|
471
502
|
| Cerebras | Yes | Yes | Yes | No | Yes (`reasoning_effort` or zai `disable_reasoning`) | Shared chat-completions path |
|
|
472
503
|
| Groq | Yes | Yes | Yes | No | No | Shared chat-completions path |
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
export const usageURL = 'https://chatgpt.com/settings/usage'
|
|
15
|
+
|
|
16
|
+
async function jsonRequest (fetcher, url, options = {}) {
|
|
17
|
+
let response
|
|
18
|
+
try {
|
|
19
|
+
response = await fetcher(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(30000) })
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error('ChatGPT connection failed. Try again.')
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const err = new Error(`ChatGPT request failed (${response.status}).${response.status === 401 || response.status === 400 ? ' Run mo chatgpt login to reconnect.' : ''}`)
|
|
25
|
+
Object.assign(err, { status: response.status })
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
return response.json()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function verifyIdentity (token, clientId, nonce) {
|
|
32
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
33
|
+
issuer: ISSUER,
|
|
34
|
+
audience: clientId,
|
|
35
|
+
requiredClaims: ['sub', 'exp', 'iat'],
|
|
36
|
+
clockTolerance: 5
|
|
37
|
+
})
|
|
38
|
+
if (payload.nonce !== nonce || typeof payload.sub !== 'string' || !payload.sub) {
|
|
39
|
+
throw new Error('ChatGPT identity validation failed')
|
|
40
|
+
}
|
|
41
|
+
return payload
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function credentials (tokens, previous = {}) {
|
|
45
|
+
if (typeof tokens.access_token !== 'string' || !tokens.access_token ||
|
|
46
|
+
typeof tokens.refresh_token !== 'string' || !tokens.refresh_token ||
|
|
47
|
+
!Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 ||
|
|
48
|
+
tokens.token_type?.toLowerCase() !== 'bearer') {
|
|
49
|
+
throw new Error('ChatGPT returned incomplete credentials; reconnect with mo chatgpt login')
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...previous,
|
|
53
|
+
access_token: tokens.access_token,
|
|
54
|
+
refresh_token: tokens.refresh_token,
|
|
55
|
+
id_token: tokens.id_token ?? previous.id_token,
|
|
56
|
+
scopes: typeof tokens.scope === 'string' ? tokens.scope.split(/\s+/) : previous.scopes ?? [],
|
|
57
|
+
expires_at: Date.now() + tokens.expires_in * 1000
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const summary = (id, account, active) => ({
|
|
62
|
+
id,
|
|
63
|
+
email: account.email ?? null,
|
|
64
|
+
active: id === active,
|
|
65
|
+
connected: !!account.access_token,
|
|
66
|
+
planUsage: !!account.access_token && account.scopes?.includes(PERMISSION) === true
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* OAuth credentials belong to this installation, independently of the public model catalog.
|
|
71
|
+
* `authorize` receives a sensitive browser URL; open it without recording it in logs.
|
|
72
|
+
* @param {{ directory?: string, fetch?: typeof fetch, verifyIdentity?: Function }} [options]
|
|
73
|
+
*/
|
|
74
|
+
export function createChatGPT (options = {}) {
|
|
75
|
+
const directory = options.directory ?? defaultDirectory()
|
|
76
|
+
const fetcher = options.fetch ?? globalThis.fetch
|
|
77
|
+
const verify = options.verifyIdentity ?? verifyIdentity
|
|
78
|
+
const tokenRequest = params => jsonRequest(fetcher, TOKEN, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
body: new URLSearchParams({ ...params, resource: RESOURCE })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const accounts = async () => {
|
|
84
|
+
const store = await readStore(directory)
|
|
85
|
+
return Object.entries(store.accounts).map(([id, account]) => summary(id, account, store.active))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} id */
|
|
89
|
+
const select = async id => withStore(directory, async (store, save) => {
|
|
90
|
+
if (!store.accounts[id]?.access_token) throw new Error('ChatGPT account is not connected; run mo chatgpt login')
|
|
91
|
+
store.active = id
|
|
92
|
+
await save(store)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
/** @param {string} [id] @returns {Promise<{accessToken: string, accountId: string}>} */
|
|
96
|
+
const access = async (id) => withStore(directory, async (store, save) => {
|
|
97
|
+
const account = store.accounts[id ?? store.active]
|
|
98
|
+
if (!account?.access_token) throw new Error('ChatGPT is not connected; run mo chatgpt login')
|
|
99
|
+
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) {
|
|
101
|
+
const tokens = await tokenRequest({ grant_type: 'refresh_token', client_id: account.client_id, refresh_token: account.refresh_token })
|
|
102
|
+
Object.assign(account, credentials(tokens, account))
|
|
103
|
+
await save(store)
|
|
104
|
+
if (!account.scopes.includes(PERMISSION)) throw new Error('ChatGPT plan usage permission was removed; reconnect with mo chatgpt login')
|
|
105
|
+
}
|
|
106
|
+
return { accessToken: account.access_token, accountId: account.client_id }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** @param {string} [id] @returns {Promise<Array<{id: string, model: string, label: string}>>} */
|
|
110
|
+
const models = async (id) => {
|
|
111
|
+
const { accessToken } = await access(id)
|
|
112
|
+
return visibleModels(await discoverModels(accessToken, { fetch: fetcher }))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} [id] @returns {Promise<{revoked: boolean}>} */
|
|
116
|
+
const logout = async (id) => withStore(directory, async (store, save) => {
|
|
117
|
+
id ??= store.active
|
|
118
|
+
const account = store.accounts[id]
|
|
119
|
+
if (!account) throw new Error('Unknown ChatGPT account')
|
|
120
|
+
let revoked = !account.refresh_token
|
|
121
|
+
try {
|
|
122
|
+
if (account.refresh_token) {
|
|
123
|
+
const discovery = await jsonRequest(fetcher, `${ISSUER}/.well-known/openid-configuration`)
|
|
124
|
+
const endpoint = new URL(discovery.revocation_endpoint)
|
|
125
|
+
if (endpoint.origin !== ISSUER) throw new Error('Invalid revocation endpoint')
|
|
126
|
+
const response = await fetcher(endpoint, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
redirect: 'error',
|
|
129
|
+
signal: AbortSignal.timeout(30000),
|
|
130
|
+
body: new URLSearchParams({ token: account.refresh_token, token_type_hint: 'refresh_token', client_id: account.client_id })
|
|
131
|
+
})
|
|
132
|
+
revoked = response.status === 200
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
revoked = false
|
|
136
|
+
}
|
|
137
|
+
for (const key of ['access_token', 'refresh_token', 'id_token', 'expires_at', 'scopes']) delete account[key]
|
|
138
|
+
if (store.active === id) store.active = null
|
|
139
|
+
await save(store)
|
|
140
|
+
return { revoked }
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/** @param {{ authorize: (url: string) => any, accountId?: string, timeoutMs?: number }} settings */
|
|
144
|
+
const login = async ({ authorize, accountId, timeoutMs = 300000 }) => {
|
|
145
|
+
const initial = await withStore(directory, async (store, save) => {
|
|
146
|
+
if (accountId && !store.accounts[accountId]) throw new Error('Unknown ChatGPT account')
|
|
147
|
+
await save(store)
|
|
148
|
+
return { host: store.host, account: store.accounts[accountId] }
|
|
149
|
+
})
|
|
150
|
+
const state = random()
|
|
151
|
+
const nonce = random()
|
|
152
|
+
const verifier = random()
|
|
153
|
+
let consume
|
|
154
|
+
let fail
|
|
155
|
+
const callback = new Promise((resolve, reject) => { consume = resolve; fail = reject })
|
|
156
|
+
// A callback may reject while the system browser is still opening.
|
|
157
|
+
callback.catch(() => {})
|
|
158
|
+
let used = false
|
|
159
|
+
const server = createServer((req, res) => {
|
|
160
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
161
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
162
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
163
|
+
if (req.method !== 'GET' || url.pathname !== '/auth/callback') { res.writeHead(404); res.end(); return }
|
|
164
|
+
if (used || url.searchParams.get('state') !== state) { res.writeHead(400); res.end('Invalid sign-in state.'); return }
|
|
165
|
+
used = true
|
|
166
|
+
if (url.searchParams.has('error')) {
|
|
167
|
+
res.end('Sign-in was not completed. Return to Mohdel.')
|
|
168
|
+
fail(new Error('ChatGPT authorization was not granted'))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
res.end('Return to Mohdel to check whether sign-in completed.')
|
|
172
|
+
consume(url.searchParams)
|
|
173
|
+
})
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
server.once('error', reject)
|
|
176
|
+
server.listen(0, '127.0.0.1', () => resolve(undefined))
|
|
177
|
+
})
|
|
178
|
+
const redirect = `http://127.0.0.1:${server.address().port}/auth/callback`
|
|
179
|
+
const url = new URL(`${ISSUER}/api/accounts/authorize`)
|
|
180
|
+
url.search = new URLSearchParams({
|
|
181
|
+
client_id: initial.account?.client_id ?? 'dynamic_agent_client',
|
|
182
|
+
ext_agent_host_id: initial.host,
|
|
183
|
+
response_type: 'code',
|
|
184
|
+
redirect_uri: redirect,
|
|
185
|
+
scope: SCOPE,
|
|
186
|
+
resource: RESOURCE,
|
|
187
|
+
state,
|
|
188
|
+
nonce,
|
|
189
|
+
code_challenge_method: 'S256',
|
|
190
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url')
|
|
191
|
+
}).toString()
|
|
192
|
+
if (!initial.account) url.searchParams.set('agent_name_hint', 'Mohdel')
|
|
193
|
+
if (initial.account?.id_token) url.searchParams.set('id_token_hint', initial.account.id_token)
|
|
194
|
+
const timer = setTimeout(() => fail(new Error('ChatGPT sign-in timed out')), timeoutMs)
|
|
195
|
+
try {
|
|
196
|
+
Promise.resolve(authorize(url.toString())).catch(fail)
|
|
197
|
+
const params = await callback
|
|
198
|
+
const clientId = params.get('client_id') ?? initial.account?.client_id
|
|
199
|
+
if (!clientId || clientId === 'dynamic_agent_client' || (accountId && clientId !== accountId) || !params.get('code')) {
|
|
200
|
+
throw new Error('ChatGPT returned an invalid registration')
|
|
201
|
+
}
|
|
202
|
+
// Keep the issued ID even if code exchange fails; it must not be registered again.
|
|
203
|
+
await withStore(directory, async (store, save) => {
|
|
204
|
+
store.accounts[clientId] ??= { client_id: clientId }
|
|
205
|
+
await save(store)
|
|
206
|
+
})
|
|
207
|
+
const tokens = await tokenRequest({ grant_type: 'authorization_code', client_id: clientId, code: params.get('code'), code_verifier: verifier, redirect_uri: redirect })
|
|
208
|
+
const identity = await verify(tokens.id_token, clientId, nonce)
|
|
209
|
+
return await withStore(directory, async (store, save) => {
|
|
210
|
+
const previous = store.accounts[clientId]
|
|
211
|
+
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 }
|
|
213
|
+
store.accounts[clientId] = account
|
|
214
|
+
store.active = clientId
|
|
215
|
+
await save(store)
|
|
216
|
+
return summary(clientId, account, store.active)
|
|
217
|
+
})
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer)
|
|
220
|
+
server.closeAllConnections()
|
|
221
|
+
await new Promise(resolve => server.close(resolve))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { accounts, select, access, models, login, logout }
|
|
226
|
+
}
|
|
@@ -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,49 @@
|
|
|
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
|
+
try {
|
|
11
|
+
return JSON.parse(await readFile(join(directory, 'accounts.json'), 'utf8'))
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err.code === 'ENOENT') return { host: `urn:uuid:${randomUUID()}`, active: null, accounts: {} }
|
|
14
|
+
throw err
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeStore (directory, store) {
|
|
19
|
+
const temporary = join(directory, `${randomUUID()}.tmp`)
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(temporary, JSON.stringify(store), { mode: 0o600, flag: 'wx' })
|
|
22
|
+
await rename(temporary, join(directory, 'accounts.json'))
|
|
23
|
+
} finally {
|
|
24
|
+
await unlink(temporary).catch(err => { if (err.code !== 'ENOENT') throw err })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A filesystem lock also serializes refresh-token rotation between session processes.
|
|
29
|
+
// Never steal a lock on a timer: a slow token exchange may still own it.
|
|
30
|
+
export async function withStore (directory, action) {
|
|
31
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
32
|
+
const lock = join(directory, 'accounts.lock')
|
|
33
|
+
const until = Date.now() + 35000
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 })
|
|
37
|
+
break
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err.code !== 'EEXIST') throw err
|
|
40
|
+
if (Date.now() >= until) throw new Error(`ChatGPT credentials are locked: ${lock}. If its owner has exited, remove this lock and retry.`)
|
|
41
|
+
await delay(100)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await action(await readStore(directory), store => writeStore(directory, store))
|
|
46
|
+
} finally {
|
|
47
|
+
await unlink(lock)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Uses only the caller's OAuth access token, including behind the gate.
|
|
12
|
+
* Session subprocesses never read the host's saved ChatGPT account.
|
|
13
|
+
* @param {import('#core/envelope.js').CallEnvelope} envelope
|
|
14
|
+
* @param {{client?: any, fetch?: typeof fetch, signal?: AbortSignal, log?: any, span?: any}} [deps]
|
|
15
|
+
*/
|
|
16
|
+
export async function * chatgpt (envelope, deps = {}) {
|
|
17
|
+
const key = envelope.auth?.key
|
|
18
|
+
const start = String(process.hrtime.bigint())
|
|
19
|
+
const model = getSpec(catalogKey(envelope.model))?.model ?? bareOf(catalogKey(envelope.model))
|
|
20
|
+
try {
|
|
21
|
+
const models = await discoverModels(key, deps)
|
|
22
|
+
if (!models.some(m => m.slug === model)) {
|
|
23
|
+
yield { type: 'error', error: { type: 'INVALID_REQUEST', severity: 'error', message: 'This model is not available to the selected ChatGPT account.', retryable: false } }
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (deps.signal?.aborted) {
|
|
28
|
+
yield abortedDone(start, null, envelope, '', 0, 0)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
yield { type: 'error', error: classifyProviderError(err, key, { provider: 'chatgpt' }) }
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
const client = deps.client ?? new OpenAI({
|
|
35
|
+
apiKey: key,
|
|
36
|
+
baseURL: 'https://api.openai.com/v1',
|
|
37
|
+
maxRetries: 0,
|
|
38
|
+
fetchOptions: { dispatcher: streamingDispatcher() }
|
|
39
|
+
})
|
|
40
|
+
// SDK errors can carry request details; never put OAuth credentials in logs.
|
|
41
|
+
const log = deps.log ? { warn: (_data, message) => deps.log.warn({ provider: 'chatgpt' }, message) } : undefined
|
|
42
|
+
yield * openai(envelope, { ...deps, client, log })
|
|
43
|
+
}
|
|
@@ -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)
|
|
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.
|
|
3
|
+
"version": "3.5.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
|
|
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,10 @@
|
|
|
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
|
+
},
|
|
45
49
|
".": {
|
|
46
50
|
"types": "./src/lib/index.d.ts",
|
|
47
51
|
"default": "./src/lib/index.js"
|
|
@@ -136,7 +140,7 @@
|
|
|
136
140
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.222.0",
|
|
137
141
|
"@opentelemetry/sdk-node": "^0.222.0",
|
|
138
142
|
"chalk": "^6.0.1",
|
|
139
|
-
"mohdel-thin-gate-linux-x64-gnu": "3.
|
|
143
|
+
"mohdel-thin-gate-linux-x64-gnu": "3.5.0"
|
|
140
144
|
},
|
|
141
145
|
"dependencies": {
|
|
142
146
|
"@anthropic-ai/sdk": "^0.129.0",
|
|
@@ -146,7 +150,8 @@
|
|
|
146
150
|
"@opentelemetry/api": "^1.9.1",
|
|
147
151
|
"env-paths": "^4.0.0",
|
|
148
152
|
"groq-sdk": "^1.6.0",
|
|
149
|
-
"
|
|
153
|
+
"jose": "^6.2.12",
|
|
154
|
+
"openai": "^7.25.0",
|
|
150
155
|
"undici": "^7.29.0"
|
|
151
156
|
},
|
|
152
157
|
"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(
|
|
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 (
|
|
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
|
@@ -40,7 +40,7 @@ if (command === '-h' || command === '--help') {
|
|
|
40
40
|
const { default: providerDefs } = await import('../lib/providers.js')
|
|
41
41
|
const keyRows = Object.keys(PROVIDER_INFO)
|
|
42
42
|
.map(name => [providerDefs[name].apiKeyEnv, `${PROVIDER_INFO[name].label} API key`])
|
|
43
|
-
.concat([[
|
|
43
|
+
.concat([[providerDefs.local.optionalApiKeyEnv, 'Bearer token for a local server, if it wants one']])
|
|
44
44
|
const keyWidth = Math.max(...keyRows.map(([k]) => k.length))
|
|
45
45
|
const keys = keyRows.map(([k, d]) => ` ${k.padEnd(keyWidth + 2)}${d}`).join('\n')
|
|
46
46
|
console.log(`mohdel — self-hosted LLM gateway and SDK for Node
|
|
@@ -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 === '
|
|
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
|
|
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
|
|
package/src/lib/providers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const LOCAL_API_KEY_ENV = 'MOHDEL_LOCAL_API_SK'
|
|
2
|
+
|
|
1
3
|
// `contextSemantics` and `outputCapStrategy` are published facts about a
|
|
2
4
|
// provider, not switches: mohdel caps `outputBudget` to the model's
|
|
3
5
|
// `outputTokenLimit` whatever they say. They exist so an embedder building its
|
|
@@ -29,6 +31,21 @@ const providers = {
|
|
|
29
31
|
contextSemantics: 'shared',
|
|
30
32
|
outputCapStrategy: 'accept'
|
|
31
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
|
+
},
|
|
32
49
|
deepseek: {
|
|
33
50
|
sdk: 'openai',
|
|
34
51
|
api: 'chatCompletions',
|
|
@@ -82,7 +99,8 @@ const providers = {
|
|
|
82
99
|
sdk: 'openai',
|
|
83
100
|
api: 'chatCompletions',
|
|
84
101
|
catalog: false,
|
|
85
|
-
|
|
102
|
+
optionalApiKeyEnv: LOCAL_API_KEY_ENV,
|
|
103
|
+
resolveConfiguration: () => ({ apiKey: process.env[LOCAL_API_KEY_ENV] || '' }),
|
|
86
104
|
contextSemantics: 'shared',
|
|
87
105
|
outputCapStrategy: 'accept'
|
|
88
106
|
},
|
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
|