pi-freeflow 1.26.1 → 1.27.0
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/cline-accounts.ts +962 -891
- package/src/commands.ts +1888 -1806
- package/src/deploy.ts +8 -5
- package/src/proxy.ts +25 -2
- package/src/relay-state.ts +1140 -1133
- package/src/relay.ts +422 -399
package/src/cline-accounts.ts
CHANGED
|
@@ -1,891 +1,962 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-user Cline API key pool for pi-freeflow.
|
|
3
|
-
*
|
|
4
|
-
* Cline serves its free models direct-only (never through the relay pool),
|
|
5
|
-
* one bearer key per login slot. Keys live in a 0600 file beside the relay
|
|
6
|
-
* state; a corrupt file reads back as an empty pool (never throws, never
|
|
7
|
-
* logs secrets). Request failover walks the pool in order, trying every slot
|
|
8
|
-
* fresh on every request — no slot is ever locked out.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import fs from "node:fs";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import { RELAY_STATE_FILE } from "./config.ts";
|
|
14
|
-
import { logWarn } from "./logger.ts";
|
|
15
|
-
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
16
|
-
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
17
|
-
|
|
18
|
-
/** Env override for the pool file location (tests/CI sandbox). */
|
|
19
|
-
export const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
|
|
20
|
-
|
|
21
|
-
/** Resolve the per-user pool file: beside the relay state file. */
|
|
22
|
-
export function resolveClinePoolPath(): string {
|
|
23
|
-
const override = (process.env[CLINE_POOL_FILE_ENV] || "").trim();
|
|
24
|
-
if (override) return override;
|
|
25
|
-
return path.join(path.dirname(RELAY_STATE_FILE), "pi-freeflow-cline-pool.json");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Recovery copy of the pool, read only when the main file is missing or
|
|
32
|
-
* unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
|
|
33
|
-
* difference: an empty pool is a legitimate state here (`/freeflow cline
|
|
34
|
-
* logout` of the last login must stick), so a valid empty main file is never
|
|
35
|
-
* overridden from the backup.
|
|
36
|
-
*/
|
|
37
|
-
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
38
|
-
|
|
39
|
-
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
40
|
-
export interface ClineAccount {
|
|
41
|
-
slot: string;
|
|
42
|
-
token: string;
|
|
43
|
-
addedAt: string;
|
|
44
|
-
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
45
|
-
refreshToken?: string;
|
|
46
|
-
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
47
|
-
expiresAt?: number;
|
|
48
|
-
/** WorkOS account id, shown when choosing which login to remove. */
|
|
49
|
-
accountId?: string;
|
|
50
|
-
/** Login email, shown when choosing which login to remove. */
|
|
51
|
-
email?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ClinePoolState {
|
|
55
|
-
accounts: ClineAccount[];
|
|
56
|
-
activeSlot?: string;
|
|
57
|
-
/**
|
|
58
|
-
* Daily free-limit resets per slot per *requested* model id (epoch ms):
|
|
59
|
-
* `limits[slot][modelId] = resetAt`. Cline reports its own model name in the
|
|
60
|
-
* 429 body, which never matches the id we asked for, so the requested id is
|
|
61
|
-
* the only key a later request can look up. Expired entries are dropped on
|
|
62
|
-
* read. Optional: pools written before this existed load unchanged.
|
|
63
|
-
*/
|
|
64
|
-
limits?: Record<string, Record<string, number>>;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
if (typeof
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
*/
|
|
437
|
-
function
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*/
|
|
473
|
-
function
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
pool: ClinePoolState,
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
)
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
):
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
*
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
if (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
try {
|
|
647
|
-
|
|
648
|
-
} catch {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
*
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
};
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
return
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Per-user Cline API key pool for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Cline serves its free models direct-only (never through the relay pool),
|
|
5
|
+
* one bearer key per login slot. Keys live in a 0600 file beside the relay
|
|
6
|
+
* state; a corrupt file reads back as an empty pool (never throws, never
|
|
7
|
+
* logs secrets). Request failover walks the pool in order, trying every slot
|
|
8
|
+
* fresh on every request — no slot is ever locked out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { RELAY_STATE_FILE } from "./config.ts";
|
|
14
|
+
import { logWarn } from "./logger.ts";
|
|
15
|
+
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
16
|
+
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
17
|
+
|
|
18
|
+
/** Env override for the pool file location (tests/CI sandbox). */
|
|
19
|
+
export const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
|
|
20
|
+
|
|
21
|
+
/** Resolve the per-user pool file: beside the relay state file. */
|
|
22
|
+
export function resolveClinePoolPath(): string {
|
|
23
|
+
const override = (process.env[CLINE_POOL_FILE_ENV] || "").trim();
|
|
24
|
+
if (override) return override;
|
|
25
|
+
return path.join(path.dirname(RELAY_STATE_FILE), "pi-freeflow-cline-pool.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recovery copy of the pool, read only when the main file is missing or
|
|
32
|
+
* unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
|
|
33
|
+
* difference: an empty pool is a legitimate state here (`/freeflow cline
|
|
34
|
+
* logout` of the last login must stick), so a valid empty main file is never
|
|
35
|
+
* overridden from the backup.
|
|
36
|
+
*/
|
|
37
|
+
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
38
|
+
|
|
39
|
+
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
40
|
+
export interface ClineAccount {
|
|
41
|
+
slot: string;
|
|
42
|
+
token: string;
|
|
43
|
+
addedAt: string;
|
|
44
|
+
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
45
|
+
refreshToken?: string;
|
|
46
|
+
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
47
|
+
expiresAt?: number;
|
|
48
|
+
/** WorkOS account id, shown when choosing which login to remove. */
|
|
49
|
+
accountId?: string;
|
|
50
|
+
/** Login email, shown when choosing which login to remove. */
|
|
51
|
+
email?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ClinePoolState {
|
|
55
|
+
accounts: ClineAccount[];
|
|
56
|
+
activeSlot?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Daily free-limit resets per slot per *requested* model id (epoch ms):
|
|
59
|
+
* `limits[slot][modelId] = resetAt`. Cline reports its own model name in the
|
|
60
|
+
* 429 body, which never matches the id we asked for, so the requested id is
|
|
61
|
+
* the only key a later request can look up. Expired entries are dropped on
|
|
62
|
+
* read. Optional: pools written before this existed load unchanged.
|
|
63
|
+
*/
|
|
64
|
+
limits?: Record<string, Record<string, number>>;
|
|
65
|
+
/**
|
|
66
|
+
* Per-slot serve counters: `usage[slot] = { served, lastAt, lastModel }`.
|
|
67
|
+
* Bumped every time the slot serves a turn (`served` counts up, `lastAt`
|
|
68
|
+
* is epoch ms, `lastModel` the requested model id). Counters only — no
|
|
69
|
+
* token, email, or other identity ever lives here; callers map
|
|
70
|
+
* slot→identity at display time. Optional: pools written before this
|
|
71
|
+
* existed load unchanged.
|
|
72
|
+
*/
|
|
73
|
+
usage?: Record<string, ClineSlotUsage>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One slot's serve counters. See `ClinePoolState.usage`. */
|
|
77
|
+
export interface ClineSlotUsage {
|
|
78
|
+
served: number;
|
|
79
|
+
lastAt: number;
|
|
80
|
+
lastModel: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fresh tokens minted via the device-login refresh flow. DeviceAuth owns the
|
|
85
|
+
* network call; this module only applies the result to the pool file.
|
|
86
|
+
*/
|
|
87
|
+
export interface ClineRefreshResult {
|
|
88
|
+
token: string;
|
|
89
|
+
refreshToken?: string;
|
|
90
|
+
expiresAt?: number;
|
|
91
|
+
accountId?: string;
|
|
92
|
+
email?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Refresh one slot. Resolves to the fresh tokens, or null when the grant is
|
|
97
|
+
* dead (invalid_grant — the slot must be logged in again). May throw on
|
|
98
|
+
* transient network faults; callers treat a throw as "keep the stale token".
|
|
99
|
+
*/
|
|
100
|
+
export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
|
|
101
|
+
|
|
102
|
+
/** Test-only: drop the loadPool mtime cache. */
|
|
103
|
+
export function _resetClinePoolCacheForTest(): void {
|
|
104
|
+
cached = null;
|
|
105
|
+
cachedMtime = -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Version this proxy reports as its Cline client build. */
|
|
109
|
+
export const CLINE_CLIENT_VERSION = "3.5.54";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Client headers the Cline API expects. Measured live (2026-09-21): the
|
|
113
|
+
* `cline-free/*` models answer 403 "only available via Cline product surfaces"
|
|
114
|
+
* with a bare bearer, and 200 with this set; every non-prefixed catalog model
|
|
115
|
+
* (e.g. z-ai/glm-5.3-flash) answers 200 either way. Header names and shape
|
|
116
|
+
* mirror reference/cline providers/request-headers.ts
|
|
117
|
+
* (DEFAULT_CLINE_REQUEST_HEADERS) — no credential is derived from them.
|
|
118
|
+
*
|
|
119
|
+
* `X-CLIENT-TYPE` is the Cline desktop app's identity, and it is what the
|
|
120
|
+
* free-model feed keys on (measured live 2026-09-22). The recommended-models
|
|
121
|
+
* endpoint returns five free entries for `cline-sdk` / `cline-cli` / the
|
|
122
|
+
* `VSCode Extension` label, but six for `cline-desktop` — the extra one being
|
|
123
|
+
* `cline-free/kimi-k3`. The identity header alone flips that list
|
|
124
|
+
* (`X-PLATFORM: Cline Desktop` does not; `X-CLIENT-TYPE` decides), and the
|
|
125
|
+
* sixth model serves live chat completions (HTTP 200 + SSE) for the saved
|
|
126
|
+
* per-user login under either identity. Version and User-Agent stay as-is:
|
|
127
|
+
* the feed serves the six-model list with them unchanged.
|
|
128
|
+
*/
|
|
129
|
+
export const CLINE_CLIENT_HEADERS: Record<string, string> = {
|
|
130
|
+
"HTTP-Referer": "https://cline.bot",
|
|
131
|
+
"X-Title": "Cline",
|
|
132
|
+
"X-IS-MULTIROOT": "false",
|
|
133
|
+
"X-CLIENT-TYPE": "cline-desktop",
|
|
134
|
+
"X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
|
|
135
|
+
"User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Free-limit report carried by a Cline 429 body, e.g.
|
|
140
|
+
* {"error":{"code":"INFERENCE_CAP_ERROR","message":"Error 429: Daily free limit
|
|
141
|
+
* reached on model deepseek/deepseek-v4.1-flash. Try again in 20h 4m"}}.
|
|
142
|
+
*
|
|
143
|
+
* `modelId` is the name Cline spells in its own body, which is a different
|
|
144
|
+
* namespace from the id this proxy requests (measured live 2026-09-22: we ask
|
|
145
|
+
* for `cline-free/deepseek-v4.1-flash`, Cline answers about
|
|
146
|
+
* `deepseek/deepseek-v4.1-flash`). The limit cache is therefore keyed by the
|
|
147
|
+
* requested id; `modelId` is diagnostics only.
|
|
148
|
+
*/
|
|
149
|
+
export interface ClineFreeLimit {
|
|
150
|
+
/** Model name as the upstream body spells it. */
|
|
151
|
+
modelId?: string;
|
|
152
|
+
/** Epoch ms the cap lifts; absent when the body stated no delay. */
|
|
153
|
+
resetAt?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const CLINE_FREE_LIMIT_MARKER = /free limit reached on model/i;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delay stated as `try again in <N>h <N>m`, `<N>m`, or a bare `<N>` (hours).
|
|
160
|
+
* Returns null when the body states no delay this parser understands.
|
|
161
|
+
*/
|
|
162
|
+
function parseResetDelayMs(text: string): number | null {
|
|
163
|
+
const hoursMinutes = text.match(/try again in\s+([0-9]+)\s*h(?:ours?|rs?)?(?:\s*([0-9]+)\s*m(?:in(?:utes?)?)?)?/i);
|
|
164
|
+
if (hoursMinutes) {
|
|
165
|
+
const hours = Number(hoursMinutes[1]);
|
|
166
|
+
const minutes = hoursMinutes[2] ? Number(hoursMinutes[2]) : 0;
|
|
167
|
+
return (hours * 60 + minutes) * 60_000;
|
|
168
|
+
}
|
|
169
|
+
const minutesOnly = text.match(/try again in\s+([0-9]+)\s*m(?:in(?:utes?)?)?/i);
|
|
170
|
+
if (minutesOnly) return Number(minutesOnly[1]) * 60_000;
|
|
171
|
+
const bareHours = text.match(/try again in\s+([0-9]+)(?![0-9a-z])/i);
|
|
172
|
+
return bareHours ? Number(bareHours[1]) * 3600_000 : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse an upstream 429 body for the daily free-limit marker. Returns null for
|
|
177
|
+
* anything else, so a plain edge 429 keeps its existing behavior.
|
|
178
|
+
*/
|
|
179
|
+
export function parseClineFreeLimit(raw: string, now = Date.now()): ClineFreeLimit | null {
|
|
180
|
+
if (typeof raw !== "string" || !CLINE_FREE_LIMIT_MARKER.test(raw)) return null;
|
|
181
|
+
const named = raw.match(/free limit reached on model\s+(\S+)/i);
|
|
182
|
+
// The model name ends the sentence, so a trailing "." belongs to the prose.
|
|
183
|
+
const modelId = named ? named[1].replace(/[.,;:]+$/, "") : "";
|
|
184
|
+
const delay = parseResetDelayMs(raw);
|
|
185
|
+
return {
|
|
186
|
+
...(modelId ? { modelId } : {}),
|
|
187
|
+
...(delay !== null ? { resetAt: now + delay } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Machine-classified outcome of one Cline chat attempt. */
|
|
192
|
+
export type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
|
|
193
|
+
|
|
194
|
+
export function mapClineError(status: number): ClineErrorKind {
|
|
195
|
+
if (status >= 200 && status < 300) return "ok";
|
|
196
|
+
if (status === 401 || status === 403) return "auth";
|
|
197
|
+
if (status === 429) return "rate-limit";
|
|
198
|
+
if (status === 402) return "exhausted";
|
|
199
|
+
if (status >= 500) return "server";
|
|
200
|
+
return "client";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function emptyPool(): ClinePoolState {
|
|
204
|
+
return { accounts: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read the optional `limits` map out of a pool document, dropping anything
|
|
209
|
+
* malformed and every entry whose reset has already passed. Returns undefined
|
|
210
|
+
* when nothing usable is left, so a pool that never saw a cap keeps its old
|
|
211
|
+
* shape on disk.
|
|
212
|
+
*/
|
|
213
|
+
function parseLimits(raw: unknown, now: number): Record<string, Record<string, number>> | undefined {
|
|
214
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
215
|
+
const limits: Record<string, Record<string, number>> = {};
|
|
216
|
+
for (const [slot, modelsRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
217
|
+
if (!slot.trim()) continue;
|
|
218
|
+
if (typeof modelsRaw !== "object" || modelsRaw === null || Array.isArray(modelsRaw)) continue;
|
|
219
|
+
const models: Record<string, number> = {};
|
|
220
|
+
for (const [model, resetRaw] of Object.entries(modelsRaw as Record<string, unknown>)) {
|
|
221
|
+
if (!model.trim()) continue;
|
|
222
|
+
if (typeof resetRaw !== "number" || !Number.isFinite(resetRaw) || resetRaw <= now) continue;
|
|
223
|
+
models[model] = resetRaw;
|
|
224
|
+
}
|
|
225
|
+
if (Object.keys(models).length > 0) limits[slot.trim()] = models;
|
|
226
|
+
}
|
|
227
|
+
return Object.keys(limits).length > 0 ? limits : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Read the optional `usage` map out of a pool document, dropping anything
|
|
232
|
+
* malformed. Returns undefined when nothing usable is left, so a pool that
|
|
233
|
+
* never served a turn keeps its old shape on disk.
|
|
234
|
+
*/
|
|
235
|
+
function parseUsage(raw: unknown): Record<string, ClineSlotUsage> | undefined {
|
|
236
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
237
|
+
const usage: Record<string, ClineSlotUsage> = {};
|
|
238
|
+
for (const [slot, entryRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
239
|
+
if (!slot.trim()) continue;
|
|
240
|
+
if (typeof entryRaw !== "object" || entryRaw === null || Array.isArray(entryRaw)) continue;
|
|
241
|
+
const entry: Record<string, unknown> = entryRaw as Record<string, unknown>;
|
|
242
|
+
const served = entry.served;
|
|
243
|
+
const lastAt = entry.lastAt;
|
|
244
|
+
const lastModel = entry.lastModel;
|
|
245
|
+
if (typeof served !== "number" || !Number.isFinite(served) || served < 0) continue;
|
|
246
|
+
if (typeof lastAt !== "number" || !Number.isFinite(lastAt) || lastAt <= 0) continue;
|
|
247
|
+
if (typeof lastModel !== "string") continue;
|
|
248
|
+
usage[slot.trim()] = { served: Math.floor(served), lastAt, lastModel };
|
|
249
|
+
}
|
|
250
|
+
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse a pool document. Returns null when the blob is unusable (bad JSON,
|
|
255
|
+
* wrong shape) so callers can fall back to the backup copy. A valid document
|
|
256
|
+
* with zero accounts is NOT null: that is a real empty pool.
|
|
257
|
+
*/
|
|
258
|
+
function parsePoolDoc(raw: string): ClinePoolState | null {
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(raw);
|
|
262
|
+
} catch {
|
|
263
|
+
logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
267
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
// Boundary-narrowed once: the on-disk blob is external input, so check
|
|
271
|
+
// its shape here and read only validated fields below.
|
|
272
|
+
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
273
|
+
if (!Array.isArray(doc.accounts)) {
|
|
274
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const accounts: ClineAccount[] = [];
|
|
278
|
+
for (const entry of doc.accounts as unknown[]) {
|
|
279
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
280
|
+
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
281
|
+
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
282
|
+
const token = typeof rec.token === "string" ? rec.token : "";
|
|
283
|
+
if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
|
|
284
|
+
const refreshRaw: unknown = rec.refreshToken;
|
|
285
|
+
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
286
|
+
const expiresRaw: unknown = rec.expiresAt;
|
|
287
|
+
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
288
|
+
? expiresRaw
|
|
289
|
+
: undefined;
|
|
290
|
+
const accountIdRaw: unknown = rec.accountId;
|
|
291
|
+
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
292
|
+
const emailRaw: unknown = rec.email;
|
|
293
|
+
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
294
|
+
accounts.push({
|
|
295
|
+
slot,
|
|
296
|
+
token,
|
|
297
|
+
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
298
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
299
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
300
|
+
...(accountId ? { accountId } : {}),
|
|
301
|
+
...(email ? { email } : {}),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const activeRaw: unknown = doc.activeSlot;
|
|
305
|
+
const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
|
|
306
|
+
? activeRaw.trim()
|
|
307
|
+
: undefined;
|
|
308
|
+
const limits = parseLimits(doc.limits, Date.now());
|
|
309
|
+
const usage = parseUsage(doc.usage);
|
|
310
|
+
const state: ClinePoolState = { accounts };
|
|
311
|
+
if (activeSlot) state.activeSlot = activeSlot;
|
|
312
|
+
if (limits) state.limits = limits;
|
|
313
|
+
if (usage) state.usage = usage;
|
|
314
|
+
return state;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Recover the pool from the backup copy and heal the main file so the recovery
|
|
319
|
+
* sticks. Returns null when no usable backup exists. Only ever called for a
|
|
320
|
+
* missing or unusable main file — never to override a valid empty pool.
|
|
321
|
+
*/
|
|
322
|
+
function recoverPoolFromBackup(description: string): ClinePoolState | null {
|
|
323
|
+
try {
|
|
324
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
325
|
+
if (parsed && parsed.accounts.length > 0) {
|
|
326
|
+
logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
|
|
327
|
+
savePool(parsed);
|
|
328
|
+
return parsed;
|
|
329
|
+
}
|
|
330
|
+
} catch { }
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function readPoolFile(): ClinePoolState {
|
|
335
|
+
try {
|
|
336
|
+
if (!fs.existsSync(CLINE_POOL_FILE)) {
|
|
337
|
+
return recoverPoolFromBackup("main file missing") ?? emptyPool();
|
|
338
|
+
}
|
|
339
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
340
|
+
if (parsed) return parsed;
|
|
341
|
+
return recoverPoolFromBackup("file unusable") ?? emptyPool();
|
|
342
|
+
} catch {
|
|
343
|
+
return emptyPool();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let cached: ClinePoolState | null = null;
|
|
348
|
+
let cachedMtime = -1;
|
|
349
|
+
|
|
350
|
+
function diskMtime(): number {
|
|
351
|
+
try {
|
|
352
|
+
return fs.statSync(CLINE_POOL_FILE).mtimeMs;
|
|
353
|
+
} catch {
|
|
354
|
+
return -1;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Load the pool, re-reading from disk only when another process changed the
|
|
360
|
+
* file (mtime moved). Never throws; corrupt reads come back empty.
|
|
361
|
+
*/
|
|
362
|
+
export function loadPool(): ClinePoolState {
|
|
363
|
+
const m = diskMtime();
|
|
364
|
+
if (cached && m === cachedMtime) return cached;
|
|
365
|
+
cached = readPoolFile();
|
|
366
|
+
cachedMtime = diskMtime();
|
|
367
|
+
return cached;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Keep the largest pool ever seen as the recovery copy.
|
|
372
|
+
*
|
|
373
|
+
* Largest-wins rather than last-write-wins, deliberately. The backup is read
|
|
374
|
+
* only when the main file is missing or unreadable, and two failure modes had
|
|
375
|
+
* to be covered at once:
|
|
376
|
+
* - an existing richer pool being overwritten (compare the file being
|
|
377
|
+
* replaced, before the write), and
|
|
378
|
+
* - growth being lost because the backup always lagged one write (compare
|
|
379
|
+
* the pool just written, after the write).
|
|
380
|
+
* Ties do not replace, so a run of equally-sized destructive writes cannot
|
|
381
|
+
* evict a good copy — which is how a real pool was lost: two good logins were
|
|
382
|
+
* replaced by fixtures, and the second fixture write replaced the backup too.
|
|
383
|
+
*/
|
|
384
|
+
function keepLargestBackup(raw: string): void {
|
|
385
|
+
try {
|
|
386
|
+
const count = accountCountIn(raw);
|
|
387
|
+
if (count <= 0) return;
|
|
388
|
+
let backupCount = -1;
|
|
389
|
+
try {
|
|
390
|
+
backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
391
|
+
} catch {
|
|
392
|
+
backupCount = -1;
|
|
393
|
+
}
|
|
394
|
+
if (count <= backupCount) return;
|
|
395
|
+
fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
|
|
396
|
+
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
397
|
+
} catch { }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** How many accounts a raw pool blob holds; -1 when it is unreadable. */
|
|
401
|
+
function accountCountIn(raw: string): number {
|
|
402
|
+
try {
|
|
403
|
+
const doc = JSON.parse(raw) as { accounts?: unknown };
|
|
404
|
+
return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
|
|
405
|
+
} catch {
|
|
406
|
+
return -1;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Atomically persist the pool with owner-only permissions. Never logs secrets. */
|
|
411
|
+
export function savePool(pool: ClinePoolState): void {
|
|
412
|
+
try {
|
|
413
|
+
const dir = path.dirname(CLINE_POOL_FILE);
|
|
414
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
415
|
+
// The pool being replaced is the only copy of itself: secure it first.
|
|
416
|
+
try {
|
|
417
|
+
if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
418
|
+
} catch { }
|
|
419
|
+
const tmp = `${CLINE_POOL_FILE}.${Date.now()}.tmp`;
|
|
420
|
+
const serialized = JSON.stringify(pool, null, 2);
|
|
421
|
+
fs.writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
|
|
422
|
+
try {
|
|
423
|
+
fs.chmodSync(tmp, 0o600);
|
|
424
|
+
} catch { }
|
|
425
|
+
fs.renameSync(tmp, CLINE_POOL_FILE);
|
|
426
|
+
// Compare the pool just written too, so growth is never the copy that lags.
|
|
427
|
+
keepLargestBackup(serialized);
|
|
428
|
+
cached = pool;
|
|
429
|
+
cachedMtime = diskMtime();
|
|
430
|
+
} catch (e) {
|
|
431
|
+
logWarn("could not persist cline pool", { slots: pool.accounts.length });
|
|
432
|
+
void e;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Last-4 display for a token; the full value is never shown or logged. */
|
|
437
|
+
export function redactedToken(token: string): string {
|
|
438
|
+
const tail = (token || "").slice(-4) || "????";
|
|
439
|
+
return `…${tail}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Persist a patch to one slot against the file as it is on disk right now.
|
|
444
|
+
* A request holds its pool snapshot across network round trips, and the host
|
|
445
|
+
* process writes the same file for login/logout, so writing the snapshot back
|
|
446
|
+
* would revert a login added or removed while the request was in flight. A
|
|
447
|
+
* slot that no longer exists is left alone rather than resurrected.
|
|
448
|
+
*/
|
|
449
|
+
function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
|
|
450
|
+
const live = readPoolFile();
|
|
451
|
+
const target = live.accounts.find((a) => a.slot === slot);
|
|
452
|
+
if (!target) return;
|
|
453
|
+
Object.assign(target, patch);
|
|
454
|
+
savePool(live);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Record which slot served a turn, without clobbering concurrent edits. */
|
|
458
|
+
function markActiveSlotOnDisk(slot: string): void {
|
|
459
|
+
const live = readPoolFile();
|
|
460
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
461
|
+
live.activeSlot = slot;
|
|
462
|
+
savePool(live);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Bump one slot's serve counters against the file as it is on disk right
|
|
467
|
+
* now: a request holds its pool snapshot across round trips, and the host
|
|
468
|
+
* process writes the same file for login/logout, so writing the snapshot
|
|
469
|
+
* back would revert a login added or removed while the request was in
|
|
470
|
+
* flight. A slot that no longer exists is left alone rather than
|
|
471
|
+
* resurrected. The row carries counters only — never token or identity.
|
|
472
|
+
*/
|
|
473
|
+
export function recordClineUsageOnDisk(slot: string, modelId: string): void {
|
|
474
|
+
const cleanSlot = (slot || "").trim();
|
|
475
|
+
if (!cleanSlot) return;
|
|
476
|
+
const live = readPoolFile();
|
|
477
|
+
if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
|
|
478
|
+
const prev = live.usage?.[cleanSlot];
|
|
479
|
+
const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
|
|
480
|
+
? Math.floor(prev.served) + 1
|
|
481
|
+
: 1;
|
|
482
|
+
live.usage = {
|
|
483
|
+
...(live.usage ?? {}),
|
|
484
|
+
[cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
|
|
485
|
+
};
|
|
486
|
+
savePool(live);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The reset still in force for one slot+requested model, or null when free. */
|
|
490
|
+
function activeLimitFor(pool: ClinePoolState, slot: string, model: string, now: number): number | null {
|
|
491
|
+
if (!model) return null;
|
|
492
|
+
const at = pool.limits?.[slot]?.[model];
|
|
493
|
+
return typeof at === "number" && Number.isFinite(at) && at > now ? at : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Persist one free-limit observation against the file as it is on disk right
|
|
498
|
+
* now: a request holds its pool snapshot across round trips, and the host
|
|
499
|
+
* process writes the same file for login/logout, so writing the snapshot back
|
|
500
|
+
* would revert a login added or removed while the request was in flight.
|
|
501
|
+
*/
|
|
502
|
+
function recordLimitOnDisk(slot: string, model: string, resetAt: number): void {
|
|
503
|
+
if (!model) return;
|
|
504
|
+
const live = readPoolFile();
|
|
505
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
506
|
+
const known = live.limits?.[slot]?.[model];
|
|
507
|
+
// Keep the later of the two: a fresh report must not shorten a window that is
|
|
508
|
+
// already known, and repeating the same reset writes nothing.
|
|
509
|
+
if (typeof known === "number" && known >= resetAt) return;
|
|
510
|
+
const limits = live.limits ?? {};
|
|
511
|
+
limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
|
|
512
|
+
live.limits = limits;
|
|
513
|
+
savePool(live);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Drop one slot+model limit once that slot serves the model again. */
|
|
517
|
+
function clearLimitOnDisk(slot: string, model: string): void {
|
|
518
|
+
if (!model) return;
|
|
519
|
+
const live = readPoolFile();
|
|
520
|
+
const limits = live.limits;
|
|
521
|
+
const forSlot = limits?.[slot];
|
|
522
|
+
if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
|
|
523
|
+
delete forSlot[model];
|
|
524
|
+
if (Object.keys(forSlot).length === 0) delete limits[slot];
|
|
525
|
+
if (Object.keys(limits).length === 0) delete live.limits;
|
|
526
|
+
savePool(live);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Identity of a Cline login. Two slots holding the same account share one free
|
|
531
|
+
* quota, so rotating between them buys nothing — callers use this to detect
|
|
532
|
+
* that instead of saving a duplicate.
|
|
533
|
+
*
|
|
534
|
+
* accountId (WorkOS user id) is authoritative; email is the readable fallback.
|
|
535
|
+
* When neither side carries an identity — a legacy `clp_` key slot has none —
|
|
536
|
+
* only an identical bearer proves the same account.
|
|
537
|
+
*/
|
|
538
|
+
function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
|
|
539
|
+
const idA = account.accountId?.trim();
|
|
540
|
+
const idB = identity.accountId?.trim();
|
|
541
|
+
if (idA && idB) return idA === idB;
|
|
542
|
+
const mailA = account.email?.trim().toLowerCase();
|
|
543
|
+
const mailB = identity.email?.trim().toLowerCase();
|
|
544
|
+
if (mailA && mailB) return mailA === mailB;
|
|
545
|
+
const token = identity.token;
|
|
546
|
+
return typeof token === "string" && token.length > 0 && account.token === token;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The other slot already holding this account, or null when it is new.
|
|
551
|
+
* `exceptSlot` skips the slot being written, so re-logging into an existing
|
|
552
|
+
* slot is never treated as a duplicate.
|
|
553
|
+
*/
|
|
554
|
+
export function findClineAccountSlot(
|
|
555
|
+
pool: ClinePoolState,
|
|
556
|
+
identity: { token?: string; accountId?: string; email?: string },
|
|
557
|
+
exceptSlot?: string,
|
|
558
|
+
): string | null {
|
|
559
|
+
const hit = pool.accounts.find(
|
|
560
|
+
(a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
|
|
561
|
+
);
|
|
562
|
+
return hit ? hit.slot : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Save (or replace) one login slot. The token must carry the `workos:` prefix.
|
|
567
|
+
* Throws on bad input — the message never echoes the token.
|
|
568
|
+
* Extras carry the device-login grant (refresh token, expiry, identity);
|
|
569
|
+
* legacy key slots keep calling with two args and load untouched.
|
|
570
|
+
* Refuses an account that another slot already holds.
|
|
571
|
+
*/
|
|
572
|
+
export function addAccount(
|
|
573
|
+
slot: string,
|
|
574
|
+
token: string,
|
|
575
|
+
extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
|
|
576
|
+
): ClinePoolState {
|
|
577
|
+
const cleanSlot = (slot || "").trim();
|
|
578
|
+
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
579
|
+
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
580
|
+
if (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
|
|
581
|
+
const pool = loadPool();
|
|
582
|
+
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
583
|
+
const duplicate = findClineAccountSlot(pool, { token, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
|
|
584
|
+
if (duplicate) {
|
|
585
|
+
const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
|
|
586
|
+
throw new Error(`That Cline account (${who}) is already saved as [${duplicate}] — log in with a different account, or use /freeflow cline logout ${duplicate} first`);
|
|
587
|
+
}
|
|
588
|
+
if (existing) {
|
|
589
|
+
existing.token = token;
|
|
590
|
+
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
591
|
+
if (extras?.expiresAt !== undefined) {
|
|
592
|
+
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
593
|
+
else delete existing.expiresAt;
|
|
594
|
+
}
|
|
595
|
+
if (extras?.accountId !== undefined) {
|
|
596
|
+
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
597
|
+
else delete existing.accountId;
|
|
598
|
+
}
|
|
599
|
+
if (extras?.email !== undefined) {
|
|
600
|
+
if (extras.email.trim()) existing.email = extras.email.trim();
|
|
601
|
+
else delete existing.email;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
pool.accounts.push({
|
|
605
|
+
slot: cleanSlot,
|
|
606
|
+
token,
|
|
607
|
+
addedAt: new Date().toISOString(),
|
|
608
|
+
...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
|
|
609
|
+
...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
|
|
610
|
+
? { expiresAt: extras.expiresAt }
|
|
611
|
+
: {}),
|
|
612
|
+
...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
|
|
613
|
+
...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
617
|
+
savePool(pool);
|
|
618
|
+
return pool;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
|
|
622
|
+
export const CLINE_REFRESH_SKEW_MS = 60_000;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* True when the slot carries a known expiry that has passed (or passes within
|
|
626
|
+
* the skew window). Slots without `expiresAt` — every legacy key slot — are
|
|
627
|
+
* never stale: they load and serve exactly as before.
|
|
628
|
+
*/
|
|
629
|
+
export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_SKEW_MS): boolean {
|
|
630
|
+
if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false;
|
|
631
|
+
return Date.now() + skewMs >= account.expiresAt;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
|
|
636
|
+
* dead grant with nothing to keep (`false`), or a transient fault where the
|
|
637
|
+
* caller should fall back to the stale bearer (`null`).
|
|
638
|
+
*/
|
|
639
|
+
async function refreshAccountInPlace(
|
|
640
|
+
account: ClineAccount,
|
|
641
|
+
refreshImpl: ClineRefresher,
|
|
642
|
+
): Promise<boolean | null> {
|
|
643
|
+
const current = account.refreshToken;
|
|
644
|
+
if (!current) return null;
|
|
645
|
+
let fresh: ClineRefreshResult | null;
|
|
646
|
+
try {
|
|
647
|
+
fresh = await refreshImpl(current);
|
|
648
|
+
} catch (e) {
|
|
649
|
+
logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
|
|
650
|
+
void e;
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
if (!fresh || typeof fresh.token !== "string" || (!fresh.token.startsWith("workos:") && !isWorkosJwt(fresh.token) && !fresh.token.startsWith("clp_"))) {
|
|
654
|
+
logWarn("cline slot refresh rejected — grant is dead", { slot: account.slot });
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
account.token = fresh.token;
|
|
658
|
+
if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
|
|
659
|
+
if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
|
|
660
|
+
account.expiresAt = fresh.expiresAt;
|
|
661
|
+
} else {
|
|
662
|
+
delete account.expiresAt;
|
|
663
|
+
}
|
|
664
|
+
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
665
|
+
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
666
|
+
// Persist only the refreshed fields: the snapshot this request holds may
|
|
667
|
+
// predate a login added or removed in the host process.
|
|
668
|
+
patchAccountOnDisk(account.slot, {
|
|
669
|
+
token: account.token,
|
|
670
|
+
...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
|
|
671
|
+
expiresAt: account.expiresAt,
|
|
672
|
+
...(account.accountId ? { accountId: account.accountId } : {}),
|
|
673
|
+
...(account.email ? { email: account.email } : {}),
|
|
674
|
+
});
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
679
|
+
export function removeAccount(slot: string): boolean {
|
|
680
|
+
const cleanSlot = (slot || "").trim();
|
|
681
|
+
const pool = loadPool();
|
|
682
|
+
const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
|
|
683
|
+
if (idx < 0) return false;
|
|
684
|
+
pool.accounts.splice(idx, 1);
|
|
685
|
+
if (pool.usage?.[cleanSlot] !== undefined) {
|
|
686
|
+
delete pool.usage[cleanSlot];
|
|
687
|
+
if (Object.keys(pool.usage).length === 0) delete pool.usage;
|
|
688
|
+
}
|
|
689
|
+
if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
|
|
690
|
+
savePool(pool);
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The model id this request asks Cline for — the key every recorded limit is
|
|
696
|
+
* looked up under. Empty when the body carries no usable model, which
|
|
697
|
+
* disables limit bookkeeping for the roll rather than guessing a key.
|
|
698
|
+
*/
|
|
699
|
+
function requestedModelId(body: string): string {
|
|
700
|
+
try {
|
|
701
|
+
const parsed: unknown = JSON.parse(body);
|
|
702
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
703
|
+
const model = (parsed as Record<string, unknown>).model;
|
|
704
|
+
return typeof model === "string" ? model.trim() : "";
|
|
705
|
+
} catch {
|
|
706
|
+
return "";
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Read a free-limit report off a 429 body through a clone: the response handed
|
|
712
|
+
* back to the caller must stay readable, so its body is never consumed here.
|
|
713
|
+
*/
|
|
714
|
+
async function freeLimitFrom(res: Response, now: number): Promise<ClineFreeLimit | null> {
|
|
715
|
+
try {
|
|
716
|
+
return parseClineFreeLimit(await res.clone().text(), now);
|
|
717
|
+
} catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export interface ClineRollOpts {
|
|
723
|
+
/** Serialized chat-completions body, reused verbatim on every attempt. */
|
|
724
|
+
body: string;
|
|
725
|
+
/** Chat completions endpoint (proxy passes its configured URL explicitly). */
|
|
726
|
+
chatUrl: string;
|
|
727
|
+
fetchImpl?: FetchImpl;
|
|
728
|
+
/**
|
|
729
|
+
* Device-login refresher. When present, a stale slot refreshes once before
|
|
730
|
+
* its attempt, and a 401/403 refreshes once before the slot is skipped.
|
|
731
|
+
* Absent: every slot serves its stored bearer exactly as before.
|
|
732
|
+
*/
|
|
733
|
+
refreshImpl?: ClineRefresher;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface ClineRollResult {
|
|
737
|
+
res: Response;
|
|
738
|
+
/** Slot that served the response; null when no account was tried. */
|
|
739
|
+
slot: string | null;
|
|
740
|
+
/** True when every account failed (or the pool is empty). */
|
|
741
|
+
exhausted: boolean;
|
|
742
|
+
kind: ClineErrorKind;
|
|
743
|
+
/**
|
|
744
|
+
* Saved logins this roll had to work with (the candidate count). On an
|
|
745
|
+
* exhausted result every one of them was tried, so a caller may name this
|
|
746
|
+
* number when telling the user how many logins are capped. It is never an
|
|
747
|
+
* upstream-attempt count: one login that refreshes and retries is still one.
|
|
748
|
+
*/
|
|
749
|
+
logins: number;
|
|
750
|
+
/**
|
|
751
|
+
* True when the roll tried at least one login and every login it tried
|
|
752
|
+
* answered the free-limit 429 for the requested model — nothing else was
|
|
753
|
+
* tried and nothing succeeded. A login skipped for a dead grant keeps this
|
|
754
|
+
* false, as does any other failure mixed into the roll.
|
|
755
|
+
*/
|
|
756
|
+
limitOnly: boolean;
|
|
757
|
+
/** Earliest reset among the free-limit 429s seen (epoch ms); null when none was stated. */
|
|
758
|
+
earliestResetAt: number | null;
|
|
759
|
+
/** Model name as Cline spelled it in its own body, when a free limit was reported. */
|
|
760
|
+
limitModelId?: string;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Synthetic body for the two states that never reached Cline: nothing is
|
|
765
|
+
* saved to sign in with, or no saved login could reach Cline. Neither is a
|
|
766
|
+
* rate limit, so it must not answer 429 — that status makes hosts back off
|
|
767
|
+
* and hide the actionable message behind a "rate limited" notice.
|
|
768
|
+
*/
|
|
769
|
+
function exhaustedResponse(reason: string, status: number): Response {
|
|
770
|
+
return new Response(
|
|
771
|
+
JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
|
|
772
|
+
{ status, headers: { "content-type": "application/json" } },
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* POST one chat body to the Cline endpoint, rolling across every saved slot.
|
|
778
|
+
* Each attempt sends its slot bearer explicitly; raw tokens never leave this
|
|
779
|
+
* module. Superseded failure bodies are cancelled here; the response handed
|
|
780
|
+
* back to the caller is left untouched so the caller can read it.
|
|
781
|
+
*/
|
|
782
|
+
export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
783
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
784
|
+
const pool = loadPool();
|
|
785
|
+
const now = Date.now();
|
|
786
|
+
const requestedModel = requestedModelId(opts.body);
|
|
787
|
+
let candidates = [...pool.accounts];
|
|
788
|
+
if (pool.activeSlot) {
|
|
789
|
+
const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
|
|
790
|
+
if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
|
|
791
|
+
}
|
|
792
|
+
if (requestedModel) {
|
|
793
|
+
// A slot recorded on this model's daily cap almost always answers 429 again:
|
|
794
|
+
// it is tried last so a healthy login serves the turn without paying the
|
|
795
|
+
// extra round trip. It stays in the list — a cap can lift before its
|
|
796
|
+
// recorded reset, and only a real attempt can discover that.
|
|
797
|
+
const open: ClineAccount[] = [];
|
|
798
|
+
const capped: ClineAccount[] = [];
|
|
799
|
+
for (const account of candidates) {
|
|
800
|
+
(activeLimitFor(pool, account.slot, requestedModel, now) === null ? open : capped).push(account);
|
|
801
|
+
}
|
|
802
|
+
candidates = [...open, ...capped];
|
|
803
|
+
}
|
|
804
|
+
if (candidates.length === 0) {
|
|
805
|
+
const reason = "No Cline logins saved — add one with /freeflow cline login";
|
|
806
|
+
logWarn("cline pool empty", { slots: pool.accounts.length });
|
|
807
|
+
return { res: exhaustedResponse(reason, 401), slot: null, exhausted: true, kind: "exhausted", logins: 0, limitOnly: false, earliestResetAt: null };
|
|
808
|
+
}
|
|
809
|
+
let lastRes: Response | null = null;
|
|
810
|
+
let lastSlot: string | null = null;
|
|
811
|
+
let lastKind: ClineErrorKind = "exhausted";
|
|
812
|
+
// Distinct logins that answered the cap, so one login refreshing and retrying
|
|
813
|
+
// through the 401 path still counts once.
|
|
814
|
+
const cappedSlots = new Set<string>();
|
|
815
|
+
let earliestResetAt: number | null = null;
|
|
816
|
+
let limitModelId: string | undefined;
|
|
817
|
+
for (const account of candidates) {
|
|
818
|
+
// Stale device-login bearer: one refresh before the attempt. A dead grant
|
|
819
|
+
// skips the slot for this turn; a transient fault keeps the stale token.
|
|
820
|
+
let refreshedThisSlot = false;
|
|
821
|
+
if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
|
|
822
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
823
|
+
if (outcome === false) {
|
|
824
|
+
logWarn("cline slot refresh rejected — skipping slot this turn", { slot: account.slot });
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
refreshedThisSlot = outcome === true;
|
|
828
|
+
}
|
|
829
|
+
const attempt = async (token: string): Promise<Response | null> => {
|
|
830
|
+
try {
|
|
831
|
+
return await fetchImpl(opts.chatUrl, {
|
|
832
|
+
method: "POST",
|
|
833
|
+
headers: {
|
|
834
|
+
"content-type": "application/json",
|
|
835
|
+
...CLINE_CLIENT_HEADERS,
|
|
836
|
+
authorization: `Bearer ${token}`,
|
|
837
|
+
},
|
|
838
|
+
body: opts.body,
|
|
839
|
+
});
|
|
840
|
+
} catch (e) {
|
|
841
|
+
logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
|
|
842
|
+
void e;
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
const succeed = async (res: Response, kind: ClineErrorKind): Promise<ClineRollResult> => {
|
|
847
|
+
if (kind === "ok") {
|
|
848
|
+
markActiveSlotOnDisk(account.slot);
|
|
849
|
+
// This slot just served the model, so whatever cap it was on has lifted.
|
|
850
|
+
// Reads the file as it is on disk and writes only when an entry exists.
|
|
851
|
+
clearLimitOnDisk(account.slot, requestedModel);
|
|
852
|
+
// Counters only — no token or identity ever lands in the usage row.
|
|
853
|
+
recordClineUsageOnDisk(account.slot, requestedModel);
|
|
854
|
+
}
|
|
855
|
+
if (lastRes && lastRes !== res) {
|
|
856
|
+
try { await lastRes.body?.cancel(); } catch { }
|
|
857
|
+
}
|
|
858
|
+
lastRes = null;
|
|
859
|
+
return {
|
|
860
|
+
res,
|
|
861
|
+
slot: account.slot,
|
|
862
|
+
exhausted: false,
|
|
863
|
+
kind,
|
|
864
|
+
logins: candidates.length,
|
|
865
|
+
limitOnly: false,
|
|
866
|
+
earliestResetAt,
|
|
867
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
868
|
+
};
|
|
869
|
+
};
|
|
870
|
+
const stashFailure = async (res: Response, kind: ClineErrorKind): Promise<void> => {
|
|
871
|
+
if (lastRes && lastRes !== res) {
|
|
872
|
+
try { await lastRes.body?.cancel(); } catch { }
|
|
873
|
+
}
|
|
874
|
+
lastRes = res;
|
|
875
|
+
lastSlot = account.slot;
|
|
876
|
+
lastKind = kind;
|
|
877
|
+
};
|
|
878
|
+
let res = await attempt(account.token);
|
|
879
|
+
if (!res) continue;
|
|
880
|
+
let kind = mapClineError(res.status);
|
|
881
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
882
|
+
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
883
|
+
// refresh, then exactly one retry with the fresh bearer.
|
|
884
|
+
if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
|
|
885
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
886
|
+
if (outcome === true) {
|
|
887
|
+
try {
|
|
888
|
+
await res.body?.cancel();
|
|
889
|
+
} catch { }
|
|
890
|
+
refreshedThisSlot = true;
|
|
891
|
+
res = await attempt(account.token);
|
|
892
|
+
if (!res) continue;
|
|
893
|
+
kind = mapClineError(res.status);
|
|
894
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
895
|
+
} else if (outcome === false) {
|
|
896
|
+
try {
|
|
897
|
+
await res.body?.cancel();
|
|
898
|
+
} catch { }
|
|
899
|
+
logWarn("cline slot refresh rejected — trying next slot", { slot: account.slot });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
// Transient refresh fault: the original response is still intact, so it is
|
|
903
|
+
// stashed below and can be surfaced to the caller.
|
|
904
|
+
}
|
|
905
|
+
// A 429 carrying the free-limit marker means this login is out of free use
|
|
906
|
+
// for the requested model until the stated reset: remember it, so the next
|
|
907
|
+
// turn tries the other logins first.
|
|
908
|
+
if (res.status === 429) {
|
|
909
|
+
const limit = await freeLimitFrom(res, now);
|
|
910
|
+
if (limit) {
|
|
911
|
+
cappedSlots.add(account.slot);
|
|
912
|
+
if (limit.modelId) limitModelId = limit.modelId;
|
|
913
|
+
if (typeof limit.resetAt === "number") {
|
|
914
|
+
if (earliestResetAt === null || limit.resetAt < earliestResetAt) earliestResetAt = limit.resetAt;
|
|
915
|
+
recordLimitOnDisk(account.slot, requestedModel, limit.resetAt);
|
|
916
|
+
}
|
|
917
|
+
logWarn("cline slot is on this model's daily free cap", {
|
|
918
|
+
slot: account.slot,
|
|
919
|
+
model: requestedModel || limit.modelId || "unknown",
|
|
920
|
+
resetInMs: typeof limit.resetAt === "number" ? limit.resetAt - now : null,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
logWarn("cline slot failed — trying next slot", { slot: account.slot, status: res.status });
|
|
925
|
+
await stashFailure(res, kind);
|
|
926
|
+
}
|
|
927
|
+
if (lastRes) {
|
|
928
|
+
// Only every saved login answering the cap counts as a limit-only roll: a
|
|
929
|
+
// login that never got through is a login problem, not a capped one.
|
|
930
|
+
const limitOnly = cappedSlots.size > 0 && candidates.every((a) => cappedSlots.has(a.slot));
|
|
931
|
+
logWarn("cline pool exhausted after roll — returning last upstream failure", {
|
|
932
|
+
slots: candidates.length,
|
|
933
|
+
cappedSlots: cappedSlots.size,
|
|
934
|
+
limitOnly,
|
|
935
|
+
...(limitModelId ? { limitModel: limitModelId } : {}),
|
|
936
|
+
});
|
|
937
|
+
return {
|
|
938
|
+
res: lastRes,
|
|
939
|
+
slot: lastSlot,
|
|
940
|
+
exhausted: true,
|
|
941
|
+
kind: lastKind,
|
|
942
|
+
logins: candidates.length,
|
|
943
|
+
limitOnly,
|
|
944
|
+
earliestResetAt,
|
|
945
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
// Every slot was skipped before producing a response: its saved login was
|
|
949
|
+
// rejected, or the network call threw. Nothing is rate-limited here, so say
|
|
950
|
+
// what actually happened instead of implying the user should wait.
|
|
951
|
+
logWarn("cline pool exhausted with no upstream response", { slots: candidates.length });
|
|
952
|
+
return {
|
|
953
|
+
res: exhaustedResponse("Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login", 502),
|
|
954
|
+
slot: null,
|
|
955
|
+
exhausted: true,
|
|
956
|
+
kind: "exhausted",
|
|
957
|
+
logins: candidates.length,
|
|
958
|
+
limitOnly: false,
|
|
959
|
+
earliestResetAt,
|
|
960
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
961
|
+
};
|
|
962
|
+
}
|