dsh-llm-adapters 1.2.0 → 1.2.2

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.
@@ -39,12 +39,12 @@ export interface RaccoonAccountRecord {
39
39
  }
40
40
 
41
41
  /**
42
- * One Trae web login.
42
+ * One Trae login.
43
43
  *
44
- * `accessToken` is the `Cloud-IDE-JWT` the website itself uses. `deviceId` and
45
- * `machineId` are the fingerprints captured from that web session — this
46
- * plugin never reads a desktop install — and are replayed on chat, quota and
47
- * check-in requests.
44
+ * `accessToken` is the `Cloud-IDE-JWT` Trae's own clients use. `deviceId` and
45
+ * `machineId` are replayed on chat, quota and check-in requests. A web login
46
+ * captures them from its browser session; a `client` login copies the desktop
47
+ * client's own values, which is the only source the check-in route accepts.
48
48
  */
49
49
  export interface TraeAccountRecord {
50
50
  accessToken: string
@@ -54,6 +54,8 @@ export interface TraeAccountRecord {
54
54
  userId?: string
55
55
  name?: string
56
56
  loginAt: number
57
+ /** Where the token came from; absent means the plugin's browser login. */
58
+ source?: 'web' | 'client'
57
59
  /** The `Cloud-IDE-JWT` expiry (about 8 hours); absent when the JWT carries none. */
58
60
  expiresAt?: number
59
61
  /**
@@ -91,12 +93,19 @@ export interface CodeArtsAccountRecord {
91
93
  d: string
92
94
  }
93
95
  userName?: string
96
+ /**
97
+ * The `x-ot-session-id` this account chats under: one stable value instead of
98
+ * one per request, because the gateway counts concurrent sessions by it.
99
+ *
100
+ * A record written before the field existed derives the same value from the
101
+ * refresh token's profile, so an existing sign-in needs no new login.
102
+ */
103
+ otSessionId?: string
94
104
  /**
95
105
  * Models the account answered `InferHub.4004.200` for.
96
106
  *
97
- * The verdict is worth persisting: asking with `maas_type: benefit` spends
98
- * one of the three chat slots the plan allows per minute, even though the
99
- * call cannot succeed.
107
+ * Worth persisting: the account has no allowance pack for them, so without the
108
+ * verdict every call would pay for a request that cannot succeed.
100
109
  */
101
110
  noBenefit?: string[]
102
111
  loginAt: number
@@ -138,9 +147,11 @@ function traeRecord(value: unknown, region: TraeRegion): TraeAccountRecord | und
138
147
  const accessToken = text(raw?.accessToken)
139
148
  if (raw === undefined || accessToken === undefined) return undefined
140
149
  const deviceId = text(raw.deviceId)
150
+ const source = raw.source === 'web' || raw.source === 'client' ? raw.source : undefined
141
151
  return {
142
152
  accessToken,
143
153
  region,
154
+ ...source === undefined ? {} : { source },
144
155
  deviceId: deviceId ?? `web-${randomBytes(8).toString('hex')}`,
145
156
  machineId: text(raw.machineId) ?? `web-${randomBytes(16).toString('hex')}`,
146
157
  loginAt: number(raw.loginAt) ?? Date.now(),
@@ -177,6 +188,7 @@ function codeartsRecord(value: unknown): CodeArtsAccountRecord | undefined {
177
188
  ...text(raw.codeVerifier) === undefined ? {} : { codeVerifier: text(raw.codeVerifier)! },
178
189
  ...key === undefined ? {} : { dpopPrivateJwk: key },
179
190
  ...text(raw.userName) === undefined ? {} : { userName: text(raw.userName)! },
191
+ ...text(raw.otSessionId) === undefined ? {} : { otSessionId: text(raw.otSessionId)! },
180
192
  ...noBenefit.length === 0 ? {} : { noBenefit },
181
193
  }
182
194
  }
package/src/adapter.ts CHANGED
@@ -22,7 +22,7 @@ import { LoomyProvider } from './loomy.ts'
22
22
  import { RaccoonProvider } from './raccoon.ts'
23
23
  import { TraeProvider } from './trae.ts'
24
24
  import type { AuthStatus } from './auth.ts'
25
- import type { CheckinStatus, ProviderStatus, QuotaSnapshot } from './types.ts'
25
+ import type { CheckinStatus, ClientLoginStatus, ProviderStatus, QuotaSnapshot } from './types.ts'
26
26
  import type { ReadImage } from './wire.ts'
27
27
 
28
28
  export interface AdapterOptions {
@@ -41,7 +41,7 @@ export interface AdapterOptions {
41
41
  *
42
42
  * Declared as an interface rather than a union so a new provider only has to
43
43
  * implement the behaviour, not edit the adapter's type list. The optional
44
- * check-in methods exist only on Trae.
44
+ * client-login and check-in methods exist only on Trae.
45
45
  */
46
46
  export interface ProviderClient {
47
47
  status(): Promise<AuthStatus>
@@ -64,6 +64,10 @@ export interface ProviderClient {
64
64
  invalidateModels(): void
65
65
  invalidateQuota(): void
66
66
  close(): void
67
+ /** Trae only: the local desktop client's current sign-in state. */
68
+ localClientLogin?(): Promise<ClientLoginStatus>
69
+ /** Trae only: copy that state into the plugin's own account store. */
70
+ importClientLogin?(): Promise<void>
67
71
  checkinStatus?(): Promise<CheckinStatus>
68
72
  claimCheckin?(): Promise<CheckinStatus>
69
73
  }
@@ -154,6 +158,17 @@ export abstract class LocalAdapter extends LlmAdapter {
154
158
  return result
155
159
  }
156
160
 
161
+ /** Import a local client login and return the refreshed account reading. */
162
+ async importClientLogin(): Promise<ProviderStatus> {
163
+ if (this.client.importClientLogin === undefined) {
164
+ throw new Error('该供应商不支持复用本机客户端登录')
165
+ }
166
+ await this.client.importClientLogin()
167
+ this.invalidateCatalog()
168
+ this.invalidateUsage()
169
+ return this.usageInfo(true)
170
+ }
171
+
157
172
  invalidateCatalog(): void {
158
173
  this.client.invalidateModels()
159
174
  }
@@ -165,7 +180,20 @@ export abstract class LocalAdapter extends LlmAdapter {
165
180
  /** Login state plus quota, shaped for the settings page. */
166
181
  async usageInfo(fresh = false): Promise<ProviderStatus> {
167
182
  const auth = await this.client.status()
168
- const base: ProviderStatus = { brand: this.brand, ...auth }
183
+ // Detection is part of the reading even while signed out: that is what
184
+ // offers the second sign-in method. Only Trae implements it.
185
+ const clientLogin = this.client.localClientLogin === undefined
186
+ ? undefined
187
+ : await this.client.localClientLogin().catch(error => ({
188
+ available: false,
189
+ signedIn: false,
190
+ error: error instanceof Error ? error.message : String(error),
191
+ }))
192
+ const base: ProviderStatus = {
193
+ brand: this.brand,
194
+ ...auth,
195
+ ...clientLogin === undefined ? {} : { clientLogin },
196
+ }
169
197
  // Trae's check-in state is part of the account reading; the other two
170
198
  // providers do not implement it, so this costs them nothing.
171
199
  const checkin = this.client.checkinStatus === undefined
package/src/api.ts CHANGED
@@ -184,8 +184,20 @@ async function handleAccount(req: IncomingMessage, res: ServerResponse, adapters
184
184
  return
185
185
  }
186
186
 
187
- // Trae and CodeArts have a daily reward; the other adapters reject the action,
188
- // so the page can keep one button that simply reports the refusal.
187
+ // Trae's second sign-in method: the desktop client must already be logged in.
188
+ // The response is an ordinary account reading, so it never carries the token.
189
+ if (action === 'client-login') {
190
+ try {
191
+ send(res, 200, await adapter.importClientLogin())
192
+ } catch (error) {
193
+ send(res, 400, { error: error instanceof Error ? error.message : String(error) })
194
+ }
195
+ return
196
+ }
197
+
198
+ // Trae (client login only) and CodeArts have a daily reward; the other
199
+ // adapters reject the action, so the page can keep one button that simply
200
+ // reports the refusal.
189
201
  if (action === 'checkin') {
190
202
  try {
191
203
  send(res, 200, { brand, checkin: await adapter.claimCheckin() })
@@ -195,7 +207,7 @@ async function handleAccount(req: IncomingMessage, res: ServerResponse, adapters
195
207
  return
196
208
  }
197
209
 
198
- send(res, 400, { error: 'action 只接受 browser、browser-cancel、checkin 或 logout' })
210
+ send(res, 400, { error: 'action 只接受 browser、browser-cancel、client-login、checkin 或 logout' })
199
211
  }
200
212
 
201
213
  export async function handleApi(
package/src/auth.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Credential state for every provider.
3
3
  *
4
- * Sign-in happens in the provider's own web page, so credentials exist in
5
- * exactly one place: the plugin's own account store. No desktop client file is
6
- * discovered, read, or written, and no token leaves this module in errors or
7
- * display fields.
4
+ * Sign-in happens in the provider's own web page. Trae additionally offers an
5
+ * explicit, read-only import of the local desktop client's login state; nothing
6
+ * is ever written back to that client. Either way credentials end up in exactly
7
+ * one place, the plugin's own account store, and no token leaves this module in
8
+ * errors or display fields.
8
9
  *
9
10
  * @module dsh-llm-adapters/auth
10
11
  */
@@ -13,7 +14,7 @@ import type {
13
14
  AccountStore, CodeArtsAccountRecord, LoomyAccountRecord, RaccoonAccountRecord, TraeAccountRecord,
14
15
  } from './account-store.ts'
15
16
  import type { TraeRegion } from './brand.ts'
16
- import { RefreshTokenExpiredError, exchangeRefreshToken, keyPairFromStoredJwk, readUserProfile } from './codearts-oauth.ts'
17
+ import { RefreshTokenExpiredError, exchangeRefreshToken, keyPairFromStoredJwk, otSessionIdOf, readUserProfile } from './codearts-oauth.ts'
17
18
  import type { CodeArtsCredential } from './codearts-oauth.ts'
18
19
  import { responseError } from './http.ts'
19
20
  import { TRAE_REGION_PROFILES, TRAE_WEB_USER_AGENT } from './trae-endpoints.ts'
@@ -34,6 +35,8 @@ export interface AuthStatus {
34
35
  possiblyExpired?: boolean
35
36
  account?: LocalAccount
36
37
  error?: string
38
+ /** Trae only: where the token came from. */
39
+ loginSource?: 'web' | 'client'
37
40
  }
38
41
 
39
42
  /** Expiry margin for Raccoon's JWT, so a call does not start on a dead token. */
@@ -111,6 +114,13 @@ export interface JwtFacts {
111
114
  phone?: string
112
115
  ownerType?: string
113
116
  sessionId?: string
117
+ /**
118
+ * The `sub` claim, which is what an account is called on the Raccoon gateway.
119
+ *
120
+ * Unlike the token itself it survives a re-sign, so it is the seed a stable
121
+ * per-account session id is derived from.
122
+ */
123
+ subject?: string
114
124
  }
115
125
 
116
126
  /** Normalize seconds, milliseconds, and ISO strings to epoch milliseconds. */
@@ -142,6 +152,7 @@ export function decodeJwtFacts(token: string): JwtFacts {
142
152
  ...typeof record.phone === 'string' ? { phone: record.phone } : {},
143
153
  ...typeof record.owner_type === 'string' ? { ownerType: record.owner_type } : {},
144
154
  ...typeof record.sid === 'string' ? { sessionId: record.sid } : {},
155
+ ...typeof record.sub === 'string' ? { subject: record.sub } : {},
145
156
  }
146
157
  } catch {
147
158
  return {}
@@ -261,12 +272,13 @@ function mergeSetCookies(current: string, headers: readonly string[]): string {
261
272
  }
262
273
 
263
274
  /**
264
- * Trae's web login, one per region.
275
+ * Trae's login, one per region.
265
276
  *
266
- * The `Cloud-IDE-JWT` the website issues lives about eight hours, but the
267
- * website session behind it lives about fourteen days. The cookies captured at
268
- * sign-in are replayed against `GetUserToken` to renew the short token silently;
269
- * only when the session itself dies does the user have to sign in again.
277
+ * A web login stores the `Cloud-IDE-JWT` from the website plus the cookies
278
+ * behind it, so the short token can be renewed silently until the website
279
+ * session dies. A client login stores the token the desktop client already
280
+ * uses; that token has no website session, and the client itself refreshes it.
281
+ * Only the client source carries the device id the check-in route accepts.
270
282
  */
271
283
  export class TraeAuth {
272
284
  private readonly inflight = new Map<TraeRegion, Promise<string>>()
@@ -286,20 +298,41 @@ export class TraeAuth {
286
298
  const region = this.region()
287
299
  const stored = await this.store.trae(region)
288
300
  if (stored === undefined) {
289
- return { signedIn: false, error: `${traeLabel(region)}尚未登录。请在设置页点「打开浏览器登录」。` }
301
+ return {
302
+ signedIn: false,
303
+ error: `${traeLabel(region)}尚未登录。可以点「打开浏览器登录」,或复用本机已登录的 Trae 客户端。`,
304
+ }
290
305
  }
306
+ const source = stored.source ?? 'web'
291
307
  const now = Date.now()
292
308
  const { token, session } = traeExpiries(stored)
293
309
  const tokenExpired = token !== undefined && token <= now + TRAE_EXPIRY_MARGIN_MS
310
+ const account = traeAccount(stored)
311
+ if (source === 'client') {
312
+ // The client owns the refresh loop; when its token dies the user signs in
313
+ // there again and imports the new state.
314
+ const signedIn = !tokenExpired
315
+ return {
316
+ signedIn,
317
+ loginSource: 'client',
318
+ loginAt: stored.loginAt,
319
+ ...token === undefined ? {} : { expiresAt: token },
320
+ ...!signedIn ? { possiblyExpired: true } : {},
321
+ ...Object.keys(account).length === 0 ? {} : { account },
322
+ ...!signedIn
323
+ ? { error: `${traeLabel(region)}客户端登录令牌已过期,请先在 Trae 客户端重新登录,再点「复用客户端登录态」。` }
324
+ : {},
325
+ }
326
+ }
294
327
  const sessionExpired = session !== undefined && session <= now + TRAE_EXPIRY_MARGIN_MS
295
328
  const refreshable = stored.cookies !== undefined && !sessionExpired
296
329
  const signedIn = !tokenExpired || refreshable
297
- const account = traeAccount(stored)
298
330
  // With a renewable session the user cares about the website-session
299
331
  // deadline, not the eight-hour JWT the plugin replaces on its own.
300
332
  const expiresAt = refreshable ? session ?? token : token
301
333
  return {
302
334
  signedIn,
335
+ loginSource: 'web',
303
336
  loginAt: stored.loginAt,
304
337
  ...expiresAt === undefined ? {} : { expiresAt },
305
338
  ...!signedIn ? { possiblyExpired: true } : {},
@@ -315,17 +348,23 @@ export class TraeAuth {
315
348
  /**
316
349
  * The bearer token, or an actionable error when there is none.
317
350
  *
318
- * A token near its expiry is renewed first; `force` means the gateway already
319
- * rejected it, so the renewal is attempted even if the clock disagrees.
351
+ * A web token near its expiry is renewed first; `force` means the gateway
352
+ * already rejected it, so the renewal is attempted even if the clock
353
+ * disagrees. A client token is returned until its own expiry and then asks
354
+ * for a fresh import, because only the client can refresh it.
320
355
  */
321
356
  async accessToken(force = false): Promise<string> {
322
357
  const region = this.region()
323
358
  const stored = await this.store.trae(region)
324
359
  if (stored === undefined) {
325
- throw new Error(`${traeLabel(region)}尚未登录。请在设置页点「打开浏览器登录」。`)
360
+ throw new Error(`${traeLabel(region)}尚未登录。可以点「打开浏览器登录」,或复用本机已登录的 Trae 客户端。`)
326
361
  }
327
362
  const { token, session } = traeExpiries(stored)
328
363
  const expired = token !== undefined && token <= Date.now() + TRAE_EXPIRY_MARGIN_MS
364
+ if (stored.source === 'client') {
365
+ if (!force && !expired) return stored.accessToken
366
+ throw new Error(`${traeLabel(region)}客户端登录令牌已${force ? '失效' : '过期'},请先在 Trae 客户端重新登录,再点「复用客户端登录态」。`)
367
+ }
329
368
  if (!force && !expired) return stored.accessToken
330
369
  if (stored.cookies === undefined) {
331
370
  throw new Error(`${traeLabel(region)}登录已${force ? '失效' : '过期'},请在设置页重新登录。`)
@@ -342,7 +381,7 @@ export class TraeAuth {
342
381
  if (existing !== undefined) return existing
343
382
  const task = (async () => {
344
383
  const stored = await this.store.trae(region)
345
- if (stored === undefined || stored.cookies === undefined) {
384
+ if (stored === undefined || stored.source === 'client' || stored.cookies === undefined) {
346
385
  throw new Error(`${traeLabel(region)}登录已失效,请在设置页重新登录。`)
347
386
  }
348
387
  const profile = TRAE_REGION_PROFILES[region]
@@ -408,7 +447,7 @@ export class TraeAuth {
408
447
  const region = this.region()
409
448
  const stored = await this.store.trae(region)
410
449
  if (stored === undefined) {
411
- throw new Error(`${traeLabel(region)}尚未登录。请在设置页点「打开浏览器登录」。`)
450
+ throw new Error(`${traeLabel(region)}尚未登录。可以点「打开浏览器登录」,或复用本机已登录的 Trae 客户端。`)
412
451
  }
413
452
  return {
414
453
  deviceId: stored.deviceId,
@@ -515,6 +554,7 @@ export class CodeArtsAuth {
515
554
  ...next.expiresAt === undefined ? {} : { expiresAt: next.expiresAt },
516
555
  ...next.refreshToken === undefined ? {} : { refreshToken: next.refreshToken },
517
556
  ...next.userName === undefined ? {} : { userName: next.userName },
557
+ ...next.otSessionId === undefined ? {} : { otSessionId: next.otSessionId },
518
558
  })
519
559
  this.lastError = undefined
520
560
  return next
@@ -539,6 +579,10 @@ export class CodeArtsAuth {
539
579
  }
540
580
 
541
581
  function credentialOf(record: CodeArtsAccountRecord): CodeArtsCredential {
582
+ // A record written before `otSessionId` existed derives the very value the
583
+ // login would have stored, so an account that is already signed in gets its
584
+ // single session without a new login.
585
+ const otSessionId = record.otSessionId ?? otSessionIdOf(readUserProfile(record.refreshToken))
542
586
  return {
543
587
  accessKeyId: record.accessKeyId,
544
588
  secretAccessKey: record.secretAccessKey,
@@ -547,5 +591,6 @@ function credentialOf(record: CodeArtsAccountRecord): CodeArtsCredential {
547
591
  ...record.refreshToken === undefined ? {} : { refreshToken: record.refreshToken },
548
592
  ...record.codeVerifier === undefined ? {} : { codeVerifier: record.codeVerifier },
549
593
  ...record.dpopPrivateJwk === undefined ? {} : { dpopPrivateJwk: record.dpopPrivateJwk },
594
+ ...otSessionId === undefined ? {} : { otSessionId },
550
595
  }
551
596
  }
@@ -35,6 +35,17 @@ export interface CodeArtsCredential {
35
35
  dpopPrivateJwk?: DpopPrivateJwk
36
36
  /** Display only, from the refresh token's profile; never used for signing. */
37
37
  userName?: string
38
+ /**
39
+ * The id the gateway counts concurrent sessions against (`x-ot-session-id`).
40
+ *
41
+ * Measured on a real account: the three-session cap is keyed by this header,
42
+ * not by `Chat-Id` / `Session-Id`. A per-request value makes every call a new
43
+ * session, so the account spends its own budget on its own history and the
44
+ * fourth call of the minute comes back `TM.00001041`. Derived from the account
45
+ * identity instead of stored at random: the same value in every process, and
46
+ * one that survives a token renewal.
47
+ */
48
+ otSessionId?: string
38
49
  }
39
50
 
40
51
  /** Who the credential belongs to, as the portal encoded it in the token. */
@@ -180,6 +191,19 @@ export function readUserProfile(token: string | undefined): CodeArtsUserProfile
180
191
  }
181
192
  }
182
193
 
194
+ /**
195
+ * The session id this account should chat under, derived from who the account is.
196
+ *
197
+ * 32 lowercase hex, the shape the IDE's own kernel sends. Nothing but stability
198
+ * matters here — the gateway uses the value as a key, not as an identity — so it
199
+ * is hashed from the principal id rather than kept in a file.
200
+ */
201
+ export function otSessionIdOf(profile: CodeArtsUserProfile | undefined): string | undefined {
202
+ const seed = profile?.userId ?? profile?.userName
203
+ if (seed === undefined) return undefined
204
+ return createHash('sha256').update(`codearts-ot-session:${seed}`).digest('hex').slice(0, 32)
205
+ }
206
+
183
207
  /** The portal URL the browser opens; the callback is on loopback. */
184
208
  export function buildAuthorizeUrl(port: number, pkce: PkcePair, ticketId: string): string {
185
209
  return `${CODEARTS_PORTAL_AUTHORIZE}?theme=2&locale=zh-cn`
@@ -263,13 +287,18 @@ export async function exchangeRefreshToken(
263
287
  }, keyPair, fetcher)
264
288
  const credential = credentialFrom(token, codeVerifier, keyPair)
265
289
  // A refresh response may omit the rotated refresh token; keep the old one so
266
- // the next refresh is still possible. The name rides on that token, so it has
267
- // to be read from whichever one this credential ends up carrying.
268
- const userName = credential.userName ?? readUserProfile(refreshToken)?.userName
290
+ // the next refresh is still possible. The name and the account identity ride on
291
+ // that token, so both are read from whichever one this credential ends up
292
+ // carrying: a response without a profile must not hand the account a new
293
+ // session id, or every renewal would cost it a slot again.
294
+ const previous = readUserProfile(refreshToken)
295
+ const userName = credential.userName ?? previous?.userName
296
+ const otSessionId = credential.otSessionId ?? otSessionIdOf(previous)
269
297
  return {
270
298
  ...credential,
271
299
  refreshToken: credential.refreshToken ?? refreshToken,
272
300
  ...userName === undefined ? {} : { userName },
301
+ ...otSessionId === undefined ? {} : { otSessionId },
273
302
  }
274
303
  }
275
304
 
@@ -277,14 +306,16 @@ function credentialFrom(token: TokenResponse, codeVerifier: string, keyPair: Dpo
277
306
  const credentials = token.credentials ?? {}
278
307
  const expiration = credentials.expiration
279
308
  const expiresAt = expiration === undefined ? undefined : Date.parse(expiration)
280
- const userName = readUserProfile(token.refresh_token)?.userName
309
+ const profile = readUserProfile(token.refresh_token)
310
+ const otSessionId = otSessionIdOf(profile)
281
311
  return {
282
312
  accessKeyId: credentials.access_key_id ?? '',
283
313
  secretAccessKey: credentials.secret_access_key ?? '',
284
314
  securityToken: credentials.security_token ?? '',
285
315
  ...expiresAt === undefined || Number.isNaN(expiresAt) ? {} : { expiresAt },
286
316
  ...token.refresh_token === undefined ? {} : { refreshToken: token.refresh_token },
287
- ...userName === undefined ? {} : { userName },
317
+ ...profile?.userName === undefined ? {} : { userName: profile.userName },
318
+ ...otSessionId === undefined ? {} : { otSessionId },
288
319
  codeVerifier,
289
320
  dpopPrivateJwk: keyPair.privateKeyJwk,
290
321
  }
package/src/codearts.ts CHANGED
@@ -46,7 +46,6 @@ import {
46
46
  } from './codearts-endpoints.ts'
47
47
  import { CodeArtsLogin } from './codearts-login.ts'
48
48
  import { headersForFetch, sha256Hex, signRequestHuawei } from './codearts-sign.ts'
49
- import { CodeArtsSessionWindow, sharedSessionWindow } from './codearts-window.ts'
50
49
  import type { CodeArtsCredential } from './codearts-oauth.ts'
51
50
  import type { LoginStatus } from './login-page.ts'
52
51
  import { parseModelName } from './model-name.ts'
@@ -115,8 +114,6 @@ export interface CodeArtsProviderOptions {
115
114
  loginTimeoutMs?: number
116
115
  /** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
117
116
  openRouter?: OpenRouterLookup
118
- /** Chat budget; defaults to the process-wide window (survives hot reloads). */
119
- window?: CodeArtsSessionWindow
120
117
  }
121
118
 
122
119
  /** One roster entry before it becomes a model row. */
@@ -306,20 +303,22 @@ const sleep = (ms: number, signal?: AbortSignal): Promise<void> => new Promise((
306
303
  signal?.addEventListener('abort', onAbort, { once: true })
307
304
  })
308
305
 
309
- /** Retries for a gateway that is busy for reasons the window cannot predict. */
306
+ /** Retries for a gateway that is busy for reasons the caller cannot predict. */
310
307
  const QUEUE_ATTEMPTS = 3
311
- /** Minimum pause before retrying a rejected call; the window adds its own. */
308
+ /** Pause before retrying a rejected call. */
312
309
  const QUEUE_BACKOFF_MS = 20_000
313
310
 
314
311
  /**
315
312
  * The message a user sees once waiting is hopeless.
316
313
  *
317
- * The raw gateway text (`TM.00001041`) reads like a bug; the limit is the
318
- * trial plan's, and the only fix on this side is to say so.
314
+ * The raw gateway text (`TM.00001041`) reads like a bug. The three concurrent
315
+ * sessions are the trial plan's, and one plugin chatting under a single session
316
+ * id is not meant to reach them: the slots are being held by something else on
317
+ * the same account.
319
318
  */
320
319
  function queueExhausted(): ProviderHttpError {
321
320
  return new ProviderHttpError(
322
- 'CodeArts 体验版每分钟只允许 3 个会话,当前已被占满。请稍等片刻再发,或关闭其它 CodeArts 会话。',
321
+ 'CodeArts 这个账号的 3 个并发会话已被占满(通常是码道 IDE 或另一台设备),请稍后再发,或关闭其它 CodeArts 会话。',
323
322
  429,
324
323
  )
325
324
  }
@@ -356,7 +355,6 @@ export class CodeArtsProvider {
356
355
  /** Models that answered `InferHub.4004.200`: no allowance pack to bill. */
357
356
  private readonly noBenefit = new Set<string>()
358
357
  private noBenefitLoaded = false
359
- private readonly window: CodeArtsSessionWindow
360
358
  private readonly openRouter: OpenRouterLookup
361
359
  private readonly sessionId = randomUUID()
362
360
  /**
@@ -371,7 +369,6 @@ export class CodeArtsProvider {
371
369
  private imageCacheWrite: Promise<void> = Promise.resolve()
372
370
 
373
371
  constructor(private readonly options: CodeArtsProviderOptions) {
374
- this.window = options.window ?? sharedSessionWindow()
375
372
  this.openRouter = options.openRouter ?? new OpenRouterCatalog(options.fetchImpl ?? fetch)
376
373
  this.catalog = new ModelCatalog(() => this.loadModels(), options.ttlMs)
377
374
  this.login = new CodeArtsLogin({
@@ -380,9 +377,7 @@ export class CodeArtsProvider {
380
377
  ...options.logger === undefined ? {} : { logger: options.logger },
381
378
  save: async (account) => {
382
379
  await options.store.saveCodearts(account as CodeArtsAccountRecord)
383
- // A different account starts with a clean budget: the slots the gateway
384
- // remembers belong to the old one.
385
- this.window.reset()
380
+ // A different account has different verdicts about what it may bill.
386
381
  this.noBenefit.clear()
387
382
  this.invalidateModels()
388
383
  this.invalidateQuota()
@@ -784,7 +779,7 @@ export class CodeArtsProvider {
784
779
  }
785
780
  }
786
781
 
787
- /** Ask the vision model to describe one image, within the same chat budget. */
782
+ /** Ask the vision model to describe one image; another turn on the same session. */
788
783
  private async describeImage(dataUrl: string, signal?: AbortSignal): Promise<string> {
789
784
  const payload = JSON.stringify({
790
785
  model: CODEARTS_VISION_MODEL,
@@ -802,8 +797,6 @@ export class CodeArtsProvider {
802
797
  let authRefreshed = false
803
798
  let queueAttempts = 0
804
799
  for (;;) {
805
- // The vision model is another chat session, so it spends the same budget.
806
- const slot = await this.window.open(signal)
807
800
  let response: Response
808
801
  try {
809
802
  response = await this.send(chatId, payload, CODEARTS_VISION_MODEL, false, signal)
@@ -814,10 +807,9 @@ export class CodeArtsProvider {
814
807
  continue
815
808
  }
816
809
  if (error instanceof CodeArtsRetry && error.kind === 'queue') {
817
- slot.release()
818
810
  if (queueAttempts < QUEUE_ATTEMPTS) {
819
811
  queueAttempts++
820
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), signal)
812
+ await sleep(QUEUE_BACKOFF_MS, signal)
821
813
  continue
822
814
  }
823
815
  throw queueExhausted()
@@ -842,10 +834,9 @@ export class CodeArtsProvider {
842
834
  }
843
835
  } catch (error) {
844
836
  if (error instanceof CodeArtsRetry && error.kind === 'queue') {
845
- slot.release()
846
837
  if (queueAttempts < QUEUE_ATTEMPTS) {
847
838
  queueAttempts++
848
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), signal)
839
+ await sleep(QUEUE_BACKOFF_MS, signal)
849
840
  continue
850
841
  }
851
842
  throw queueExhausted()
@@ -906,10 +897,6 @@ export class CodeArtsProvider {
906
897
  let benefitDropped = false
907
898
  let queueAttempts = 0
908
899
  for (;;) {
909
- // The plan allows three sessions a minute for the whole account, and a
910
- // rejected call spends one just like a served one. Wait for a slot here
911
- // instead of firing into `TM.00001041` and hoping a retry lands later.
912
- const slot = await this.window.open(options.signal)
913
900
  let response: Response
914
901
  try {
915
902
  response = await this.send(chatId, payload, model, benefitDropped, options.signal)
@@ -933,8 +920,7 @@ export class CodeArtsProvider {
933
920
  }
934
921
  if (error.kind === 'queue' && queueAttempts < QUEUE_ATTEMPTS) {
935
922
  queueAttempts++
936
- slot.release()
937
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), options.signal)
923
+ await sleep(QUEUE_BACKOFF_MS, options.signal)
938
924
  continue
939
925
  }
940
926
  if (error.kind === 'queue') throw queueExhausted()
@@ -966,8 +952,7 @@ export class CodeArtsProvider {
966
952
  // safe while nothing has been emitted, or the answer would repeat.
967
953
  if (!produced && error instanceof CodeArtsRetry && error.kind === 'queue' && queueAttempts < QUEUE_ATTEMPTS) {
968
954
  queueAttempts++
969
- slot.release()
970
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), options.signal)
955
+ await sleep(QUEUE_BACKOFF_MS, options.signal)
971
956
  continue
972
957
  }
973
958
  if (error instanceof CodeArtsRetry && error.kind === 'queue') throw queueExhausted()
@@ -997,6 +982,9 @@ export class CodeArtsProvider {
997
982
  ): Promise<Response> {
998
983
  const credential = await this.credential()
999
984
  const benefit = !benefitDropped && this.isBenefitModel(model)
985
+ // A credential whose token carries no identity falls back to this process's
986
+ // own id: still one session for the account, never one per request.
987
+ const otSessionId = credential.otSessionId ?? this.sessionId.replace(/-/g, '')
1000
988
  const signed = await signRequestHuawei(
1001
989
  credential.accessKeyId,
1002
990
  credential.secretAccessKey,
@@ -1015,6 +1003,14 @@ export class CodeArtsProvider {
1015
1003
  'chat-id': chatId,
1016
1004
  'session-id': this.sessionId,
1017
1005
  lang: 'en',
1006
+ // Measured on a real account: the gateway counts the account's concurrent
1007
+ // sessions by `x-ot-session-id`, not by `chat-id` / `session-id`. One stable
1008
+ // value is why the trial plan stops charging its own history for every
1009
+ // turn; the trace id stays per request, as the IDE sends it. These join the
1010
+ // unsigned set — `chat-id` and `session-id` already do.
1011
+ 'x-ot-session-id': otSessionId,
1012
+ 'user-session-id': otSessionId,
1013
+ 'x-ot-trace-id': randomUUID().replace(/-/g, ''),
1018
1014
  }
1019
1015
  const timeout = AbortSignal.timeout(Math.max(this.options.timeoutMs(), 60_000))
1020
1016
  const response = await this.fetcher(CODEARTS_CHAT_URL, {
@@ -1052,7 +1048,6 @@ export class CodeArtsProvider {
1052
1048
 
1053
1049
  async logoutAccount(): Promise<void> {
1054
1050
  await this.options.auth.logout()
1055
- this.window.reset()
1056
1051
  this.invalidateModels()
1057
1052
  this.invalidateQuota()
1058
1053
  }