pi-freeflow 1.21.0 → 1.22.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Correct Cline free-model specs: DeepSeek V4.1 Flash and GLM 5.3 Flash are 1M context (not 262K) with vision support and low/high/max thinking levels; Solar Pro 4 is 512K context with the full thinking range. Model selection and docs now show the right numbers.
8
+
9
+ ## 1.22.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Sign in to Cline right inside the extension: `/freeflow cline login` shows a browser link and code, approves your own Cline account, and saves it to a per-user pool on your machine — no pasting keys. Extra logins auto-name themselves (`default`, `slot-2`, …), saved logins refresh on their own, and requests roll across them when one hits its daily free limit. Pasting a key stays available behind `login --key`. Model selection labels every free model with its source ([OpenCode], [Kilo], [Cline]).
14
+
3
15
  ## 1.21.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 27 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 31 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **27 free models** | 8 from OpenCode Zen, 19 from KiloCode Gateway, context windows up to 1M. Full list below. | **$0** |
19
+ | **31 free models** | 8 from OpenCode Zen, 19 from KiloCode Gateway, 4 from Cline, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers and Vercel Edge relays. Requests rotate across the pool. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Every model has a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`. | **$0** |
@@ -174,13 +174,16 @@ The same command set works identically in OMP and Pi:
174
174
  /freeflow update # Check for and install a package update
175
175
  /freeflow debug on | off # Toggle full HTTP lifecycle debug logging
176
176
  /freeflow kill # Stop the shared proxy daemon now (restarts on next use)
177
+ /freeflow cline login # Sign in through the browser, save to the per-user pool
178
+ /freeflow cline accounts # List saved Cline logins
179
+ /freeflow cline logout # Remove a saved Cline login
177
180
  /freeflow export [path] [--include-secrets] # Save the relay pool to a file (default freeflow-relays.json; passwords left out unless asked)
178
181
  /freeflow import <path> [--merge|--replace] [--dry-run] # Load a relay pool from a file (merge is default; replace asks first; dry-run previews only)
179
182
  ```
180
183
 
181
184
  ---
182
185
 
183
- ### 27 models, one command
186
+ ### 31 models, one command
184
187
 
185
188
  ```bash
186
189
  /model → freeflow → pick
@@ -229,6 +232,17 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
229
232
 
230
233
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
231
234
 
235
+ #### Cline (4 models), OpenAI compatible
236
+
237
+ Rotating per-account promo, direct only (never through the relay pool). Sign in through your browser with `/freeflow cline login`, manage with `/freeflow cline accounts` and `/freeflow cline logout`. Requests roll across saved logins when one hits its daily free limit.
238
+
239
+ | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
240
+ | :--- | :--- | :--- | :--- | :--- | :--- |
241
+ | `cline-free/deepseek-v4.1-flash` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `low/high/max` | ✅ |
242
+ | `cline-free/muse-spark-1.3-contributor` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
243
+ | `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.000.000) | **131K** (131.072) | `low/high/max` | ✅ |
244
+ | `cline-free/solar-pro4` | Upstage | **512K** (524.288) | **131K** (131.072) | `minimal…max`\* | ❌ |
245
+
232
246
  ---
233
247
 
234
248
  ### Logs and debugging
@@ -254,7 +268,7 @@ About 19k lines including tests. The full suite (sandboxed, network-mocked) and
254
268
  ### FAQ
255
269
 
256
270
  **Do I need API keys?**
257
- No. Kilo uses a shared free credential and OpenCode free models need no header. You never paste a key.
271
+ No. Kilo uses a shared free credential and OpenCode free models need no header. You never paste a key. Cline free models need a browser login instead: `/freeflow cline login` saves it to a per-user pool on your machine.
258
272
 
259
273
  **What if all relays hit rate limits?**
260
274
  The proxy tries direct upstream. If that is also rate-limited, the host shows the limit. That number is the shared upstream cap; without relays you would hit the same wall sooner.
