@gatemux/dsh-gatemux-login 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,32 +4,12 @@ Sign in to the DeepSeek Harness desktop app with your own GateMux account. Model
4
4
  requests are then billed to **your** GateMux balance instead of to the
5
5
  application's server key.
6
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` or `0.2.x`.
26
- The plugin declares `@deepseek-ai/dsh-*` peer dependencies across both lines.
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.
7
+ This is the bundle for the **international** site, `gatemux.ai`. If your account
8
+ is on the mainland China site, install
9
+ [`@gatemux/dsh-gatemux-login-cn`](https://www.npmjs.com/package/@gatemux/dsh-gatemux-login-cn)
10
+ instead. The two sites are independent deployments with separate databases,
11
+ wallets, and OAuth applications: an account on one does not exist on the other,
12
+ and installing the wrong bundle signs you in to a site you have no account on.
33
13
 
34
14
  ## Install
35
15
 
@@ -39,11 +19,9 @@ In the desktop app:
39
19
  2. Click **添加插件** (Add plugin).
40
20
  3. Enter `@gatemux/dsh-gatemux-login` and click **安装** (Install).
41
21
  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.
22
+ 5. Restart the app. Replacing installed JavaScript needs a fresh process
23
+ generation, which is why the plugin manager reports that the change takes
24
+ effect at the next start.
47
25
 
48
26
  From the CLI, for a non-desktop profile:
49
27
 
@@ -54,47 +32,35 @@ dsh plugin --profile <name> add @gatemux/dsh-gatemux-login
54
32
  The `desktop` profile is managed exclusively by the Electron application and
55
33
  cannot be installed into from the CLI.
56
34
 
57
- ## Sign in
35
+ ## What it does
58
36
 
59
- Send a message. Until you are signed in, the GateMux route reports:
37
+ Installing this bundle changes three things about the composed profile:
60
38
 
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. The tab itself is left on a confirmation page — GateMux
68
- serves no hosted completion page, so there is nothing else to show there — and it
69
- never auto-closes; you can close it once the app is signed in.
70
-
71
- ## Models
39
+ - Adds a GateMux account service that signs you in through GateMux's OAuth 2.0
40
+ redirect flow (authorization code with PKCE `S256`).
41
+ - Registers a model route, provider id `gatemux-account`, that authenticates
42
+ every request with your own credential and advertises the models
43
+ `https://rest.gatemux.ai` reports it serves over the Messages protocol.
44
+ - Turns off the shipped vendor account route (`deepseek-account` and
45
+ `llm-deepseek-account`) so there is one account provider instead of two.
72
46
 
73
- | Model id | Context | Input |
74
- |---|---|---|
75
- | `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
76
- | `gatemux/deepseek-v4-pro` | 1,000,000 | text |
47
+ It also sets the default model to `gatemux/deepseek-v4-flash`.
77
48
 
78
- Both are served from `https://rest.gatemux.ai/v1`.
49
+ ## Requirements
79
50
 
80
- ## Changing the default model
51
+ - DeepSeek Harness desktop app, `0.1.7.x` or `0.2.x`. The bundle declares
52
+ `@deepseek-ai/dsh-*` peer dependencies across both lines; a different DSH
53
+ release is rejected at install time.
54
+ - A GateMux account **on `gatemux.ai`**.
81
55
 
82
- The bundle sets the default model in its own patch layer. A profile's own
83
- `cordis.patch.yml` is applied **after** every bundle layer, so it wins. Selecting
84
- another model in the app writes exactly that override for you; to set it by hand,
85
- add to `$DSH_HOME/profiles/<name>/cordis.patch.yml`:
56
+ No install scripts are run: installation does not execute any build step, and the
57
+ app never asks you to approve package scripts for this plugin.
86
58
 
87
- ```yaml
88
- - id: agent-default-model
89
- name: "@deepseek-ai/dsh-agent-default-model"
90
- config:
91
- provider: some-other-provider
92
- model: some-other-model
93
- reasoningEffort: high
94
- ```
59
+ ## Sign in
95
60
 
96
- A patch `name` is a guard, not a rename: it must match the addressed row's current
97
- module or the patch is skipped.
61
+ Send a message. Until you are signed in, the route reports
62
+ `ACCOUNT_SIGN_IN_REQUIRED`. Sign in from the app's account settings;
63
+ authorization opens in your browser and returns to the app.
98
64
 
99
65
  ## Troubleshooting
100
66
 
@@ -103,24 +69,30 @@ module or the patch is skipped.
103
69
  | `ACCOUNT_SIGN_IN_REQUIRED` | No credential yet. Sign in. |
104
70
  | `ACCOUNT_TOKEN_INVALID` | The credential was revoked, expired, or suspended. It has been discarded; sign in again. |
105
71
  | `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
106
- | `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. |
107
- | `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. Contact the plugin operator. |
72
+ | `ip_not_allowed` (HTTP 403) | Your network is not in the application's trusted egress IP allowlist. Re-authorizing will not fix it. |
73
+ | `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. |
108
74
  | Install refused for peer dependencies | Your DSH version is outside `0.1.7.x` and `0.2.x`. |
109
75
 
