@gatemux/dsh-gatemux-login 1.0.4 → 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 +56 -108
- package/cordis.patch.yml +11 -6
- package/package.json +7 -10
- package/account.js +0 -723
- package/llm.js +0 -253
package/README.md
CHANGED
|
@@ -4,33 +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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 and advertises the models the endpoint reports
|
|
16
|
-
it serves.
|
|
17
|
-
- Turns off the shipped vendor account route (`deepseek-account` and
|
|
18
|
-
`llm-deepseek-account`) so there is one account provider instead of two.
|
|
19
|
-
|
|
20
|
-
It also sets the default model to `gatemux/deepseek-v4-flash`. See
|
|
21
|
-
[Changing the default model](#changing-the-default-model) if you would rather
|
|
22
|
-
keep another default.
|
|
23
|
-
|
|
24
|
-
## Requirements
|
|
25
|
-
|
|
26
|
-
- DeepSeek Harness desktop app, `0.1.7.x` or `0.2.x`.
|
|
27
|
-
The plugin declares `@deepseek-ai/dsh-*` peer dependencies across both lines.
|
|
28
|
-
A different DSH release is rejected at install time; installing anyway needs a
|
|
29
|
-
version exemption, which risks crashes and data loss.
|
|
30
|
-
- A GateMux account.
|
|
31
|
-
|
|
32
|
-
No install scripts are run: installation does not execute any build step, and the
|
|
33
|
-
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.
|
|
34
13
|
|
|
35
14
|
## Install
|
|
36
15
|
|
|
@@ -40,11 +19,9 @@ In the desktop app:
|
|
|
40
19
|
2. Click **添加插件** (Add plugin).
|
|
41
20
|
3. Enter `@gatemux/dsh-gatemux-login` and click **安装** (Install).
|
|
42
21
|
4. When it reports **已安装** (Installed), click **立即启用** (Enable now).
|
|
43
|
-
5.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
The plugin manager writes the dependency and adds the bundle to the profile's
|
|
47
|
-
`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.
|
|
48
25
|
|
|
49
26
|
From the CLI, for a non-desktop profile:
|
|
50
27
|
|
|
@@ -55,70 +32,35 @@ dsh plugin --profile <name> add @gatemux/dsh-gatemux-login
|
|
|
55
32
|
The `desktop` profile is managed exclusively by the Electron application and
|
|
56
33
|
cannot be installed into from the CLI.
|
|
57
34
|
|
|
58
|
-
##
|
|
35
|
+
## What it does
|
|
59
36
|
|
|
60
|
-
|
|
37
|
+
Installing this bundle changes three things about the composed profile:
|
|
61
38
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
66
46
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
`https://rest.gatemux.ai/v1/models` and advertises every entry whose `protocols`
|
|
76
|
-
include `anthropic-messages`. An entry served over any other protocol is left
|
|
77
|
-
out, because every request this route makes speaks the Messages protocol: a
|
|
78
|
-
model that speaks only `openai-completions` would fail on every request, so
|
|
79
|
-
offering it in the model selector would be offering a choice that cannot work.
|
|
80
|
-
|
|
81
|
-
Context window, output cap, display name, and image input come from the listing.
|
|
82
|
-
|
|
83
|
-
### Fallback catalog
|
|
84
|
-
|
|
85
|
-
The bundle's patch layer also declares two models, which the route advertises
|
|
86
|
-
before you sign in and keeps when a listing read fails:
|
|
87
|
-
|
|
88
|
-
| Model id | Context | Input |
|
|
89
|
-
|---|---|---|
|
|
90
|
-
| `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
|
|
91
|
-
| `gatemux/deepseek-v4-pro` | 1,000,000 | text |
|
|
92
|
-
|
|
93
|
-
A failed read is logged as a warning and leaves the last catalog that was read
|
|
94
|
-
successfully in force, so a listing outage never empties the model selector or
|
|
95
|
-
drops the provider from it. Before the first successful read, the declared
|
|
96
|
-
catalog above is what the route advertises.
|
|
97
|
-
|
|
98
|
-
For a model the endpoint also lists, every field the declaration states wins and
|
|
99
|
-
the endpoint fills the rest. `gatemux/deepseek-v4-flash` therefore keeps the
|
|
100
|
-
declared 1,000,000-token context window and image input while taking its output
|
|
101
|
-
cap from the endpoint. A declared model the endpoint does not list is still
|
|
102
|
-
advertised.
|
|
103
|
-
|
|
104
|
-
## Changing the default model
|
|
105
|
-
|
|
106
|
-
The bundle sets the default model in its own patch layer. A profile's own
|
|
107
|
-
`cordis.patch.yml` is applied **after** every bundle layer, so it wins. Selecting
|
|
108
|
-
another model in the app writes exactly that override for you; to set it by hand,
|
|
109
|
-
add to `$DSH_HOME/profiles/<name>/cordis.patch.yml`:
|
|
110
|
-
|
|
111
|
-
```yaml
|
|
112
|
-
- id: agent-default-model
|
|
113
|
-
name: "@deepseek-ai/dsh-agent-default-model"
|
|
114
|
-
config:
|
|
115
|
-
provider: some-other-provider
|
|
116
|
-
model: some-other-model
|
|
117
|
-
reasoningEffort: high
|
|
118
|
-
```
|
|
47
|
+
It also sets the default model to `gatemux/deepseek-v4-flash`.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
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`**.
|
|
119
55
|
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
58
|
+
|
|
59
|
+
## Sign in
|
|
60
|
+
|
|
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.
|
|
122
64
|
|
|
123
65
|
## Troubleshooting
|
|
124
66
|
|
|
@@ -127,24 +69,30 @@ module or the patch is skipped.
|
|
|
127
69
|
| `ACCOUNT_SIGN_IN_REQUIRED` | No credential yet. Sign in. |
|
|
128
70
|
| `ACCOUNT_TOKEN_INVALID` | The credential was revoked, expired, or suspended. It has been discarded; sign in again. |
|
|
129
71
|
| `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
|
|
130
|
-
| `ip_not_allowed` (HTTP 403) | Your network is not in the
|
|
131
|
-
| `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request.
|
|
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. |
|
|
132
74
|
| Install refused for peer dependencies | Your DSH version is outside `0.1.7.x` and `0.2.x`. |
|
|
133
75
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
148
96
|
|
|
149
97
|
## License
|
|
150
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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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,23 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gatemux/dsh-gatemux-login",
|
|
3
|
-
"version": "1.0
|
|
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.",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"test": "node test/discovery.spec.mjs"
|
|
8
|
-
},
|
|
5
|
+
"description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance. International (gatemux.ai) bundle.",
|
|
9
6
|
"publishConfig": {
|
|
10
7
|
"access": "public"
|
|
11
8
|
},
|
|
12
9
|
"license": "MIT",
|
|
13
10
|
"files": [
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"cordis.patch.yml"
|
|
11
|
+
"cordis.patch.yml",
|
|
12
|
+
"README.md"
|
|
17
13
|
],
|
|
18
14
|
"exports": {
|
|
19
|
-
"./account": "./account.js",
|
|
20
|
-
"./llm": "./llm.js",
|
|
21
15
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
22
16
|
"./package.json": "./package.json"
|
|
23
17
|
},
|
|
@@ -30,6 +24,9 @@
|
|
|
30
24
|
"title": "GateMux Sign-In",
|
|
31
25
|
"description": "Signs in with GateMux and bills model requests to the user's own GateMux balance."
|
|
32
26
|
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@gatemux/dsh-gatemux-login-core": "^1.0.0"
|
|
29
|
+
},
|
|
33
30
|
"peerDependencies": {
|
|
34
31
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
35
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,253 +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, retry policy, and the fallback catalog come from this entry's
|
|
13
|
-
* `config` in `cordis.patch.yml`, so the composition remains the single source
|
|
14
|
-
* of truth for deployment choices. Which models the route serves is read from
|
|
15
|
-
* the endpoint's `/v1/models` listing instead; see `mergeCatalog` for how the
|
|
16
|
-
* two combine.
|
|
17
|
-
*
|
|
18
|
-
* @module @gatemux/dsh-gatemux-login/llm
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { ACCOUNT_QUOTA_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
|
22
|
-
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
23
|
-
import { Config, catalogModelInfo, plainOptions, registerDeepSeekProvider, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
|
24
|
-
|
|
25
|
-
export { Config }
|
|
26
|
-
|
|
27
|
-
/** Route owned by this plugin; distinct from the pi-ai `gatemux` route. */
|
|
28
|
-
export const PROVIDER = 'gatemux-account'
|
|
29
|
-
export const name = 'gatemux-account'
|
|
30
|
-
export const inject = ['llm']
|
|
31
|
-
|
|
32
|
-
/** Listing value a model must carry to be reachable through this route's Messages transport. */
|
|
33
|
-
const MESSAGES_PROTOCOL = 'anthropic-messages'
|
|
34
|
-
|
|
35
|
-
/** Protocol version this transport sends on every request, the model listing included. */
|
|
36
|
-
const ANTHROPIC_VERSION = '2023-06-01'
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Anthropic's listing path, read from a Messages base URL.
|
|
40
|
-
*
|
|
41
|
-
* The listing lives at `{root}/v1/models`, where the root is the base without a
|
|
42
|
-
* trailing slash and without one trailing `/v1` segment; GateMux publishes both
|
|
43
|
-
* spellings of the same root. `messagesApiRoot` implements this normalization
|
|
44
|
-
* for the transport but is not exported from the package, so the rule is
|
|
45
|
-
* restated here rather than pulled in through a deep import.
|
|
46
|
-
*
|
|
47
|
-
* @param baseURL - the route's configured Messages base URL.
|
|
48
|
-
* @returns the absolute model listing URL.
|
|
49
|
-
*/
|
|
50
|
-
function listingUrl(baseURL) {
|
|
51
|
-
const base = baseURL.replace(/\/+$/u, '')
|
|
52
|
-
return `${base.endsWith('/v1') ? base.slice(0, -3) : base}/v1/models`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Read one positive catalog number, treating a missing, null, or unusable value as absent.
|
|
57
|
-
* @param value - candidate field from a listing entry.
|
|
58
|
-
* @returns the number, or `undefined` when the endpoint did not state a usable one.
|
|
59
|
-
*/
|
|
60
|
-
function positiveInteger(value) {
|
|
61
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Read one non-empty catalog string, treating anything else as absent.
|
|
66
|
-
* @param value - candidate field from a listing entry.
|
|
67
|
-
* @returns the string, or `undefined` when the endpoint did not state a usable one.
|
|
68
|
-
*/
|
|
69
|
-
function nonEmptyString(value) {
|
|
70
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Project one listing entry into a catalog entry, or drop it as unserved.
|
|
75
|
-
*
|
|
76
|
-
* `protocols` is the criterion, not `category`: a chat model is not
|
|
77
|
-
* necessarily reachable through `/v1/messages`. GateMux reports `kimi-k3` as a
|
|
78
|
-
* chat model that speaks only `openai-completions`, and advertising it here
|
|
79
|
-
* would offer the user a model every request to this route fails on.
|
|
80
|
-
*
|
|
81
|
-
* @param entry - one element of the listing's `data` array.
|
|
82
|
-
* @returns the catalog entry, or `undefined` when this route cannot serve it.
|
|
83
|
-
*/
|
|
84
|
-
function servedModel(entry) {
|
|
85
|
-
if (entry === null || typeof entry !== 'object') return undefined
|
|
86
|
-
const id = nonEmptyString(entry.id)
|
|
87
|
-
const protocols = Array.isArray(entry.protocols) ? entry.protocols : []
|
|
88
|
-
if (id === undefined || !protocols.includes(MESSAGES_PROTOCOL)) return undefined
|
|
89
|
-
const name = nonEmptyString(entry.name)
|
|
90
|
-
const contextWindow = positiveInteger(entry.contextWindow)
|
|
91
|
-
const maxTokens = positiveInteger(entry.maxTokens)
|
|
92
|
-
// `capabilities` describes the model; `inputModalities` describes what this
|
|
93
|
-
// transport may send. Only vision is projected, because a model advertising
|
|
94
|
-
// image input that the endpoint rejects would fail on every later turn.
|
|
95
|
-
const vision = Array.isArray(entry.capabilities) && entry.capabilities.includes('vision')
|
|
96
|
-
return {
|
|
97
|
-
id,
|
|
98
|
-
...name === undefined ? {} : { name },
|
|
99
|
-
...contextWindow === undefined ? {} : { contextWindow },
|
|
100
|
-
...maxTokens === undefined ? {} : { maxTokens },
|
|
101
|
-
inputModalities: vision ? ['text', 'image'] : ['text'],
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Combine the endpoint's served models with the declared catalog.
|
|
107
|
-
*
|
|
108
|
-
* A declared entry keeps every field it states, so the deployment's reviewed
|
|
109
|
-
* values win over fetched ones and a profile can still pin a model the endpoint
|
|
110
|
-
* reports differently; the endpoint fills only what the declaration leaves out.
|
|
111
|
-
* Wire-behavior fields — `systemPromptUpdate`, `toolUpdate`, the image budgets —
|
|
112
|
-
* exist only in a declaration, because they describe how to talk to the
|
|
113
|
-
* endpoint rather than a property of the model.
|
|
114
|
-
*
|
|
115
|
-
* Declared models the endpoint does not list are retained: an endpoint that
|
|
116
|
-
* omits an entry, or drops one, must not empty a route the operator configured.
|
|
117
|
-
*
|
|
118
|
-
* @param declared - catalog from this entry's `config`.
|
|
119
|
-
* @param remote - models the endpoint reported as served.
|
|
120
|
-
* @returns the effective catalog: remote entries in listing order, then declared
|
|
121
|
-
* entries the endpoint did not list in their own order.
|
|
122
|
-
*/
|
|
123
|
-
function mergeCatalog(declared, remote) {
|
|
124
|
-
const declaredById = new Map(declared.map(model => [model.id, model]))
|
|
125
|
-
const merged = remote.map((model) => {
|
|
126
|
-
const declaration = declaredById.get(model.id)
|
|
127
|
-
return declaration === undefined ? model : { ...model, ...declaration }
|
|
128
|
-
})
|
|
129
|
-
const listed = new Set(remote.map(model => model.id))
|
|
130
|
-
return [...merged, ...declared.filter(model => !listed.has(model.id))]
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Register the GateMux account-authenticated provider route.
|
|
135
|
-
* @param ctx - Host context with the LLM registry injected.
|
|
136
|
-
* @param config - this entry's Messages protocol configuration.
|
|
137
|
-
*/
|
|
138
|
-
export function apply(ctx, config) {
|
|
139
|
-
const logger = ctx.logger('gatemux-account')
|
|
140
|
-
const baseOptions = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
|
|
141
|
-
// Validate the composed configuration now so a misconfiguration fails loudly at load.
|
|
142
|
-
baseOptions()
|
|
143
|
-
|
|
144
|
-
// The catalog the endpoint last reported. Both the advertised model list and
|
|
145
|
-
// every later capability resolution read it, because `modelInfo` derives a
|
|
146
|
-
// model's context window and input modalities from `connection.models`: a
|
|
147
|
-
// model that only reached the listing would be resolved as text-only with the
|
|
148
|
-
// connection's fallback context window. Null until one read succeeds, which
|
|
149
|
-
// leaves the declared catalog in force before the first sign-in.
|
|
150
|
-
let discovered = null
|
|
151
|
-
const options = () => {
|
|
152
|
-
const base = baseOptions()
|
|
153
|
-
return discovered === null ? base : { ...base, models: mergeCatalog(base.models, discovered) }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Read the served models from the endpoint's listing.
|
|
158
|
-
* @param connection - resolved route configuration carrying the endpoint base.
|
|
159
|
-
* @param headers - request authentication for the signed-in user.
|
|
160
|
-
* @returns the catalog entries this route can serve, in listing order.
|
|
161
|
-
*/
|
|
162
|
-
const loadCatalog = async (connection, headers) => {
|
|
163
|
-
const url = listingUrl(connection.baseURL)
|
|
164
|
-
const response = await fetch(url, {
|
|
165
|
-
method: 'GET',
|
|
166
|
-
headers: { ...headers, 'accept': 'application/json', 'anthropic-version': ANTHROPIC_VERSION },
|
|
167
|
-
})
|
|
168
|
-
if (!response.ok) throw new Error(`${url} answered ${response.status}`)
|
|
169
|
-
const body = await response.json()
|
|
170
|
-
const entries = body !== null && typeof body === 'object' && Array.isArray(body.data) ? body.data : undefined
|
|
171
|
-
if (entries === undefined) throw new Error(`${url} did not answer with a model list`)
|
|
172
|
-
return entries.map(servedModel).filter(model => model !== undefined)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const resolveAuth = async (connection) => {
|
|
176
|
-
const account = ctx.get('deepseekAccount')
|
|
177
|
-
const token = await account?.resolveToken(connection.baseURL)
|
|
178
|
-
if (token === undefined) {
|
|
179
|
-
throw new LlmError(
|
|
180
|
-
'Sign in with GateMux to use the GateMux model route. Model requests are billed to your own GateMux balance.',
|
|
181
|
-
'ACCOUNT_SIGN_IN_REQUIRED',
|
|
182
|
-
)
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
// GateMux accepts `Authorization: Bearer`; it rejects the DeepSeek-only `x-dsh-auth-token` header.
|
|
186
|
-
headers: { authorization: `Bearer ${token}` },
|
|
187
|
-
onRequestError: async (error) => {
|
|
188
|
-
if (!(error instanceof LlmError)) return error
|
|
189
|
-
const status = error.failure.status
|
|
190
|
-
// 402 insufficient_balance and an exhausted quota both mean the user must top up.
|
|
191
|
-
if (error.code === QUOTA_EXCEEDED_CODE) {
|
|
192
|
-
return new LlmError(error.message, ACCOUNT_QUOTA_EXCEEDED_CODE, { ...error.failure, cause: error })
|
|
193
|
-
}
|
|
194
|
-
// 403 scope_denied / model_not_allowed / ip_not_allowed stay AUTH: they are
|
|
195
|
-
// configuration faults that re-authorizing the same way would not fix.
|
|
196
|
-
if (status !== 401) return error
|
|
197
|
-
// 401 credential_invalid: the credential is revoked, expired, or suspended.
|
|
198
|
-
// Re-authorization is required, so the stored credential is dropped.
|
|
199
|
-
const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error })
|
|
200
|
-
try {
|
|
201
|
-
await account?.rejectToken(token)
|
|
202
|
-
} catch (failure) {
|
|
203
|
-
logger.warn('credential removal after rejection failed', failure)
|
|
204
|
-
}
|
|
205
|
-
return rejected
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
ctx.llm.registerConfigurableProviders([
|
|
211
|
-
{ provider: PROVIDER, displayName: 'GateMux Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] },
|
|
212
|
-
])
|
|
213
|
-
|
|
214
|
-
registerDeepSeekProvider(ctx, PROVIDER, {
|
|
215
|
-
options,
|
|
216
|
-
resolveAuth,
|
|
217
|
-
providerName: 'GateMux Account',
|
|
218
|
-
discoverModels: async (provider) => {
|
|
219
|
-
const connection = baseOptions()
|
|
220
|
-
let auth
|
|
221
|
-
try {
|
|
222
|
-
auth = await resolveAuth(connection)
|
|
223
|
-
} catch (error) {
|
|
224
|
-
// With no signed-in user the route advertises nothing rather than failing discovery.
|
|
225
|
-
if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return []
|
|
226
|
-
throw error
|
|
227
|
-
}
|
|
228
|
-
try {
|
|
229
|
-
discovered = await loadCatalog(connection, auth.headers)
|
|
230
|
-
} catch (error) {
|
|
231
|
-
// The listing is an auxiliary surface. A failed read keeps the catalog
|
|
232
|
-
// that is already in force — the declared one before the first
|
|
233
|
-
// success, the last reported one after — rather than emptying the
|
|
234
|
-
// route's model list and dropping the provider from the selector. A
|
|
235
|
-
// credential this endpoint rejects is still classified where it is
|
|
236
|
-
// acted on, by `onRequestError` on the model request itself.
|
|
237
|
-
logger.warn(
|
|
238
|
-
`GateMux model listing read failed at ${listingUrl(connection.baseURL)}; `
|
|
239
|
-
+ `keeping the ${discovered === null ? 'declared' : 'last reported'} catalog`,
|
|
240
|
-
error,
|
|
241
|
-
)
|
|
242
|
-
}
|
|
243
|
-
return options().models.map((model) => catalogModelInfo(provider, model))
|
|
244
|
-
},
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// The loader normalizes a module with `exports.default ?? exports` and then reads
|
|
249
|
-
// `inject` and `Config` off that value, so named exports beside a default are
|
|
250
|
-
// discarded. A bare `apply` default therefore has no `inject` and `ctx.llm` throws
|
|
251
|
-
// "cannot get property \"llm\" without inject"; the plugin object has to be the
|
|
252
|
-
// default export itself.
|
|
253
|
-
export default { name, inject, Config, apply }
|