dsh-llm-adapters 1.2.0 → 1.2.1

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/src/index.ts CHANGED
@@ -104,8 +104,8 @@ export {
104
104
  RaccoonProvider, RACCOON_BASE, RACCOON_CLIENT_VERSION, raccoonAccessTokenValid, raccoonRefreshCandidate,
105
105
  } from './raccoon.ts'
106
106
  export {
107
- TraeProvider, TRAE_CHAT_FUNCTIONS, TRAE_DIRECTORY_FUNCTIONS, mergeTraeModels,
108
- normalizeEffort, openTraeStream, parseTraeGlobalUsage, parseTraeRemoteModels,
107
+ TraeProvider, TRAE_CHAT_FUNCTIONS, TRAE_CHECKIN_REQ_SOURCE, TRAE_DIRECTORY_FUNCTIONS, mergeTraeModels,
108
+ normalizeEffort, openTraeStream, parseTraeCheckin, parseTraeGlobalUsage, parseTraeRemoteModels,
109
109
  parseTraeUsage, parseTraeWireModels, traeMessages, traeTools,
110
110
  } from './trae.ts'
111
111
  export type {
@@ -113,6 +113,8 @@ export type {
113
113
  } from './trae.ts'
114
114
  export { TRAE_APP_ID, TRAE_REGION_PROFILES, TRAE_SOLO_CHAT_PATH, TRAE_WEB_USER_AGENT } from './trae-endpoints.ts'
115
115
  export type { TraeRegionProfile } from './trae-endpoints.ts'
116
+ export { parseTraeClientStorage, readTraeClientLogin, traeClientDataDirs } from './trae-client.ts'
117
+ export type { TraeClientDetection, TraeClientLoginState, TraeClientReadOptions } from './trae-client.ts'
116
118
  export { TraeWebLogin, machineIdOf } from './trae-login.ts'
117
119
  export type { TraeLoginAccount, TraeWebLoginOptions } from './trae-login.ts'
118
120
  export {
@@ -143,7 +145,7 @@ export type { WireMessage } from './wire.ts'
143
145
  export { API_PREFIX, buildRows } from './api.ts'
144
146
  export { liveConfig, resolveConfig } from './config.ts'
145
147
  export type { Config as ConfigShape, LiveConfig, ParsedConfig, ResolvedConfig } from './config.ts'
146
- export type { CheckinStatus, ProviderStatus, QuotaPack, QuotaSnapshot } from './types.ts'
148
+ export type { CheckinStatus, ClientLoginStatus, ProviderStatus, QuotaPack, QuotaSnapshot } from './types.ts'
147
149
 
148
150
  export const name = 'llm-adapters'
149
151
  export const inject = ['llm']
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The local Trae desktop client's sign-in state.
3
+ *
4
+ * Trae's check-in route insists on the device id its own client registered, so
5
+ * the website login cannot use it. When the client is installed and already
6
+ * signed in, the plugin can reuse that state instead:
7
+ *
8
+ * - the `Cloud-IDE-JWT` stored under `iCubeAuthInfo://icube.cloudide`;
9
+ * - the 16-digit device id encoded in the `iCubeAuthInfo://icube-dc:<id>` key;
10
+ * - `telemetry.machineId`, the value the client sends as `x-machine-id`.
11
+ *
12
+ * The stored value uses the client's own AES-CBC envelope: six magic bytes, a
13
+ * 32-byte key material, then the ciphertext. The AES key and IV are derived
14
+ * from that key material with SHA-512 and a fixed salt, and the plaintext is a
15
+ * SHA-512 digest followed by the JSON document. Nothing here writes to the
16
+ * client, and the token is never returned by the settings API.
17
+ *
18
+ * @module dsh-llm-adapters/trae-client
19
+ */
20
+
21
+ import { createDecipheriv, createHash } from 'node:crypto'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import type { TraeRegion } from './brand.ts'
26
+ import type { ClientLoginStatus } from './types.ts'
27
+
28
+ /** What the provider stores after importing a client login. */
29
+ export interface TraeClientLoginState {
30
+ region: TraeRegion
31
+ token: string
32
+ deviceId: string
33
+ machineId: string
34
+ userId?: string
35
+ name?: string
36
+ expiresAt?: number
37
+ }
38
+
39
+ /** The settings-facing half (no token) plus the internal state when usable. */
40
+ export interface TraeClientDetection {
41
+ status: ClientLoginStatus
42
+ state?: TraeClientLoginState
43
+ }
44
+
45
+ export interface TraeClientReadOptions {
46
+ platform?: NodeJS.Platform
47
+ env?: NodeJS.ProcessEnv
48
+ home?: string
49
+ }
50
+
51
+ const AUTH_PROVIDER_KEY = 'iCubeAuthInfo://icube.cloudide'
52
+ const DEVICE_KEY_PREFIX = 'iCubeAuthInfo://icube-dc:'
53
+ const MACHINE_KEY = 'telemetry.machineId'
54
+ const SERVICE_MACHINE_KEY = 'storage.serviceMachineId'
55
+
56
+ const AES_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00])
57
+ const AES_PRIVATE_MAGIC = Buffer.from([0x12, 0x39, 0x20, 0x20, 0x02, 0x03])
58
+ const KEY_OFFSET = 6
59
+ const KEY_LENGTH = 32
60
+ const HASH_LENGTH = 64
61
+ /** Aue ^ Due from the client's byteCrypto module. */
62
+ const AES_SALT = Buffer.from(
63
+ '4dd4c2e6b83162090e52b3c7a6733ba41cb2462b829ab58a196b39db57177524'
64
+ + 'f49baf7f08e8d68d26a72e37c1a95a2f1f05a51892aef2949732b62a38aadd58',
65
+ 'hex',
66
+ )
67
+ /** Tue ^ Iue, the AES_PRIVATE variant of the same envelope. */
68
+ const AES_PRIVATE_SALT = Buffer.from(
69
+ '490cc21292b05d0cc06ccbe91fb92e21b5a7c538811dc85e27a96e28104e1cf6'
70
+ + '9c7166efab232807390da78a8b91e49c44b49a43feb941a1e46f2656e613e47b',
71
+ 'hex',
72
+ )
73
+ const DEVICE_RE = /^\d{16}$/
74
+
75
+ function record(value: unknown): Record<string, unknown> | undefined {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
77
+ ? value as Record<string, unknown>
78
+ : undefined
79
+ }
80
+
81
+ function text(value: unknown): string | undefined {
82
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
83
+ }
84
+
85
+ /** Accept epoch seconds, epoch milliseconds, or an ISO date. */
86
+ function epochMs(value: unknown): number | undefined {
87
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
88
+ return value < 1e12 ? Math.round(value * 1_000) : Math.round(value)
89
+ }
90
+ if (typeof value === 'string') {
91
+ if (/^\d+$/.test(value.trim())) return epochMs(Number(value))
92
+ const parsed = Date.parse(value)
93
+ return Number.isFinite(parsed) ? parsed : undefined
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ function jwtExpiry(token: string): number | undefined {
99
+ const parts = token.split('.')
100
+ if (parts.length < 2) return undefined
101
+ try {
102
+ const base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
103
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
104
+ const payload: unknown = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
105
+ return epochMs(record(payload)?.exp)
106
+ } catch {
107
+ return undefined
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Open one `iCubeAuthInfo` value.
113
+ *
114
+ * The digest check is the client's own integrity check: the first 64 plaintext
115
+ * bytes are SHA-512 of the document that follows. A malformed or changed
116
+ * envelope is rejected instead of being parsed as garbage.
117
+ */
118
+ function decryptAuthValue(value: string): unknown {
119
+ const bytes = Buffer.from(value, 'base64')
120
+ if (bytes.length < KEY_OFFSET + KEY_LENGTH + HASH_LENGTH) {
121
+ throw new Error('数据长度不足')
122
+ }
123
+ const magic = bytes.subarray(0, KEY_OFFSET)
124
+ const salt = magic.equals(AES_MAGIC)
125
+ ? AES_SALT
126
+ : magic.equals(AES_PRIVATE_MAGIC) ? AES_PRIVATE_SALT : undefined
127
+ if (salt === undefined) throw new Error('未知的加密格式')
128
+ const keyMaterial = bytes.subarray(KEY_OFFSET, KEY_OFFSET + KEY_LENGTH)
129
+ const mixed = createHash('sha512').update(keyMaterial).digest()
130
+ const derived = createHash('sha512').update(Buffer.concat([mixed, salt])).digest()
131
+ const decipher = createDecipheriv(
132
+ 'aes-128-cbc',
133
+ derived.subarray(0, 16),
134
+ derived.subarray(16, 32),
135
+ )
136
+ const plain = Buffer.concat([
137
+ decipher.update(bytes.subarray(KEY_OFFSET + KEY_LENGTH)),
138
+ decipher.final(),
139
+ ])
140
+ if (plain.length < HASH_LENGTH) throw new Error('解密结果为空')
141
+ const body = plain.subarray(HASH_LENGTH)
142
+ const digest = createHash('sha512').update(body).digest()
143
+ if (!digest.equals(plain.subarray(0, HASH_LENGTH))) throw new Error('校验失败')
144
+ return JSON.parse(body.toString('utf8'))
145
+ }
146
+
147
+ /**
148
+ * The client's data directory, most specific first.
149
+ *
150
+ * `DSH_TRAE_CLIENT_DIR` is a portable/test override; the region-specific
151
+ * variables let one machine keep separate CN and international installs.
152
+ */
153
+ export function traeClientDataDirs(
154
+ region: TraeRegion,
155
+ options: TraeClientReadOptions = {},
156
+ ): string[] {
157
+ const platform = options.platform ?? process.platform
158
+ const env = options.env ?? process.env
159
+ const home = options.home ?? homedir()
160
+ const name = region === 'cn' ? 'Trae CN' : 'Trae'
161
+ const out: string[] = []
162
+ const add = (directory: string | undefined): void => {
163
+ if (directory !== undefined && directory !== '') out.push(directory)
164
+ }
165
+ add(env[region === 'cn' ? 'DSH_TRAE_CN_CLIENT_DIR' : 'DSH_TRAE_GLOBAL_CLIENT_DIR'])
166
+ add(env['DSH_TRAE_CLIENT_DIR'])
167
+ if (platform === 'win32') {
168
+ const appData = env['APPDATA'] ?? join(home, 'AppData', 'Roaming')
169
+ add(join(appData, name))
170
+ } else if (platform === 'darwin') {
171
+ add(join(home, 'Library', 'Application Support', name))
172
+ } else {
173
+ const config = env['XDG_CONFIG_HOME'] ?? join(home, '.config')
174
+ add(join(config, name))
175
+ }
176
+ return [...new Set(out)]
177
+ }
178
+
179
+ function storageFile(userDataDir: string): string {
180
+ return join(userDataDir, 'User', 'globalStorage', 'storage.json')
181
+ }
182
+
183
+ function authValueOf(storage: Record<string, unknown>): string | undefined {
184
+ const direct = text(storage[AUTH_PROVIDER_KEY])
185
+ if (direct !== undefined) return direct
186
+ for (const key of Object.keys(storage)) {
187
+ if (key.startsWith('iCubeAuthInfo://') && key.endsWith('.cloudide')) {
188
+ const value = text(storage[key])
189
+ if (value !== undefined) return value
190
+ }
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ /** The client has one device id per machine; the last valid key is current. */
196
+ function deviceIdOf(storage: Record<string, unknown>): string | undefined {
197
+ const keys = Object.keys(storage).filter(key => key.startsWith(DEVICE_KEY_PREFIX))
198
+ for (let index = keys.length - 1; index >= 0; index--) {
199
+ const value = text(keys[index]!.slice(DEVICE_KEY_PREFIX.length))
200
+ if (value !== undefined && DEVICE_RE.test(value)) return value
201
+ }
202
+ return undefined
203
+ }
204
+
205
+ function regionMismatch(region: TraeRegion, userInfo: Record<string, unknown>): boolean {
206
+ const account = record(userInfo.account)
207
+ const userRegion = record(userInfo.userRegion)
208
+ const evidence = (text(userRegion?.region) ?? text(account?.storeRegion))?.toUpperCase()
209
+ if (evidence === undefined) return false
210
+ return region === 'cn' ? evidence !== 'CN' : evidence === 'CN'
211
+ }
212
+
213
+ /** Parse one `storage.json` document; exported for tests and diagnostics. */
214
+ export function parseTraeClientStorage(
215
+ region: TraeRegion,
216
+ storage: Record<string, unknown>,
217
+ ): TraeClientDetection {
218
+ const encrypted = authValueOf(storage)
219
+ if (encrypted === undefined) {
220
+ return {
221
+ status: {
222
+ available: true,
223
+ signedIn: false,
224
+ error: '本机 Trae 客户端尚未登录。请先在客户端完成登录。',
225
+ },
226
+ }
227
+ }
228
+ let parsed: unknown
229
+ try {
230
+ parsed = decryptAuthValue(encrypted)
231
+ } catch (error) {
232
+ return {
233
+ status: {
234
+ available: true,
235
+ signedIn: false,
236
+ error: `读取本机 Trae 客户端登录态失败:${error instanceof Error ? error.message : String(error)}`,
237
+ },
238
+ }
239
+ }
240
+ const userInfo = record(parsed)
241
+ const token = text(userInfo?.token)
242
+ if (userInfo === undefined || token === undefined) {
243
+ return {
244
+ status: {
245
+ available: true,
246
+ signedIn: false,
247
+ error: '本机 Trae 客户端登录态里没有令牌,请先在客户端重新登录。',
248
+ },
249
+ }
250
+ }
251
+ const account = record(userInfo.account)
252
+ const name = text(account?.username) ?? text(account?.name)
253
+ const userId = text(userInfo.userId)
254
+ const expiresAt = epochMs(userInfo.expiredAt) ?? jwtExpiry(token)
255
+ const identity = {
256
+ ...name === undefined ? {} : { name },
257
+ ...userId === undefined ? {} : { userId },
258
+ ...expiresAt === undefined ? {} : { expiresAt },
259
+ }
260
+ const unavailable = (error: string): TraeClientDetection => ({
261
+ status: { available: true, signedIn: false, ...identity, error },
262
+ })
263
+ if (regionMismatch(region, userInfo)) {
264
+ return unavailable(region === 'cn'
265
+ ? '本机 Trae 客户端登录的是国际版账号,请切换到国际版后再复用。'
266
+ : '本机 Trae 客户端登录的是国内版账号,请切换到国内版后再复用。')
267
+ }
268
+ if (expiresAt !== undefined && expiresAt <= Date.now()) {
269
+ return unavailable('本机 Trae 客户端登录已过期,请先在客户端重新登录。')
270
+ }
271
+ const deviceId = deviceIdOf(storage)
272
+ if (deviceId === undefined) {
273
+ return unavailable('本机 Trae 客户端登录缺少 16 位设备号,请先在客户端重新登录。')
274
+ }
275
+ const machineId = text(storage[MACHINE_KEY]) ?? text(storage[SERVICE_MACHINE_KEY])
276
+ if (machineId === undefined) {
277
+ return unavailable('本机 Trae 客户端登录缺少机器码,请先在客户端重新登录。')
278
+ }
279
+ return {
280
+ status: { available: true, signedIn: true, ...identity },
281
+ state: { region, token, deviceId, machineId, ...identity },
282
+ }
283
+ }
284
+
285
+ /** Read the currently selected region's client state from this machine. */
286
+ export function readTraeClientLogin(
287
+ region: TraeRegion,
288
+ options: TraeClientReadOptions = {},
289
+ ): TraeClientDetection {
290
+ let available = false
291
+ let error: string | undefined
292
+ for (const directory of traeClientDataDirs(region, options)) {
293
+ const file = storageFile(directory)
294
+ if (!existsSync(file)) continue
295
+ available = true
296
+ try {
297
+ const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
298
+ const storage = record(JSON.parse(raw))
299
+ if (storage === undefined) throw new Error('storage.json 不是对象')
300
+ return parseTraeClientStorage(region, storage)
301
+ } catch (cause) {
302
+ error = `读取本机 Trae 客户端登录态失败:${cause instanceof Error ? cause.message : String(cause)}`
303
+ }
304
+ }
305
+ return {
306
+ status: {
307
+ available,
308
+ signedIn: false,
309
+ ...error === undefined ? {} : { error },
310
+ },
311
+ }
312
+ }
@@ -35,6 +35,8 @@ export interface TraeRegionProfile {
35
35
  * the dollar allowance instead.
36
36
  */
37
37
  usageV2: boolean
38
+ /** Daily check-in exists only on the mainland deployment. */
39
+ checkin: boolean
38
40
  }
39
41
 
40
42
  export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
@@ -49,6 +51,7 @@ export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
49
51
  timezone: 'Asia/Shanghai',
50
52
  language: 'zh-cn',
51
53
  usageV2: true,
54
+ checkin: true,
52
55
  },
53
56
  global: {
54
57
  id: 'global',
@@ -61,6 +64,7 @@ export const TRAE_REGION_PROFILES: Record<TraeRegion, TraeRegionProfile> = {
61
64
  timezone: 'Asia/Singapore',
62
65
  language: 'en',
63
66
  usageV2: false,
67
+ checkin: false,
64
68
  },
65
69
  }
66
70
 
package/src/trae-login.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Trae sign-in through the provider's own website.
3
3
  *
4
- * The plugin does not read a desktop install: it opens a dedicated browser
4
+ * This flow does not read a desktop install: it opens a dedicated browser
5
5
  * profile, lets the user sign in on trae.cn / trae.ai, and then takes the
6
6
  * session the website itself created. Two things come out of that session:
7
7
  *
8
8
  * - the `Cloud-IDE-JWT` token, fetched with the page's own cookies through
9
9
  * `/cloudide/api/v3/common/GetUserToken` — the same call the site makes;
10
10
  * - the device fingerprint (`__msuuid__` from the page, or one generated in
11
- * the page), which Trae's check-in route requires and which therefore must
12
- * belong to this web session rather than to a machine install.
11
+ * the page), stored with the web login.
12
+ *
13
+ * The check-in route does not accept this web fingerprint, so check-in is
14
+ * enabled only for the separate client-login import in `trae-client.ts`.
13
15
  *
14
16
  * Cookies are captured over the DevTools protocol and kept in the plugin's own
15
17
  * account store so the short-lived JWT can be renewed later; the dedicated
package/src/trae.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Trae provider: model directory, SOLO chat and quota.
2
+ * Trae provider: model directory, SOLO chat, quota and client check-in.
3
3
  *
4
4
  * Trae's website login yields the same `Cloud-IDE-JWT` its own clients use, so
5
- * the plugin talks to the same gateways they do. Two catalog sources are merged:
5
+ * the plugin talks to the same gateways they do. A second sign-in method copies
6
+ * that token from the local desktop client; only that method carries the device
7
+ * id the daily check-in route accepts. Two catalog sources are merged:
6
8
  *
7
9
  * - the Remote directory (`/api/remote/v1/models`) is the model skeleton —
8
10
  * display name, context window, multiplier, reasoning options, multimodal;
@@ -44,9 +46,11 @@ import {
44
46
  TRAE_APP_ID, TRAE_REGION_PROFILES, TRAE_SOLO_CHAT_PATH, TRAE_SOLO_MODELS_PATH, TRAE_VERSION_CODE,
45
47
  TRAE_WEB_USER_AGENT,
46
48
  } from './trae-endpoints.ts'
49
+ import { readTraeClientLogin } from './trae-client.ts'
50
+ import type { TraeClientDetection } from './trae-client.ts'
47
51
  import { TraeWebLogin } from './trae-login.ts'
48
52
  import type { TraeLoginAccount } from './trae-login.ts'
49
- import type { QuotaPack, QuotaSnapshot } from './types.ts'
53
+ import type { CheckinStatus, ClientLoginStatus, QuotaPack, QuotaSnapshot } from './types.ts'
50
54
  import { ProviderHttpError, buildMessages } from './wire.ts'
51
55
  import type { ReadImage, WireMessage } from './wire.ts'
52
56
 
@@ -181,6 +185,8 @@ export interface TraeProviderOptions {
181
185
  logger?: (message: string) => void
182
186
  /** The shared OpenRouter snapshot; a private one is created when omitted. */
183
187
  openRouter?: OpenRouterLookup
188
+ /** Overridable in tests; defaults to reading this machine's Trae client. */
189
+ clientLogin?: () => TraeClientDetection
184
190
  }
185
191
 
186
192
  function record(value: unknown): Record<string, unknown> | undefined {
@@ -895,6 +901,26 @@ export function parseTraeGlobalUsage(payload: unknown): QuotaSnapshot {
895
901
  }
896
902
  }
897
903
 
904
+ /**
905
+ * The check-in body's `req_source`: 1 is the IDE, 2 is Trae Work. The desktop
906
+ * client sends 1; the claim route rejects the plugin's former empty body.
907
+ */
908
+ export const TRAE_CHECKIN_REQ_SOURCE = 1
909
+
910
+ export function parseTraeCheckin(payload: unknown): CheckinStatus {
911
+ const root = record(payload) ?? {}
912
+ // `extra_credits` is a campaign bonus the site does not add to the daily
913
+ // reward; summing it made the card promise 200 when the check-in pays 100.
914
+ const reward = num(root.credits) ?? 0
915
+ return {
916
+ available: root.enable !== false,
917
+ claimed: root.checked_in === true,
918
+ deviceClaimed: root.did_checked_in === true,
919
+ ...reward === 0 ? {} : { reward },
920
+ ...text(root.message) === undefined ? {} : { message: text(root.message)! },
921
+ }
922
+ }
923
+
898
924
  export class TraeProvider {
899
925
  private catalogInstance: ModelCatalog
900
926
  private catalogRegion: TraeRegion
@@ -1142,6 +1168,167 @@ export class TraeProvider {
1142
1168
  return this.quotaInflight
1143
1169
  }
1144
1170
 
1171
+ /**
1172
+ * The second sign-in method: the desktop client's current state.
1173
+ *
1174
+ * Read-only; the settings page only receives the non-secret status, never
1175
+ * the token.
1176
+ */
1177
+ async localClientLogin(): Promise<ClientLoginStatus> {
1178
+ return this.readClientLogin().status
1179
+ }
1180
+
1181
+ private readClientLogin(): TraeClientDetection {
1182
+ return (this.options.clientLogin ?? (() => readTraeClientLogin(this.options.region())))()
1183
+ }
1184
+
1185
+ /** Import the desktop client's token, device id and machine id. */
1186
+ async importClientLogin(): Promise<void> {
1187
+ const region = this.ensureRegion()
1188
+ const detection = this.readClientLogin()
1189
+ if (!detection.status.available) {
1190
+ throw new Error('本机没有检测到 Trae 客户端。请先安装并登录客户端,或改用网页登录。')
1191
+ }
1192
+ const state = detection.state
1193
+ if (!detection.status.signedIn || state === undefined) {
1194
+ throw new Error(detection.status.error ?? '本机 Trae 客户端尚未登录。请先在客户端完成登录。')
1195
+ }
1196
+ // A browser flow that is still running would save its own account after
1197
+ // this import, so cancel it before replacing the record.
1198
+ await this.loginFlow?.cancel()
1199
+ await this.options.store.saveTrae({
1200
+ accessToken: state.token,
1201
+ region,
1202
+ source: 'client',
1203
+ deviceId: state.deviceId,
1204
+ machineId: state.machineId,
1205
+ loginAt: Date.now(),
1206
+ ...state.userId === undefined ? {} : { userId: state.userId },
1207
+ ...state.name === undefined ? {} : { name: state.name },
1208
+ ...state.expiresAt === undefined ? {} : { expiresAt: state.expiresAt },
1209
+ })
1210
+ this.invalidateModels()
1211
+ this.invalidateQuota()
1212
+ }
1213
+
1214
+ private async checkinContext(): Promise<
1215
+ | { ok: true, profile: TraeRegionProfile, token: string, identity: { deviceId: string, machineId: string } }
1216
+ | { ok: false, status: CheckinStatus }
1217
+ > {
1218
+ this.ensureRegion()
1219
+ const profile = this.profile()
1220
+ if (!profile.checkin) {
1221
+ return {
1222
+ ok: false,
1223
+ status: {
1224
+ available: false, claimed: false, deviceClaimed: false,
1225
+ message: 'Trae 国际版没有签到活动。',
1226
+ },
1227
+ }
1228
+ }
1229
+ const auth = await this.options.auth.status()
1230
+ if (!auth.signedIn) {
1231
+ return {
1232
+ ok: false,
1233
+ status: {
1234
+ available: false, claimed: false, deviceClaimed: false,
1235
+ message: auth.error ?? '尚未登录,无法签到。',
1236
+ },
1237
+ }
1238
+ }
1239
+ if (auth.loginSource !== 'client') {
1240
+ return {
1241
+ ok: false,
1242
+ status: {
1243
+ available: false, claimed: false, deviceClaimed: false,
1244
+ message: '签到仅在复用本机 Trae 客户端登录时可用;网页登录没有客户端设备号。',
1245
+ },
1246
+ }
1247
+ }
1248
+ let token: string
1249
+ let identity: { deviceId: string, machineId: string }
1250
+ try {
1251
+ token = await this.options.auth.accessToken()
1252
+ identity = await this.options.auth.identity()
1253
+ } catch (error) {
1254
+ return {
1255
+ ok: false,
1256
+ status: {
1257
+ available: false, claimed: false, deviceClaimed: false,
1258
+ message: error instanceof Error ? error.message : String(error),
1259
+ },
1260
+ }
1261
+ }
1262
+ if (!/^\d{16}$/.test(identity.deviceId)) {
1263
+ return {
1264
+ ok: false,
1265
+ status: {
1266
+ available: false, claimed: false, deviceClaimed: false,
1267
+ message: '本机 Trae 客户端登录缺少 16 位设备号,请先在客户端重新登录后再复用。',
1268
+ },
1269
+ }
1270
+ }
1271
+ return {
1272
+ ok: true,
1273
+ profile,
1274
+ token,
1275
+ identity: { deviceId: identity.deviceId, machineId: identity.machineId },
1276
+ }
1277
+ }
1278
+
1279
+ async checkinStatus(): Promise<CheckinStatus> {
1280
+ const context = await this.checkinContext()
1281
+ if (!context.ok) return context.status
1282
+ const response = await this.fetchImpl(`${context.profile.apiBase}/trae/api/v2/ug/checkin_credits/status`, {
1283
+ method: 'POST',
1284
+ headers: {
1285
+ ...this.headers(context.token, context.profile),
1286
+ 'x-user-region': 'CN',
1287
+ 'x-device-id': context.identity.deviceId,
1288
+ 'x-machine-id': context.identity.machineId,
1289
+ },
1290
+ body: JSON.stringify({ req_source: TRAE_CHECKIN_REQ_SOURCE }),
1291
+ signal: AbortSignal.timeout(this.options.timeoutMs()),
1292
+ })
1293
+ if (!response.ok) throw new ProviderHttpError(await responseError(response), response.status)
1294
+ return parseTraeCheckin(await response.json())
1295
+ }
1296
+
1297
+ /** Claim today's reward. The only state-changing call in this provider. */
1298
+ async claimCheckin(): Promise<CheckinStatus> {
1299
+ const context = await this.checkinContext()
1300
+ if (!context.ok) throw new Error(context.status.message ?? '当前登录方式不能签到。')
1301
+ const response = await this.fetchImpl(`${context.profile.apiBase}/trae/api/v2/ug/checkin_credits/claim`, {
1302
+ method: 'POST',
1303
+ headers: {
1304
+ ...this.headers(context.token, context.profile),
1305
+ 'x-user-region': 'CN',
1306
+ 'x-device-id': context.identity.deviceId,
1307
+ 'x-machine-id': context.identity.machineId,
1308
+ },
1309
+ body: JSON.stringify({ req_source: TRAE_CHECKIN_REQ_SOURCE }),
1310
+ signal: AbortSignal.timeout(this.options.timeoutMs()),
1311
+ })
1312
+ if (!response.ok) throw new ProviderHttpError(await responseError(response), response.status)
1313
+ const payload = record(await response.json()) ?? {}
1314
+ const code = typeof payload.code === 'number' ? payload.code : 0
1315
+ if (code === 0) {
1316
+ this.invalidateQuota()
1317
+ const reward = num(payload.credits)
1318
+ return {
1319
+ available: true,
1320
+ claimed: true,
1321
+ deviceClaimed: false,
1322
+ ...reward === undefined || reward === 0 ? {} : { reward },
1323
+ ...text(payload.message) === undefined ? {} : { message: text(payload.message)! },
1324
+ }
1325
+ }
1326
+ if (code === 9095) {
1327
+ return { available: true, claimed: false, deviceClaimed: true, message: '本机今日已签到' }
1328
+ }
1329
+ throw new Error(text(payload.message) ?? `签到失败(code ${code})`)
1330
+ }
1331
+
1145
1332
  async *stream(
1146
1333
  options: GenerateOptions,
1147
1334
  model: string,
@@ -1218,6 +1405,7 @@ export class TraeProvider {
1218
1405
  await this.options.store.saveTrae({
1219
1406
  accessToken: account.accessToken,
1220
1407
  region: target,
1408
+ source: 'web',
1221
1409
  deviceId: account.deviceId,
1222
1410
  machineId: account.machineId,
1223
1411
  loginAt: Date.now(),
package/src/types.ts CHANGED
@@ -58,6 +58,23 @@ export interface CheckinStatus {
58
58
  url?: string
59
59
  }
60
60
 
61
+ /**
62
+ * The local Trae client's state, as shown on the settings page.
63
+ *
64
+ * Never carries the token: only whether the client was found, whether a usable
65
+ * login was read, and the non-secret account fields.
66
+ */
67
+ export interface ClientLoginStatus {
68
+ /** A Trae client data directory exists on this machine. */
69
+ available: boolean
70
+ /** The stored login is present, unexpired and usable. */
71
+ signedIn: boolean
72
+ name?: string
73
+ userId?: string
74
+ expiresAt?: number
75
+ error?: string
76
+ }
77
+
61
78
  export interface ProviderStatus extends AuthStatus {
62
79
  brand: BrandId
63
80
  /** Trae only: the region this reading belongs to. */
@@ -65,7 +82,9 @@ export interface ProviderStatus extends AuthStatus {
65
82
  /** Present only after a successful quota read. */
66
83
  quota?: QuotaSnapshot
67
84
  quotaError?: string
68
- /** Trae CN only. */
85
+ /** Trae only: the second sign-in method, when the desktop client exists. */
86
+ clientLogin?: ClientLoginStatus
87
+ /** Trae CN only, and only for a login imported from the client. */
69
88
  checkin?: CheckinStatus
70
89
  }
71
90