pi-clinepass 0.1.0 → 0.1.2

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/catalog.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * (verified against real usage), not the published reference table which
6
6
  * drifts from actual billing. `cacheWrite` is kept at 0: pi's cost model
7
7
  * requires the field, but we do not display or track cache-write pricing.
8
+ *
9
+ * Context is capped to 921,600 for models whose gateway reports ~1M
10
+ * (pool max, not single-node) to leave headroom; max output is capped to
11
+ * 131,072. Models under 1M keep their tested values.
8
12
  */
9
13
 
10
14
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -15,7 +19,7 @@ export interface ClinePassModel {
15
19
  id: string;
16
20
  name: string;
17
21
  reasoning: boolean;
18
- input: readonly ["text"];
22
+ input: readonly ("text" | "image")[];
19
23
  cost: {
20
24
  input: number;
21
25
  output: number;
@@ -32,10 +36,10 @@ export interface ClinePassModel {
32
36
  };
33
37
  }
34
38
 
35
- /** All levels supported (off → "none"), minimal unsupported. */
36
- const FULL_THINKING: ThinkingLevelMap = {
39
+ /** All 7 levels supported (off → "none"). Used when `supportedEfforts: null` + mandatory false. */
40
+ const ALL_THINKING: ThinkingLevelMap = {
37
41
  off: "none",
38
- minimal: null,
42
+ minimal: "minimal",
39
43
  low: "low",
40
44
  medium: "medium",
41
45
  high: "high",
@@ -43,10 +47,59 @@ const FULL_THINKING: ThinkingLevelMap = {
43
47
  max: "max",
44
48
  };
45
49
 
46
- /** ox-alpha requires reasoning upstream: off → null (unsupported). */
47
- const OX_ALPHA_THINKING: ThinkingLevelMap = {
48
- ...FULL_THINKING,
50
+ /** All levels except off (mandatory reasoning). Used when `mandatory: true` + `supportedEfforts: null`. */
51
+ const ALL_MANDATORY: ThinkingLevelMap = {
52
+ off: null,
53
+ minimal: "minimal",
54
+ low: "low",
55
+ medium: "medium",
56
+ high: "high",
57
+ xhigh: "xhigh",
58
+ max: "max",
59
+ };
60
+
61
+ /** Only max/high/low + off. Used for deepseek, glm-5.3, kimi-k3, free models. */
62
+ const MAX_HIGH_LOW: ThinkingLevelMap = {
63
+ off: "none",
64
+ minimal: null,
65
+ low: "low",
66
+ medium: null,
67
+ high: "high",
68
+ xhigh: null,
69
+ max: "max",
70
+ };
71
+
72
+ /** Max/high/low but off not allowed (mandatory). */
73
+ const MAX_HIGH_LOW_MANDATORY: ThinkingLevelMap = {
49
74
  off: null,
75
+ minimal: null,
76
+ low: "low",
77
+ medium: null,
78
+ high: "high",
79
+ xhigh: null,
80
+ max: "max",
81
+ };
82
+
83
+ /** Only xhigh/high + off. Used for glm-5.2. */
84
+ const XHIGH_HIGH: ThinkingLevelMap = {
85
+ off: "none",
86
+ minimal: null,
87
+ low: null,
88
+ medium: null,
89
+ high: "high",
90
+ xhigh: "xhigh",
91
+ max: null,
92
+ };
93
+
94
+ /** xhigh/medium/low + off. Used for qwen3.8-max. */
95
+ const XHIGH_MEDIUM_LOW: ThinkingLevelMap = {
96
+ off: "none",
97
+ minimal: null,
98
+ low: "low",
99
+ medium: "medium",
100
+ high: null,
101
+ xhigh: "xhigh",
102
+ max: null,
50
103
  };
51
104
 
52
105
  /**
@@ -70,13 +123,14 @@ function model(
70
123
  cost: [input: number, output: number, cacheRead: number],
71
124
  contextWindow: number,
72
125
  maxTokens: number,
73
- thinkingLevelMap: ThinkingLevelMap = FULL_THINKING,
126
+ input: readonly ("text" | "image")[] = ["text"],
127
+ thinkingLevelMap: ThinkingLevelMap = ALL_THINKING,
74
128
  ): ClinePassModel {
75
129
  return {
76
130
  id,
77
131
  name,
78
132
  reasoning: true,
79
- input: ["text"],
133
+ input,
80
134
  cost: { input: cost[0], output: cost[1], cacheRead: cost[2], cacheWrite: 0 },
81
135
  contextWindow,
82
136
  maxTokens,
@@ -85,33 +139,36 @@ function model(
85
139
  };
86
140
  }
87
141
 
88
- /** Measured prices ($/1M tokens: input/output/cacheRead). */
142
+ /** Measured prices ($/1M tokens: input/output/cacheRead) — latest verified. */
89
143
  export const MODELS: readonly ClinePassModel[] = [
90
144
  // ── Free models (Cline free tier, cost 0) ──────────────────────────────
91
- model("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash (Cline Free)", [0, 0, 0], 1_024_000, 384_000),
92
- model("stealth/ox-alpha", "Ox Alpha (Cline Free)", [0, 0, 0], 1_048_576, 131_072, OX_ALPHA_THINKING),
93
- model("poolside/laguna-s-2.1:free", "Poolside Laguna S-2.1 (Cline Free)", [0, 0, 0], 262_144, 32_768),
145
+ model("cline-free/longcat-2.0", "LongCat 2.0", [0, 0, 0], 921_600, 131_072, ["text"], ALL_THINKING),
146
+ model("z-ai/glm-5.3-flash", "GLM-5.3 Flash", [0, 0, 0], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW_MANDATORY),
147
+ model("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", [0, 0, 0], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW),
148
+ model("poolside/laguna-s-2.1:free", "Laguna S-2.1", [0, 0, 0], 262_144, 131_072, ["text"], ALL_THINKING),
94
149
  // ── ClinePass models (measured billing prices) ─────────────────────────
95
- model("cline-pass/glm-5.3", "GLM-5.3 (ClinePass)", [1.4, 4.4, 0.26], 1_048_576, 131_072),
96
- model("cline-pass/glm-5.2", "GLM-5.2 (ClinePass)", [1.4, 4.4, 0.26], 1_048_576, 262_144),
97
- model("cline-pass/kimi-k2.7-code", "Kimi K2.7 Code (ClinePass)", [1.58, 6.67, 0.317], 262_144, 262_144),
98
- model("cline-pass/kimi-k2.6", "Kimi K2.6 (ClinePass)", [1.58, 6.67, 0.267], 262_144, 262_144),
99
- model("cline-pass/kimi-k3", "Kimi K3 (ClinePass)", [6.0, 30.0, 0.6], 1_048_576, 1_048_576),
100
- model("cline-pass/deepseek-v4-pro", "DeepSeek V4 Pro (ClinePass)", [1.65, 4.95, 0.06], 1_024_000, 384_000),
101
- model("cline-pass/deepseek-v4-flash", "DeepSeek V4 Flash (ClinePass)", [0.44, 1.32, 0.014], 1_024_000, 384_000),
102
- model("cline-pass/mimo-v2.5", "MiMo-V2.5 (ClinePass)", [0.14, 0.28, 0.0028], 1_048_576, 131_072),
103
- model("cline-pass/mimo-v2.5-pro", "MiMo-V2.5-Pro (ClinePass)", [0.435, 0.87, 0.0036], 1_048_576, 131_072),
104
- model("cline-pass/minimax-m3", "MiniMax M3 (ClinePass)", [0.5, 2.0, 0.1], 524_288, 512_000),
105
- model("cline-pass/qwen3.7-plus", "Qwen3.7 Plus (ClinePass)", [0.67, 2.68, 0.067], 1_000_000, 131_072),
106
- model("cline-pass/qwen3.7-max", "Qwen3.7 Max (ClinePass)", [4.17, 12.51, 0.83], 1_000_000, 131_072),
107
- model("cline-pass/qwen3.8-max", "Qwen3.8 Max (ClinePass)", [2.75, 8.25, 0.344], 1_000_000, 131_072),
150
+ model("cline-pass/glm-5.3-flash", "GLM-5.3 Flash", [0.15, 0.5, 0.03], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW_MANDATORY),
151
+ model("cline-pass/glm-5.3", "GLM-5.3", [1.4, 4.4, 0.26], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW_MANDATORY),
152
+ model("cline-pass/glm-5.2", "GLM-5.2", [1.4, 4.4, 0.26], 921_600, 131_072, ["text"], XHIGH_HIGH),
153
+ model("cline-pass/kimi-k2.7-code", "Kimi K2.7 Code", [1.58, 6.67, 0.32], 262_144, 131_072, ["text", "image"], ALL_MANDATORY),
154
+ model("cline-pass/kimi-k2.6", "Kimi K2.6", [1.58, 6.67, 0.27], 262_144, 131_072, ["text", "image"], ALL_THINKING),
155
+ model("cline-pass/kimi-k3", "Kimi K3", [6.0, 30.0, 0.6], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW),
156
+ model("cline-pass/deepseek-v4-pro", "DeepSeek V4 Pro", [1.65, 4.95, 0.06], 921_600, 131_072, ["text"], MAX_HIGH_LOW),
157
+ model("cline-pass/deepseek-v4-flash", "DeepSeek V4 Flash", [0.44, 1.32, 0.014], 921_600, 131_072, ["text", "image"], MAX_HIGH_LOW),
158
+ model("cline-pass/mimo-v2.5", "MiMo-V2.5", [0.14, 0.28, 0.0028], 921_600, 131_072, ["text"], ALL_THINKING),
159
+ model("cline-pass/mimo-v2.5-pro", "MiMo-V2.5-Pro", [0.435, 0.87, 0.0036], 921_600, 131_072, ["text"], ALL_THINKING),
160
+ model("cline-pass/minimax-m3", "MiniMax M3", [0.5, 2.0, 0.1], 921_600, 131_072, ["text", "image"], ALL_THINKING),
161
+ model("cline-pass/qwen3.7-plus", "Qwen3.7 Plus", [0.67, 2.67, 0.07], 921_600, 131_072, ["text", "image"], ALL_THINKING),
162
+ model("cline-pass/qwen3.7-max", "Qwen3.7 Max", [4.17, 12.5, 0.83], 921_600, 131_072, ["text"], ALL_THINKING),
163
+ model("cline-pass/qwen3.8-max", "Qwen3.8 Max", [2.75, 8.25, 0.34], 921_600, 131_072, ["text"], XHIGH_MEDIUM_LOW),
108
164
  ];
109
165
 
110
166
  export function modelIds(): string[] {
111
167
  return MODELS.map((m) => m.id);
112
168
  }
113
169
 
114
- /** True for the Cline free-tier models (cost 0). */
170
+ /** True for the Cline free-tier models — derived from the catalog itself
171
+ * (cost 0), so adding a free model never needs a second edit here. */
115
172
  export function isFreeModel(id: string): boolean {
116
- return id.includes(":free") || id.includes("ox-alpha") || id === "deepseek/deepseek-v4-flash";
173
+ return MODELS.some((m) => m.id === id && m.cost.input === 0);
117
174
  }
package/src/errors.ts CHANGED
@@ -1,112 +1,114 @@
1
- /**
2
- * ClinePass error classification — maps provider error text to friendly,
3
- * actionable messages surfaced through pi's UI.
4
- *
5
- * Classification order matters: the free-model route returns plain
6
- * `403 Forbidden` when its Cline-CLI header gate rejects us, which must not
7
- * be reported as a subscription problem, and free-limit errors may arrive
8
- * wrapped in a generic 403/429 shell.
9
- */
10
-
11
- import { isFreeDeepSeekModel } from "./headers.js";
12
-
13
- export type ClinePassErrorType =
14
- | "not_subscribed"
15
- | "auth_expired"
16
- | "rate_limited"
17
- | "free_limit_reached"
18
- | "free_route_forbidden"
19
- | "unknown";
20
-
21
- function matchesAny(text: string, patterns: string[]): boolean {
22
- return patterns.some((p) => text.includes(p));
23
- }
24
-
25
- export const CLINEPASS_ERROR_MESSAGES: Record<ClinePassErrorType, string> = {
26
- not_subscribed:
27
- "ClinePass subscription required — or the organization account cannot use ClinePass. " +
28
- "Visit app.cline.bot to subscribe / switch to your personal account, or run `pi /login`.",
29
- auth_expired:
30
- "ClinePass authentication expired. Run `pi /login` and select ClinePass to refresh credentials.",
31
- rate_limited:
32
- "ClinePass rate limit reached. Wait a moment and try again, or check your plan at app.cline.bot.",
33
- free_limit_reached:
34
- "Free model rate limit reached. Please wait a few moments and try again.",
35
- free_route_forbidden:
36
- "Free model route unavailable (HTTP 403). The free DeepSeek route is gated to Cline product " +
37
- "surfaces — retry in a moment, or switch to a ClinePass model.",
38
- unknown: "ClinePass request failed. Check your subscription at app.cline.bot or run `pi /login`.",
39
- };
40
-
41
- /**
42
- * Classify a provider error string. `modelId` (the model of the failed
43
- * request, e.g. "deepseek/deepseek-v4-flash") disambiguates 403s on the
44
- * free route from subscription problems.
45
- */
46
- export function classifyClinePassError(
47
- errorMessage: string,
48
- modelId?: string,
49
- ): {
50
- type: ClinePassErrorType;
51
- message: string;
52
- } {
53
- const lower = errorMessage.toLowerCase();
54
-
55
- if (matchesAny(lower, ["401", "unauthorized", "invalid api key", "invalid_api_key"])) {
56
- return { type: "auth_expired", message: CLINEPASS_ERROR_MESSAGES.auth_expired };
57
- }
58
- if (matchesAny(lower, ["429", "rate limit", "too many requests", "rate_limit"])) {
59
- return { type: "rate_limited", message: CLINEPASS_ERROR_MESSAGES.rate_limited };
60
- }
61
- if (matchesAny(lower, ["free limit reached", "free limit", "try again in"])) {
62
- return { type: "free_limit_reached", message: CLINEPASS_ERROR_MESSAGES.free_limit_reached };
63
- }
64
- if (
65
- matchesAny(lower, [
66
- "403",
67
- "forbidden",
68
- "subscription required",
69
- "not subscribed",
70
- "organization accounts cannot use",
71
- ])
72
- ) {
73
- if (modelId && isFreeDeepSeekModel(modelId)) {
74
- return { type: "free_route_forbidden", message: CLINEPASS_ERROR_MESSAGES.free_route_forbidden };
75
- }
76
- return { type: "not_subscribed", message: CLINEPASS_ERROR_MESSAGES.not_subscribed };
77
- }
78
- return { type: "unknown", message: CLINEPASS_ERROR_MESSAGES.unknown };
79
- }
80
-
81
- export interface ErrorContext {
82
- hasUI: boolean;
83
- ui: {
84
- notify: (msg: string, type: "info" | "warning" | "error") => void;
85
- };
86
- model?: { provider?: string };
87
- }
88
-
89
- /** Handle a message_end event: classify ClinePass errors and notify. */
90
- export function handleClinePassError(
91
- event: { message: unknown },
92
- ctx: ErrorContext,
93
- ): void {
94
- if (!event.message) return;
95
- const msg = event.message as {
96
- stopReason?: string;
97
- errorMessage?: string;
98
- provider?: string;
99
- model?: string;
100
- };
101
- if (msg.stopReason !== "error" || !msg.errorMessage) return;
102
-
103
- const provider = msg.provider ?? ctx.model?.provider;
104
- if (provider !== "clinepass" && provider !== "cline-pass") return;
105
-
106
- const { message: friendly } = classifyClinePassError(msg.errorMessage, msg.model);
107
- if (ctx.hasUI) {
108
- ctx.ui.notify(friendly, "error");
109
- } else {
110
- console.error(`[pi-clinepass] ${friendly}`);
111
- }
112
- }
1
+ /**
2
+ * ClinePass error classification — maps provider error text to friendly,
3
+ * actionable messages surfaced through pi's UI.
4
+ *
5
+ * Classification order matters: the free-model routes return plain
6
+ * `403 Forbidden` when their Cline-CLI header gate rejects us, which must not
7
+ * be reported as a subscription problem, and free-limit errors may arrive
8
+ * wrapped in a generic 403/429 shell.
9
+ */
10
+
11
+ import { isFreeModel } from "./catalog.js";
12
+
13
+ export type ClinePassErrorType =
14
+ | "not_subscribed"
15
+ | "auth_expired"
16
+ | "rate_limited"
17
+ | "free_limit_reached"
18
+ | "free_route_forbidden"
19
+ | "unknown";
20
+
21
+ function matchesAny(text: string, patterns: string[]): boolean {
22
+ return patterns.some((p) => text.includes(p));
23
+ }
24
+
25
+ export const CLINEPASS_ERROR_MESSAGES: Record<ClinePassErrorType, string> = {
26
+ not_subscribed:
27
+ "ClinePass subscription required — or the organization account cannot use ClinePass. " +
28
+ "Visit app.cline.bot to subscribe / switch to your personal account, or run `pi /login`.",
29
+ auth_expired:
30
+ "ClinePass authentication expired. Run `pi /login` and select ClinePass to refresh credentials.",
31
+ rate_limited:
32
+ "ClinePass rate limit reached. Wait a moment and try again, or check your plan at app.cline.bot.",
33
+ free_limit_reached:
34
+ "Free model rate limit reached. Please wait a few moments and try again.",
35
+ free_route_forbidden:
36
+ "Free model route unavailable (HTTP 403). The free route is gated to Cline product " +
37
+ "surfaces — retry in a moment, or switch to a ClinePass model.",
38
+ unknown: "ClinePass request failed. Check your subscription at app.cline.bot or run `pi /login`.",
39
+ };
40
+
41
+ /**
42
+ * Classify a provider error string. `modelId` (the model of the failed
43
+ * request, e.g. "deepseek/deepseek-v4-flash") disambiguates 403s on free
44
+ * routes from subscription problems.
45
+ */
46
+ export function classifyClinePassError(
47
+ errorMessage: string,
48
+ modelId?: string,
49
+ ): {
50
+ type: ClinePassErrorType;
51
+ message: string;
52
+ } {
53
+ const lower = errorMessage.toLowerCase();
54
+
55
+ if (matchesAny(lower, ["401", "unauthorized", "invalid api key", "invalid_api_key"])) {
56
+ return { type: "auth_expired", message: CLINEPASS_ERROR_MESSAGES.auth_expired };
57
+ }
58
+ if (matchesAny(lower, ["429", "rate limit", "too many requests", "rate_limit"])) {
59
+ return { type: "rate_limited", message: CLINEPASS_ERROR_MESSAGES.rate_limited };
60
+ }
61
+ if (matchesAny(lower, ["free limit reached", "free limit", "try again in"])) {
62
+ return { type: "free_limit_reached", message: CLINEPASS_ERROR_MESSAGES.free_limit_reached };
63
+ }
64
+ if (
65
+ matchesAny(lower, [
66
+ "403",
67
+ "forbidden",
68
+ "subscription required",
69
+ "not subscribed",
70
+ "organization accounts cannot use",
71
+ ])
72
+ ) {
73
+ // Every free-tier route is gated to Cline product surfaces — a 403 on a
74
+ // free model is a route-gate rejection, not a subscription problem.
75
+ if (modelId && isFreeModel(modelId)) {
76
+ return { type: "free_route_forbidden", message: CLINEPASS_ERROR_MESSAGES.free_route_forbidden };
77
+ }
78
+ return { type: "not_subscribed", message: CLINEPASS_ERROR_MESSAGES.not_subscribed };
79
+ }
80
+ return { type: "unknown", message: CLINEPASS_ERROR_MESSAGES.unknown };
81
+ }
82
+
83
+ export interface ErrorContext {
84
+ hasUI: boolean;
85
+ ui: {
86
+ notify: (msg: string, type: "info" | "warning" | "error") => void;
87
+ };
88
+ model?: { provider?: string };
89
+ }
90
+
91
+ /** Handle a message_end event: classify ClinePass errors and notify. */
92
+ export function handleClinePassError(
93
+ event: { message: unknown },
94
+ ctx: ErrorContext,
95
+ ): void {
96
+ if (!event.message) return;
97
+ const msg = event.message as {
98
+ stopReason?: string;
99
+ errorMessage?: string;
100
+ provider?: string;
101
+ model?: string;
102
+ };
103
+ if (msg.stopReason !== "error" || !msg.errorMessage) return;
104
+
105
+ const provider = msg.provider ?? ctx.model?.provider;
106
+ if (provider !== "clinepass" && provider !== "cline-pass") return;
107
+
108
+ const { message: friendly } = classifyClinePassError(msg.errorMessage, msg.model);
109
+ if (ctx.hasUI) {
110
+ ctx.ui.notify(friendly, "error");
111
+ } else {
112
+ console.error(`[pi-clinepass] ${friendly}`);
113
+ }
114
+ }
package/src/headers.ts CHANGED
@@ -1,80 +1,83 @@
1
- /**
2
- * Free-model request headers.
3
- *
4
- * The gateway gates the free `deepseek/deepseek-v4-flash` route behind
5
- * "Cline product surfaces" (HTTP 403 without them). We identify as the
6
- * Cline CLI with a current version fetched from the npm registry, cached
7
- * for 24h with a bundled fallback. Only the free deepseek model is affected.
8
- */
9
-
10
- import { release } from "node:os";
11
-
12
- export const FREE_DEEPSEEK_MODEL = "deepseek/deepseek-v4-flash";
13
- const FALLBACK_CLINE_VERSION = "3.0.54";
14
- const VERSION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
15
- const NPM_REGISTRY_URL = "https://registry.npmjs.org/cline/latest";
16
-
17
- let cachedVersion: string | undefined;
18
- let cachedAt = 0;
19
- let inflightVersion: Promise<string> | undefined;
20
-
21
- export async function getClineVersion(
22
- fetchFn: typeof globalThis.fetch = globalThis.fetch,
23
- ): Promise<string> {
24
- if (cachedVersion && Date.now() - cachedAt < VERSION_CACHE_TTL_MS) return cachedVersion;
25
- // Deduplicate concurrent callers (e.g. session_start + a command racing).
26
- if (inflightVersion) return inflightVersion;
27
- inflightVersion = (async () => {
28
- try {
29
- const res = await fetchFn(NPM_REGISTRY_URL);
30
- if (res.ok) {
31
- const data = (await res.json()) as { version?: unknown };
32
- if (typeof data.version === "string" && data.version) {
33
- cachedVersion = data.version;
34
- cachedAt = Date.now();
35
- return cachedVersion;
36
- }
37
- }
38
- } catch {
39
- // fall through to bundled default
40
- }
41
- // Registry unreachable: use the bundled fallback but do not cache it
42
- // for the full TTL — the next session retries so a transient outage
43
- // doesn't pin an outdated version for a day.
44
- cachedVersion = FALLBACK_CLINE_VERSION;
45
- cachedAt = 0;
46
- return cachedVersion;
47
- })();
48
- try {
49
- return await inflightVersion;
50
- } finally {
51
- inflightVersion = undefined;
52
- }
53
- }
54
-
55
- export function isFreeDeepSeekModel(modelId: string): boolean {
56
- return modelId.includes(FREE_DEEPSEEK_MODEL) && !modelId.includes("cline-pass/");
57
- }
58
-
59
- /** Whether the given model id needs the Cline-CLI identifying headers. */
60
- export function needsFreeModelHeaders(modelId: string): boolean {
61
- return isFreeDeepSeekModel(modelId);
62
- }
63
-
64
- /**
65
- * Build the headers that make the free deepseek route servable.
66
- * Synchronous: uses the cached version (pre-warmed at startup or from a
67
- * previous request), falling back to the bundled default. Never fetches
68
- * inside a request path.
69
- */
70
- export function buildFreeModelHeadersSync(): Record<string, string> {
71
- const version = cachedVersion ?? FALLBACK_CLINE_VERSION;
72
- return {
73
- "x-client-type": "cli",
74
- "x-client-version": version,
75
- "x-core-version": version,
76
- "x-platform": process.platform,
77
- "x-platform-version": release(),
78
- "user-agent": `Cline/${version}`,
79
- };
80
- }
1
+ /**
2
+ * Free-model request headers.
3
+ *
4
+ * The gateway gates Cline's free routes (":free" / cost-0 models) behind
5
+ * "Cline product surfaces" (HTTP 403 without them). We identify as the Cline
6
+ * CLI with a current version fetched from the npm registry, cached for 24h
7
+ * with a bundled fallback. Every free-tier model goes through this gate, so
8
+ * the identifying headers are applied to all of them via `isFreeModel`.
9
+ */
10
+
11
+ import { release } from "node:os";
12
+ import { isFreeModel } from "./catalog.js";
13
+
14
+ const FALLBACK_CLINE_VERSION = "3.0.60";
15
+ const VERSION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
16
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/cline/latest";
17
+
18
+ /** Header-safe version shape (semver-ish tokens only): a malformed registry
19
+ * response must never reach request headers, where control chars would make
20
+ * undici throw and break the request. */
21
+ const VERSION_PATTERN = /^[\w.\-+]+$/;
22
+
23
+ let cachedVersion: string | undefined;
24
+ let cachedAt = 0;
25
+ let inflightVersion: Promise<string> | undefined;
26
+
27
+ export async function getClineVersion(
28
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
29
+ ): Promise<string> {
30
+ if (cachedVersion && Date.now() - cachedAt < VERSION_CACHE_TTL_MS) return cachedVersion;
31
+ // Deduplicate concurrent callers (e.g. session_start + a command racing).
32
+ if (inflightVersion) return inflightVersion;
33
+ inflightVersion = (async () => {
34
+ try {
35
+ const res = await fetchFn(NPM_REGISTRY_URL);
36
+ if (res.ok) {
37
+ const data = (await res.json()) as { version?: unknown };
38
+ if (typeof data.version === "string" && VERSION_PATTERN.test(data.version)) {
39
+ cachedVersion = data.version;
40
+ cachedAt = Date.now();
41
+ return cachedVersion;
42
+ }
43
+ }
44
+ } catch {
45
+ // fall through to bundled default
46
+ }
47
+ // Registry unreachable: use the bundled fallback but do not cache it
48
+ // for the full TTL — the next session retries so a transient outage
49
+ // doesn't pin an outdated version for a day.
50
+ cachedVersion = FALLBACK_CLINE_VERSION;
51
+ cachedAt = 0;
52
+ return cachedVersion;
53
+ })();
54
+ try {
55
+ return await inflightVersion;
56
+ } finally {
57
+ inflightVersion = undefined;
58
+ }
59
+ }
60
+
61
+ /** Whether the given model id needs the Cline-CLI identifying headers (all
62
+ * free-tier models do — the free route is gated to Cline product surfaces). */
63
+ export function needsFreeModelHeaders(modelId: string): boolean {
64
+ return isFreeModel(modelId);
65
+ }
66
+
67
+ /**
68
+ * Build the headers that make the free deepseek route servable.
69
+ * Synchronous: uses the cached version (pre-warmed at startup or from a
70
+ * previous request), falling back to the bundled default. Never fetches
71
+ * inside a request path.
72
+ */
73
+ export function buildFreeModelHeadersSync(): Record<string, string> {
74
+ const version = cachedVersion ?? FALLBACK_CLINE_VERSION;
75
+ return {
76
+ "x-client-type": "cli",
77
+ "x-client-version": version,
78
+ "x-core-version": version,
79
+ "x-platform": process.platform,
80
+ "x-platform-version": release(),
81
+ "user-agent": `Cline/${version}`,
82
+ };
83
+ }