pi-freeflow 1.22.1 → 1.22.4
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/package.json +1 -1
- package/src/catalog.ts +294 -286
- package/src/cline-accounts.ts +118 -117
- package/src/cline-device-auth.ts +35 -13
- package/src/commands.ts +57 -22
- package/src/daemon.ts +86 -91
- package/src/index.ts +2 -7
- package/src/proxy.ts +64 -5
- package/src/system-ca-fetch.ts +235 -0
package/src/commands.ts
CHANGED
|
@@ -58,9 +58,10 @@ import {
|
|
|
58
58
|
formatRelayPickerItem,
|
|
59
59
|
formatRelayStatusLabel,
|
|
60
60
|
} from "./relay-state.ts";
|
|
61
|
-
import { addAccount,
|
|
61
|
+
import { addAccount, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
62
|
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
63
|
import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
64
|
+
import { isCertError } from "./system-ca-fetch.ts";
|
|
64
65
|
import type {
|
|
65
66
|
ExtensionAPI,
|
|
66
67
|
ExtensionContext,
|
|
@@ -366,8 +367,9 @@ function nextClineSlot(pool: ClinePoolState): string {
|
|
|
366
367
|
function formatClineAccountLines(pool: ClinePoolState): string[] {
|
|
367
368
|
return pool.accounts.map((a, idx) => {
|
|
368
369
|
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
369
|
-
const
|
|
370
|
-
|
|
370
|
+
const who = a.email || a.accountId;
|
|
371
|
+
const whoPart = who ? ` ${who}` : "";
|
|
372
|
+
return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}`;
|
|
371
373
|
});
|
|
372
374
|
}
|
|
373
375
|
/** User-safe one-line message for caught values (never echoes secrets). */
|
|
@@ -1452,20 +1454,25 @@ export function createCommandSpec(
|
|
|
1452
1454
|
try {
|
|
1453
1455
|
started = await startDeviceAuth();
|
|
1454
1456
|
} catch (e) {
|
|
1457
|
+
if (isCertError(e)) {
|
|
1458
|
+
ctx.ui.notify("Could not reach Cline: your antivirus or network proxy intercepts TLS and Node does not trust it. The login retries with your OS certificates automatically — if this persists, restart the host with NODE_USE_SYSTEM_CA=1 set, or point NODE_EXTRA_CA_CERTS at your proxy CA file.", "error");
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1455
1461
|
ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
|
|
1456
1462
|
return;
|
|
1457
1463
|
}
|
|
1458
1464
|
const link = started.verificationUriComplete ?? started.verificationUri;
|
|
1459
1465
|
const minutes = Math.max(1, Math.round(started.expiresIn / 60));
|
|
1460
|
-
|
|
1461
|
-
ctx.ui.notify(
|
|
1466
|
+
const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.`;
|
|
1467
|
+
ctx.ui.notify(loginLine, "info");
|
|
1468
|
+
try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
|
|
1462
1469
|
const progress = setInterval(() => {
|
|
1463
|
-
ctx.ui.notify(
|
|
1470
|
+
ctx.ui.notify(loginLine, "info");
|
|
1464
1471
|
}, 45000);
|
|
1465
1472
|
// @ts-ignore allow unref to not block process exit in CLI
|
|
1466
1473
|
progress.unref?.();
|
|
1467
1474
|
try {
|
|
1468
|
-
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval);
|
|
1475
|
+
const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval, { maxWaitMs: Math.max(60_000, started.expiresIn * 1000) });
|
|
1469
1476
|
const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
|
|
1470
1477
|
const apiKey = toApiKey(creds.access);
|
|
1471
1478
|
addAccount(slot, apiKey, {
|
|
@@ -1482,9 +1489,7 @@ export function createCommandSpec(
|
|
|
1482
1489
|
if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
|
|
1483
1490
|
code = e.errorCode;
|
|
1484
1491
|
}
|
|
1485
|
-
if (code === "
|
|
1486
|
-
ctx.ui.notify("Still waiting for Cline approval — approve or cancel in the browser.", "info");
|
|
1487
|
-
} else if (code === "access_denied" || code === "cancelled") {
|
|
1492
|
+
if (code === "access_denied") {
|
|
1488
1493
|
ctx.ui.notify("Cline login cancelled.", "warning");
|
|
1489
1494
|
} else if (code === "expired_token") {
|
|
1490
1495
|
ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
|
|
@@ -1493,27 +1498,57 @@ export function createCommandSpec(
|
|
|
1493
1498
|
}
|
|
1494
1499
|
} finally {
|
|
1495
1500
|
clearInterval(progress);
|
|
1501
|
+
try { ctx.ui.setStatus("cline-login", undefined); } catch { }
|
|
1496
1502
|
}
|
|
1497
1503
|
} else if (action === "accounts" || action === "list") {
|
|
1498
1504
|
const pool = loadPool();
|
|
1499
1505
|
if (!pool.accounts.length) {
|
|
1500
1506
|
ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
|
|
1501
1507
|
} else {
|
|
1502
|
-
|
|
1503
|
-
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
1504
|
-
const health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
|
|
1505
|
-
return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
|
|
1506
|
-
});
|
|
1507
|
-
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${lines.join("\n")}`, "info");
|
|
1508
|
+
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
|
|
1508
1509
|
}
|
|
1509
1510
|
} else if (action === "logout" || action === "remove") {
|
|
1510
|
-
const
|
|
1511
|
-
if (!
|
|
1512
|
-
ctx.ui.notify("
|
|
1513
|
-
} else if (!removeAccount(slot)) {
|
|
1514
|
-
ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
|
|
1511
|
+
const pool = loadPool();
|
|
1512
|
+
if (!pool.accounts.length) {
|
|
1513
|
+
ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
|
|
1515
1514
|
} else {
|
|
1516
|
-
|
|
1515
|
+
const resolveSlot = (raw: string): string | null => {
|
|
1516
|
+
const clean = (raw || "").trim();
|
|
1517
|
+
if (!clean) return null;
|
|
1518
|
+
const byIndex = Number.parseInt(clean, 10);
|
|
1519
|
+
if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
|
|
1520
|
+
const exact = pool.accounts.find((a) => a.slot === clean);
|
|
1521
|
+
if (exact) return exact.slot;
|
|
1522
|
+
const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
|
|
1523
|
+
return folded ? folded.slot : null;
|
|
1524
|
+
};
|
|
1525
|
+
let slot = resolveSlot(arg);
|
|
1526
|
+
if (arg && !slot) {
|
|
1527
|
+
const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
|
|
1528
|
+
ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
|
|
1529
|
+
} else {
|
|
1530
|
+
if (!slot) {
|
|
1531
|
+
if (pool.accounts.length === 1) {
|
|
1532
|
+
slot = pool.accounts[0].slot;
|
|
1533
|
+
} else {
|
|
1534
|
+
const opts = formatClineAccountLines(pool);
|
|
1535
|
+
const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
|
|
1536
|
+
if (!choice || choice === "Cancel") {
|
|
1537
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
|
|
1541
|
+
slot = hit ? hit.slot : null;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!slot) {
|
|
1545
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1546
|
+
} else if (!removeAccount(slot)) {
|
|
1547
|
+
ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
|
|
1548
|
+
} else {
|
|
1549
|
+
ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1517
1552
|
}
|
|
1518
1553
|
} else {
|
|
1519
1554
|
ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
|
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,9 @@ 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 { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
45
46
|
import { rollChat } from "./cline-accounts.ts";
|
|
47
|
+
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
46
48
|
import {
|
|
47
49
|
chatResponsesJsonFromChatCompletion,
|
|
48
50
|
chatResponsesSseFromChatCompletion,
|
|
@@ -133,6 +135,9 @@ const CLINE_MODEL_HINT =
|
|
|
133
135
|
* bodies — passes through untouched.
|
|
134
136
|
*/
|
|
135
137
|
export function mapClineError(status: number, data: string): string {
|
|
138
|
+
// Synthetic pool-exhausted bodies already tell the user exactly what to do
|
|
139
|
+
// (sign in again) — a rate-limit hint would mislead.
|
|
140
|
+
if (data.includes("cline_pool_exhausted")) return data;
|
|
136
141
|
const hint = status === 429
|
|
137
142
|
? CLINE_RATE_LIMIT_HINT
|
|
138
143
|
: status === 403
|
|
@@ -151,6 +156,29 @@ export function mapClineError(status: number, data: string): string {
|
|
|
151
156
|
} catch { }
|
|
152
157
|
return data;
|
|
153
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Device-login refresher for the Cline pool: one refresh per stale slot per
|
|
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.
|
|
165
|
+
*/
|
|
166
|
+
async function clineRefreshImpl(refreshToken: string): Promise<{ token: string; refreshToken?: string; expiresAt?: number } | null> {
|
|
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
|
+
}
|
|
181
|
+
}
|
|
154
182
|
|
|
155
183
|
/**
|
|
156
184
|
* Serve a Cline-model request direct (never relay pool, no opencode
|
|
@@ -181,7 +209,7 @@ async function handleClineRequest(opts: {
|
|
|
181
209
|
chatBody.stream = true;
|
|
182
210
|
let upstreamRes: Response;
|
|
183
211
|
try {
|
|
184
|
-
const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL });
|
|
212
|
+
const result = await rollChat({ body: JSON.stringify(chatBody), chatUrl: CLINE_CHAT_URL, refreshImpl: clineRefreshImpl, fetchImpl: fetchWithSystemCA });
|
|
185
213
|
upstreamRes = result.res;
|
|
186
214
|
if (typeof result.slot === "string" && result.slot.length > 0) {
|
|
187
215
|
log("debug", `cline served by slot ${result.slot}`, { model }, reqId);
|
|
@@ -200,10 +228,23 @@ async function handleClineRequest(opts: {
|
|
|
200
228
|
}
|
|
201
229
|
if (!upstreamRes.ok) {
|
|
202
230
|
log("warn", `cline upstream ${upstreamRes.status} for model ${model}`, { status: upstreamRes.status, model }, reqId);
|
|
203
|
-
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);
|
|
204
243
|
if (!res.headersSent) {
|
|
205
244
|
res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
|
|
206
245
|
res.end(data);
|
|
246
|
+
} else if (!res.writableEnded) {
|
|
247
|
+
res.end();
|
|
207
248
|
}
|
|
208
249
|
return;
|
|
209
250
|
}
|
|
@@ -223,12 +264,24 @@ async function handleClineRequest(opts: {
|
|
|
223
264
|
);
|
|
224
265
|
return;
|
|
225
266
|
}
|
|
226
|
-
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
|
+
}
|
|
227
278
|
if (!responsesRequest) {
|
|
228
279
|
const data = convertSseToJson(sseText, pathname);
|
|
229
280
|
if (!res.headersSent) {
|
|
230
281
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
231
282
|
res.end(data);
|
|
283
|
+
} else if (!res.writableEnded) {
|
|
284
|
+
res.end();
|
|
232
285
|
}
|
|
233
286
|
return;
|
|
234
287
|
}
|
|
@@ -243,12 +296,16 @@ async function handleClineRequest(opts: {
|
|
|
243
296
|
"x-accel-buffering": "no",
|
|
244
297
|
});
|
|
245
298
|
res.end(chatResponsesSseFromChatCompletion(resp));
|
|
299
|
+
} else if (!res.writableEnded) {
|
|
300
|
+
res.end();
|
|
246
301
|
}
|
|
247
302
|
return;
|
|
248
303
|
}
|
|
249
304
|
if (!res.headersSent) {
|
|
250
305
|
res.writeHead(200, { "content-type": "application/json" });
|
|
251
306
|
res.end(JSON.stringify(resp));
|
|
307
|
+
} else if (!res.writableEnded) {
|
|
308
|
+
res.end();
|
|
252
309
|
}
|
|
253
310
|
}
|
|
254
311
|
|
|
@@ -257,13 +314,15 @@ async function handleClineRequest(opts: {
|
|
|
257
314
|
* when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
|
|
258
315
|
* (Kilo traffic leaves via its own branch regardless), and when the model is
|
|
259
316
|
* missing/empty (e.g. GET /v1/models carries no body — preserve routing).
|
|
260
|
-
*
|
|
317
|
+
* Cline models are direct-only and never relay-eligible. Anything else
|
|
318
|
+
* (unknown/paid model ids, non-strings) goes direct upstream.
|
|
261
319
|
*/
|
|
262
320
|
export function isRelayEligibleModel(model: unknown): boolean {
|
|
263
321
|
if (model === undefined || model === null) return true;
|
|
264
322
|
if (typeof model !== "string") return false;
|
|
265
323
|
if (model.trim() === "") return true;
|
|
266
324
|
const canonical = resolveCanonicalModelId(model.trim());
|
|
325
|
+
if (isClineModel(canonical)) return false;
|
|
267
326
|
return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical);
|
|
268
327
|
}
|
|
269
328
|
|
|
@@ -752,7 +811,7 @@ export function startProxy(
|
|
|
752
811
|
id: m.id,
|
|
753
812
|
object: "model",
|
|
754
813
|
created: 0,
|
|
755
|
-
owned_by: m.source === "kilo" ? "kilocode" : "opencode",
|
|
814
|
+
owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
|
|
756
815
|
})),
|
|
757
816
|
});
|
|
758
817
|
res.writeHead(200, {
|