pi-freeflow 1.22.2 → 1.23.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 +18 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/catalog.ts +294 -286
- package/src/cline-accounts.ts +112 -113
- package/src/cline-device-auth.ts +27 -8
- package/src/commands.ts +56 -21
- package/src/daemon.ts +86 -91
- package/src/index.ts +2 -7
- package/src/proxy.ts +55 -10
package/src/daemon.ts
CHANGED
|
@@ -12,38 +12,37 @@
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
DAEMON_GC_MS,
|
|
16
|
+
DAEMON_GRACE_MS,
|
|
17
|
+
DAEMON_TTL_MS,
|
|
18
|
+
HOST,
|
|
19
|
+
PKG_VERSION,
|
|
20
|
+
PORT,
|
|
21
21
|
} from "./config.ts";
|
|
22
22
|
import { setShutdownShouldExit } from "./proxy.ts";
|
|
23
23
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
setAliveCatalog,
|
|
24
|
+
getAliveCatalog,
|
|
25
|
+
refreshCatalog,
|
|
26
|
+
setAliveCatalog,
|
|
28
27
|
} from "./catalog.ts";
|
|
29
28
|
import { log, logInfo, logWarn } from "./logger.ts";
|
|
30
29
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
startLeaseGC,
|
|
31
|
+
stopLeaseGC,
|
|
32
|
+
touchActivity,
|
|
34
33
|
} from "./lease.ts";
|
|
35
34
|
import { getActiveRequests, startProxy } from "./proxy.ts";
|
|
36
35
|
import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
|
|
37
36
|
|
|
38
37
|
/** True when this module was run as the entry script (not imported by tests). */
|
|
39
38
|
export function isDaemonMain(): boolean {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
try {
|
|
40
|
+
const entry = process.argv[1];
|
|
41
|
+
if (!entry) return false;
|
|
42
|
+
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
/**
|
|
@@ -51,11 +50,11 @@ export function isDaemonMain(): boolean {
|
|
|
51
50
|
* uses the latest client edits without a daemon restart. Non-fatal on failure.
|
|
52
51
|
*/
|
|
53
52
|
export function syncRelayStateFromDisk(): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
try {
|
|
54
|
+
setActiveRelayState(loadRelayState(), false);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
log("warn", "daemon failed to load relay state from disk", { error: String(e) });
|
|
57
|
+
}
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
/**
|
|
@@ -64,21 +63,17 @@ export function syncRelayStateFromDisk(): void {
|
|
|
64
63
|
* 24h cache TTL without requiring a daemon restart.
|
|
65
64
|
*/
|
|
66
65
|
export async function seedCatalog(): Promise<void> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
error: String(e),
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
|
|
66
|
+
try {
|
|
67
|
+
const fresh = await refreshCatalog(false);
|
|
68
|
+
if (fresh.length > 0) {
|
|
69
|
+
setAliveCatalog(fresh);
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
logWarn("daemon catalog refresh failed; retaining static catalog", {
|
|
73
|
+
error: String(e),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
/**
|
|
@@ -87,63 +82,63 @@ export async function seedCatalog(): Promise<void> {
|
|
|
87
82
|
* cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
|
|
88
83
|
*/
|
|
89
84
|
export async function runDaemon(): Promise<void> {
|
|
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
|
-
|
|
85
|
+
let server: { close(): void } | null = null;
|
|
86
|
+
const retire = (why: string): void => {
|
|
87
|
+
logInfo(`daemon retiring: ${why}`);
|
|
88
|
+
stopLeaseGC();
|
|
89
|
+
try {
|
|
90
|
+
server?.close();
|
|
91
|
+
} catch { }
|
|
92
|
+
process.exit(0);
|
|
93
|
+
};
|
|
94
|
+
process.on("SIGTERM", () => retire("SIGTERM"));
|
|
95
|
+
process.on("SIGINT", () => retire("SIGINT"));
|
|
96
|
+
process.on("uncaughtException", (err) => {
|
|
97
|
+
log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack, activeRequests: getActiveRequests() });
|
|
98
|
+
});
|
|
99
|
+
process.on("unhandledRejection", (reason) => {
|
|
100
|
+
log("error", "daemon unhandledRejection", { error: String(reason), activeRequests: getActiveRequests() });
|
|
101
|
+
});
|
|
102
|
+
setShutdownShouldExit(true);
|
|
103
|
+
try {
|
|
104
|
+
const r = await startProxy();
|
|
105
|
+
if (!r.server) {
|
|
106
|
+
// Port was taken by another (possibly newer) daemon — the parent
|
|
107
|
+
// attaches to the winner; this spawn exits quietly.
|
|
108
|
+
logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
server = r.server;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
log("error", "daemon failed to bind proxy port", { error: String(e) });
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
117
|
+
// lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
|
|
118
|
+
// retirement no longer reads it — only the zero-lease persistence window
|
|
119
|
+
// gates the GC, so a fresh spawn always survives its re-attach window.
|
|
120
|
+
touchActivity();
|
|
121
|
+
syncRelayStateFromDisk();
|
|
122
|
+
void seedCatalog();
|
|
128
123
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
// Retire only when zero leases persist past the grace window with nothing
|
|
125
|
+
// in flight — never for request-idleness.
|
|
126
|
+
startLeaseGC({
|
|
127
|
+
ttlMs: DAEMON_TTL_MS,
|
|
128
|
+
gcMs: DAEMON_GC_MS,
|
|
129
|
+
graceMs: DAEMON_GRACE_MS,
|
|
130
|
+
getActiveRequests,
|
|
131
|
+
onIdle: () => retire("no clients"),
|
|
132
|
+
});
|
|
138
133
|
|
|
139
|
-
|
|
134
|
+
logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
|
|
140
135
|
}
|
|
141
136
|
|
|
142
137
|
// Entry guard: run only when executed as the entry script. When imported by
|
|
143
138
|
// tests, isDaemonMain() is false and no port is bound.
|
|
144
139
|
if (isDaemonMain()) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
runDaemon().catch((e) => {
|
|
141
|
+
log("error", "daemon crashed", { error: String(e) });
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
149
144
|
}
|
package/src/index.ts
CHANGED
|
@@ -146,17 +146,12 @@ export function buildProviderConfig(
|
|
|
146
146
|
}
|
|
147
147
|
: m.api === "openai-responses"
|
|
148
148
|
? { sessionAffinityFormat: "openai-nosession" }
|
|
149
|
-
: m.source === "kilo"
|
|
149
|
+
: m.source === "kilo" || m.source === "cline"
|
|
150
150
|
? {
|
|
151
151
|
supportsDeveloperRole: false,
|
|
152
152
|
supportsReasoningEffort: !!m.thinkingLevelMap,
|
|
153
153
|
}
|
|
154
|
-
:
|
|
155
|
-
? {
|
|
156
|
-
supportsDeveloperRole: false,
|
|
157
|
-
supportsReasoningEffort: !!m.thinkingLevelMap,
|
|
158
|
-
}
|
|
159
|
-
: {
|
|
154
|
+
: {
|
|
160
155
|
supportsDeveloperRole: false,
|
|
161
156
|
supportsReasoningEffort: true,
|
|
162
157
|
},
|
package/src/proxy.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
} from "./opencode-fingerprint.ts";
|
|
43
43
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
44
44
|
import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
45
|
-
import { refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
45
|
+
import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
46
46
|
import { rollChat } from "./cline-accounts.ts";
|
|
47
47
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
48
48
|
import {
|
|
@@ -136,7 +136,7 @@ const CLINE_MODEL_HINT =
|
|
|
136
136
|
*/
|
|
137
137
|
export function mapClineError(status: number, data: string): string {
|
|
138
138
|
// Synthetic pool-exhausted bodies already tell the user exactly what to do
|
|
139
|
-
// (
|
|
139
|
+
// (sign in again) — a rate-limit hint would mislead.
|
|
140
140
|
if (data.includes("cline_pool_exhausted")) return data;
|
|
141
141
|
const hint = status === 429
|
|
142
142
|
? CLINE_RATE_LIMIT_HINT
|
|
@@ -158,12 +158,26 @@ export function mapClineError(status: number, data: string): string {
|
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
160
|
* Device-login refresher for the Cline pool: one refresh per stale slot per
|
|
161
|
-
* turn. A
|
|
162
|
-
*
|
|
161
|
+
* turn. A grant Cline rejects outright (HTTP 4xx, or a 200 without usable
|
|
162
|
+
* tokens) is dead — it resolves null so the pool stops trying that slot until
|
|
163
|
+
* the user logs in again. Anything else (network fault, upstream 5xx) throws,
|
|
164
|
+
* which keeps the stale bearer for this turn.
|
|
163
165
|
*/
|
|
164
166
|
async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
try {
|
|
168
|
+
const creds = await refreshClineToken(undefined, refreshToken);
|
|
169
|
+
return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e instanceof ClineAuthError && typeof e.status === "number" && e.status >= 400 && e.status < 500) {
|
|
172
|
+
log("warn", "cline refresh grant rejected — slot needs a fresh login", { status: e.status });
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (e instanceof Error && /^Invalid token response|^Token response did not include a refresh token/.test(e.message)) {
|
|
176
|
+
log("warn", "cline refresh returned no usable tokens — slot needs a fresh login", { reason: e.message });
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
167
181
|
}
|
|
168
182
|
|
|
169
183
|
/**
|
|
@@ -214,10 +228,23 @@ async function handleClineRequest(opts: {
|
|
|
214
228
|
}
|
|
215
229
|
if (!upstreamRes.ok) {
|
|
216
230
|
log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
|
|
217
|
-
const
|
|
231
|
+
const raw = await upstreamRes.text().catch(() => null);
|
|
232
|
+
if (raw === null) {
|
|
233
|
+
log("error", "cline upstream body unreadable", { status: upstreamRes.status, model }, reqId);
|
|
234
|
+
if (!res.headersSent) {
|
|
235
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
236
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
237
|
+
} else if (!res.writableEnded) {
|
|
238
|
+
res.end();
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const data = mapClineError(upstreamRes.status, raw);
|
|
218
243
|
if (!res.headersSent) {
|
|
219
244
|
res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
|
|
220
245
|
res.end(data);
|
|
246
|
+
} else if (!res.writableEnded) {
|
|
247
|
+
res.end();
|
|
221
248
|
}
|
|
222
249
|
return;
|
|
223
250
|
}
|
|
@@ -237,12 +264,24 @@ async function handleClineRequest(opts: {
|
|
|
237
264
|
);
|
|
238
265
|
return;
|
|
239
266
|
}
|
|
240
|
-
const sseText = await upstreamRes.text();
|
|
267
|
+
const sseText = await upstreamRes.text().catch(() => null);
|
|
268
|
+
if (sseText === null) {
|
|
269
|
+
log("error", "cline upstream stream unreadable", { status: upstreamRes.status, model }, reqId);
|
|
270
|
+
if (!res.headersSent) {
|
|
271
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
272
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
273
|
+
} else if (!res.writableEnded) {
|
|
274
|
+
res.end();
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
241
278
|
if (!responsesRequest) {
|
|
242
279
|
const data = convertSseToJson(sseText, pathname);
|
|
243
280
|
if (!res.headersSent) {
|
|
244
281
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
245
282
|
res.end(data);
|
|
283
|
+
} else if (!res.writableEnded) {
|
|
284
|
+
res.end();
|
|
246
285
|
}
|
|
247
286
|
return;
|
|
248
287
|
}
|
|
@@ -257,12 +296,16 @@ async function handleClineRequest(opts: {
|
|
|
257
296
|
"x-accel-buffering": "no",
|
|
258
297
|
});
|
|
259
298
|
res.end(chatResponsesSseFromChatCompletion(resp));
|
|
299
|
+
} else if (!res.writableEnded) {
|
|
300
|
+
res.end();
|
|
260
301
|
}
|
|
261
302
|
return;
|
|
262
303
|
}
|
|
263
304
|
if (!res.headersSent) {
|
|
264
305
|
res.writeHead(200, { "content-type": "application/json" });
|
|
265
306
|
res.end(JSON.stringify(resp));
|
|
307
|
+
} else if (!res.writableEnded) {
|
|
308
|
+
res.end();
|
|
266
309
|
}
|
|
267
310
|
}
|
|
268
311
|
|
|
@@ -271,13 +314,15 @@ async function handleClineRequest(opts: {
|
|
|
271
314
|
* when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
|
|
272
315
|
* (Kilo traffic leaves via its own branch regardless), and when the model is
|
|
273
316
|
* missing/empty (e.g. GET /v1/models carries no body — preserve routing).
|
|
274
|
-
*
|
|
317
|
+
* Cline models are direct-only and never relay-eligible. Anything else
|
|
318
|
+
* (unknown/paid model ids, non-strings) goes direct upstream.
|
|
275
319
|
*/
|
|
276
320
|
export function isRelayEligibleModel(model: unknown): boolean {
|
|
277
321
|
if (model === undefined || model === null) return true;
|
|
278
322
|
if (typeof model !== "string") return false;
|
|
279
323
|
if (model.trim() === "") return true;
|
|
280
324
|
const canonical = resolveCanonicalModelId(model.trim());
|
|
325
|
+
if (isClineModel(canonical)) return false;
|
|
281
326
|
return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical);
|
|
282
327
|
}
|
|
283
328
|
|
|
@@ -766,7 +811,7 @@ export function startProxy(
|
|
|
766
811
|
id: m.id,
|
|
767
812
|
object: "model",
|
|
768
813
|
created: 0,
|
|
769
|
-
owned_by: m.source === "kilo" ? "kilocode" : "opencode",
|
|
814
|
+
owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
|
|
770
815
|
})),
|
|
771
816
|
});
|
|
772
817
|
res.writeHead(200, {
|