pi-freeflow 1.44.0 → 1.45.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 +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cline chat roll across pooled accounts, limit inspection, and failover mechanics.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
6
|
+
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
7
|
+
import { logWarn } from "./logger.ts";
|
|
8
|
+
import { CLINE_CLIENT_HEADERS } from "./cline-accounts.ts";
|
|
9
|
+
import {
|
|
10
|
+
activeLimitFor,
|
|
11
|
+
clearLimitOnDisk,
|
|
12
|
+
loadPool,
|
|
13
|
+
markActiveSlotOnDisk,
|
|
14
|
+
patchAccountOnDisk,
|
|
15
|
+
recordClineUsageOnDisk,
|
|
16
|
+
recordLimitOnDisk,
|
|
17
|
+
type ClineAccount,
|
|
18
|
+
} from "./cline-pool.ts";
|
|
19
|
+
|
|
20
|
+
export const CLINE_MODEL_PREFIX = "cline/";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Maps a catalog Cline model ID to the exact wire model ID expected by Cline API.
|
|
24
|
+
*/
|
|
25
|
+
export function clineUpstreamModelId(id: string): string {
|
|
26
|
+
const trimmed = id.trim();
|
|
27
|
+
if (trimmed === "cline/nemotron-3.5-super-vl") {
|
|
28
|
+
return "nvidia/nemotron-3.5-super-vl:free";
|
|
29
|
+
}
|
|
30
|
+
if (trimmed.startsWith(CLINE_MODEL_PREFIX)) {
|
|
31
|
+
return trimmed.slice(CLINE_MODEL_PREFIX.length);
|
|
32
|
+
}
|
|
33
|
+
if (trimmed.startsWith("cline-free/")) {
|
|
34
|
+
return trimmed.slice("cline-free/".length);
|
|
35
|
+
}
|
|
36
|
+
return trimmed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Free-limit report carried by a Cline 429 body, e.g.
|
|
41
|
+
* {"error":{"code":"INFERENCE_CAP_ERROR","message":"Error 429: Daily free limit
|
|
42
|
+
* reached on model meta/muse-spark-1.3-contributor. Try again in 20h 4m"}}.
|
|
43
|
+
*
|
|
44
|
+
* `modelId` is the name Cline spells in its own body, which is a different
|
|
45
|
+
* namespace from the id this proxy requests (measured live 2026-10-13: we ask
|
|
46
|
+
* for `cline-free/muse-spark-1.3-contributor`, Cline answers about
|
|
47
|
+
* `meta/muse-spark-1.3-contributor`). The limit cache is therefore keyed by the
|
|
48
|
+
* requested id; `modelId` is diagnostics only.
|
|
49
|
+
*/
|
|
50
|
+
export interface ClineFreeLimit {
|
|
51
|
+
/** Model name as the upstream body spells it. */
|
|
52
|
+
modelId?: string;
|
|
53
|
+
/** Epoch ms the cap lifts; absent when the body stated no delay. */
|
|
54
|
+
resetAt?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLINE_FREE_LIMIT_MARKER = /(?:free limit reached on model|Daily limit reached for)/i;
|
|
58
|
+
const CLINE_RATE_LIMIT_RESET_RE = /X-RateLimit-Reset[\\"\s:]+([0-9.]+)/i;
|
|
59
|
+
const CLINE_RESET_HOURS_MINUTES_RE = /try again in\s+([0-9]+)\s*h(?:ours?|rs?)?(?:\s*([0-9]+)\s*m(?:in(?:utes?)?)?)?/i;
|
|
60
|
+
const CLINE_RESET_MINUTES_ONLY_RE = /try again in\s+([0-9]+)\s*m(?:in(?:utes?)?)?/i;
|
|
61
|
+
const CLINE_RESET_BARE_HOURS_RE = /try again in\s+([0-9]+)(?![0-9a-z])/i;
|
|
62
|
+
const CLINE_FREE_LIMIT_NAMED_RE = /(?:free limit reached on model|Daily limit reached for)\s+([^\\"\s,;]+)/i;
|
|
63
|
+
const CLINE_TRAILING_PUNCT_RE = /[.,;:]+$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract epoch reset from X-RateLimit-Reset if present in headers or metadata.
|
|
67
|
+
* Converts epoch seconds (<1e11) to ms.
|
|
68
|
+
*/
|
|
69
|
+
export function parseXRateLimitReset(text: string): number | null {
|
|
70
|
+
const m = text.match(CLINE_RATE_LIMIT_RESET_RE);
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
const val = Number(m[1]);
|
|
73
|
+
if (!Number.isFinite(val) || val <= 0) return null;
|
|
74
|
+
return val < 1e11 ? Math.round(val * 1000) : Math.round(val);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Delay stated as `try again in <N>h <N>m`, `<N>m`, or a bare `<N>` (hours).
|
|
79
|
+
* Returns null when the body states no delay this parser understands.
|
|
80
|
+
*/
|
|
81
|
+
export function parseResetDelayMs(text: string): number | null {
|
|
82
|
+
const hoursMinutes = text.match(CLINE_RESET_HOURS_MINUTES_RE);
|
|
83
|
+
if (hoursMinutes) {
|
|
84
|
+
const hours = Number(hoursMinutes[1]);
|
|
85
|
+
const minutes = hoursMinutes[2] ? Number(hoursMinutes[2]) : 0;
|
|
86
|
+
return (hours * 60 + minutes) * 60_000;
|
|
87
|
+
}
|
|
88
|
+
const minutesOnly = text.match(CLINE_RESET_MINUTES_ONLY_RE);
|
|
89
|
+
if (minutesOnly) return Number(minutesOnly[1]) * 60_000;
|
|
90
|
+
const bareHours = text.match(CLINE_RESET_BARE_HOURS_RE);
|
|
91
|
+
return bareHours ? Number(bareHours[1]) * 3600_000 : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse an upstream 429 body for the daily free-limit marker. Returns null for
|
|
96
|
+
* anything else, so a plain edge 429 keeps its existing behavior.
|
|
97
|
+
*/
|
|
98
|
+
export function parseClineFreeLimit(raw: string, now = Date.now()): ClineFreeLimit | null {
|
|
99
|
+
if (typeof raw !== "string" || !CLINE_FREE_LIMIT_MARKER.test(raw)) return null;
|
|
100
|
+
const named = raw.match(CLINE_FREE_LIMIT_NAMED_RE);
|
|
101
|
+
// The model name ends the sentence, so a trailing "." belongs to the prose.
|
|
102
|
+
const modelId = named ? named[1].replace(CLINE_TRAILING_PUNCT_RE, "") : "";
|
|
103
|
+
const epochReset = parseXRateLimitReset(raw);
|
|
104
|
+
const delay = epochReset !== null ? null : parseResetDelayMs(raw);
|
|
105
|
+
const resetAt = epochReset !== null ? epochReset : delay !== null ? now + delay : undefined;
|
|
106
|
+
return {
|
|
107
|
+
...(modelId ? { modelId } : {}),
|
|
108
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Machine-classified outcome of one Cline chat attempt. */
|
|
113
|
+
export type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
|
|
114
|
+
|
|
115
|
+
export function mapClineError(status: number): ClineErrorKind {
|
|
116
|
+
if (status >= 200 && status < 300) return "ok";
|
|
117
|
+
if (status === 401 || status === 403) return "auth";
|
|
118
|
+
if (status === 429) return "rate-limit";
|
|
119
|
+
if (status === 402) return "exhausted";
|
|
120
|
+
if (status >= 500) return "server";
|
|
121
|
+
return "client";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The model id this request asks Cline for — the key every recorded limit is
|
|
126
|
+
* looked up under. Empty when the body carries no usable model, which
|
|
127
|
+
* disables limit bookkeeping for the roll rather than guessing a key.
|
|
128
|
+
*/
|
|
129
|
+
export function requestedModelId(body: string): string {
|
|
130
|
+
try {
|
|
131
|
+
const parsed: unknown = JSON.parse(body);
|
|
132
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
133
|
+
const model = (parsed as Record<string, unknown>).model;
|
|
134
|
+
return typeof model === "string" ? model.trim() : "";
|
|
135
|
+
} catch {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read a free-limit report off a 429 body through a clone: the response handed
|
|
142
|
+
* back to the caller must stay readable, so its body is never consumed here.
|
|
143
|
+
*/
|
|
144
|
+
async function freeLimitFrom(res: Response, now: number): Promise<ClineFreeLimit | null> {
|
|
145
|
+
try {
|
|
146
|
+
return parseClineFreeLimit(await res.clone().text(), now);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ClineRollOpts {
|
|
153
|
+
/** Serialized chat-completions body, reused verbatim on every attempt. */
|
|
154
|
+
body: string;
|
|
155
|
+
/** Chat completions endpoint (proxy passes its configured URL explicitly). */
|
|
156
|
+
chatUrl: string;
|
|
157
|
+
fetchImpl?: FetchImpl;
|
|
158
|
+
/**
|
|
159
|
+
* Device-login refresher. When present, a stale slot refreshes once before
|
|
160
|
+
* its attempt, and a 401/403 refreshes once before the slot is skipped.
|
|
161
|
+
* Absent: every slot serves its stored bearer exactly as before.
|
|
162
|
+
*/
|
|
163
|
+
refreshImpl?: ClineRefresher;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ClineRollResult {
|
|
167
|
+
res: Response;
|
|
168
|
+
/** Slot that served the response; null when no account was tried. */
|
|
169
|
+
slot: string | null;
|
|
170
|
+
/** True when every account failed (or the pool is empty). */
|
|
171
|
+
exhausted: boolean;
|
|
172
|
+
kind: ClineErrorKind;
|
|
173
|
+
/**
|
|
174
|
+
* Saved logins this roll had to work with (the candidate count). On an
|
|
175
|
+
* exhausted result every one of them was tried, so a caller may name this
|
|
176
|
+
* number when telling the user how many logins are capped. It is never an
|
|
177
|
+
* upstream-attempt count: one login that refreshes and retries is still one.
|
|
178
|
+
* A slot already parked after a rejected grant is not counted — it was not a
|
|
179
|
+
* candidate on this roll.
|
|
180
|
+
*/
|
|
181
|
+
logins: number;
|
|
182
|
+
/**
|
|
183
|
+
* True when the roll tried at least one login and every login it tried
|
|
184
|
+
* answered the free-limit 429 for the requested model — nothing else was
|
|
185
|
+
* tried and nothing succeeded. A login skipped for a dead grant keeps this
|
|
186
|
+
* false, as does any other failure mixed into the roll.
|
|
187
|
+
*/
|
|
188
|
+
limitOnly: boolean;
|
|
189
|
+
/** Earliest reset among the free-limit 429s seen (epoch ms); null when none was stated. */
|
|
190
|
+
earliestResetAt: number | null;
|
|
191
|
+
/** Model name as Cline spelled it in its own body, when a free limit was reported. */
|
|
192
|
+
limitModelId?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Synthetic body for the two states that never reached Cline: nothing is
|
|
197
|
+
* saved to sign in with, or no saved login could reach Cline. Neither is a
|
|
198
|
+
* rate limit, so it must not answer 429 — that status makes hosts back off
|
|
199
|
+
* and hide the actionable message behind a "rate limited" notice.
|
|
200
|
+
*/
|
|
201
|
+
function exhaustedResponse(reason: string, status: number): Response {
|
|
202
|
+
return new Response(
|
|
203
|
+
JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
|
|
204
|
+
{ status, headers: { "content-type": "application/json" } },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* For OpenRouter-backed Cline models, Cline sometimes answers HTTP 200
|
|
210
|
+
* `text/event-stream` where the first event is an in-stream 429 error
|
|
211
|
+
* (e.g. `stream_initialization_failed` with status 429 / daily limit reached).
|
|
212
|
+
* Peek the first chunk: if it is an in-stream 429 error, convert it to a 429
|
|
213
|
+
* Response so rollChat rolls to the next slot; otherwise, reconstruct the
|
|
214
|
+
* stream with the peeked chunk re-enqueued.
|
|
215
|
+
*/
|
|
216
|
+
async function peekStreamForInStreamError(res: Response): Promise<{ res: Response; inStreamError: boolean; errorText: string }> {
|
|
217
|
+
const contentType = res.headers.get("content-type") || "";
|
|
218
|
+
if (!contentType.includes("text/event-stream") || !res.body) {
|
|
219
|
+
return { res, inStreamError: false, errorText: "" };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const reader = res.body.getReader();
|
|
223
|
+
const { done, value } = await reader.read();
|
|
224
|
+
if (done || !value || value.byteLength === 0) {
|
|
225
|
+
return {
|
|
226
|
+
res: new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
227
|
+
inStreamError: false,
|
|
228
|
+
errorText: "",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const text = new TextDecoder().decode(value);
|
|
232
|
+
// Strict: only an in-stream initialization failure carrying a 429 signal
|
|
233
|
+
// converts. A 200 whose first chunk merely mentions "error" or "429" (model
|
|
234
|
+
// content, tool output) must pass through untouched.
|
|
235
|
+
const hasInitFailed = text.includes("stream_initialization_failed");
|
|
236
|
+
const has429Signal = (
|
|
237
|
+
/status[^0-9]{0,12}429/i.test(text) ||
|
|
238
|
+
/"code"\s*:\s*429/.test(text) ||
|
|
239
|
+
/Rate limit exceeded/i.test(text) ||
|
|
240
|
+
/Daily limit reached/i.test(text) ||
|
|
241
|
+
/free limit reached/i.test(text) ||
|
|
242
|
+
/Daily free limit/i.test(text)
|
|
243
|
+
);
|
|
244
|
+
const isInStream429 = hasInitFailed && has429Signal;
|
|
245
|
+
|
|
246
|
+
if (isInStream429) {
|
|
247
|
+
try { await reader.cancel(); } catch { }
|
|
248
|
+
const errRes = new Response(text, {
|
|
249
|
+
status: 429,
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
});
|
|
252
|
+
return { res: errRes, inStreamError: true, errorText: text };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const reconstructed = new ReadableStream<Uint8Array>({
|
|
256
|
+
start(controller) {
|
|
257
|
+
controller.enqueue(value);
|
|
258
|
+
},
|
|
259
|
+
async pull(controller) {
|
|
260
|
+
try {
|
|
261
|
+
const { done: nextDone, value: nextValue } = await reader.read();
|
|
262
|
+
if (nextDone) {
|
|
263
|
+
controller.close();
|
|
264
|
+
} else if (nextValue) {
|
|
265
|
+
controller.enqueue(nextValue);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
controller.error(err);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
cancel(reason) {
|
|
272
|
+
return reader.cancel(reason);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
res: new Response(reconstructed, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
278
|
+
inStreamError: false,
|
|
279
|
+
errorText: "",
|
|
280
|
+
};
|
|
281
|
+
} catch {
|
|
282
|
+
return { res, inStreamError: false, errorText: "" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Fresh tokens minted via the device-login refresh flow. DeviceAuth owns the
|
|
288
|
+
* network call; this module only applies the result to the pool file.
|
|
289
|
+
*/
|
|
290
|
+
export interface ClineRefreshResult {
|
|
291
|
+
token: string;
|
|
292
|
+
refreshToken?: string;
|
|
293
|
+
expiresAt?: number;
|
|
294
|
+
accountId?: string;
|
|
295
|
+
email?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Refresh one slot. Resolves to the fresh tokens, or null when the grant is
|
|
300
|
+
* dead (invalid_grant — the slot must be logged in again). May throw on
|
|
301
|
+
* transient network faults; callers treat a throw as "keep the stale token".
|
|
302
|
+
*/
|
|
303
|
+
export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
|
|
304
|
+
|
|
305
|
+
/** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
|
|
306
|
+
const CLINE_REFRESH_SKEW_MS = 60_000;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* How long a slot stays parked after Cline rejects its grant outright. Long
|
|
310
|
+
* enough that a live session cannot re-pay the refresh round trip on every
|
|
311
|
+
* turn, short enough that the slot comes back on its own: the pause never
|
|
312
|
+
* blocks a slot whose credential a fresh login replaced.
|
|
313
|
+
*/
|
|
314
|
+
export const CLINE_DEAD_GRANT_COOLDOWN_MS = 15 * 60_000;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* True when the slot carries a known expiry that has passed (or passes within
|
|
318
|
+
* the skew window). Slots without `expiresAt` — every legacy key slot — are
|
|
319
|
+
* never stale: they load and serve exactly as before.
|
|
320
|
+
*/
|
|
321
|
+
export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_SKEW_MS): boolean {
|
|
322
|
+
if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false;
|
|
323
|
+
return Date.now() + skewMs >= account.expiresAt;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const refreshInFlight = new Map<string, Promise<boolean | null>>();
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
|
|
330
|
+
* dead grant with nothing to keep (`false`), or a transient fault where the
|
|
331
|
+
* caller should fall back to the stale bearer (`null`).
|
|
332
|
+
*/
|
|
333
|
+
export async function refreshAccountInPlace(
|
|
334
|
+
account: ClineAccount,
|
|
335
|
+
refreshImpl: ClineRefresher,
|
|
336
|
+
): Promise<boolean | null> {
|
|
337
|
+
const current = account.refreshToken;
|
|
338
|
+
if (!current) return null;
|
|
339
|
+
if (refreshInFlight.has(account.slot)) {
|
|
340
|
+
const result = await refreshInFlight.get(account.slot)!;
|
|
341
|
+
const pool = loadPool();
|
|
342
|
+
const refreshed = pool.accounts.find((a) => a.slot === account.slot);
|
|
343
|
+
if (refreshed) {
|
|
344
|
+
account.token = refreshed.token;
|
|
345
|
+
if (refreshed.refreshToken) account.refreshToken = refreshed.refreshToken;
|
|
346
|
+
if (typeof refreshed.expiresAt === "number") {
|
|
347
|
+
account.expiresAt = refreshed.expiresAt;
|
|
348
|
+
} else {
|
|
349
|
+
delete account.expiresAt;
|
|
350
|
+
}
|
|
351
|
+
if (refreshed.accountId) account.accountId = refreshed.accountId;
|
|
352
|
+
if (refreshed.email) account.email = refreshed.email;
|
|
353
|
+
if (refreshed.deadUntil) account.deadUntil = refreshed.deadUntil;
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
const promise = (async (): Promise<boolean | null> => {
|
|
358
|
+
let fresh: ClineRefreshResult | null;
|
|
359
|
+
try {
|
|
360
|
+
fresh = await refreshImpl(current);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
|
|
363
|
+
void e;
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const freshToken = fresh !== null && typeof fresh.token === "string" ? fresh.token.trim() : "";
|
|
367
|
+
if (fresh === null || !freshToken || (!freshToken.startsWith("workos:") && !isWorkosJwt(freshToken) && !freshToken.startsWith("clp_"))) {
|
|
368
|
+
// Cline rejected the grant outright: park the slot on disk so the next
|
|
369
|
+
// turns skip it instead of re-paying a refresh that cannot succeed. The
|
|
370
|
+
// credential stays in the pool — a fresh login into the slot clears this —
|
|
371
|
+
// and the cooldown ends the pause on its own.
|
|
372
|
+
account.deadUntil = Date.now() + CLINE_DEAD_GRANT_COOLDOWN_MS;
|
|
373
|
+
logWarn("cline slot refresh rejected — quarantining slot", {
|
|
374
|
+
slot: account.slot,
|
|
375
|
+
forMs: CLINE_DEAD_GRANT_COOLDOWN_MS,
|
|
376
|
+
});
|
|
377
|
+
patchAccountOnDisk(account.slot, { deadUntil: account.deadUntil });
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
account.token = freshToken;
|
|
381
|
+
if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
|
|
382
|
+
if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
|
|
383
|
+
account.expiresAt = fresh.expiresAt;
|
|
384
|
+
} else {
|
|
385
|
+
delete account.expiresAt;
|
|
386
|
+
}
|
|
387
|
+
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
388
|
+
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
389
|
+
// Persist only the refreshed fields: the snapshot this request holds may
|
|
390
|
+
// predate a login added or removed in the host process.
|
|
391
|
+
patchAccountOnDisk(account.slot, {
|
|
392
|
+
token: account.token,
|
|
393
|
+
...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
|
|
394
|
+
expiresAt: account.expiresAt,
|
|
395
|
+
...(account.accountId ? { accountId: account.accountId } : {}),
|
|
396
|
+
...(account.email ? { email: account.email } : {}),
|
|
397
|
+
});
|
|
398
|
+
return true;
|
|
399
|
+
})();
|
|
400
|
+
refreshInFlight.set(account.slot, promise);
|
|
401
|
+
try {
|
|
402
|
+
return await promise;
|
|
403
|
+
} finally {
|
|
404
|
+
refreshInFlight.delete(account.slot);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* POST one chat body to the Cline endpoint, rolling across every saved slot.
|
|
410
|
+
* Each attempt sends its slot bearer explicitly; raw tokens never leave this
|
|
411
|
+
* module. Superseded failure bodies are cancelled here; the response handed
|
|
412
|
+
* back to the caller is left untouched so the caller can read it.
|
|
413
|
+
*/
|
|
414
|
+
export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
415
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
416
|
+
const pool = loadPool();
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
const requestedModel = requestedModelId(opts.body);
|
|
419
|
+
// A slot Cline rejected outright is parked for a cooldown: retrying its dead
|
|
420
|
+
// grant on every turn re-pays a refresh that cannot succeed. The credential
|
|
421
|
+
// stays in the pool and the stamp expires, so this is a pause, not a
|
|
422
|
+
// removal — pool order and size are untouched.
|
|
423
|
+
const quarantinedSlots = new Set(
|
|
424
|
+
pool.accounts
|
|
425
|
+
.filter((a) => typeof a.deadUntil === "number" && Number.isFinite(a.deadUntil) && a.deadUntil > now)
|
|
426
|
+
.map((a) => a.slot),
|
|
427
|
+
);
|
|
428
|
+
let candidates = pool.accounts.filter((a) => !quarantinedSlots.has(a.slot));
|
|
429
|
+
if (pool.activeSlot) {
|
|
430
|
+
const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
|
|
431
|
+
if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
|
|
432
|
+
}
|
|
433
|
+
if (requestedModel) {
|
|
434
|
+
// A slot recorded on this model's daily cap almost always answers 429 again:
|
|
435
|
+
// it is tried last so a healthy login serves the turn without paying the
|
|
436
|
+
// extra round trip. It stays in the list — a cap can lift before its
|
|
437
|
+
// recorded reset, and only a real attempt can discover that.
|
|
438
|
+
const open: ClineAccount[] = [];
|
|
439
|
+
const capped: ClineAccount[] = [];
|
|
440
|
+
for (const account of candidates) {
|
|
441
|
+
(activeLimitFor(pool, account.slot, requestedModel, now) === null ? open : capped).push(account);
|
|
442
|
+
}
|
|
443
|
+
candidates = [...open, ...capped];
|
|
444
|
+
}
|
|
445
|
+
if (candidates.length === 0) {
|
|
446
|
+
if (quarantinedSlots.size > 0) {
|
|
447
|
+
// Logins are saved but Cline rejected every one of them: telling the user
|
|
448
|
+
// nothing is saved would point at the wrong problem.
|
|
449
|
+
logWarn("cline pool fully quarantined — every saved grant was rejected", {
|
|
450
|
+
slots: pool.accounts.length,
|
|
451
|
+
quarantined: quarantinedSlots.size,
|
|
452
|
+
});
|
|
453
|
+
return {
|
|
454
|
+
res: exhaustedResponse("Every saved Cline login was rejected by Cline — sign in again with /freeflow cline login", 502),
|
|
455
|
+
slot: null,
|
|
456
|
+
exhausted: true,
|
|
457
|
+
kind: "exhausted",
|
|
458
|
+
logins: 0,
|
|
459
|
+
limitOnly: false,
|
|
460
|
+
earliestResetAt: null,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const reason = "No Cline logins saved — add one with /freeflow cline login";
|
|
464
|
+
logWarn("cline pool empty", { slots: pool.accounts.length });
|
|
465
|
+
return { res: exhaustedResponse(reason, 401), slot: null, exhausted: true, kind: "exhausted", logins: 0, limitOnly: false, earliestResetAt: null };
|
|
466
|
+
}
|
|
467
|
+
let lastRes: Response | null = null;
|
|
468
|
+
let lastSlot: string | null = null;
|
|
469
|
+
let lastKind: ClineErrorKind = "exhausted";
|
|
470
|
+
// Slots whose grant Cline rejected during this roll: they are now parked on
|
|
471
|
+
// disk, so the exhausted answer can say "rejected" instead of "unreachable".
|
|
472
|
+
let rejectedThisRoll = 0;
|
|
473
|
+
// Distinct logins that answered the cap, so one login refreshing and retrying
|
|
474
|
+
// through the 401 path still counts once.
|
|
475
|
+
const cappedSlots = new Set<string>();
|
|
476
|
+
let earliestResetAt: number | null = null;
|
|
477
|
+
let limitModelId: string | undefined;
|
|
478
|
+
for (const account of candidates) {
|
|
479
|
+
// Stale device-login bearer: one refresh before the attempt. A dead grant
|
|
480
|
+
// parks the slot (see refreshAccountInPlace); a transient fault keeps the
|
|
481
|
+
// stale token.
|
|
482
|
+
let refreshedThisSlot = false;
|
|
483
|
+
if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
|
|
484
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
485
|
+
if (outcome === false) {
|
|
486
|
+
rejectedThisRoll += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
refreshedThisSlot = outcome === true;
|
|
490
|
+
}
|
|
491
|
+
const attempt = async (token: string): Promise<Response | null> => {
|
|
492
|
+
try {
|
|
493
|
+
return await fetchImpl(opts.chatUrl, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: {
|
|
496
|
+
"content-type": "application/json",
|
|
497
|
+
...CLINE_CLIENT_HEADERS,
|
|
498
|
+
authorization: `Bearer ${token}`,
|
|
499
|
+
},
|
|
500
|
+
body: opts.body,
|
|
501
|
+
});
|
|
502
|
+
} catch (e) {
|
|
503
|
+
logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
|
|
504
|
+
void e;
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const succeed = async (res: Response, kind: ClineErrorKind): Promise<ClineRollResult> => {
|
|
509
|
+
if (kind === "ok") {
|
|
510
|
+
markActiveSlotOnDisk(account.slot);
|
|
511
|
+
// This slot just served the model, so whatever cap it was on has lifted.
|
|
512
|
+
// Reads the file as it is on disk and writes only when an entry exists.
|
|
513
|
+
clearLimitOnDisk(account.slot, requestedModel);
|
|
514
|
+
// Counters only — no token or identity ever lands in the usage row.
|
|
515
|
+
recordClineUsageOnDisk(account.slot, requestedModel);
|
|
516
|
+
}
|
|
517
|
+
if (lastRes && lastRes !== res) {
|
|
518
|
+
try { await lastRes.body?.cancel(); } catch { }
|
|
519
|
+
}
|
|
520
|
+
lastRes = null;
|
|
521
|
+
return {
|
|
522
|
+
res,
|
|
523
|
+
slot: account.slot,
|
|
524
|
+
exhausted: false,
|
|
525
|
+
kind,
|
|
526
|
+
logins: candidates.length,
|
|
527
|
+
limitOnly: false,
|
|
528
|
+
earliestResetAt,
|
|
529
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
const stashFailure = async (res: Response, kind: ClineErrorKind): Promise<void> => {
|
|
533
|
+
if (lastRes && lastRes !== res) {
|
|
534
|
+
try { await lastRes.body?.cancel(); } catch { }
|
|
535
|
+
}
|
|
536
|
+
lastRes = res;
|
|
537
|
+
lastSlot = account.slot;
|
|
538
|
+
lastKind = kind;
|
|
539
|
+
};
|
|
540
|
+
let res = await attempt(account.token);
|
|
541
|
+
if (!res) continue;
|
|
542
|
+
let inStreamLimitText: string | null = null;
|
|
543
|
+
if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
544
|
+
const peek = await peekStreamForInStreamError(res);
|
|
545
|
+
res = peek.res;
|
|
546
|
+
if (peek.inStreamError) {
|
|
547
|
+
inStreamLimitText = peek.errorText;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
let kind = mapClineError(res.status);
|
|
551
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
552
|
+
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
553
|
+
// refresh, then exactly one retry with the fresh bearer.
|
|
554
|
+
if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
|
|
555
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
556
|
+
if (outcome === true) {
|
|
557
|
+
try {
|
|
558
|
+
await res.body?.cancel();
|
|
559
|
+
} catch { }
|
|
560
|
+
refreshedThisSlot = true;
|
|
561
|
+
res = await attempt(account.token);
|
|
562
|
+
if (!res) continue;
|
|
563
|
+
if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
564
|
+
const peek = await peekStreamForInStreamError(res);
|
|
565
|
+
res = peek.res;
|
|
566
|
+
if (peek.inStreamError) inStreamLimitText = peek.errorText;
|
|
567
|
+
}
|
|
568
|
+
kind = mapClineError(res.status);
|
|
569
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
570
|
+
} else if (outcome === false) {
|
|
571
|
+
try {
|
|
572
|
+
await res.body?.cancel();
|
|
573
|
+
} catch { }
|
|
574
|
+
rejectedThisRoll += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Transient refresh fault: the original response is still intact, so it is
|
|
578
|
+
// stashed below and can be surfaced to the caller.
|
|
579
|
+
}
|
|
580
|
+
// A 429 carrying the free-limit marker means this login is out of free use
|
|
581
|
+
// for the requested model until the stated reset: remember it, so the next
|
|
582
|
+
// turn tries the other logins first.
|
|
583
|
+
if (res.status === 429) {
|
|
584
|
+
const limit = inStreamLimitText ? parseClineFreeLimit(inStreamLimitText, now) : await freeLimitFrom(res, now);
|
|
585
|
+
if (limit) {
|
|
586
|
+
cappedSlots.add(account.slot);
|
|
587
|
+
if (limit.modelId) limitModelId = limit.modelId;
|
|
588
|
+
if (typeof limit.resetAt === "number") {
|
|
589
|
+
if (earliestResetAt === null || limit.resetAt < earliestResetAt) earliestResetAt = limit.resetAt;
|
|
590
|
+
recordLimitOnDisk(account.slot, requestedModel, limit.resetAt);
|
|
591
|
+
}
|
|
592
|
+
logWarn("cline slot is on this model's daily free cap", {
|
|
593
|
+
slot: account.slot,
|
|
594
|
+
model: requestedModel || limit.modelId || "unknown",
|
|
595
|
+
resetInMs: typeof limit.resetAt === "number" ? limit.resetAt - now : null,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
logWarn("cline slot failed — trying next slot", { slot: account.slot, status: res.status });
|
|
600
|
+
await stashFailure(res, kind);
|
|
601
|
+
}
|
|
602
|
+
if (lastRes) {
|
|
603
|
+
// Only every saved login answering the cap counts as a limit-only roll: a
|
|
604
|
+
// login that never got through is a login problem, not a capped one.
|
|
605
|
+
const limitOnly = cappedSlots.size > 0 && candidates.every((a) => cappedSlots.has(a.slot));
|
|
606
|
+
logWarn("cline pool exhausted after roll — returning last upstream failure", {
|
|
607
|
+
slots: candidates.length,
|
|
608
|
+
cappedSlots: cappedSlots.size,
|
|
609
|
+
limitOnly,
|
|
610
|
+
...(limitModelId ? { limitModel: limitModelId } : {}),
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
res: lastRes,
|
|
614
|
+
slot: lastSlot,
|
|
615
|
+
exhausted: true,
|
|
616
|
+
kind: lastKind,
|
|
617
|
+
logins: candidates.length,
|
|
618
|
+
limitOnly,
|
|
619
|
+
earliestResetAt,
|
|
620
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
// Every slot was skipped before producing a response: its saved login was
|
|
624
|
+
// rejected, or the network call threw. Nothing is rate-limited here, so say
|
|
625
|
+
// what actually happened instead of implying the user should wait.
|
|
626
|
+
logWarn("cline pool exhausted with no upstream response", { slots: candidates.length, rejected: rejectedThisRoll });
|
|
627
|
+
return {
|
|
628
|
+
res: exhaustedResponse(
|
|
629
|
+
rejectedThisRoll >= candidates.length
|
|
630
|
+
? "Every saved Cline login was rejected by Cline — sign in again with /freeflow cline login"
|
|
631
|
+
: "Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login",
|
|
632
|
+
502,
|
|
633
|
+
),
|
|
634
|
+
slot: null,
|
|
635
|
+
exhausted: true,
|
|
636
|
+
kind: "exhausted",
|
|
637
|
+
logins: candidates.length,
|
|
638
|
+
limitOnly: false,
|
|
639
|
+
earliestResetAt,
|
|
640
|
+
...(limitModelId ? { limitModelId } : {}),
|
|
641
|
+
};
|
|
642
|
+
}
|