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/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
 
@@ -1,65 +0,0 @@
1
- /**
2
- * The trial plan's chat budget.
3
- *
4
- * The gateway opens one "session" per chat request and allows **three per
5
- * rolling minute** for the whole account. The window is shared by every model,
6
- * it is not keyed by `session-id` (re-sending the same id does not buy a slot),
7
- * and a request rejected with `InferHub.4004.200` spends a slot just like a
8
- * successful one. Measured behaviour, three slots then a lockout:
9
- *
10
- * ```
11
- * t+0s ok ok ok cap cap cap burst of six
12
- * t+10s cap …
13
- * t+51s cap
14
- * t+61s ok ok ok the burst ages out of the window
15
- * ```
16
- *
17
- * So a turn cannot be made to "go faster" by retrying: the only way a request
18
- * stops failing is to spend the budget deliberately — wait for the oldest start
19
- * to age out instead of firing into a wall. This class owns that bookkeeping and
20
- * is shared by every provider instance in the process, because a hot reload
21
- * must not reset the count the gateway still remembers.
22
- *
23
- * @module dsh-llm-adapters/codearts-window
24
- */
25
- /** Sessions the trial plan allows inside one window. */
26
- export declare const CODEARTS_SESSION_LIMIT = 3;
27
- /** How long a start keeps its slot. Measured: the burst frees at ~60 s. */
28
- export declare const CODEARTS_SESSION_WINDOW_MS = 60000;
29
- /** The claim a caller holds while its request is in flight. */
30
- export interface CodeArtsSessionSlot {
31
- /**
32
- * Give the slot back.
33
- *
34
- * The gateway does not charge for a call it turned away: a burst rejected
35
- * with `TM.00001041` at t+0 s lifts at the same moment as one that was never
36
- * sent. Keeping the slot after a refusal would therefore throttle the next
37
- * minute to one request per twenty seconds for no reason.
38
- */
39
- release(): void;
40
- }
41
- export declare class CodeArtsSessionWindow {
42
- private readonly limit;
43
- private readonly windowMs;
44
- private readonly now;
45
- private readonly starts;
46
- /** Serialises waiters so two callers cannot both take the last slot. */
47
- private tail;
48
- constructor(limit?: number, windowMs?: number, now?: () => number);
49
- /** Starts recorded inside the current window. */
50
- get size(): number;
51
- /** Milliseconds until another start fits; `0` when the window has room. */
52
- waitMs(): number;
53
- /**
54
- * Claim a slot, waiting for the window to roll over when it is full.
55
- *
56
- * The slot is recorded on return, not on completion: the gateway counts the
57
- * start, and holds it for a fixed time whether the answer was short or long.
58
- */
59
- open(signal?: AbortSignal): Promise<CodeArtsSessionSlot>;
60
- /** Forget every start (tests, and a fresh login on another account). */
61
- reset(): void;
62
- private trim;
63
- }
64
- /** The process-wide window; a plugin reload reuses it on purpose. */
65
- export declare function sharedSessionWindow(): CodeArtsSessionWindow;
@@ -1,136 +0,0 @@
1
- /**
2
- * The trial plan's chat budget.
3
- *
4
- * The gateway opens one "session" per chat request and allows **three per
5
- * rolling minute** for the whole account. The window is shared by every model,
6
- * it is not keyed by `session-id` (re-sending the same id does not buy a slot),
7
- * and a request rejected with `InferHub.4004.200` spends a slot just like a
8
- * successful one. Measured behaviour, three slots then a lockout:
9
- *
10
- * ```
11
- * t+0s ok ok ok cap cap cap burst of six
12
- * t+10s cap …
13
- * t+51s cap
14
- * t+61s ok ok ok the burst ages out of the window
15
- * ```
16
- *
17
- * So a turn cannot be made to "go faster" by retrying: the only way a request
18
- * stops failing is to spend the budget deliberately — wait for the oldest start
19
- * to age out instead of firing into a wall. This class owns that bookkeeping and
20
- * is shared by every provider instance in the process, because a hot reload
21
- * must not reset the count the gateway still remembers.
22
- *
23
- * @module dsh-llm-adapters/codearts-window
24
- */
25
-
26
- /** Sessions the trial plan allows inside one window. */
27
- export const CODEARTS_SESSION_LIMIT = 3
28
-
29
- /** How long a start keeps its slot. Measured: the burst frees at ~60 s. */
30
- export const CODEARTS_SESSION_WINDOW_MS = 60_000
31
-
32
- /** A little slack so a request never lands a few milliseconds early. */
33
- const MARGIN_MS = 1_500
34
-
35
- /** The claim a caller holds while its request is in flight. */
36
- export interface CodeArtsSessionSlot {
37
- /**
38
- * Give the slot back.
39
- *
40
- * The gateway does not charge for a call it turned away: a burst rejected
41
- * with `TM.00001041` at t+0 s lifts at the same moment as one that was never
42
- * sent. Keeping the slot after a refusal would therefore throttle the next
43
- * minute to one request per twenty seconds for no reason.
44
- */
45
- release(): void
46
- }
47
-
48
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
49
- return new Promise((resolve, reject) => {
50
- if (signal?.aborted === true) {
51
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
52
- return
53
- }
54
- const onAbort = (): void => {
55
- clearTimeout(timer)
56
- reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
57
- }
58
- const timer = setTimeout(() => {
59
- signal?.removeEventListener('abort', onAbort)
60
- resolve()
61
- }, ms)
62
- signal?.addEventListener('abort', onAbort, { once: true })
63
- })
64
- }
65
-
66
- export class CodeArtsSessionWindow {
67
- private readonly starts: number[] = []
68
- /** Serialises waiters so two callers cannot both take the last slot. */
69
- private tail: Promise<void> = Promise.resolve()
70
-
71
- constructor(
72
- private readonly limit: number = CODEARTS_SESSION_LIMIT,
73
- private readonly windowMs: number = CODEARTS_SESSION_WINDOW_MS,
74
- private readonly now: () => number = Date.now,
75
- ) {}
76
-
77
- /** Starts recorded inside the current window. */
78
- get size(): number {
79
- this.trim()
80
- return this.starts.length
81
- }
82
-
83
- /** Milliseconds until another start fits; `0` when the window has room. */
84
- waitMs(): number {
85
- this.trim()
86
- if (this.starts.length < this.limit) return 0
87
- const oldest = this.starts[0] ?? 0
88
- return Math.max(0, this.windowMs - (this.now() - oldest))
89
- }
90
-
91
- /**
92
- * Claim a slot, waiting for the window to roll over when it is full.
93
- *
94
- * The slot is recorded on return, not on completion: the gateway counts the
95
- * start, and holds it for a fixed time whether the answer was short or long.
96
- */
97
- async open(signal?: AbortSignal): Promise<CodeArtsSessionSlot> {
98
- const run = this.tail.then(async (): Promise<CodeArtsSessionSlot> => {
99
- for (;;) {
100
- const wait = this.waitMs()
101
- if (wait === 0) {
102
- const start = this.now()
103
- this.starts.push(start)
104
- return {
105
- release: () => {
106
- const index = this.starts.indexOf(start)
107
- if (index >= 0) this.starts.splice(index, 1)
108
- },
109
- }
110
- }
111
- await sleep(wait + MARGIN_MS, signal)
112
- }
113
- })
114
- // A failed waiter must not poison the queue for the next one.
115
- this.tail = run.then(() => undefined, () => undefined)
116
- return await run
117
- }
118
-
119
- /** Forget every start (tests, and a fresh login on another account). */
120
- reset(): void {
121
- this.starts.length = 0
122
- }
123
-
124
- private trim(): void {
125
- const now = this.now()
126
- while (this.starts.length > 0 && now - (this.starts[0] ?? 0) >= this.windowMs) this.starts.shift()
127
- }
128
- }
129
-
130
- let shared: CodeArtsSessionWindow | undefined
131
-
132
- /** The process-wide window; a plugin reload reuses it on purpose. */
133
- export function sharedSessionWindow(): CodeArtsSessionWindow {
134
- shared ??= new CodeArtsSessionWindow()
135
- return shared
136
- }