@gatemux/dsh-gatemux-login 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GateMux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # GateMux Sign-In for DeepSeek Harness
2
+
3
+ Sign in to the DeepSeek Harness desktop app with your own GateMux account. Model
4
+ requests are then billed to **your** GateMux balance instead of to the
5
+ application's server key.
6
+
7
+ ## What this plugin does
8
+
9
+ It is a DeepSeek Harness **bundle**. Installing it changes three things about the
10
+ composed profile:
11
+
12
+ - Adds a GateMux account service that signs you in through GateMux's OAuth 2.0
13
+ redirect flow (authorization code with PKCE `S256`).
14
+ - Registers a model route, provider id `gatemux-account`, that authenticates every
15
+ request with your own credential.
16
+ - Turns off the shipped vendor account route (`deepseek-account` and
17
+ `llm-deepseek-account`) so there is one account provider instead of two.
18
+
19
+ It also sets the default model to `gatemux/deepseek-v4-flash`. See
20
+ [Changing the default model](#changing-the-default-model) if you would rather
21
+ keep another default.
22
+
23
+ ## Requirements
24
+
25
+ - DeepSeek Harness desktop app, `0.1.7.x`.
26
+ The plugin declares `@deepseek-ai/dsh-*` peer dependencies pinned to that line.
27
+ A different DSH release is rejected at install time; installing anyway needs a
28
+ version exemption, which risks crashes and data loss.
29
+ - A GateMux account.
30
+
31
+ No install scripts are run: installation does not execute any build step, and the
32
+ app never asks you to approve package scripts for this plugin.
33
+
34
+ ## Install
35
+
36
+ In the desktop app:
37
+
38
+ 1. Open **插件** (Plugins) in the left sidebar.
39
+ 2. Click **添加插件** (Add plugin).
40
+ 3. Enter `@gatemux/dsh-gatemux-login` and click **安装** (Install).
41
+ 4. When it reports **已安装** (Installed), click **立即启用** (Enable now).
42
+ 5. **Restart the app.** The desktop profile is a startup profile, so the new
43
+ bundle is composed at launch, not applied live.
44
+
45
+ The plugin manager writes the dependency and adds the bundle to the profile's
46
+ `dsh.profile.bundles` itself. You do not edit any configuration by hand.
47
+
48
+ From the CLI, for a non-desktop profile:
49
+
50
+ ```sh
51
+ dsh plugin --profile <name> add @gatemux/dsh-gatemux-login
52
+ ```
53
+
54
+ The `desktop` profile is managed exclusively by the Electron application and
55
+ cannot be installed into from the CLI.
56
+
57
+ ## Sign in
58
+
59
+ Send a message. Until you are signed in, the GateMux route reports:
60
+
61
+ ```
62
+ ACCOUNT_SIGN_IN_REQUIRED: Sign in with GateMux to use the GateMux model route.
63
+ Model requests are billed to your own GateMux balance.
64
+ ```
65
+
66
+ Sign in from the app's account settings. Authorization opens in your browser and
67
+ returns to the app.
68
+
69
+ ## Models
70
+
71
+ | Model id | Context | Input |
72
+ |---|---|---|
73
+ | `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
74
+ | `gatemux/deepseek-v4-pro` | 1,000,000 | text |
75
+
76
+ Both are served from `https://rest.gatemux.ai/v1`.
77
+
78
+ ## Changing the default model
79
+
80
+ The bundle sets the default model in its own patch layer. A profile's own
81
+ `cordis.patch.yml` is applied **after** every bundle layer, so it wins. Selecting
82
+ another model in the app writes exactly that override for you; to set it by hand,
83
+ add to `$DSH_HOME/profiles/<name>/cordis.patch.yml`:
84
+
85
+ ```yaml
86
+ - id: agent-default-model
87
+ name: "@deepseek-ai/dsh-agent-default-model"
88
+ config:
89
+ provider: some-other-provider
90
+ model: some-other-model
91
+ reasoningEffort: high
92
+ ```
93
+
94
+ A patch `name` is a guard, not a rename: it must match the addressed row's current
95
+ module or the patch is skipped.
96
+
97
+ ## Troubleshooting
98
+
99
+ | What you see | Meaning |
100
+ |---|---|
101
+ | `ACCOUNT_SIGN_IN_REQUIRED` | No credential yet. Sign in. |
102
+ | `ACCOUNT_TOKEN_INVALID` | The credential was revoked, expired, or suspended. It has been discarded; sign in again. |
103
+ | `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
104
+ | `ip_not_allowed` (HTTP 403) | Your network is not in the GateMux application's trusted egress IP allowlist. Contact the plugin operator. Re-authorizing will not fix it. |
105
+ | `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. Contact the plugin operator. |
106
+ | Install refused for peer dependencies | Your DSH version is outside `0.1.7.x`. |
107
+
108
+ 403 errors are reported as authorization faults, not retried: they are
109
+ configuration problems on the application's side.
110
+
111
+ ## Credentials and privacy
112
+
113
+ Your GateMux credential is stored locally at
114
+ `$DSH_HOME/.credentials.yaml` under the key `gatemux/account`, with file mode
115
+ `0600`. It never leaves your machine except as the `Authorization: Bearer` header
116
+ on requests to `https://rest.gatemux.ai`.
117
+
118
+ Refresh tokens rotate on every refresh, and the rotated value is persisted
119
+ immediately; a reused rotated refresh token is treated as credential leakage. If
120
+ a token exchange returns `invalid_grant`, that authorization code is voided on the
121
+ server and is not retried.
122
+
123
+ ## License
124
+
125
+ MIT
package/account.js ADDED
@@ -0,0 +1,671 @@
1
+ /**
2
+ * GateMux OAuth 2.0 account provider.
3
+ *
4
+ * Implements the `ctx.deepseekAccount` contract with the GateMux redirect flow
5
+ * (authorization code + PKCE S256). The user signs in with their own GateMux
6
+ * account, so model requests are billed to the user's balance rather than to
7
+ * the application's server key.
8
+ *
9
+ * Security invariants enforced here:
10
+ * - `code_verifier` is generated per attempt, kept in memory, and never placed
11
+ * in the authorize URL, the `state`, a log line, or an error message.
12
+ * - `state` is compared with a constant-time equality check.
13
+ * - A token exchange that returns `invalid_grant` voids that authorization code
14
+ * on the server, so it is never retried with a corrected verifier.
15
+ * - Refresh tokens rotate on every refresh; the rotated value is persisted
16
+ * immediately and refreshes are single-flight, because reusing a rotated
17
+ * refresh token is treated as credential leakage.
18
+ *
19
+ * @module @gatemux/dsh-gatemux-login/account
20
+ */
21
+
22
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
23
+ import { promises as streamPromises } from 'node:stream'
24
+
25
+ import { Service } from '@deepseek-ai/cordis'
26
+ import { credentialKey } from '@deepseek-ai/dsh-credentials'
27
+ import {
28
+ DeepSeekAccount,
29
+ installAccountTaskCancellation,
30
+ } from '@deepseek-ai/dsh-deepseek-account'
31
+
32
+ /**
33
+ * Deployment defaults. Every one of these is overridable through the row's
34
+ * `config`, so the composition stays the single source of truth and this module
35
+ * carries no deployment-specific literal that a patch cannot correct.
36
+ */
37
+ const DEFAULT_ISSUER = 'https://rest.gatemux.ai'
38
+ const DEFAULT_CONSOLE_ORIGIN = 'https://gatemux.ai'
39
+ /** Exact callback path registered for this application; loopback permits any port. */
40
+ const CALLBACK_PATH = '/callback'
41
+ const KEY = credentialKey('gatemux', 'account')
42
+ /** Refresh this long before actual expiry so an in-flight request cannot race it. */
43
+ const REFRESH_MARGIN_MS = 60_000
44
+ const ATTEMPT_TIMEOUT_MS = 600_000
45
+ const REQUEST_TIMEOUT_MS = 30_000
46
+ const DEFAULT_SCOPES = ['models:read', 'responses:create', 'media:create', 'account:read', 'openid', 'email']
47
+
48
+ /** Local attempt failure taxonomy; the abstract contract renders these through locale copy. */
49
+ class GateMuxAuthError extends Error {
50
+ /** @param message - diagnostic text without credentials. @param code - stable failure class. */
51
+ constructor(message, code) {
52
+ super(message)
53
+ this.name = 'GateMuxAuthError'
54
+ this.code = code
55
+ }
56
+ }
57
+
58
+ /** Validate the stored grant without trusting its shape. */
59
+ function parseGrant(payload, issuer) {
60
+ if (typeof payload !== 'object' || payload === null) return null
61
+ const { version, accessToken, refreshToken, expiresAt } = payload
62
+ if (version !== 1) return null
63
+ if (typeof accessToken !== 'string' || accessToken.length === 0) return null
64
+ if (typeof refreshToken !== 'string' || refreshToken.length === 0) return null
65
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
66
+ // A grant minted by any other issuer is not one this deployment may use.
67
+ if (payload.issuer !== issuer) return null
68
+ return { version: 1, accessToken, refreshToken, expiresAt, issuer }
69
+ }
70
+
71
+ /** Accept a bare HTTPS origin, rejecting paths, credentials, and non-HTTPS schemes. */
72
+ function originOf(value) {
73
+ let url
74
+ try { url = new URL(value) } catch { throw new GateMuxAuthError('origin must be an absolute URL', 'protocol') }
75
+ if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
76
+ throw new GateMuxAuthError('origin must be a bare HTTPS origin without a path', 'protocol')
77
+ }
78
+ return url.origin
79
+ }
80
+
81
+ /** Accept a browser-accessible loopback HTTP origin, mirroring the account seam's expectation. */
82
+ function loginOrigin(value) {
83
+ const explicitPort = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/i.exec(value)?.[1]
84
+ if (explicitPort === undefined || Number(explicitPort) === 0) {
85
+ throw new GateMuxAuthError('callback origin must be loopback HTTP with an explicit port', 'protocol')
86
+ }
87
+ return `http://${new URL(value).hostname}:${Number(explicitPort)}`
88
+ }
89
+
90
+ /**
91
+ * Extract the RFC 6749 §5.2 error code from a token-endpoint response body.
92
+ * The token endpoint uses a flat envelope, unlike the nested API envelope.
93
+ */
94
+ function tokenErrorCode(body) {
95
+ if (typeof body !== 'object' || body === null) return undefined
96
+ return typeof body.error === 'string' ? body.error : undefined
97
+ }
98
+
99
+ /** Extract the machine code from a nested API error envelope. */
100
+ function apiErrorCode(body) {
101
+ if (typeof body !== 'object' || body === null) return undefined
102
+ const error = body.error
103
+ if (typeof error !== 'object' || error === null) return undefined
104
+ return typeof error.code === 'string' ? error.code : undefined
105
+ }
106
+
107
+ /** GateMux login state, stored grant, and refresh rotation. */
108
+ export class GateMuxAccount extends DeepSeekAccount {
109
+ static inject = ['credentials', 'authorization']
110
+
111
+ clientId
112
+ issuer
113
+ consoleOrigin
114
+ authorizeEndpoint
115
+ tokenEndpoint
116
+ revokeEndpoint
117
+ accountEndpoint
118
+ userinfoEndpoint
119
+ logger
120
+ scopes
121
+ attempt
122
+ listeners = new Set()
123
+ detailsLifetime = new AbortController()
124
+ lastProfile
125
+ refreshInFlight
126
+ removing
127
+ closed = false
128
+
129
+ /**
130
+ * @param ctx - Host with the credentials and authorization services.
131
+ * @param config - application identity and the granted scope set.
132
+ */
133
+ constructor(ctx, config = {}) {
134
+ super(ctx)
135
+ installAccountTaskCancellation(ctx)
136
+ const clientId = config.clientId
137
+ if (typeof clientId !== 'string' || clientId.length === 0) {
138
+ throw new GateMuxAuthError('clientId is required', 'protocol')
139
+ }
140
+ this.clientId = clientId
141
+ // `rest.gatemux.ai` serves both the API and the OAuth endpoints; the console
142
+ // is a separate origin used only for links the user opens.
143
+ const issuer = originOf(config.authorizeOrigin ?? DEFAULT_ISSUER)
144
+ this.issuer = issuer
145
+ this.consoleOrigin = originOf(config.consoleOrigin ?? DEFAULT_CONSOLE_ORIGIN)
146
+ this.authorizeEndpoint = `${issuer}/v1/oauth/authorize`
147
+ this.tokenEndpoint = `${issuer}/v1/oauth/token`
148
+ this.revokeEndpoint = `${issuer}/v1/oauth/revoke`
149
+ this.accountEndpoint = `${issuer}/v1/account/me`
150
+ this.userinfoEndpoint = `${issuer}/v1/oauth/userinfo`
151
+ this.logger = ctx.logger('gatemux-account')
152
+ this.scopes = Array.isArray(config.scopes) && config.scopes.length > 0 ? [...config.scopes] : DEFAULT_SCOPES
153
+ ctx.authorization.registerFlow({
154
+ key: KEY,
155
+ label: 'GateMux',
156
+ methods: [{ id: 'browser', label: 'GateMux' }],
157
+ run: (session) => {
158
+ const attempt = this.attempt
159
+ if (attempt === undefined) return Promise.reject(new GateMuxAuthError('no attempt', 'protocol'))
160
+ attempt.running = this.run(session, attempt)
161
+ return attempt.running
162
+ },
163
+ })
164
+ ctx.on('credentials/record-updated', (key) => {
165
+ if (key !== KEY) return
166
+ this.invalidateDetails()
167
+ this.changed()
168
+ })
169
+ ctx.effect(() => async () => {
170
+ this.closed = true
171
+ this.invalidateDetails()
172
+ const active = this.attempt
173
+ if (active !== undefined) {
174
+ if (active.view.phase !== 'committing') active.controller.abort()
175
+ await active.done
176
+ }
177
+ await this.removing
178
+ this.changed()
179
+ }, 'gatemux-account: active attempt lifetime')
180
+ }
181
+
182
+ /** Discard a stored grant issued by another environment. */
183
+ async [Service.init]() {
184
+ const record = await this.ctx.credentials.readRecord(KEY)
185
+ if (record === undefined) return
186
+ const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
187
+ if (grant !== null) return
188
+ await this.ctx.credentials.deleteRecord(KEY)
189
+ this.logger.warn('stored grant discarded', { reason: 'unreadable-or-foreign-issuer' })
190
+ }
191
+
192
+ async getState() {
193
+ const record = await this.ctx.credentials.readRecord(KEY)
194
+ if (record !== undefined && (record.kind !== 'grant' || parseGrant(record.payload, this.issuer) === null)) {
195
+ throw new GateMuxAuthError('stored grant is unreadable', 'storage')
196
+ }
197
+ const attempt = this.attempt?.view ?? null
198
+ return {
199
+ status: record === undefined ? 'signed-out' : 'credential-stored',
200
+ attempt: record === undefined && attempt?.phase === 'succeeded' ? null : attempt,
201
+ links: {
202
+ usageUrl: `${this.consoleOrigin}/app/usage`,
203
+ topUpUrl: `${this.consoleOrigin}/app/billing`,
204
+ },
205
+ }
206
+ }
207
+
208
+ async getProfile(client) {
209
+ const lifetime = this.detailsLifetime
210
+ const grant = await this.readGrant(lifetime)
211
+ if (grant === null || lifetime.signal.aborted) return null
212
+ const identity = await this.readIdentity(grant, lifetime, client)
213
+ if (this.detailsLifetime !== lifetime) return null
214
+ if (identity === null) return { status: 'failed' }
215
+ this.cacheProfile(grant.accessToken, identity)
216
+ return { status: 'ready', value: identity }
217
+ }
218
+
219
+ /**
220
+ * Wallet balances require the `billing:read` scope, which this application is
221
+ * not granted. A response without a usable wallet is reported as a failed
222
+ * balance rather than an invented zero.
223
+ */
224
+ async getBalance(client) {
225
+ const lifetime = this.detailsLifetime
226
+ const grant = await this.readGrant(lifetime)
227
+ if (grant === null || lifetime.signal.aborted) return null
228
+ const body = await this.callApi(this.accountEndpoint, grant, lifetime, client)
229
+ if (this.detailsLifetime !== lifetime) return null
230
+ if (body === null) return { status: 'failed' }
231
+ const wallet = body.wallet
232
+ if (typeof wallet !== 'object' || wallet === null) return { status: 'failed' }
233
+ const { currency, balance } = wallet
234
+ if (currency !== 'CNY' && currency !== 'USD') return { status: 'failed' }
235
+ if (typeof balance !== 'string') return { status: 'failed' }
236
+ return { status: 'ready', value: [{ currency, balance }], bonusWallets: [] }
237
+ }
238
+
239
+ /** GateMux grants no campaign bonuses; the contract reports an empty batch. */
240
+ async getUnnotifiedBonuses(client) {
241
+ const lifetime = this.detailsLifetime
242
+ const grant = await this.readGrant(lifetime)
243
+ if (grant === null || lifetime.signal.aborted) return null
244
+ const profile = this.lastProfile?.token === grant.accessToken ? this.lastProfile.profile : null
245
+ const accountId = profile?.id ?? (await this.getProfile(client))?.value?.id ?? null
246
+ if (accountId === null) return null
247
+ return { accountId, bonuses: [] }
248
+ }
249
+
250
+ /** No bonus can be acknowledged because none is ever granted. */
251
+ async ackBonusNotified() {
252
+ return false
253
+ }
254
+
255
+ async startSignIn(_client, callbackOrigin, loginSource) {
256
+ if (this.removing !== undefined) await this.removing
257
+ const origin = loginOrigin(callbackOrigin)
258
+ if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
259
+ const active = this.attempt
260
+ if (active !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(active.view.phase)) {
261
+ return this.getState()
262
+ }
263
+ const attempt = {
264
+ origin,
265
+ loginSource,
266
+ view: { id: randomUUID(), phase: 'initializing' },
267
+ controller: new AbortController(),
268
+ done: Promise.resolve(),
269
+ running: Promise.resolve(),
270
+ }
271
+ this.attempt = attempt
272
+ attempt.done = this.ctx.authorization.begin({
273
+ key: KEY,
274
+ signal: attempt.controller.signal,
275
+ interaction: { notify: () => undefined, prompt: () => Promise.reject(new GateMuxAuthError('no prompt', 'protocol')) },
276
+ }).then((outcome) => {
277
+ this.update(attempt, { phase: outcome.status === 'authorized' ? 'succeeded' : 'cancelled' })
278
+ if (outcome.status === 'authorized' && attempt.completionUrl !== undefined) {
279
+ attempt.callback?.writeHead(302, { location: attempt.completionUrl, 'cache-control': 'no-store' }).end()
280
+ } else {
281
+ attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
282
+ }
283
+ }).catch((error) => {
284
+ const code = error instanceof GateMuxAuthError ? error.code : 'protocol'
285
+ // Anything that is not a GateMuxAuthError is a defect rather than a rejected
286
+ // attempt, so its message is kept: otherwise every such failure reads as a bare
287
+ // `protocol` and the exchange that rejected it cannot be told from the seam.
288
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
289
+ this.logger.warn('sign-in failed', { errorCode: code, detail })
290
+ this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code })
291
+ this.finishFailedCallback(attempt)
292
+ }).then(async () => {
293
+ await attempt.running.catch(() => undefined)
294
+ if (attempt.callback !== undefined) {
295
+ await streamPromises.finished(attempt.callback, { cleanup: true }).catch(() => undefined)
296
+ }
297
+ await attempt.disposeCallback?.()
298
+ })
299
+ this.changed()
300
+ return this.getState()
301
+ }
302
+
303
+ async cancelSignIn(id) {
304
+ const attempt = this.attempt
305
+ if (attempt?.view.id === id) {
306
+ if (attempt.view.phase !== 'committing') {
307
+ attempt.controller.abort()
308
+ this.ctx.authorization.cancel(KEY)
309
+ }
310
+ await attempt.done
311
+ }
312
+ return this.getState()
313
+ }
314
+
315
+ signOut(client) {
316
+ this.removing ??= (async () => {
317
+ if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
318
+ if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
319
+ const record = await this.ctx.credentials.readRecord(KEY)
320
+ if (record !== undefined) {
321
+ const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
322
+ if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
323
+ await this.ctx.credentials.deleteRecord(KEY)
324
+ // Revoking the refresh token cascades to every access token it issued.
325
+ this.revoke(grant.refreshToken)
326
+ }
327
+ this.attempt = undefined
328
+ this.ctx.emit('deepseek-account/signed-out')
329
+ this.changed()
330
+ return this.getState()
331
+ })().finally(() => { this.removing = undefined })
332
+ return this.removing
333
+ }
334
+
335
+ async *watch(signal) {
336
+ let dirty = true
337
+ let wake
338
+ const changed = () => { dirty = true; wake?.() }
339
+ this.listeners.add(changed)
340
+ signal.addEventListener('abort', changed, { once: true })
341
+ try {
342
+ while (!this.closed && !signal.aborted) {
343
+ if (dirty) { dirty = false; yield await this.getState(); continue }
344
+ await new Promise((resolve) => { wake = resolve })
345
+ }
346
+ } finally {
347
+ this.listeners.delete(changed)
348
+ signal.removeEventListener('abort', changed)
349
+ }
350
+ }
351
+
352
+ /** Resolve a token only for the GateMux REST origin, refreshing it when stale. */
353
+ async resolveToken(url) {
354
+ if (this.closed || this.removing !== undefined) return undefined
355
+ let destination
356
+ try { destination = new URL(url) } catch { return undefined }
357
+ if (destination.origin !== this.issuer || destination.username || destination.password) return undefined
358
+ const grant = await this.freshGrant()
359
+ return grant?.accessToken
360
+ }
361
+
362
+ /** Remove a rejected token only while it still matches the stored credential. */
363
+ async rejectToken(token) {
364
+ const lifetime = this.detailsLifetime
365
+ const grant = await this.readGrant(lifetime)
366
+ if (grant?.accessToken !== token) return
367
+ await this.expireCredential(token, lifetime)
368
+ }
369
+
370
+ /** GateMux embeds no console documents, so there is no host-only session to publish. */
371
+ async getPlatformSession() {
372
+ return null
373
+ }
374
+
375
+ /**
376
+ * Run one authorization attempt: PKCE, browser callback, code exchange, commit.
377
+ * @param session - the attempt's cancellation signal and commit channel.
378
+ * @param attempt - the attempt record whose phase drives the UI.
379
+ */
380
+ async run(session, attempt) {
381
+ const webServer = this.ctx.get('webServer')
382
+ if (webServer === undefined) throw new GateMuxAuthError('web server is unavailable', 'protocol')
383
+ const verifier = randomBytes(32).toString('base64url')
384
+ const state = randomBytes(32).toString('base64url')
385
+ const challenge = createHash('sha256').update(verifier).digest('base64url')
386
+ const redirectUri = `${attempt.origin}${CALLBACK_PATH}`
387
+ const code = Promise.withResolvers()
388
+ // A callback may arrive before initialization settles; observe the rejection immediately.
389
+ void code.promise.catch(() => undefined)
390
+ const deadline = new AbortController()
391
+ const signal = AbortSignal.any([session.signal, deadline.signal])
392
+ // Published to the client and re-checked after the exchange, so it has to be
393
+ // the same instant the deadline aborts at rather than a second clock.
394
+ const expiresAt = Date.now() + ATTEMPT_TIMEOUT_MS
395
+ const timer = setTimeout(() =>{ deadline.abort() }, ATTEMPT_TIMEOUT_MS)
396
+ const abort = () => { code.reject(new GateMuxAuthError('attempt expired', 'expired')) }
397
+ signal.addEventListener('abort', abort, { once: true })
398
+ try {
399
+ attempt.disposeCallback = this.ctx.effect(() => webServer.register({
400
+ kind: 'exact', path: CALLBACK_PATH, handler: (req, res) => {
401
+ let url
402
+ try { url = new URL(req.url ?? '/', 'http://127.0.0.1') }
403
+ catch { res.writeHead(400, { 'cache-control': 'no-store' }).end(); return }
404
+ const receivedCode = url.searchParams.get('code')
405
+ const receivedState = url.searchParams.get('state') ?? ''
406
+ const validState = Buffer.byteLength(receivedState) === Buffer.byteLength(state)
407
+ && timingSafeEqual(Buffer.from(receivedState), Buffer.from(state))
408
+ if (req.method !== 'GET' || url.pathname !== CALLBACK_PATH || !validState || !receivedCode
409
+ || url.searchParams.getAll('state').length !== 1 || url.searchParams.getAll('code').length !== 1) {
410
+ res.writeHead(400, { 'cache-control': 'no-store' }).end(); return
411
+ }
412
+ if (signal.aborted || attempt.callback !== undefined || attempt.view.phase !== 'waiting-browser') {
413
+ res.writeHead(410, { 'cache-control': 'no-store' }).end(); return
414
+ }
415
+ attempt.callback = res
416
+ code.resolve(receivedCode)
417
+ },
418
+ }), 'gatemux-account: browser callback')
419
+ signal.throwIfAborted()
420
+ const authorizeUrl = new URL(this.authorizeEndpoint)
421
+ authorizeUrl.searchParams.set('response_type', 'code')
422
+ authorizeUrl.searchParams.set('client_id', this.clientId)
423
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri)
424
+ authorizeUrl.searchParams.set('state', state)
425
+ authorizeUrl.searchParams.set('scope', this.scopes.join(' '))
426
+ authorizeUrl.searchParams.set('code_challenge', challenge)
427
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256')
428
+ this.update(attempt, {
429
+ phase: 'waiting-browser',
430
+ authorizeUrl: authorizeUrl.href,
431
+ expiresAt,
432
+ })
433
+ const receivedCode = await code.promise
434
+ signal.throwIfAborted()
435
+ this.update(attempt, { phase: 'exchanging' })
436
+ const grant = await this.exchange(receivedCode, verifier, redirectUri, signal)
437
+ if (Date.now() >= expiresAt) throw new GateMuxAuthError('attempt expired', 'expired')
438
+ signal.throwIfAborted()
439
+ this.update(attempt, { phase: 'committing' })
440
+ try {
441
+ await session.commit({ kind: 'grant', payload: grant })
442
+ } catch {
443
+ throw new GateMuxAuthError('stored grant could not be written', 'storage')
444
+ }
445
+ } catch (error) {
446
+ if (deadline.signal.aborted) throw new GateMuxAuthError('attempt expired', 'expired')
447
+ throw error
448
+ } finally {
449
+ clearTimeout(timer)
450
+ signal.removeEventListener('abort', abort)
451
+ // begin() settles the browser response and removes only this attempt's route.
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Exchange an authorization code for tokens.
457
+ *
458
+ * `invalid_grant` is terminal: the server voids the code when the verifier
459
+ * does not match, so retrying the same code can never succeed.
460
+ */
461
+ async exchange(code, verifier, redirectUri, signal) {
462
+ const response = await this.postToken({
463
+ grant_type: 'authorization_code',
464
+ code,
465
+ code_verifier: verifier,
466
+ client_id: this.clientId,
467
+ redirect_uri: redirectUri,
468
+ }, signal)
469
+ if (!response.ok) {
470
+ // RFC 6749 §5.2 flat body. A reused `code`, a `code_verifier` that does not match
471
+ // the challenge, and a `redirect_uri` that differs from the authorize request all
472
+ // arrive as `invalid_grant`, so the server's own description is the only thing
473
+ // that tells them apart — discarding it leaves nothing to act on.
474
+ const code = tokenErrorCode(response.body)
475
+ const description = typeof response.body?.error_description === 'string' ? response.body.error_description : undefined
476
+ this.logger.warn('authorization code rejected', { status: response.status, errorCode: code ?? 'unknown', description })
477
+ throw new GateMuxAuthError(`authorization code rejected (${response.status} ${code ?? 'unknown'})`, 'protocol')
478
+ }
479
+ return this.grantFrom(response.body)
480
+ }
481
+
482
+ /** Read the newest grant, refreshing it when it is close to expiry. */
483
+ async freshGrant() {
484
+ const grant = await this.readGrant(this.detailsLifetime)
485
+ if (grant === null) return null
486
+ if (grant.expiresAt - Date.now() > REFRESH_MARGIN_MS) return grant
487
+ this.refreshInFlight ??= this.rotate().finally(() => { this.refreshInFlight = undefined })
488
+ try {
489
+ return await this.refreshInFlight
490
+ } catch {
491
+ return null
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Rotate the refresh token exactly once at a time.
497
+ *
498
+ * Refreshes are serialized because GateMux voids the whole authorization
499
+ * chain when a rotated refresh token reappears after a grace period.
500
+ */
501
+ async rotate() {
502
+ const lifetime = this.detailsLifetime
503
+ const grant = await this.readGrant(lifetime)
504
+ if (grant === null) throw new GateMuxAuthError('signed out', 'expired')
505
+ const response = await this.postToken({
506
+ grant_type: 'refresh_token',
507
+ refresh_token: grant.refreshToken,
508
+ client_id: this.clientId,
509
+ }, lifetime.signal)
510
+ if (!response.ok) {
511
+ const code = tokenErrorCode(response.body)
512
+ // An unknown or expired refresh token means the authorization is gone.
513
+ this.logger.warn('refresh rejected', { errorCode: code ?? 'unknown' })
514
+ await this.expireCredential(grant.accessToken, lifetime)
515
+ throw new GateMuxAuthError('refresh token rejected', 'expired')
516
+ }
517
+ const next = this.grantFrom(response.body)
518
+ // Persist the rotated token before any other request can read the old one.
519
+ await this.ctx.credentials.modifyRecord(KEY, (current) => Promise.resolve(
520
+ current !== undefined && parseGrant(current.payload, this.issuer) !== null
521
+ ? { kind: 'grant', payload: next }
522
+ : undefined,
523
+ ))
524
+ return next
525
+ }
526
+
527
+ /** Build a validated grant from a token-endpoint response. */
528
+ grantFrom(body) {
529
+ if (typeof body !== 'object' || body === null) throw new GateMuxAuthError('token response was not an object', 'protocol')
530
+ const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = body
531
+ if (typeof accessToken !== 'string' || accessToken.length === 0) throw new GateMuxAuthError('token response carried no access token', 'protocol')
532
+ if (typeof refreshToken !== 'string' || refreshToken.length === 0) throw new GateMuxAuthError('token response carried no refresh token', 'protocol')
533
+ const seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600
534
+ return { version: 1, accessToken, refreshToken, expiresAt: Date.now() + seconds * 1000, issuer: this.issuer }
535
+ }
536
+
537
+ /** Post one token-endpoint request and decode its RFC 6749 §5.2 envelope. */
538
+ async postToken(fields, signal) {
539
+ const response = await fetch(this.tokenEndpoint, {
540
+ method: 'POST',
541
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
542
+ body: JSON.stringify(fields),
543
+ signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
544
+ })
545
+ let body
546
+ try { body = await response.json() } catch { body = undefined }
547
+ return { ok: response.ok, status: response.status, body }
548
+ }
549
+
550
+ /**
551
+ * Call one account endpoint, expiring the credential on an authenticated rejection.
552
+ * @returns the decoded body, or null when the credential was rejected.
553
+ */
554
+ async callApi(url, grant, lifetime, _client) {
555
+ let response
556
+ try {
557
+ response = await fetch(url, {
558
+ headers: { authorization: `Bearer ${grant.accessToken}`, accept: 'application/json' },
559
+ signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
560
+ })
561
+ } catch {
562
+ return null
563
+ }
564
+ let body
565
+ try { body = await response.json() } catch { body = undefined }
566
+ if (response.ok) return body
567
+ // 401 credential_invalid means re-authorization; other codes are configuration problems.
568
+ if (response.status === 401 || apiErrorCode(body) === 'credential_invalid') {
569
+ if (this.detailsLifetime === lifetime) await this.expireCredential(grant.accessToken, lifetime)
570
+ return null
571
+ }
572
+ return null
573
+ }
574
+
575
+ /** Read the profile identity, enriching it with the OpenID email when available. */
576
+ async readIdentity(grant, lifetime, client) {
577
+ const me = await this.callApi(this.accountEndpoint, grant, lifetime, client)
578
+ if (me === null || typeof me !== 'object') return null
579
+ const accountId = typeof me.account_id === 'string' && me.account_id.length > 0 ? me.account_id : null
580
+ const masked = typeof me.email_masked === 'string' && me.email_masked.length > 0 ? me.email_masked : null
581
+ let contact = masked
582
+ if (contact === null) {
583
+ const info = await this.callApi(this.userinfoEndpoint, grant, lifetime, client)
584
+ if (typeof info === 'object' && info !== null && typeof info.email === 'string' && info.email.length > 0) {
585
+ contact = info.email
586
+ }
587
+ }
588
+ const id = accountId ?? (typeof me.user_id === 'string' && me.user_id.length > 0 ? me.user_id : null)
589
+ if (id === null && contact === null) return null
590
+ return { id, name: contact, contact, avatarUrl: null }
591
+ }
592
+
593
+ cacheProfile(token, profile) {
594
+ const previous = this.lastProfile?.profile.id ?? null
595
+ this.lastProfile = { token, profile }
596
+ if (previous !== profile.id) this.changed()
597
+ }
598
+
599
+ async readGrant(lifetime) {
600
+ if (this.closed) return null
601
+ const record = await this.ctx.credentials.readRecord(KEY)
602
+ if (record === undefined || lifetime.signal.aborted) return null
603
+ if (record.kind !== 'grant') throw new GateMuxAuthError('stored record is not a grant', 'storage')
604
+ const grant = parseGrant(record.payload, this.issuer)
605
+ if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
606
+ return grant
607
+ }
608
+
609
+ /** Delete the credential and notify consumers that the session ended. */
610
+ async expireCredential(token, lifetime) {
611
+ this.removing ??= (async () => {
612
+ if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
613
+ const record = await this.ctx.credentials.readRecord(KEY)
614
+ if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState()
615
+ const current = parseGrant(record.payload, this.issuer)
616
+ if (current === null || current.accessToken !== token) return this.getState()
617
+ await this.ctx.credentials.deleteRecord(KEY)
618
+ this.ctx.emit('deepseek-account/session-expired')
619
+ this.attempt = undefined
620
+ this.ctx.emit('deepseek-account/signed-out')
621
+ this.changed()
622
+ return this.getState()
623
+ })().finally(() => { this.removing = undefined })
624
+ await this.removing
625
+ }
626
+
627
+ /** Revoke a refresh token, cascading to its access tokens; failures are not fatal. */
628
+ revoke(refreshToken) {
629
+ if (this.closed) return
630
+ void fetch(this.revokeEndpoint, {
631
+ method: 'POST',
632
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
633
+ body: JSON.stringify({ token: refreshToken }),
634
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
635
+ }).then(() => undefined, () => undefined)
636
+ }
637
+
638
+ invalidateDetails() {
639
+ this.lastProfile = undefined
640
+ this.detailsLifetime.abort()
641
+ this.detailsLifetime = new AbortController()
642
+ }
643
+
644
+ changed() { for (const listener of this.listeners) listener() }
645
+
646
+ update(attempt, value) {
647
+ const { authorizeUrl: _url, ...rest } = attempt.view
648
+ attempt.view = { ...rest, ...value }
649
+ this.changed()
650
+ }
651
+
652
+
653
+ /** Return the browser to the application after a failed exchange. */
654
+ finishFailedCallback(attempt) {
655
+ if (attempt.loginSource === 'web') {
656
+ const nonce = randomBytes(16).toString('base64url')
657
+ const message = 'Sign-in failed. Close this tab and try again in the original tab.'
658
+ attempt.callback?.writeHead(200, {
659
+ 'content-type': 'text/html; charset=utf-8',
660
+ 'cache-control': 'no-store',
661
+ 'content-security-policy': `default-src 'none'; frame-ancestors 'none'`,
662
+ 'referrer-policy': 'no-referrer',
663
+ }).end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${message}</title>`
664
+ + `<body><p>${message}</p></body></html>`)
665
+ } else {
666
+ attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
667
+ }
668
+ }
669
+ }
670
+
671
+ export default GateMuxAccount
@@ -0,0 +1,96 @@
1
+ # GateMux Sign-In for DeepSeek Harness.
2
+ #
3
+ # Composition shape.
4
+ #
5
+ # A patch's `name` is a GUARD, not a rename: `applyEntryPatches` skips any patch
6
+ # whose `name` differs from the addressed row's current module, warning
7
+ # "name mismatch ... skipping". A shipped row's module therefore can never be
8
+ # swapped by a patch, and only a whole new row can be inserted. Two consequences
9
+ # shape this file:
10
+ #
11
+ # * the GateMux account service is added as its own row rather than by
12
+ # re-pointing `deepseek-account`, whose module would ignore the rename;
13
+ # * `ctx.deepseekAccount` is a single service slot — a second provider throws
14
+ # "service has been registered at ..." — so the shipped row is turned off
15
+ # instead, which is what frees the slot for the GateMux implementation.
16
+ #
17
+ # `disabled: true` on a shipped row is a normal patch override and persists
18
+ # across boots, because the bundle layers are re-read from these packages on
19
+ # every launch.
20
+ #
21
+ # Every row `name` below carries this package's own name. Renaming the package
22
+ # in package.json without mirroring it here leaves the rows unresolvable, and
23
+ # the load reports "failed to import" rather than anything naming the rename.
24
+
25
+ - id: deepseek-account
26
+ disabled: true
27
+
28
+ - id: llm-deepseek-account
29
+ disabled: true
30
+
31
+ - insert:
32
+ - id: gatemux-account
33
+ name: '@gatemux/dsh-gatemux-login/account'
34
+ config:
35
+ clientId: app_cbd5c3e0ac4469cf
36
+ authorizeOrigin: https://rest.gatemux.ai
37
+ consoleOrigin: https://gatemux.ai
38
+ # Exactly the scopes granted to this application in the GateMux portal.
39
+ # `responses:create` gates /v1/chat/completions, /v1/messages,
40
+ # /v1/responses, /v1/embeddings and /v1/rerank; it is the only paid LLM
41
+ # scope here, and authorization returns invalid_scope without it.
42
+ scopes:
43
+ - models:read
44
+ - responses:create
45
+ - media:create
46
+ - account:read
47
+ - openid
48
+ - email
49
+
50
+ # The user-funded GateMux route. Its provider id is `gatemux-account`, not
51
+ # `gatemux`: the profile's `llm-pi-ai` row already owns the `gatemux` route
52
+ # with the application's own server key, and the LLM registry throws
53
+ # DUPLICATE_ADAPTER when one route is registered twice.
54
+ - id: gatemux-messages
55
+ name: '@gatemux/dsh-gatemux-login/llm'
56
+ config:
57
+ baseURL: https://rest.gatemux.ai/v1
58
+ maxTokens: 8192
59
+ # GateMux answers both `budget_exceeded` (a spend cap) and
60
+ # `app_rate_limited` (a transient limit) with HTTP 429, and the upstream
61
+ # classifier folds both into RATE_LIMIT, so retries are kept low: an
62
+ # exhausted budget must fail fast for the user to act on rather than be
63
+ # retried as though it were transient.
64
+ retryPolicy:
65
+ mode: normal
66
+ maxRetries: 3
67
+ retryableCodes:
68
+ - RATE_LIMIT
69
+ - SERVER
70
+ - TIMEOUT
71
+ - TRANSPORT
72
+ - EMPTY_RESPONSE
73
+ models:
74
+ - id: gatemux/deepseek-v4-flash
75
+ name: DeepSeek V4 Flash (GateMux)
76
+ description: GateMux-routed DeepSeek V4 Flash; vision, tools and reasoning.
77
+ contextWindow: 1000000
78
+ inputModalities:
79
+ - text
80
+ - image
81
+ - id: gatemux/deepseek-v4-pro
82
+ name: DeepSeek V4 Pro (GateMux)
83
+ description: GateMux-routed DeepSeek V4 Pro; tools and reasoning.
84
+ contextWindow: 1000000
85
+ inputModalities:
86
+ - text
87
+
88
+ # Point the default model at the user-funded route, so installing this bundle is
89
+ # enough to start using it. A profile's own cordis.patch.yml is applied after
90
+ # every bundle layer, so a user who prefers another default still overrides this.
91
+ - id: agent-default-model
92
+ name: '@deepseek-ai/dsh-agent-default-model'
93
+ config:
94
+ provider: gatemux-account
95
+ model: gatemux/deepseek-v4-flash
96
+ reasoningEffort: high
package/llm.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * GateMux model route authenticated by the signed-in user's OAuth credential.
3
+ *
4
+ * Reuses the DeepSeek Messages adapter because GateMux's `/v1/messages` speaks
5
+ * the Anthropic protocol verbatim, including DeepSeek's proprietary `thinking`
6
+ * and `output_config` request fields. Only authentication differs.
7
+ *
8
+ * The provider id is deliberately NOT `gatemux`: the profile's `llm-pi-ai` row
9
+ * already claims that route with the application's server key, and claiming it
10
+ * twice would throw `DUPLICATE_ADAPTER` and break plugin loading.
11
+ *
12
+ * Endpoint, model catalog, and retry policy come from this entry's `config` in
13
+ * `cordis.patch.yml`, so the composition remains the single source of truth for
14
+ * deployment choices.
15
+ *
16
+ * @module @gatemux/dsh-gatemux-login/llm
17
+ */
18
+
19
+ import { ACCOUNT_QUOTA_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
20
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
21
+ import { Config, catalogModelInfo, plainOptions, registerDeepSeekProvider, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
22
+
23
+ export { Config }
24
+
25
+ /** Route owned by this plugin; distinct from the pi-ai `gatemux` route. */
26
+ export const PROVIDER = 'gatemux-account'
27
+ export const name = 'gatemux-account'
28
+ export const inject = ['llm']
29
+
30
+ /**
31
+ * Register the GateMux account-authenticated provider route.
32
+ * @param ctx - Host context with the LLM registry injected.
33
+ * @param config - this entry's Messages protocol configuration.
34
+ */
35
+ export function apply(ctx, config) {
36
+ const logger = ctx.logger('gatemux-account')
37
+ const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
38
+ // Validate the composed configuration now so a misconfiguration fails loudly at load.
39
+ options()
40
+
41
+ const resolveAuth = async (connection) => {
42
+ const account = ctx.get('deepseekAccount')
43
+ const token = await account?.resolveToken(connection.baseURL)
44
+ if (token === undefined) {
45
+ throw new LlmError(
46
+ 'Sign in with GateMux to use the GateMux model route. Model requests are billed to your own GateMux balance.',
47
+ 'ACCOUNT_SIGN_IN_REQUIRED',
48
+ )
49
+ }
50
+ return {
51
+ // GateMux accepts `Authorization: Bearer`; it rejects the DeepSeek-only `x-dsh-auth-token` header.
52
+ headers: { authorization: `Bearer ${token}` },
53
+ onRequestError: async (error) => {
54
+ if (!(error instanceof LlmError)) return error
55
+ const status = error.failure.status
56
+ // 402 insufficient_balance and an exhausted quota both mean the user must top up.
57
+ if (error.code === QUOTA_EXCEEDED_CODE) {
58
+ return new LlmError(error.message, ACCOUNT_QUOTA_EXCEEDED_CODE, { ...error.failure, cause: error })
59
+ }
60
+ // 403 scope_denied / model_not_allowed / ip_not_allowed stay AUTH: they are
61
+ // configuration faults that re-authorizing the same way would not fix.
62
+ if (status !== 401) return error
63
+ // 401 credential_invalid: the credential is revoked, expired, or suspended.
64
+ // Re-authorization is required, so the stored credential is dropped.
65
+ const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error })
66
+ try {
67
+ await account?.rejectToken(token)
68
+ } catch (failure) {
69
+ logger.warn('credential removal after rejection failed', failure)
70
+ }
71
+ return rejected
72
+ },
73
+ }
74
+ }
75
+
76
+ ctx.llm.registerConfigurableProviders([
77
+ { provider: PROVIDER, displayName: 'GateMux Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] },
78
+ ])
79
+
80
+ registerDeepSeekProvider(ctx, PROVIDER, {
81
+ options,
82
+ resolveAuth,
83
+ providerName: 'GateMux Account',
84
+ discoverModels: async (provider) => {
85
+ const connection = options()
86
+ try {
87
+ await resolveAuth(connection)
88
+ } catch (error) {
89
+ // With no signed-in user the route advertises nothing rather than failing discovery.
90
+ if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return []
91
+ throw error
92
+ }
93
+ return connection.models.map((model) => catalogModelInfo(provider, model))
94
+ },
95
+ })
96
+ }
97
+
98
+ // The loader normalizes a module with `exports.default ?? exports` and then reads
99
+ // `inject` and `Config` off that value, so named exports beside a default are
100
+ // discarded. A bare `apply` default therefore has no `inject` and `ctx.llm` throws
101
+ // "cannot get property \"llm\" without inject"; the plugin object has to be the
102
+ // default export itself.
103
+ export default { name, inject, Config, apply }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@gatemux/dsh-gatemux-login",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance.",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "license": "MIT",
10
+ "files": [
11
+ "account.js",
12
+ "llm.js",
13
+ "cordis.patch.yml"
14
+ ],
15
+ "exports": {
16
+ "./account": "./account.js",
17
+ "./llm": "./llm.js",
18
+ "./cordis.patch.yml": "./cordis.patch.yml",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ }
25
+ },
26
+ "meta": {
27
+ "title": "GateMux Sign-In",
28
+ "description": "Signs in with GateMux and bills model requests to the user's own GateMux balance."
29
+ },
30
+ "peerDependencies": {
31
+ "@deepseek-ai/cordis": "^4.0.4",
32
+ "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
33
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
34
+ "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
35
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
36
+ "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
37
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
38
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
39
+ },
40
+ "devDependencies": {
41
+ "@deepseek-ai/cordis": "^4.0.4",
42
+ "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
43
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
44
+ "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
45
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
46
+ "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
47
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
48
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
49
+ }
50
+ }