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.
- package/DEVELOPMENT.md +92 -24
- package/FAQ.md +47 -10
- package/README.md +39 -25
- package/client.js +1802 -1733
- package/lib/index.js +733 -302
- package/lib/types/account-store.d.ts +17 -8
- package/lib/types/adapter.d.ts +8 -2
- package/lib/types/auth.d.ts +24 -11
- package/lib/types/codearts-oauth.d.ts +19 -0
- package/lib/types/codearts.d.ts +1 -5
- package/lib/types/config.d.ts +0 -5
- package/lib/types/index.d.ts +5 -6
- package/lib/types/raccoon.d.ts +79 -6
- package/lib/types/trae-client.d.ts +52 -0
- package/lib/types/trae-endpoints.d.ts +2 -0
- package/lib/types/trae-login.d.ts +5 -3
- package/lib/types/trae.d.ts +29 -3
- package/lib/types/types.d.ts +19 -1
- package/package.json +3 -2
- package/src/account-store.ts +20 -8
- package/src/adapter.ts +31 -3
- package/src/api.ts +15 -3
- package/src/auth.ts +62 -17
- package/src/codearts-oauth.ts +36 -5
- package/src/codearts.ts +24 -29
- package/src/config.ts +258 -264
- package/src/index.ts +6 -29
- package/src/raccoon.ts +163 -50
- package/src/trae-client.ts +312 -0
- package/src/trae-endpoints.ts +4 -0
- package/src/trae-login.ts +5 -3
- package/src/trae.ts +191 -3
- package/src/types.ts +20 -1
- package/lib/types/codearts-window.d.ts +0 -65
- package/src/codearts-window.ts +0 -136
package/src/account-store.ts
CHANGED
|
@@ -39,12 +39,12 @@ export interface RaccoonAccountRecord {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* One Trae
|
|
42
|
+
* One Trae login.
|
|
43
43
|
*
|
|
44
|
-
* `accessToken` is the `Cloud-IDE-JWT`
|
|
45
|
-
* `machineId` are
|
|
46
|
-
*
|
|
47
|
-
* check-in
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
|
188
|
-
//
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
275
|
+
* Trae's login, one per region.
|
|
265
276
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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 {
|
|
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
|
|
319
|
-
* rejected it, so the renewal is attempted even if the clock
|
|
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
|
}
|
package/src/codearts-oauth.ts
CHANGED
|
@@ -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
|
|
267
|
-
//
|
|
268
|
-
|
|
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
|
|
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
|
|
306
|
+
/** Retries for a gateway that is busy for reasons the caller cannot predict. */
|
|
310
307
|
const QUEUE_ATTEMPTS = 3
|
|
311
|
-
/**
|
|
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
|
|
318
|
-
* trial plan's, and
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|