@gatemux/dsh-gatemux-login 1.0.2 → 1.0.4

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.
Files changed (4) hide show
  1. package/README.md +29 -5
  2. package/account.js +22 -0
  3. package/llm.js +158 -8
  4. package/package.json +18 -15
package/README.md CHANGED
@@ -12,7 +12,8 @@ composed profile:
12
12
  - Adds a GateMux account service that signs you in through GateMux's OAuth 2.0
13
13
  redirect flow (authorization code with PKCE `S256`).
14
14
  - Registers a model route, provider id `gatemux-account`, that authenticates every
15
- request with your own credential.
15
+ request with your own credential and advertises the models the endpoint reports
16
+ it serves.
16
17
  - Turns off the shipped vendor account route (`deepseek-account` and
17
18
  `llm-deepseek-account`) so there is one account provider instead of two.
18
19
 
@@ -22,8 +23,8 @@ keep another default.
22
23
 
23
24
  ## Requirements
24
25
 
25
- - DeepSeek Harness desktop app, `0.1.7.x`.
26
- The plugin declares `@deepseek-ai/dsh-*` peer dependencies pinned to that line.
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.
27
28
  A different DSH release is rejected at install time; installing anyway needs a
28
29
  version exemption, which risks crashes and data loss.
29
30
  - A GateMux account.
@@ -70,12 +71,35 @@ never auto-closes; you can close it once the app is signed in.
70
71
 
71
72
  ## Models
72
73
 
74
+ Once you are signed in, the route reads what the endpoint serves from
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
+
73
88
  | Model id | Context | Input |
74
89
  |---|---|---|
75
90
  | `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
76
91
  | `gatemux/deepseek-v4-pro` | 1,000,000 | text |
77
92
 
78
- Both are served from `https://rest.gatemux.ai/v1`.
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.
79
103
 
80
104
  ## Changing the default model
81
105
 
@@ -105,7 +129,7 @@ module or the patch is skipped.
105
129
  | `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
106
130
  | `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
131
  | `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. Contact the plugin operator. |
108
- | Install refused for peer dependencies | Your DSH version is outside `0.1.7.x`. |
132
+ | Install refused for peer dependencies | Your DSH version is outside `0.1.7.x` and `0.2.x`. |
109
133
 
110
134
  403 errors are reported as authorization faults, not retried: they are
111
135
  configuration problems on the application's side.
package/account.js CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
23
+ import { platform, release } from 'node:os'
23
24
  import { promises as streamPromises } from 'node:stream'
24
25
 
25
26
  import { Service } from '@deepseek-ai/cordis'
@@ -393,6 +394,27 @@ export class GateMuxAccount extends DeepSeekAccount {
393
394
  return null
394
395
  }
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
+
396
418
  /**
397
419
  * Run one authorization attempt: PKCE, browser callback, code exchange, commit.
398
420
  * @param session - the attempt's cancellation signal and commit channel.
package/llm.js CHANGED
@@ -9,9 +9,11 @@
9
9
  * already claims that route with the application's server key, and claiming it
10
10
  * twice would throw `DUPLICATE_ADAPTER` and break plugin loading.
11
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.
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.
15
17
  *
16
18
  * @module @gatemux/dsh-gatemux-login/llm
17
19
  */
@@ -27,6 +29,107 @@ export const PROVIDER = 'gatemux-account'
27
29
  export const name = 'gatemux-account'
28
30
  export const inject = ['llm']
29
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
+
30
133
  /**
31
134
  * Register the GateMux account-authenticated provider route.
32
135
  * @param ctx - Host context with the LLM registry injected.
@@ -34,9 +137,40 @@ export const inject = ['llm']
34
137
  */
35
138
  export function apply(ctx, config) {
36
139
  const logger = ctx.logger('gatemux-account')
37
- const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
140
+ const baseOptions = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
38
141
  // Validate the composed configuration now so a misconfiguration fails loudly at load.
39
- options()
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
+ }
40
174
 
41
175
  const resolveAuth = async (connection) => {
42
176
  const account = ctx.get('deepseekAccount')
@@ -82,15 +216,31 @@ export function apply(ctx, config) {
82
216
  resolveAuth,
83
217
  providerName: 'GateMux Account',
84
218
  discoverModels: async (provider) => {
85
- const connection = options()
219
+ const connection = baseOptions()
220
+ let auth
86
221
  try {
87
- await resolveAuth(connection)
222
+ auth = await resolveAuth(connection)
88
223
  } catch (error) {
89
224
  // With no signed-in user the route advertises nothing rather than failing discovery.
90
225
  if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return []
91
226
  throw error
92
227
  }
93
- return connection.models.map((model) => catalogModelInfo(provider, model))
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))
94
244
  },
95
245
  })
96
246
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@gatemux/dsh-gatemux-login",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
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
+ },
6
9
  "publishConfig": {
7
10
  "access": "public"
8
11
  },
@@ -29,22 +32,22 @@
29
32
  },
30
33
  "peerDependencies": {
31
34
  "@deepseek-ai/cordis": "^4.0.4",
32
- "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
33
- "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
34
- "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
35
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
36
- "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
37
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
38
- "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
35
+ "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2 || ^0.2.0-rc.2",
36
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.2",
37
+ "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2 || ^0.2.0-rc.2",
38
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.2",
39
+ "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2 || ^0.2.0-rc.2",
40
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2",
41
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2 || ^0.2.0-rc.2"
39
42
  },
40
43
  "devDependencies": {
41
44
  "@deepseek-ai/cordis": "^4.0.4",
42
- "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
43
- "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
44
- "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
45
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
46
- "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
47
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
48
- "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
45
+ "@deepseek-ai/dsh-authorization": "^0.1.7-rc.2 || ^0.2.0-rc.2",
46
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.2",
47
+ "@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2 || ^0.2.0-rc.2",
48
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.2",
49
+ "@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2 || ^0.2.0-rc.2",
50
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2 || ^0.2.0-rc.2"
49
52
  }
50
53
  }