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/trae.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Trae provider: model directory, SOLO chat and
|
|
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.
|
|
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
|
|
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;
|
package/src/codearts-window.ts
DELETED
|
@@ -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
|
-
}
|