110
- 403 errors are reported as authorization faults, not retried: they are
111
- configuration problems on the application's side.
112
-
113
- ## Credentials and privacy
114
-
115
- Your GateMux credential is stored locally at
116
- `$DSH_HOME/.credentials.yaml` under the key `gatemux/account`, with file mode
117
- `0600`. It never leaves your machine except as the `Authorization: Bearer` header
118
- on requests to `https://rest.gatemux.ai`.
119
-
120
- Refresh tokens rotate on every refresh, and the rotated value is persisted
121
- immediately; a reused rotated refresh token is treated as credential leakage. If
122
- a token exchange returns `invalid_grant`, that authorization code is voided on the
123
- server and is not retried.
76
+ ## This package is configuration only
77
+
78
+ The account provider and the model route ship in
79
+ [`@gatemux/dsh-gatemux-login-core`](https://www.npmjs.com/package/@gatemux/dsh-gatemux-login-core),
80
+ a dependency of this package; this package is the patch layer that points them at
81
+ `gatemux.ai` and nothing else. The mainland bundle depends on the same core and
82
+ differs only in those values.
83
+
84
+ The account provider reads a model's context window, output cap, display name,
85
+ and image support from `https://rest.gatemux.ai/v1/models`, keeps the last good
86
+ catalog across a listing outage, and advertises `gatemux/deepseek-v4-flash` and
87
+ `gatemux/deepseek-v4-pro` before the first read. Your credential is stored at
88
+ `$DSH_HOME/.credentials.yaml` under the key `gatemux/account`, mode `0600`, and
89
+ leaves your machine only as the `Authorization: Bearer` header on requests to
90
+ `rest.gatemux.ai`.
91
+
92
+ To change the default model, or to override any value this bundle sets, add a row
93
+ to `$DSH_HOME/profiles/<name>/cordis.patch.yml` — that layer is applied after
94
+ every bundle layer, so it wins. Overriding a row's `config` replaces the whole
95
+ block rather than merging into it, so restate every field you still want.
124
96
 
125
97
  ## License
126
98
 
package/cordis.patch.yml CHANGED
@@ -1,4 +1,9 @@
1
- # GateMux Sign-In for DeepSeek Harness.
1
+ # GateMux Sign-In for DeepSeek Harness — international (gatemux.ai).
2
+ #
3
+ # This file is the whole of this package. The account provider and the model
4
+ # route are shipped by @gatemux/dsh-gatemux-login-core, which this bundle depends
5
+ # on; everything that differs between the international and the mainland China
6
+ # deployment is the configuration below.
2
7
  #
3
8
  # Composition shape.
4
9
  #
@@ -18,9 +23,9 @@
18
23
  # across boots, because the bundle layers are re-read from these packages on
19
24
  # every launch.
20
25
  #
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.
26
+ # Every row `name` below is a module of the core package. Renaming that package
27
+ # without mirroring it here leaves the rows unresolvable, and the load reports
28
+ # "failed to import" rather than anything naming the rename.
24
29
 
25
30
  - id: deepseek-account
26
31
  disabled: true
@@ -30,7 +35,7 @@
30
35
 
31
36
  - insert:
32
37
  - id: gatemux-account
33
- name: '@gatemux/dsh-gatemux-login/account'
38
+ name: '@gatemux/dsh-gatemux-login-core/account'
34
39
  config:
35
40
  clientId: app_cbd5c3e0ac4469cf
36
41
  authorizeOrigin: https://rest.gatemux.ai
@@ -52,7 +57,7 @@
52
57
  # with the application's own server key, and the LLM registry throws
53
58
  # DUPLICATE_ADAPTER when one route is registered twice.
54
59
  - id: gatemux-messages
55
- name: '@gatemux/dsh-gatemux-login/llm'
60
+ name: '@gatemux/dsh-gatemux-login-core/llm'
56
61
  config:
57
62
  baseURL: https://rest.gatemux.ai/v1
58
63
  maxTokens: 8192
package/package.json CHANGED
@@ -1,20 +1,17 @@
1
1
  {
2
2
  "name": "@gatemux/dsh-gatemux-login",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
- "description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance.",
5
+ "description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance. International (gatemux.ai) bundle.",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "license": "MIT",
10
10
  "files": [
11
- "account.js",
12
- "llm.js",
13
- "cordis.patch.yml"
11
+ "cordis.patch.yml",
12
+ "README.md"
14
13
  ],
15
14
  "exports": {
16
- "./account": "./account.js",
17
- "./llm": "./llm.js",
18
15
  "./cordis.patch.yml": "./cordis.patch.yml",
19
16
  "./package.json": "./package.json"
20
17
  },
@@ -27,6 +24,9 @@
27
24
  "title": "GateMux Sign-In",
28
25
  "description": "Signs in with GateMux and bills model requests to the user's own GateMux balance."
29
26
  },
27
+ "dependencies": {
28
+ "@gatemux/dsh-gatemux-login-core": "^1.0.0"
29
+ },
30
30
  "peerDependencies": {
31
31
  "@deepseek-ai/cordis": "^4.0.4",
32
32
  "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2 || ^0.2.0-rc.2",
package/account.js DELETED
@@ -1,723 +0,0 @@
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 { platform, release } from 'node:os'
24
- import { promises as streamPromises } from 'node:stream'
25
-
26
- import { Service } from '@deepseek-ai/cordis'
27
- import { credentialKey } from '@deepseek-ai/dsh-credentials'
28
- import {
29
- DeepSeekAccount,
30
- installAccountTaskCancellation,
31
- } from '@deepseek-ai/dsh-deepseek-account'
32
-
33
- /**
34
- * Deployment defaults. Every one of these is overridable through the row's
35
- * `config`, so the composition stays the single source of truth and this module
36
- * carries no deployment-specific literal that a patch cannot correct.
37
- */
38
- const DEFAULT_ISSUER = 'https://rest.gatemux.ai'
39
- const DEFAULT_CONSOLE_ORIGIN = 'https://gatemux.ai'
40
- /** Exact callback path registered for this application; loopback permits any port. */
41
- const CALLBACK_PATH = '/callback'
42
- const KEY = credentialKey('gatemux', 'account')
43
- /** Refresh this long before actual expiry so an in-flight request cannot race it. */
44
- const REFRESH_MARGIN_MS = 60_000
45
- const ATTEMPT_TIMEOUT_MS = 600_000
46
- const REQUEST_TIMEOUT_MS = 30_000
47
- const DEFAULT_SCOPES = ['models:read', 'responses:create', 'media:create', 'account:read', 'openid', 'email']
48
-
49
- /** Local attempt failure taxonomy; the abstract contract renders these through locale copy. */
50
- class GateMuxAuthError extends Error {
51
- /** @param message - diagnostic text without credentials. @param code - stable failure class. */
52
- constructor(message, code) {
53
- super(message)
54
- this.name = 'GateMuxAuthError'
55
- this.code = code
56
- }
57
- }
58
-
59
- /** Validate the stored grant without trusting its shape. */
60
- function parseGrant(payload, issuer) {
61
- if (typeof payload !== 'object' || payload === null) return null
62
- const { version, accessToken, refreshToken, expiresAt } = payload
63
- if (version !== 1) return null
64
- if (typeof accessToken !== 'string' || accessToken.length === 0) return null
65
- if (typeof refreshToken !== 'string' || refreshToken.length === 0) return null
66
- if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
67
- // A grant minted by any other issuer is not one this deployment may use.
68
- if (payload.issuer !== issuer) return null
69
- return { version: 1, accessToken, refreshToken, expiresAt, issuer }
70
- }
71
-
72
- /**
73
- * Read a stored record as a grant this deployment may use.
74
- * @param record - record at this provider's key, or undefined when none is stored.
75
- * @param issuer - origin a grant must carry to be usable here.
76
- * @returns the parsed grant, or null when no record is stored or it is unusable.
77
- */
78
- function usableGrant(record, issuer) {
79
- return record !== undefined && record.kind === 'grant' ? parseGrant(record.payload, issuer) : null
80
- }
81
-
82
- /** Accept a bare HTTPS origin, rejecting paths, credentials, and non-HTTPS schemes. */
83
- function originOf(value) {
84
- let url
85
- try { url = new URL(value) } catch { throw new GateMuxAuthError('origin must be an absolute URL', 'protocol') }
86
- if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
87
- throw new GateMuxAuthError('origin must be a bare HTTPS origin without a path', 'protocol')
88
- }
89
- return url.origin
90
- }
91
-
92
- /** Accept a browser-accessible loopback HTTP origin, mirroring the account seam's expectation. */
93
- function loginOrigin(value) {
94
- const explicitPort = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/i.exec(value)?.[1]
95
- if (explicitPort === undefined || Number(explicitPort) === 0) {
96
- throw new GateMuxAuthError('callback origin must be loopback HTTP with an explicit port', 'protocol')
97
- }
98
- return `http://${new URL(value).hostname}:${Number(explicitPort)}`
99
- }
100
-
101
- /**
102
- * Extract the RFC 6749 §5.2 error code from a token-endpoint response body.
103
- * The token endpoint uses a flat envelope, unlike the nested API envelope.
104
- */
105
- function tokenErrorCode(body) {
106
- if (typeof body !== 'object' || body === null) return undefined
107
- return typeof body.error === 'string' ? body.error : undefined
108
- }
109
-
110
- /** Extract the machine code from a nested API error envelope. */
111
- function apiErrorCode(body) {
112
- if (typeof body !== 'object' || body === null) return undefined
113
- const error = body.error
114
- if (typeof error !== 'object' || error === null) return undefined
115
- return typeof error.code === 'string' ? error.code : undefined
116
- }
117
-
118
- /** GateMux login state, stored grant, and refresh rotation. */
119
- export class GateMuxAccount extends DeepSeekAccount {
120
- static inject = ['credentials', 'authorization']
121
-
122
- clientId
123
- issuer
124
- consoleOrigin
125
- authorizeEndpoint
126
- tokenEndpoint
127
- revokeEndpoint
128
- accountEndpoint
129
- userinfoEndpoint
130
- logger
131
- scopes
132
- attempt
133
- listeners = new Set()
134
- detailsLifetime = new AbortController()
135
- lastProfile
136
- refreshInFlight
137
- removing
138
- closed = false
139
-
140
- /**
141
- * @param ctx - Host with the credentials and authorization services.
142
- * @param config - application identity and the granted scope set.
143
- */
144
- constructor(ctx, config = {}) {
145
- super(ctx)
146
- installAccountTaskCancellation(ctx)
147
- const clientId = config.clientId
148
- if (typeof clientId !== 'string' || clientId.length === 0) {
149
- throw new GateMuxAuthError('clientId is required', 'protocol')
150
- }
151
- this.clientId = clientId
152
- // `rest.gatemux.ai` serves both the API and the OAuth endpoints; the console
153
- // is a separate origin used only for links the user opens.
154
- const issuer = originOf(config.authorizeOrigin ?? DEFAULT_ISSUER)
155
- this.issuer = issuer
156
- this.consoleOrigin = originOf(config.consoleOrigin ?? DEFAULT_CONSOLE_ORIGIN)
157
- this.authorizeEndpoint = `${issuer}/v1/oauth/authorize`
158
- this.tokenEndpoint = `${issuer}/v1/oauth/token`
159
- this.revokeEndpoint = `${issuer}/v1/oauth/revoke`
160
- this.accountEndpoint = `${issuer}/v1/account/me`
161
- this.userinfoEndpoint = `${issuer}/v1/oauth/userinfo`
162
- this.logger = ctx.logger('gatemux-account')
163
- this.scopes = Array.isArray(config.scopes) && config.scopes.length > 0 ? [...config.scopes] : DEFAULT_SCOPES
164
- ctx.authorization.registerFlow({
165
- key: KEY,
166
- label: 'GateMux',
167
- methods: [{ id: 'browser', label: 'GateMux' }],
168
- run: (session) => {
169
- const attempt = this.attempt
170
- if (attempt === undefined) return Promise.reject(new GateMuxAuthError('no attempt', 'protocol'))
171
- attempt.running = this.run(session, attempt)
172
- return attempt.running
173
- },
174
- })
175
- ctx.on('credentials/record-updated', (key) => {
176
- if (key !== KEY) return
177
- this.invalidateDetails()
178
- this.changed()
179
- })
180
- ctx.effect(() => async () => {
181
- this.closed = true
182
- this.invalidateDetails()
183
- const active = this.attempt
184
- if (active !== undefined) {
185
- if (active.view.phase !== 'committing') active.controller.abort()
186
- await active.done
187
- }
188
- await this.removing
189
- this.changed()
190
- }, 'gatemux-account: active attempt lifetime')
191
- }
192
-
193
- /** Discard a stored grant issued by another environment. */
194
- async [Service.init]() {
195
- const record = await this.ctx.credentials.readRecord(KEY)
196
- if (record === undefined || usableGrant(record, this.issuer) !== null) return
197
- await this.ctx.credentials.deleteRecord(KEY)
198
- this.logger.warn('stored grant discarded', { reason: 'unreadable-or-foreign-issuer' })
199
- }
200
-
201
- async getState() {
202
- const record = await this.ctx.credentials.readRecord(KEY)
203
- const grant = usableGrant(record, this.issuer)
204
- // A record this deployment cannot use reads as absent instead of raising: every
205
- // caller turns a throw into a failure with no diagnosis. `startSignIn` returns
206
- // this outside its own catch, so a raise there rejects the start call and the
207
- // dialog reports a sign-in failure for a record the person never supplied, and
208
- // `watch` ends the account stream. The record is left in place for a sign-in to
209
- // overwrite; discarding it here could delete a grant committed since the read.
210
- if (record !== undefined && grant === null) {
211
- this.logger.warn('stored grant is unreadable', { reason: 'unreadable-or-foreign-issuer' })
212
- }
213
- const attempt = this.attempt?.view ?? null
214
- return {
215
- status: grant === null ? 'signed-out' : 'credential-stored',
216
- attempt: grant === null && attempt?.phase === 'succeeded' ? null : attempt,
217
- links: {
218
- usageUrl: `${this.consoleOrigin}/app/usage`,
219
- topUpUrl: `${this.consoleOrigin}/app/billing`,
220
- },
221
- }
222
- }
223
-
224
- async getProfile(client) {
225
- const lifetime = this.detailsLifetime
226
- const grant = await this.readGrant(lifetime)
227
- if (grant === null || lifetime.signal.aborted) return null
228
- const identity = await this.readIdentity(grant, lifetime, client)
229
- if (this.detailsLifetime !== lifetime) return null
230
- if (identity === null) return { status: 'failed' }
231
- this.cacheProfile(grant.accessToken, identity)
232
- return { status: 'ready', value: identity }
233
- }
234
-
235
- /**
236
- * Wallet balances require the `billing:read` scope, which this application is
237
- * not granted. A response without a usable wallet is reported as a failed
238
- * balance rather than an invented zero.
239
- */
240
- async getBalance(client) {
241
- const lifetime = this.detailsLifetime
242
- const grant = await this.readGrant(lifetime)
243
- if (grant === null || lifetime.signal.aborted) return null
244
- const body = await this.callApi(this.accountEndpoint, grant, lifetime, client)
245
- if (this.detailsLifetime !== lifetime) return null
246
- if (body === null) return { status: 'failed' }
247
- const wallet = body.wallet
248
- if (typeof wallet !== 'object' || wallet === null) return { status: 'failed' }
249
- const { currency, balance } = wallet
250
- if (currency !== 'CNY' && currency !== 'USD') return { status: 'failed' }
251
- if (typeof balance !== 'string') return { status: 'failed' }
252
- return { status: 'ready', value: [{ currency, balance }], bonusWallets: [] }
253
- }
254
-
255
- /** GateMux grants no campaign bonuses; the contract reports an empty batch. */
256
- async getUnnotifiedBonuses(client) {
257
- const lifetime = this.detailsLifetime
258
- const grant = await this.readGrant(lifetime)
259
- if (grant === null || lifetime.signal.aborted) return null
260
- const profile = this.lastProfile?.token === grant.accessToken ? this.lastProfile.profile : null
261
- const accountId = profile?.id ?? (await this.getProfile(client))?.value?.id ?? null
262
- if (accountId === null) return null
263
- return { accountId, bonuses: [] }
264
- }
265
-
266
- /** No bonus can be acknowledged because none is ever granted. */
267
- async ackBonusNotified() {
268
- return false
269
- }
270
-
271
- async startSignIn(_client, callbackOrigin, loginSource) {
272
- if (this.removing !== undefined) await this.removing
273
- const origin = loginOrigin(callbackOrigin)
274
- if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
275
- const active = this.attempt
276
- if (active !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(active.view.phase)) {
277
- return this.getState()
278
- }
279
- const attempt = {
280
- origin,
281
- loginSource,
282
- view: { id: randomUUID(), phase: 'initializing' },
283
- controller: new AbortController(),
284
- done: Promise.resolve(),
285
- running: Promise.resolve(),
286
- }
287
- this.attempt = attempt
288
- attempt.done = this.ctx.authorization.begin({
289
- key: KEY,
290
- signal: attempt.controller.signal,
291
- interaction: { notify: () => undefined, prompt: () => Promise.reject(new GateMuxAuthError('no prompt', 'protocol')) },
292
- }).then((outcome) => {
293
- this.update(attempt, { phase: outcome.status === 'authorized' ? 'succeeded' : 'cancelled' })
294
- if (outcome.status === 'authorized' && attempt.completionUrl !== undefined) {
295
- attempt.callback?.writeHead(302, { location: attempt.completionUrl, 'cache-control': 'no-store' }).end()
296
- } else if (outcome.status === 'authorized') {
297
- // GateMux serves no hosted completion page, so this tab is the only place
298
- // the result is visible; answering 204 would leave it blank.
299
- this.writeCallbackPage(attempt, 'Signed in', attempt.loginSource === 'web'
300
- ? 'Signed in to GateMux. Close this tab and continue in the original tab.'
301
- : 'Signed in to GateMux. Close this tab and continue in DeepSeek Harness.')
302
- } else {
303
- attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
304
- }
305
- }).catch((error) => {
306
- const code = error instanceof GateMuxAuthError ? error.code : 'protocol'
307
- // Anything that is not a GateMuxAuthError is a defect rather than a rejected
308
- // attempt, so its message is kept: otherwise every such failure reads as a bare
309
- // `protocol` and the exchange that rejected it cannot be told from the seam.
310
- const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
311
- this.logger.warn('sign-in failed', { errorCode: code, detail })
312
- this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code })
313
- this.finishFailedCallback(attempt)
314
- }).then(async () => {
315
- await attempt.running.catch(() => undefined)
316
- if (attempt.callback !== undefined) {
317
- await streamPromises.finished(attempt.callback, { cleanup: true }).catch(() => undefined)
318
- }
319
- await attempt.disposeCallback?.()
320
- })
321
- this.changed()
322
- return this.getState()
323
- }
324
-
325
- async cancelSignIn(id) {
326
- const attempt = this.attempt
327
- if (attempt?.view.id === id) {
328
- if (attempt.view.phase !== 'committing') {
329
- attempt.controller.abort()
330
- this.ctx.authorization.cancel(KEY)
331
- }
332
- await attempt.done
333
- }
334
- return this.getState()
335
- }
336
-
337
- signOut(client) {
338
- this.removing ??= (async () => {
339
- if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
340
- if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
341
- const record = await this.ctx.credentials.readRecord(KEY)
342
- if (record !== undefined) {
343
- const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
344
- if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
345
- await this.ctx.credentials.deleteRecord(KEY)
346
- // Revoking the refresh token cascades to every access token it issued.
347
- this.revoke(grant.refreshToken)
348
- }
349
- this.attempt = undefined
350
- this.ctx.emit('deepseek-account/signed-out')
351
- this.changed()
352
- return this.getState()
353
- })().finally(() => { this.removing = undefined })
354
- return this.removing
355
- }
356
-
357
- async *watch(signal) {
358
- let dirty = true
359
- let wake
360
- const changed = () => { dirty = true; wake?.() }
361
- this.listeners.add(changed)
362
- signal.addEventListener('abort', changed, { once: true })
363
- try {
364
- while (!this.closed && !signal.aborted) {
365
- if (dirty) { dirty = false; yield await this.getState(); continue }
366
- await new Promise((resolve) => { wake = resolve })
367
- }
368
- } finally {
369
- this.listeners.delete(changed)
370
- signal.removeEventListener('abort', changed)
371
- }
372
- }
373
-
374
- /** Resolve a token only for the GateMux REST origin, refreshing it when stale. */
375
- async resolveToken(url) {
376
- if (this.closed || this.removing !== undefined) return undefined
377
- let destination
378
- try { destination = new URL(url) } catch { return undefined }
379
- if (destination.origin !== this.issuer || destination.username || destination.password) return undefined
380
- const grant = await this.freshGrant()
381
- return grant?.accessToken
382
- }
383
-
384
- /** Remove a rejected token only while it still matches the stored credential. */
385
- async rejectToken(token) {
386
- const lifetime = this.detailsLifetime
387
- const grant = await this.readGrant(lifetime)
388
- if (grant?.accessToken !== token) return
389
- await this.expireCredential(token, lifetime)
390
- }
391
-
392
- /** GateMux embeds no console documents, so there is no host-only session to publish. */
393
- async getPlatformSession() {
394
- return null
395
- }
396
-
397
- /**
398
- * Read the signed-in account ID without a request and without credentials.
399
- *
400
- * GateMux registers no device, so `deviceId` is omitted rather than invented.
401
- * The ID comes from the last successful profile read for this same grant; a
402
- * caller that reads it before that profile read gets the OS version alone.
403
- *
404
- * @returns the Node platform version, plus the account ID while a profile for the stored grant is cached.
405
- */
406
- async getDeviceIdentity() {
407
- const lifetime = this.detailsLifetime
408
- const grant = await this.readGrant(lifetime)
409
- const profile = grant !== null && this.lastProfile?.token === grant.accessToken
410
- ? this.lastProfile.profile
411
- : null
412
- return {
413
- ...(profile?.id == null ? {} : { userId: profile.id }),
414
- osVersion: `${platform()} ${release()}`,
415
- }
416
- }
417
-
418
- /**
419
- * Run one authorization attempt: PKCE, browser callback, code exchange, commit.
420
- * @param session - the attempt's cancellation signal and commit channel.
421
- * @param attempt - the attempt record whose phase drives the UI.
422
- */
423
- async run(session, attempt) {
424
- const webServer = this.ctx.get('webServer')
425
- if (webServer === undefined) throw new GateMuxAuthError('web server is unavailable', 'protocol')
426
- const verifier = randomBytes(32).toString('base64url')
427
- const state = randomBytes(32).toString('base64url')
428
- const challenge = createHash('sha256').update(verifier).digest('base64url')
429
- const redirectUri = `${attempt.origin}${CALLBACK_PATH}`
430
- const code = Promise.withResolvers()
431
- // A callback may arrive before initialization settles; observe the rejection immediately.
432
- void code.promise.catch(() => undefined)
433
- const deadline = new AbortController()
434
- const signal = AbortSignal.any([session.signal, deadline.signal])
435
- // Published to the client and re-checked after the exchange, so it has to be
436
- // the same instant the deadline aborts at rather than a second clock.
437
- const expiresAt = Date.now() + ATTEMPT_TIMEOUT_MS
438
- const timer = setTimeout(() =>{ deadline.abort() }, ATTEMPT_TIMEOUT_MS)
439
- const abort = () => { code.reject(new GateMuxAuthError('attempt expired', 'expired')) }
440
- signal.addEventListener('abort', abort, { once: true })
441
- try {
442
- attempt.disposeCallback = this.ctx.effect(() => webServer.register({
443
- kind: 'exact', path: CALLBACK_PATH, handler: (req, res) => {
444
- let url
445
- try { url = new URL(req.url ?? '/', 'http://127.0.0.1') }
446
- catch { res.writeHead(400, { 'cache-control': 'no-store' }).end(); return }
447
- const receivedCode = url.searchParams.get('code')
448
- const receivedState = url.searchParams.get('state') ?? ''
449
- const validState = Buffer.byteLength(receivedState) === Buffer.byteLength(state)
450
- && timingSafeEqual(Buffer.from(receivedState), Buffer.from(state))
451
- if (req.method !== 'GET' || url.pathname !== CALLBACK_PATH || !validState || !receivedCode
452
- || url.searchParams.getAll('state').length !== 1 || url.searchParams.getAll('code').length !== 1) {
453
- res.writeHead(400, { 'cache-control': 'no-store' }).end(); return
454
- }
455
- if (signal.aborted || attempt.callback !== undefined || attempt.view.phase !== 'waiting-browser') {
456
- res.writeHead(410, { 'cache-control': 'no-store' }).end(); return
457
- }
458
- attempt.callback = res
459
- code.resolve(receivedCode)
460
- },
461
- }), 'gatemux-account: browser callback')
462
- signal.throwIfAborted()
463
- const authorizeUrl = new URL(this.authorizeEndpoint)
464
- authorizeUrl.searchParams.set('response_type', 'code')
465
- authorizeUrl.searchParams.set('client_id', this.clientId)
466
- authorizeUrl.searchParams.set('redirect_uri', redirectUri)
467
- authorizeUrl.searchParams.set('state', state)
468
- authorizeUrl.searchParams.set('scope', this.scopes.join(' '))
469
- authorizeUrl.searchParams.set('code_challenge', challenge)
470
- authorizeUrl.searchParams.set('code_challenge_method', 'S256')
471
- this.update(attempt, {
472
- phase: 'waiting-browser',
473
- authorizeUrl: authorizeUrl.href,
474
- expiresAt,
475
- })
476
- const receivedCode = await code.promise
477
- signal.throwIfAborted()
478
- this.update(attempt, { phase: 'exchanging' })
479
- const grant = await this.exchange(receivedCode, verifier, redirectUri, signal)
480
- if (Date.now() >= expiresAt) throw new GateMuxAuthError('attempt expired', 'expired')
481
- signal.throwIfAborted()
482
- this.update(attempt, { phase: 'committing' })
483
- try {
484
- await session.commit({ kind: 'grant', payload: grant })
485
- } catch {
486
- throw new GateMuxAuthError('stored grant could not be written', 'storage')
487
- }
488
- } catch (error) {
489
- if (deadline.signal.aborted) throw new GateMuxAuthError('attempt expired', 'expired')
490
- throw error
491
- } finally {
492
- clearTimeout(timer)
493
- signal.removeEventListener('abort', abort)
494
- // begin() settles the browser response and removes only this attempt's route.
495
- }
496
- }
497
-
498
- /**
499
- * Exchange an authorization code for tokens.
500
- *
501
- * `invalid_grant` is terminal: the server voids the code when the verifier
502
- * does not match, so retrying the same code can never succeed.
503
- */
504
- async exchange(code, verifier, redirectUri, signal) {
505
- const response = await this.postToken({
506
- grant_type: 'authorization_code',
507
- code,
508
- code_verifier: verifier,
509
- client_id: this.clientId,
510
- redirect_uri: redirectUri,
511
- }, signal)
512
- if (!response.ok) {
513
- // RFC 6749 §5.2 flat body. A reused `code`, a `code_verifier` that does not match
514
- // the challenge, and a `redirect_uri` that differs from the authorize request all
515
- // arrive as `invalid_grant`, so the server's own description is the only thing
516
- // that tells them apart — discarding it leaves nothing to act on.
517
- const code = tokenErrorCode(response.body)
518
- const description = typeof response.body?.error_description === 'string' ? response.body.error_description : undefined
519
- this.logger.warn('authorization code rejected', { status: response.status, errorCode: code ?? 'unknown', description })
520
- throw new GateMuxAuthError(`authorization code rejected (${response.status} ${code ?? 'unknown'})`, 'protocol')
521
- }
522
- return this.grantFrom(response.body)
523
- }
524
-
525
- /** Read the newest grant, refreshing it when it is close to expiry. */
526
- async freshGrant() {
527
- const grant = await this.readGrant(this.detailsLifetime)
528
- if (grant === null) return null
529
- if (grant.expiresAt - Date.now() > REFRESH_MARGIN_MS) return grant
530
- this.refreshInFlight ??= this.rotate().finally(() => { this.refreshInFlight = undefined })
531
- try {
532
- return await this.refreshInFlight
533
- } catch {
534
- return null
535
- }
536
- }
537
-
538
- /**
539
- * Rotate the refresh token exactly once at a time.
540
- *
541
- * Refreshes are serialized because GateMux voids the whole authorization
542
- * chain when a rotated refresh token reappears after a grace period.
543
- */
544
- async rotate() {
545
- const lifetime = this.detailsLifetime
546
- const grant = await this.readGrant(lifetime)
547
- if (grant === null) throw new GateMuxAuthError('signed out', 'expired')
548
- const response = await this.postToken({
549
- grant_type: 'refresh_token',
550
- refresh_token: grant.refreshToken,
551
- client_id: this.clientId,
552
- }, lifetime.signal)
553
- if (!response.ok) {
554
- const code = tokenErrorCode(response.body)
555
- // An unknown or expired refresh token means the authorization is gone.
556
- this.logger.warn('refresh rejected', { errorCode: code ?? 'unknown' })
557
- await this.expireCredential(grant.accessToken, lifetime)
558
- throw new GateMuxAuthError('refresh token rejected', 'expired')
559
- }
560
- const next = this.grantFrom(response.body)
561
- // Persist the rotated token before any other request can read the old one.
562
- await this.ctx.credentials.modifyRecord(KEY, (current) => Promise.resolve(
563
- current !== undefined && parseGrant(current.payload, this.issuer) !== null
564
- ? { kind: 'grant', payload: next }
565
- : undefined,
566
- ))
567
- return next
568
- }
569
-
570
- /** Build a validated grant from a token-endpoint response. */
571
- grantFrom(body) {
572
- if (typeof body !== 'object' || body === null) throw new GateMuxAuthError('token response was not an object', 'protocol')
573
- const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = body
574
- if (typeof accessToken !== 'string' || accessToken.length === 0) throw new GateMuxAuthError('token response carried no access token', 'protocol')
575
- if (typeof refreshToken !== 'string' || refreshToken.length === 0) throw new GateMuxAuthError('token response carried no refresh token', 'protocol')
576
- const seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600
577
- return { version: 1, accessToken, refreshToken, expiresAt: Date.now() + seconds * 1000, issuer: this.issuer }
578
- }
579
-
580
- /** Post one token-endpoint request and decode its RFC 6749 §5.2 envelope. */
581
- async postToken(fields, signal) {
582
- const response = await fetch(this.tokenEndpoint, {
583
- method: 'POST',
584
- headers: { 'content-type': 'application/json', accept: 'application/json' },
585
- body: JSON.stringify(fields),
586
- signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
587
- })
588
- let body
589
- try { body = await response.json() } catch { body = undefined }
590
- return { ok: response.ok, status: response.status, body }
591
- }
592
-
593
- /**
594
- * Call one account endpoint, expiring the credential on an authenticated rejection.
595
- * @returns the decoded body, or null when the credential was rejected.
596
- */
597
- async callApi(url, grant, lifetime, _client) {
598
- let response
599
- try {
600
- response = await fetch(url, {
601
- headers: { authorization: `Bearer ${grant.accessToken}`, accept: 'application/json' },
602
- signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
603
- })
604
- } catch {
605
- return null
606
- }
607
- let body
608
- try { body = await response.json() } catch { body = undefined }
609
- if (response.ok) return body
610
- // 401 credential_invalid means re-authorization; other codes are configuration problems.
611
- if (response.status === 401 || apiErrorCode(body) === 'credential_invalid') {
612
- if (this.detailsLifetime === lifetime) await this.expireCredential(grant.accessToken, lifetime)
613
- return null
614
- }
615
- return null
616
- }
617
-
618
- /** Read the profile identity, enriching it with the OpenID email when available. */
619
- async readIdentity(grant, lifetime, client) {
620
- const me = await this.callApi(this.accountEndpoint, grant, lifetime, client)
621
- if (me === null || typeof me !== 'object') return null
622
- const accountId = typeof me.account_id === 'string' && me.account_id.length > 0 ? me.account_id : null
623
- const masked = typeof me.email_masked === 'string' && me.email_masked.length > 0 ? me.email_masked : null
624
- let contact = masked
625
- if (contact === null) {
626
- const info = await this.callApi(this.userinfoEndpoint, grant, lifetime, client)
627
- if (typeof info === 'object' && info !== null && typeof info.email === 'string' && info.email.length > 0) {
628
- contact = info.email
629
- }
630
- }
631
- const id = accountId ?? (typeof me.user_id === 'string' && me.user_id.length > 0 ? me.user_id : null)
632
- if (id === null && contact === null) return null
633
- return { id, name: contact, contact, avatarUrl: null }
634
- }
635
-
636
- cacheProfile(token, profile) {
637
- const previous = this.lastProfile?.profile.id ?? null
638
- this.lastProfile = { token, profile }
639
- if (previous !== profile.id) this.changed()
640
- }
641
-
642
- async readGrant(lifetime) {
643
- if (this.closed) return null
644
- const record = await this.ctx.credentials.readRecord(KEY)
645
- if (record === undefined || lifetime.signal.aborted) return null
646
- if (record.kind !== 'grant') throw new GateMuxAuthError('stored record is not a grant', 'storage')
647
- const grant = parseGrant(record.payload, this.issuer)
648
- if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
649
- return grant
650
- }
651
-
652
- /** Delete the credential and notify consumers that the session ended. */
653
- async expireCredential(token, lifetime) {
654
- this.removing ??= (async () => {
655
- if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
656
- const record = await this.ctx.credentials.readRecord(KEY)
657
- if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState()
658
- const current = parseGrant(record.payload, this.issuer)
659
- if (current === null || current.accessToken !== token) return this.getState()
660
- await this.ctx.credentials.deleteRecord(KEY)
661
- this.ctx.emit('deepseek-account/session-expired')
662
- this.attempt = undefined
663
- this.ctx.emit('deepseek-account/signed-out')
664
- this.changed()
665
- return this.getState()
666
- })().finally(() => { this.removing = undefined })
667
- await this.removing
668
- }
669
-
670
- /** Revoke a refresh token, cascading to its access tokens; failures are not fatal. */
671
- revoke(refreshToken) {
672
- if (this.closed) return
673
- void fetch(this.revokeEndpoint, {
674
- method: 'POST',
675
- headers: { 'content-type': 'application/json', accept: 'application/json' },
676
- body: JSON.stringify({ token: refreshToken }),
677
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
678
- }).then(() => undefined, () => undefined)
679
- }
680
-
681
- invalidateDetails() {
682
- this.lastProfile = undefined
683
- this.detailsLifetime.abort()
684
- this.detailsLifetime = new AbortController()
685
- }
686
-
687
- changed() { for (const listener of this.listeners) listener() }
688
-
689
- update(attempt, value) {
690
- const { authorizeUrl: _url, ...rest } = attempt.view
691
- attempt.view = { ...rest, ...value }
692
- this.changed()
693
- }
694
-
695
-
696
- /**
697
- * Answer the browser tab the flow was authorized in with a short page. The
698
- * application opens that tab outside itself and never reads this response, so
699
- * the page exists only for the person looking at it; text is static, which is
700
- * why no script is needed and the policy can forbid every source.
701
- * @param attempt - in-flight attempt whose callback is being answered.
702
- * @param title - page title and heading.
703
- * @param message - what the person should do next.
704
- */
705
- writeCallbackPage(attempt, title, message) {
706
- attempt.callback?.writeHead(200, {
707
- 'content-type': 'text/html; charset=utf-8',
708
- 'cache-control': 'no-store',
709
- 'content-security-policy': `default-src 'none'; frame-ancestors 'none'`,
710
- 'referrer-policy': 'no-referrer',
711
- }).end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${title}</title>`
712
- + `<body><p>${message}</p></body></html>`)
713
- }
714
-
715
- /** Return the browser to the application after a failed exchange. */
716
- finishFailedCallback(attempt) {
717
- this.writeCallbackPage(attempt, 'Sign-in failed', attempt.loginSource === 'web'
718
- ? 'Sign-in failed. Close this tab and try again in the original tab.'
719
- : 'Sign-in failed. Close this tab and try again in DeepSeek Harness.')
720
- }
721
- }
722
-
723
- export default GateMuxAccount
package/llm.js DELETED
@@ -1,103 +0,0 @@
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 }