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/src/commands.ts CHANGED
@@ -58,9 +58,10 @@ import {
58
58
  formatRelayPickerItem,
59
59
  formatRelayStatusLabel,
60
60
  } from "./relay-state.ts";
61
- import { addAccount, isClineSlotHealthy, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
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 health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
370
- return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
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
- ctx.ui.notify(`Cline login [${slot}]: open ${link} and enter code ${started.userCode} (expires in ~${minutes} min)`, "info");
1461
- ctx.ui.notify("Waiting for approval in the browser — approve or cancel there; this finishes on its own.", "info");
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("Still waiting for Cline approval — approve or cancel in the browser.", "info");
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 === "authorization_pending") {
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
- const lines = pool.accounts.map((a, idx) => {
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 slot = arg || ((await ctx.ui.input("Cline slot to remove:", ""))?.trim() || "");
1511
- if (!slot) {
1512
- ctx.ui.notify("Cancelled — no slot provided", "warning");
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
- ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
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
- DAEMON_GC_MS,
16
- DAEMON_GRACE_MS,
17
- DAEMON_TTL_MS,
18
- HOST,
19
- PKG_VERSION,
20
- PORT,
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
- getAliveCatalog,
25
- readCatalogCache,
26
- refreshCatalog,
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
- startLeaseGC,
32
- stopLeaseGC,
33
- touchActivity,
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
- try {
41
- const entry = process.argv[1];
42
- if (!entry) return false;
43
- return path.resolve(entry) === fileURLToPath(import.meta.url);
44
- } catch {
45
- return false;
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
- try {
55
- setActiveRelayState(loadRelayState(), false);
56
- } catch (e) {
57
- log("warn", "daemon failed to load relay state from disk", { error: String(e) });
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
- const cached = readCatalogCache();
68
- if (cached && Array.isArray(cached.models) && cached.models.length > 0) {
69
- setAliveCatalog(cached.models);
70
- }
71
- try {
72
- const fresh = await refreshCatalog(false);
73
- if (fresh.length > 0) {
74
- setAliveCatalog(fresh);
75
- }
76
- } catch (e) {
77
- logWarn("daemon catalog refresh failed; retaining cached catalog", {
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
- let server: { close(): void } | null = null;
91
- const retire = (why: string): void => {
92
- logInfo(`daemon retiring: ${why}`);
93
- stopLeaseGC();
94
- try {
95
- server?.close();
96
- } catch {}
97
- process.exit(0);
98
- };
99
- process.on("SIGTERM", () => retire("SIGTERM"));
100
- process.on("SIGINT", () => retire("SIGINT"));
101
- process.on("uncaughtException", (err) => {
102
- log("error", "daemon uncaughtException", { error: String(err), stack: (err as Error)?.stack, activeRequests: getActiveRequests() });
103
- });
104
- process.on("unhandledRejection", (reason) => {
105
- log("error", "daemon unhandledRejection", { error: String(reason), activeRequests: getActiveRequests() });
106
- });
107
- setShutdownShouldExit(true);
108
- try {
109
- const r = await startProxy();
110
- if (!r.server) {
111
- // Port was taken by another (possibly newer) daemon — the parent
112
- // attaches to the winner; this spawn exits quietly.
113
- logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
114
- process.exit(0);
115
- }
116
- server = r.server;
117
- } catch (e) {
118
- log("error", "daemon failed to bind proxy port", { error: String(e) });
119
- process.exit(1);
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
- // lastActivityAt is still seeded AT BIND TIME for the /_health snapshot, but
123
- // retirement no longer reads it — only the zero-lease persistence window
124
- // gates the GC, so a fresh spawn always survives its re-attach window.
125
- touchActivity();
126
- syncRelayStateFromDisk();
127
- void seedCatalog();
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
- // Retire only when zero leases persist past the grace window with nothing
130
- // in flight — never for request-idleness.
131
- startLeaseGC({
132
- ttlMs: DAEMON_TTL_MS,
133
- gcMs: DAEMON_GC_MS,
134
- graceMs: DAEMON_GRACE_MS,
135
- getActiveRequests,
136
- onIdle: () => retire("no clients"),
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
- logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
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
- runDaemon().catch((e) => {
146
- log("error", "daemon crashed", { error: String(e) });
147
- process.exit(1);
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
- : m.source === "cline"
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 data = mapClineError(upstreamRes.status, await upstreamRes.text());
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
- * Anything else (unknown/paid model ids, non-strings) goes direct upstream.
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, {