@@ -322,7 +336,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
322
336
  ```
323
337
  src/
324
338
  ├── index.ts # extension entry, lifecycle hooks
325
- ├── models.ts # 27-model catalog definitions
339
+ ├── models.ts # 31-model catalog definitions
326
340
  ├── catalog.ts # model catalog cache (24h disk)
327
341
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
328
342
  ├── relay.ts # relay selection and round-robin
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.21.0",
4
+ "version": "1.22.1",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
@@ -30,6 +30,14 @@ export interface ClineAccount {
30
30
  token: string;
31
31
  addedAt: string;
32
32
  lastOkAt?: number;
33
+ /** WorkOS refresh token for device-login slots; absent on legacy key slots. */
34
+ refreshToken?: string;
35
+ /** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
36
+ expiresAt?: number;
37
+ /** WorkOS account id, kept for display/diagnostics only. */
38
+ accountId?: string;
39
+ /** Login email, kept for display/diagnostics only. */
40
+ email?: string;
33
41
  }
34
42
 
35
43
  export interface ClinePoolState {
@@ -37,6 +45,31 @@ export interface ClinePoolState {
37
45
  activeSlot?: string;
38
46
  }
39
47
 
48
+ /**
49
+ * Fresh tokens minted via the device-login refresh flow. DeviceAuth owns the
50
+ * network call; this module only applies the result to the pool file.
51
+ */
52
+ export interface ClineRefreshResult {
53
+ token: string;
54
+ refreshToken?: string;
55
+ expiresAt?: number;
56
+ accountId?: string;
57
+ email?: string;
58
+ }
59
+
60
+ /**
61
+ * Refresh one slot. Resolves to the fresh tokens, or null when the grant is
62
+ * dead (invalid_grant — the slot must be logged in again). May throw on
63
+ * transient network faults; callers treat a throw as "keep the stale token".
64
+ */
65
+ export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
66
+
67
+ /** Usable bearer for one slot, resolved via refresh when stale. */
68
+ export interface ValidSlotCredentials {
69
+ slot: string;
70
+ token: string;
71
+ }
72
+
40
73
  /** Cooldowns after failures so a bad key stops absorbing traffic. */
41
74
  const AUTH_COOLDOWN_MS = 10 * 60_000;
42
75
  const RATE_COOLDOWN_MS = 90_000;
@@ -112,11 +145,25 @@ function readPoolFile(): ClinePoolState {
112
145
  const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
113
146
  const token = typeof rec.token === "string" ? rec.token : "";
114
147
  if (!slot || !token.startsWith("workos:")) continue;
148
+ const refreshRaw: unknown = rec.refreshToken;
149
+ const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
150
+ const expiresRaw: unknown = rec.expiresAt;
151
+ const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
152
+ ? expiresRaw
153
+ : undefined;
154
+ const accountIdRaw: unknown = rec.accountId;
155
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
156
+ const emailRaw: unknown = rec.email;
157
+ const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
115
158
  accounts.push({
116
159
  slot,
117
160
  token,
118
161
  addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
119
162
  ...(typeof rec.lastOkAt === "number" ? { lastOkAt: rec.lastOkAt } : {}),
163
+ ...(refreshToken ? { refreshToken } : {}),
164
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
165
+ ...(accountId ? { accountId } : {}),
166
+ ...(email ? { email } : {}),
120
167
  });
121
168
  }
122
169
  const activeRaw: unknown = doc.activeSlot;
@@ -180,8 +227,14 @@ export function redactedToken(token: string): string {
180
227
  /**
181
228
  * Save (or replace) one login slot. The token must carry the `workos:` prefix.
182
229
  * Throws on bad input — the message never echoes the token.
230
+ * Extras carry the device-login grant (refresh token, expiry, identity);
231
+ * legacy key slots keep calling with two args and load untouched.
183
232
  */
184
- export function addAccount(slot: string, token: string): ClinePoolState {
233
+ export function addAccount(
234
+ slot: string,
235
+ token: string,
236
+ extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
237
+ ): ClinePoolState {
185
238
  const cleanSlot = (slot || "").trim();
186
239
  if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
187
240
  if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
@@ -190,8 +243,31 @@ export function addAccount(slot: string, token: string): ClinePoolState {
190
243
  const existing = pool.accounts.find((a) => a.slot === cleanSlot);
191
244
  if (existing) {
192
245
  existing.token = token;
246
+ if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
247
+ if (extras?.expiresAt !== undefined) {
248
+ if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
249
+ else delete existing.expiresAt;
250
+ }
251
+ if (extras?.accountId !== undefined) {
252
+ if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
253
+ else delete existing.accountId;
254
+ }
255
+ if (extras?.email !== undefined) {
256
+ if (extras.email.trim()) existing.email = extras.email.trim();
257
+ else delete existing.email;
258
+ }
193
259
  } else {
194
- pool.accounts.push({ slot: cleanSlot, token, addedAt: new Date().toISOString() });
260
+ pool.accounts.push({
261
+ slot: cleanSlot,
262
+ token,
263
+ addedAt: new Date().toISOString(),
264
+ ...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
265
+ ...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
266
+ ? { expiresAt: extras.expiresAt }
267
+ : {}),
268
+ ...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
269
+ ...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
270
+ });
195
271
  }
196
272
  if (!pool.activeSlot) pool.activeSlot = cleanSlot;
197
273
  slotCooldowns.delete(cleanSlot);
@@ -199,6 +275,79 @@ export function addAccount(slot: string, token: string): ClinePoolState {
199
275
  return pool;
200
276
  }
201
277
 
278
+ /** Skew so a token expiring mid-flight counts as stale before it breaks a call. */
279
+ export const CLINE_REFRESH_SKEW_MS = 60_000;
280
+
281
+ /**
282
+ * True when the slot carries a known expiry that has passed (or passes within
283
+ * the skew window). Slots without `expiresAt` — every legacy key slot — are
284
+ * never stale: they load and serve exactly as before.
285
+ */
286
+ export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_SKEW_MS): boolean {
287
+ if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false;
288
+ return Date.now() + skewMs >= account.expiresAt;
289
+ }
290
+
291
+ /**
292
+ * Refresh outcome for one slot: fresh tokens applied and persisted (`true`),
293
+ * dead grant with nothing to keep (`false`), or a transient fault where the
294
+ * caller should fall back to the stale bearer (`null`).
295
+ */
296
+ async function refreshAccountInPlace(
297
+ pool: ClinePoolState,
298
+ account: ClineAccount,
299
+ refreshImpl: ClineRefresher,
300
+ ): Promise<boolean | null> {
301
+ const current = account.refreshToken;
302
+ if (!current) return null;
303
+ let fresh: ClineRefreshResult | null;
304
+ try {
305
+ fresh = await refreshImpl(current);
306
+ } catch (e) {
307
+ logWarn("cline slot refresh failed — keeping stale bearer", { slot: account.slot });
308
+ void e;
309
+ return null;
310
+ }
311
+ if (!fresh || typeof fresh.token !== "string" || !fresh.token.startsWith("workos:")) {
312
+ logWarn("cline slot refresh rejected — grant is dead", { slot: account.slot });
313
+ return false;
314
+ }
315
+ account.token = fresh.token;
316
+ if (fresh.refreshToken) account.refreshToken = fresh.refreshToken;
317
+ if (typeof fresh.expiresAt === "number" && Number.isFinite(fresh.expiresAt) && fresh.expiresAt > 0) {
318
+ account.expiresAt = fresh.expiresAt;
319
+ } else {
320
+ delete account.expiresAt;
321
+ }
322
+ if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
323
+ if (fresh.email?.trim()) account.email = fresh.email.trim();
324
+ savePool(pool);
325
+ return true;
326
+ }
327
+
328
+ /**
329
+ * Resolve a usable bearer for one slot. Fresh slots and legacy slots without
330
+ * a known expiry return immediately; stale slots with a refresh grant try one
331
+ * refresh and persist it. Resolves null when the slot is unknown or its grant
332
+ * is dead (invalid_grant) — the caller should prompt for a fresh login.
333
+ */
334
+ export async function getValidSlotCredentials(
335
+ slot: string,
336
+ opts?: { refreshImpl?: ClineRefresher; skewMs?: number },
337
+ ): Promise<ValidSlotCredentials | null> {
338
+ const cleanSlot = (slot || "").trim();
339
+ if (!cleanSlot) return null;
340
+ const pool = loadPool();
341
+ const account = pool.accounts.find((a) => a.slot === cleanSlot);
342
+ if (!account) return null;
343
+ if (!isClineTokenStale(account, opts?.skewMs)) return { slot: account.slot, token: account.token };
344
+ if (!account.refreshToken || !opts?.refreshImpl) return { slot: account.slot, token: account.token };
345
+ const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
346
+ if (outcome === true) return { slot: account.slot, token: account.token };
347
+ if (outcome === false) return null;
348
+ return { slot: account.slot, token: account.token };
349
+ }
350
+
202
351
  /** Drop one login slot. Returns false when the slot was not saved. */
203
352
  export function removeAccount(slot: string): boolean {
204
353
  const cleanSlot = (slot || "").trim();
@@ -221,6 +370,12 @@ export interface ClineRollOpts {
221
370
  slots?: string[];
222
371
  fetchImpl?: typeof fetch;
223
372
  reqId?: string;
373
+ /**
374
+ * Device-login refresher. When present, a stale slot refreshes once before
375
+ * its attempt, and a 401/403 refreshes once before the slot cools out.
376
+ * Absent: every slot serves its stored bearer exactly as before.
377
+ */
378
+ refreshImpl?: ClineRefresher;
224
379
  }
225
380
 
226
381
  export interface ClineRollResult {
@@ -265,40 +420,81 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
265
420
  return { res: exhaustedResponse(reason), slot: null, exhausted: true, kind: "exhausted" };
266
421
  }
267
422
  for (const account of candidates) {
268
- const token = account.token;
269
- let res: Response;
270
- try {
271
- res = await fetchImpl(opts.chatUrl, {
272
- method: "POST",
273
- headers: {
274
- "content-type": "application/json",
275
- authorization: `Bearer ${token}`,
276
- },
277
- body: opts.body,
278
- });
279
- } catch (e) {
280
- markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
281
- logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
282
- void e;
283
- continue;
423
+ // Stale device-login bearer: one refresh before the attempt. A dead grant
424
+ // cools the slot out immediately; a transient fault keeps the stale token.
425
+ let refreshedThisSlot = false;
426
+ if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
427
+ const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
428
+ if (outcome === false) {
429
+ markSlotCooldown(account.slot, AUTH_COOLDOWN_MS);
430
+ logWarn("cline slot refresh rejected — cooling slot", { slot: account.slot });
431
+ continue;
432
+ }
433
+ refreshedThisSlot = outcome === true;
284
434
  }
285
- const kind = mapClineError(res.status);
286
- if (kind === "ok" || kind === "client") {
435
+ const attempt = async (token: string): Promise<Response | null> => {
436
+ try {
437
+ return await fetchImpl(opts.chatUrl, {
438
+ method: "POST",
439
+ headers: {
440
+ "content-type": "application/json",
441
+ authorization: `Bearer ${token}`,
442
+ },
443
+ body: opts.body,
444
+ });
445
+ } catch (e) {
446
+ markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
447
+ logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
448
+ void e;
449
+ return null;
450
+ }
451
+ };
452
+ const succeed = (res: Response, kind: ClineErrorKind): ClineRollResult => {
287
453
  if (kind === "ok") {
288
454
  account.lastOkAt = Date.now();
289
455
  pool.activeSlot = account.slot;
290
456
  savePool(pool);
291
457
  }
292
458
  return { res, slot: account.slot, exhausted: false, kind };
459
+ };
460
+ const cool = (kind: ClineErrorKind, status: number): void => {
461
+ markSlotCooldown(
462
+ account.slot,
463
+ kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
464
+ );
465
+ logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status });
466
+ };
467
+ let res = await attempt(account.token);
468
+ if (!res) continue;
469
+ let kind = mapClineError(res.status);
470
+ if (kind === "ok" || kind === "client") return succeed(res, kind);
471
+ // Auth failure on a refreshable slot that has not refreshed yet: one
472
+ // refresh, then exactly one retry with the fresh bearer.
473
+ if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
474
+ try {
475
+ await res.body?.cancel();
476
+ } catch { }
477
+ const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
478
+ if (outcome === true) {
479
+ refreshedThisSlot = true;
480
+ res = await attempt(account.token);
481
+ if (!res) continue;
482
+ kind = mapClineError(res.status);
483
+ if (kind === "ok" || kind === "client") return succeed(res, kind);
484
+ } else if (outcome === false) {
485
+ try {
486
+ await res.body?.cancel();
487
+ } catch { }
488
+ cool(kind, res.status);
489
+ continue;
490
+ }
491
+ // Transient refresh fault: fall through and cool the slot on the
492
+ // original auth result below.
293
493
  }
294
- markSlotCooldown(
295
- account.slot,
296
- kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
297
- );
494
+ cool(kind, res.status);
298
495
  try {
299
496
  await res.body?.cancel();
300
497
  } catch { }
301
- logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status: res.status });
302
498
  }
303
499
  logWarn("cline pool exhausted after roll", { slots: candidates.length });
304
500
  return {
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Cline WorkOS device-flow auth (extension login, no paste).
3
+ * Mirrors C:/tmp/cline-login-proto/src/cline-auth.ts + reference/cline
4
+ * sdk/packages/core/src/auth/cline.ts (device authorize / poll /
5
+ * register / refresh) and provider-auth-registry.ts (expiry derive,
6
+ * workos: prefix). All network goes through injectable fetchImpl;
7
+ * mocked tests only, no live calls here.
8
+ */
9
+
10
+ export interface OAuthCredentials {
11
+ access: string;
12
+ refresh: string;
13
+ /** Expiration timestamp in milliseconds since epoch. */
14
+ expires: number;
15
+ accountId?: string;
16
+ email?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ export interface DeviceAuth {
21
+ deviceCode: string;
22
+ userCode: string;
23
+ verificationUri: string;
24
+ verificationUriComplete?: string;
25
+ expiresIn: number;
26
+ interval: number;
27
+ }
28
+
29
+ export interface DevicePollTokens {
30
+ accessToken: string;
31
+ refreshToken: string;
32
+ }
33
+
34
+ export interface PollOptions {
35
+ timeoutMs?: number;
36
+ maxWaitMs?: number;
37
+ fetchImpl?: FetchImpl;
38
+ /** Cooperative cancel from the login UI (esc / timeout). */
39
+ signal?: AbortSignal;
40
+ }
41
+
42
+ export const DEFAULT_WORKOS_BASE = "https://api.workos.com";
43
+ export const DEFAULT_API_BASE = "https://api.cline.bot";
44
+ export const WORKOS_TOKEN_PREFIX = "workos:";
45
+
46
+ /** Env override for the WorkOS client id (wins over the bundled default). */
47
+ export const CLINE_WORKOS_CLIENT_ID_ENV = "PI_FREEFLOW_CLINE_WORKOS_CLIENT_ID";
48
+
49
+ /**
50
+ * Bundled production client id (reference shared/src/runtime/cline-environment.ts,
51
+ * production entry). Env override wins when set and non-blank.
52
+ */
53
+ export const BUNDLED_CLINE_WORKOS_CLIENT_ID = "client_01K3A541FN8TA3EPPHTD2325AR";
54
+
55
+ export function resolveClineWorkosClientId(): string {
56
+ const fromEnv = process.env[CLINE_WORKOS_CLIENT_ID_ENV]?.trim();
57
+ if (fromEnv) return fromEnv;
58
+ return BUNDLED_CLINE_WORKOS_CLIENT_ID;
59
+ }
60
+
61
+ /** Resolved client id used by start/poll. */
62
+ export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
63
+
64
+ export function toApiKey(accessToken: string): string {
65
+ const token = accessToken.trim();
66
+ return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX) ? token : `${WORKOS_TOKEN_PREFIX}${token}`;
67
+ }
68
+
69
+ const DEVICE_AUTHORIZATION_PATH = "/user_management/authorize/device";
70
+ const AUTHENTICATE_PATH = "/user_management/authenticate";
71
+ const REGISTER_PATH = "/api/v1/auth/register";
72
+ const REFRESH_PATH = "/api/v1/auth/refresh";
73
+
74
+ const HTTP_TIMEOUT_MS = 30 * 1000;
75
+
76
+ export type FetchImpl = typeof fetch;
77
+
78
+ function joinUrl(base: string, path: string): string {
79
+ return base.replace(/\/+$/, "") + path;
80
+ }
81
+
82
+ export class ClineAuthError extends Error {
83
+ readonly status?: number;
84
+ readonly errorCode?: string;
85
+ constructor(message: string, opts?: { status?: number; errorCode?: string }) {
86
+ super(message);
87
+ this.name = "ClineAuthError";
88
+ this.status = opts?.status;
89
+ this.errorCode = opts?.errorCode;
90
+ }
91
+ }
92
+
93
+ function toEpochMs(isoDateTime: string): number {
94
+ const epoch = Date.parse(isoDateTime);
95
+ if (Number.isNaN(epoch)) {
96
+ throw new Error(`Invalid expiresAt value: ${isoDateTime}`);
97
+ }
98
+ return epoch;
99
+ }
100
+
101
+ function toSeconds(value: unknown, fallback: number): number {
102
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
103
+ return fallback;
104
+ }
105
+
106
+ function decodeJwtExpMs(accessToken: string): number | null {
107
+ try {
108
+ const parts = accessToken.split(".");
109
+ if (parts.length < 2) return null;
110
+ const payload = JSON.parse(
111
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
112
+ ) as { exp?: unknown };
113
+ if (typeof payload.exp === "number" && payload.exp > 0) return payload.exp * 1000;
114
+ return null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // Expiry derivation order (per registry deriveCredentialExpiry):
121
+ // explicit server expiry -> JWT exp claim -> already-expired (forces refresh).
122
+ export function deriveExpiry(explicitMs: number | undefined, accessToken: string): number {
123
+ if (typeof explicitMs === "number" && Number.isFinite(explicitMs) && explicitMs > 0) return explicitMs;
124
+ return decodeJwtExpMs(accessToken) ?? Date.now() - 1;
125
+ }
126
+
127
+ export async function startDeviceAuth(
128
+ workosBase: string = DEFAULT_WORKOS_BASE,
129
+ fetchImpl: FetchImpl = fetch,
130
+ ): Promise<DeviceAuth> {
131
+ const response = await fetchImpl(joinUrl(workosBase, DEVICE_AUTHORIZATION_PATH), {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
134
+ body: new URLSearchParams({ client_id: resolveClineWorkosClientId() }),
135
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
136
+ });
137
+ const json = (await response.json().catch(() => ({}))) as Record<string, unknown>;
138
+ if (!response.ok) {
139
+ throw new ClineAuthError(
140
+ `Device authorization failed: ${response.status}` +
141
+ (typeof json.error_description === "string" ? ` - ${json.error_description}` : ""),
142
+ { status: response.status, errorCode: typeof json.error === "string" ? json.error : undefined },
143
+ );
144
+ }
145
+ if (
146
+ typeof json.device_code !== "string" ||
147
+ typeof json.user_code !== "string" ||
148
+ typeof json.verification_uri !== "string"
149
+ ) {
150
+ throw new Error("Invalid WorkOS device authorization response");
151
+ }
152
+ return {
153
+ deviceCode: json.device_code,
154
+ userCode: json.user_code,
155
+ verificationUri: json.verification_uri,
156
+ verificationUriComplete:
157
+ typeof json.verification_uri_complete === "string" ? json.verification_uri_complete : undefined,
158
+ expiresIn: toSeconds(json.expires_in, 300),
159
+ interval: toSeconds(json.interval, 5),
160
+ };
161
+ }
162
+
163
+ function throwDenied(payload: Record<string, unknown>, status: number, fallback: string): never {
164
+ const code = typeof payload.error === "string" ? payload.error : undefined;
165
+ const detail = typeof payload.error_description === "string" ? payload.error_description : fallback;
166
+ throw new ClineAuthError(detail, { status, errorCode: code });
167
+ }
168
+
169
+ export async function pollDeviceToken(
170
+ workosBase: string = DEFAULT_WORKOS_BASE,
171
+ deviceCode = "",
172
+ intervalSeconds = 5,
173
+ options?: PollOptions,
174
+ ): Promise<DevicePollTokens> {
175
+ if (!deviceCode) throw new Error("deviceCode is required");
176
+ const fetchFn = options?.fetchImpl ?? fetch;
177
+ const outerSignal = options?.signal;
178
+ const deadline = Date.now() + (options?.maxWaitMs ?? intervalSeconds * 1000 * 60 * 10);
179
+ let interval = Math.max(1, intervalSeconds);
180
+ for (; ;) {
181
+ if (outerSignal?.aborted) {
182
+ throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
183
+ }
184
+ let response: Response;
185
+ try {
186
+ response = await fetchFn(joinUrl(workosBase, AUTHENTICATE_PATH), {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
189
+ body: new URLSearchParams({
190
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
191
+ device_code: deviceCode,
192
+ client_id: resolveClineWorkosClientId(),
193
+ }),
194
+ signal: AbortSignal.timeout(options?.timeoutMs ?? HTTP_TIMEOUT_MS),
195
+ });
196
+ } catch (error) {
197
+ if (outerSignal?.aborted) {
198
+ throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
199
+ }
200
+ if (error instanceof ClineAuthError) throw error;
201
+ if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
202
+ await sleepMs(interval * 1000, outerSignal);
203
+ continue;
204
+ }
205
+ const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
206
+ if (response.ok) {
207
+ if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
208
+ throw new Error("Invalid WorkOS token response");
209
+ }
210
+ return { accessToken: payload.access_token, refreshToken: payload.refresh_token };
211
+ }
212
+ if (payload.error === "authorization_pending") {
213
+ // keep polling at the current interval
214
+ } else if (payload.error === "slow_down") {
215
+ interval += 1;
216
+ } else if (
217
+ payload.error === "access_denied" ||
218
+ payload.error === "expired_token" ||
219
+ payload.error === "invalid_grant"
220
+ ) {
221
+ throwDenied(payload, response.status, `Device login refused (${String(payload.error)})`);
222
+ } else {
223
+ throw new ClineAuthError(
224
+ typeof payload.error_description === "string"
225
+ ? String(payload.error_description)
226
+ : `WorkOS token polling failed: ${response.status}`,
227
+ { status: response.status, errorCode: typeof payload.error === "string" ? payload.error : undefined },
228
+ );
229
+ }
230
+ if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
231
+ await sleepMs(interval * 1000, outerSignal);
232
+ }
233
+ }
234
+
235
+ async function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
236
+ if (signal?.aborted) throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
237
+ if (!signal) {
238
+ const { promise, resolve } = Promise.withResolvers<void>();
239
+ setTimeout(resolve, ms);
240
+ await promise;
241
+ return;
242
+ }
243
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
244
+ const onAbort = (): void => {
245
+ clearTimeout(timer);
246
+ reject(new ClineAuthError("Device login cancelled", { errorCode: "cancelled" }));
247
+ };
248
+ const timer = setTimeout(() => {
249
+ signal.removeEventListener("abort", onAbort);
250
+ resolve();
251
+ }, ms);
252
+ signal.addEventListener("abort", onAbort, { once: true });
253
+ await promise;
254
+ }
255
+
256
+ interface ClineTokenData {
257
+ accessToken: string;
258
+ refreshToken?: string;
259
+ tokenType: string;
260
+ expiresAt: string;
261
+ userInfo: { email: string; clineUserId: string | null };
262
+ }
263
+
264
+ function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCredentials {
265
+ const refresh = data.refreshToken ?? fallbackRefresh;
266
+ if (!refresh) throw new Error("Token response did not include a refresh token");
267
+ let explicit: number | undefined;
268
+ try {
269
+ explicit = toEpochMs(data.expiresAt);
270
+ } catch {
271
+ explicit = undefined;
272
+ }
273
+ return {
274
+ access: data.accessToken,
275
+ refresh,
276
+ expires: deriveExpiry(explicit, data.accessToken),
277
+ accountId: data.userInfo.clineUserId ?? undefined,
278
+ email: data.userInfo.email || undefined,
279
+ metadata: { tokenType: data.tokenType },
280
+ };
281
+ }
282
+
283
+ async function readTokenData(response: Response, message: string): Promise<ClineTokenData> {
284
+ const json = (await response.json().catch(() => ({}))) as {
285
+ success?: boolean;
286
+ data?: ClineTokenData;
287
+ };
288
+ if (!json.success || !json.data?.accessToken) {
289
+ throw new Error(`Invalid token response: ${message}`);
290
+ }
291
+ return json.data;
292
+ }
293
+
294
+ export async function registerClineToken(
295
+ apiBase: string = DEFAULT_API_BASE,
296
+ accessToken = "",
297
+ refreshToken = "",
298
+ fetchImpl: FetchImpl = fetch,
299
+ ): Promise<OAuthCredentials> {
300
+ if (!accessToken || !refreshToken) throw new Error("accessToken and refreshToken are required");
301
+ const response = await fetchImpl(joinUrl(apiBase, REGISTER_PATH), {
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/json" },
304
+ body: JSON.stringify({ accessToken, refreshToken }),
305
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
306
+ });
307
+ if (!response.ok) {
308
+ const text = await response.text().catch(() => "");
309
+ throw new ClineAuthError(`Token registration failed: ${response.status}${text ? ` - ${text}` : ""}`, {
310
+ status: response.status,
311
+ });
312
+ }
313
+ return toCredentials(await readTokenData(response, "register"));
314
+ }
315
+
316
+ export async function refreshClineToken(
317
+ apiBase: string = DEFAULT_API_BASE,
318
+ refreshToken = "",
319
+ fetchImpl: FetchImpl = fetch,
320
+ ): Promise<OAuthCredentials> {
321
+ if (!refreshToken) throw new Error("refreshToken is required");
322
+ const response = await fetchImpl(joinUrl(apiBase, REFRESH_PATH), {
323
+ method: "POST",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify({ refreshToken, grantType: "refresh_token" }),
326
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
327
+ });
328
+ if (!response.ok) {
329
+ const text = await response.text().catch(() => "");
330
+ throw new ClineAuthError(`Token refresh failed: ${response.status}${text ? ` - ${text}` : ""}`, {
331
+ status: response.status,
332
+ });
333
+ }
334
+ return toCredentials(await readTokenData(response, "refresh"), refreshToken);
335
+ }
package/src/commands.ts CHANGED
@@ -59,6 +59,8 @@ import {
59
59
  formatRelayStatusLabel,
60
60
  } from "./relay-state.ts";
61
61
  import { addAccount, isClineSlotHealthy, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
62
+ import type { ClinePoolState } from "./cline-accounts.ts";
63
+ import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
62
64
  import type {
63
65
  ExtensionAPI,
64
66
  ExtensionContext,
@@ -348,20 +350,45 @@ function startLogsFollow(
348
350
  logsFollowTimer.unref?.();
349
351
  }
350
352
 
353
+ /**
354
+ * Pick the login slot when the user gives none: "default" for the first
355
+ * login, then the smallest free "slot-2", "slot-3", … — never overwriting
356
+ * a saved login. Pure — takes the pool, returns a name.
357
+ */
358
+ function nextClineSlot(pool: ClinePoolState): string {
359
+ if (!pool.accounts.some((a) => a.slot === "default")) return "default";
360
+ let n = 2;
361
+ while (pool.accounts.some((a) => a.slot === `slot-${n}`)) n += 1;
362
+ return `slot-${n}`;
363
+ }
364
+
365
+ /** One notify-ready line per saved login, in pool order. */
366
+ function formatClineAccountLines(pool: ClinePoolState): string[] {
367
+ return pool.accounts.map((a, idx) => {
368
+ 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}`;
371
+ });
372
+ }
373
+ /** User-safe one-line message for caught values (never echoes secrets). */
374
+ function clineErrorMessage(e: unknown): string {
375
+ if (e instanceof Error && e.message) return e.message;
376
+ if (typeof e === "string" && e) return e;
377
+ return "unknown error";
378
+ }
351
379
  export function createCommandSpec(
352
380
  _pi: ExtensionAPI,
353
381
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
354
382
  ): Omit<RegisteredCommand, "name"> {
355
383
  return {
356
384
  description:
357
- "Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup | cline login [slot] | cline accounts | cline logout [slot]",
385
+ "Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test [target|opencode] [--chat] | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup | cline login [--key] [slot] | cline accounts | cline logout [slot]",
358
386
  getArgumentCompletions: (prefix: string) =>
359
387
  [
360
388
  "auto",
361
389
  "on",
362
390
  "off",
363
391
  "hide",
364
- "show",
365
392
  "widget hide",
366
393
  "widget show",
367
394
  "status",
@@ -384,6 +411,7 @@ export function createCommandSpec(
384
411
  "uninstall-startup",
385
412
  "cline",
386
413
  "cline login",
414
+ "cline login --key",
387
415
  "cline accounts",
388
416
  "cline logout",
389
417
  "refresh",
@@ -1399,17 +1427,72 @@ export function createCommandSpec(
1399
1427
  const action = (tokens[0] || "accounts").toLowerCase();
1400
1428
  const arg = tokens.slice(1).join(" ").trim();
1401
1429
  if (action === "login") {
1402
- const slot = arg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
1403
- const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
1404
- if (!token) {
1405
- ctx.ui.notify("Cancelled — no API key provided", "warning");
1406
- } else {
1407
- try {
1408
- addAccount(slot, token);
1409
- ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
1410
- } catch (e) {
1411
- ctx.ui.notify((e as Error).message, "warning");
1430
+ const raw = tokens.slice(1);
1431
+ const pasteMode = raw.some((t) => t === "--key");
1432
+ const slotArg = raw.filter((t) => t !== "--key").join(" ").trim();
1433
+ if (pasteMode) {
1434
+ // Manual fallback: paste a workos: API key (never shown back).
1435
+ const slot = slotArg || ((await ctx.ui.input("Cline slot name (empty = default):", "default"))?.trim() || "default");
1436
+ const token = ((await ctx.ui.input(`Paste the API key for Cline slot [${slot}]:`, ""))?.trim() || "");
1437
+ if (!token) {
1438
+ ctx.ui.notify("Cancelled no API key provided", "warning");
1439
+ } else {
1440
+ try {
1441
+ addAccount(slot, token);
1442
+ ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(token)}) — free Cline models are ready to use`, "info");
1443
+ } catch (e) {
1444
+ ctx.ui.notify((e as Error).message, "warning");
1445
+ }
1446
+ }
1447
+ return;
1448
+ }
1449
+ // Device login inside the extension: approve in the browser, no paste.
1450
+ const slot = slotArg || nextClineSlot(loadPool());
1451
+ let started;
1452
+ try {
1453
+ started = await startDeviceAuth();
1454
+ } catch (e) {
1455
+ ctx.ui.notify(`Could not reach Cline to start login: ${clineErrorMessage(e)}`, "error");
1456
+ return;
1457
+ }
1458
+ const link = started.verificationUriComplete ?? started.verificationUri;
1459
+ 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");
1462
+ const progress = setInterval(() => {
1463
+ ctx.ui.notify("Still waiting for Cline approval — approve or cancel in the browser.", "info");
1464
+ }, 45000);
1465
+ // @ts-ignore allow unref to not block process exit in CLI
1466
+ progress.unref?.();
1467
+ try {
1468
+ const deviceTokens = await pollDeviceToken(undefined, started.deviceCode, started.interval);
1469
+ const creds = await registerClineToken(undefined, deviceTokens.accessToken, deviceTokens.refreshToken);
1470
+ const apiKey = toApiKey(creds.access);
1471
+ addAccount(slot, apiKey, {
1472
+ refreshToken: creds.refresh,
1473
+ expiresAt: creds.expires,
1474
+ ...(creds.accountId ? { accountId: creds.accountId } : {}),
1475
+ ...(creds.email ? { email: creds.email } : {}),
1476
+ });
1477
+ ctx.ui.notify(`Saved Cline login [${slot}] (key ending ${redactedToken(apiKey)}) — free Cline models are ready to use`, "info");
1478
+ const pool = loadPool();
1479
+ ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
1480
+ } catch (e) {
1481
+ let code: string | undefined;
1482
+ if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
1483
+ code = e.errorCode;
1484
+ }
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") {
1488
+ ctx.ui.notify("Cline login cancelled.", "warning");
1489
+ } else if (code === "expired_token") {
1490
+ ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
1491
+ } else {
1492
+ ctx.ui.notify(`Cline login did not finish: ${clineErrorMessage(e)}`, "warning");
1412
1493
  }
1494
+ } finally {
1495
+ clearInterval(progress);
1413
1496
  }
1414
1497
  } else if (action === "accounts" || action === "list") {
1415
1498
  const pool = loadPool();
@@ -1433,7 +1516,7 @@ export function createCommandSpec(
1433
1516
  ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
1434
1517
  }
1435
1518
  } else {
1436
- ctx.ui.notify("Usage: /freeflow cline login [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
1519
+ ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
1437
1520
  }
1438
1521
  } else if (sub === "export") {
1439
1522
  const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
package/src/models.ts CHANGED
@@ -370,9 +370,9 @@ export const KILO_MODELS: ModelDef[] = [
370
370
  ];
371
371
 
372
372
  /**
373
- * Shared effort map for Cline free models same flat reasoning_effort
374
- * minimal..xhigh shape as the Kilo map; the proxy passes effort through
375
- * verbatim and upstream serves the levels it supports.
373
+ * Full-range effort map (Solar Pro 4reference lists none..max; off stays
374
+ * null per repo convention). The proxy passes effort through verbatim and
375
+ * upstream serves the levels it supports.
376
376
  */
377
377
  const CLINE_REASONING_MAP: ThinkingLevelMap = {
378
378
  off: null,
@@ -381,7 +381,22 @@ const CLINE_REASONING_MAP: ThinkingLevelMap = {
381
381
  medium: "medium",
382
382
  high: "high",
383
383
  xhigh: "xhigh",
384
- max: null,
384
+ max: "max",
385
+ };
386
+ /**
387
+ * Narrow effort map for DeepSeek V4 Flash + GLM Flash — reference
388
+ * reasoningOptions list effort values [low, high, max] only (no toggle
389
+ * on GLM); minimal/medium/xhigh are not offered so the host never sends
390
+ * an unsupported level (the proxy forwards effort verbatim).
391
+ */
392
+ const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
393
+ off: null,
394
+ minimal: null,
395
+ low: "low",
396
+ medium: null,
397
+ high: "high",
398
+ xhigh: null,
399
+ max: "max",
385
400
  };
386
401
 
387
402
  /**
@@ -396,10 +411,10 @@ export const CLINE_MODELS: ModelDef[] = [
396
411
  id: "cline-free/deepseek-v4.1-flash",
397
412
  name: "DeepSeek V4.1 Flash [Cline]",
398
413
  reasoning: true,
399
- contextWindow: 262_144,
400
- maxTokens: 32_768,
401
- input: ["text"],
402
- thinkingLevelMap: CLINE_REASONING_MAP,
414
+ contextWindow: 1_000_000,
415
+ maxTokens: 384_000,
416
+ input: ["text", "image"],
417
+ thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
403
418
  },
404
419
  {
405
420
  id: "cline-free/muse-spark-1.3-contributor",
@@ -423,17 +438,17 @@ export const CLINE_MODELS: ModelDef[] = [
423
438
  id: "z-ai/glm-5.3-flash",
424
439
  name: "GLM 5.3 Flash [Cline]",
425
440
  reasoning: true,
426
- contextWindow: 262_144,
427
- maxTokens: 65_536,
428
- input: ["text"],
429
- thinkingLevelMap: CLINE_REASONING_MAP,
441
+ contextWindow: 1_000_000,
442
+ maxTokens: 131_072,
443
+ input: ["text", "image"],
444
+ thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
430
445
  },
431
446
  {
432
447
  id: "cline-free/solar-pro4",
433
448
  name: "Solar Pro4 [Cline]",
434
449
  reasoning: true,
435
- contextWindow: 131_072,
436
- maxTokens: 32_768,
450
+ contextWindow: 524_288,
451
+ maxTokens: 131_072,
437
452
  input: ["text"],
438
453
  thinkingLevelMap: CLINE_REASONING_MAP,
439
454
  },