From 199173fe5878a7b2902d93ae3376afec6dd497f1 Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Sat, 26 Sep 2026 16:29:14 +0700 Subject: [PATCH] feat(cline): expose the cline-free/* tier and price it at zero (#4334) --- open-sse/providers/pricing.js | 38 +++++-- open-sse/services/clinepassModels.js | 52 ++++++++- tests/unit/cline-free-tier-models.test.js | 125 ++++++++++++++++++++++ 3 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 tests/unit/cline-free-tier-models.test.js diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index d09452e2..0cdd6844 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -2,8 +2,28 @@ // // Fallback order (first match wins): // 1. PROVIDER_PRICING[provider][model] — provider-specific override -// 2. MODEL_PRICING[model] — canonical model price (provider-agnostic) -// 3. PATTERN_PRICING — glob pattern match (e.g. "codex-*") +// 2. FREE_MODEL_NAMESPACES — upstream bills these at $0 +// 3. MODEL_PRICING[model] — canonical model price (provider-agnostic) +// 4. PATTERN_PRICING — glob pattern match (e.g. "codex-*") + +/** + * Namespaces upstream meters at $0. A free model must never inherit a paid + * rate: the vendor-prefix strip in getPricingForModel() would turn + * "cline-free/deepseek-v4.1-flash" into "deepseek-v4.1-flash" and match + * MODEL_PRICING, so the namespace is checked before both fallbacks. + */ +export const FREE_MODEL_NAMESPACES = ["cline-free/"]; + +export const ZERO_PRICING = { + input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0, +}; + +/** True when the model id sits in a namespace upstream bills at $0. */ +export function isFreeModel(model) { + if (!model) return false; + const lower = String(model).toLowerCase(); + return FREE_MODEL_NAMESPACES.some((ns) => lower.startsWith(ns)); +} /** * Canonical model pricing — provider-agnostic. @@ -361,10 +381,11 @@ export function matchPattern(pattern, model) { } /** - * Resolve pricing for a model using the 3-step fallback chain: + * Resolve pricing for a model using the 4-step fallback chain: * 1. PROVIDER_PRICING[provider][model] - * 2. MODEL_PRICING[model] - * 3. PATTERN_PRICING (glob match) + * 2. free namespace (upstream bills $0) + * 3. MODEL_PRICING[model] + * 4. PATTERN_PRICING (glob match) * * @param {string} provider * @param {string} model @@ -378,12 +399,15 @@ export function getPricingForModel(provider, model) { return PROVIDER_PRICING[provider][model]; } - // 2. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") + // 2. Free namespaces bill $0 regardless of the model name behind them. + if (isFreeModel(model)) return ZERO_PRICING; + + // 3. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") const baseModel = model.includes("/") ? model.split("/").pop() : model; if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel]; if (MODEL_PRICING[model]) return MODEL_PRICING[model]; - // 3. Pattern match + // 4. Pattern match for (const { pattern, pricing } of PATTERN_PRICING) { if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { return pricing; diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 0aa96ffe..8ba86147 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -1,6 +1,12 @@ import { buildClineHeaders } from "../shared/clineAuth.js"; const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; +// Cline's free tier is published here, not in /api/v1/models: the catalog +// endpoint carries no `cline-free/*` ids at all. Cline's own SDK calls this +// feed unauthenticated (sdk/packages/core/src/services/llms/cline-recommended-models.ts), +// so no Authorization header is sent — adding one would only make the request +// fail on a header the endpoint ignores. +const CLINE_RECOMMENDED_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/ai/cline/recommended-models"; const FETCH_TIMEOUT_MS = 5000; /** @@ -72,6 +78,40 @@ export async function resolveClinepassModels(credentials) { return models.length ? { models } : null; } +/** + * Fetch Cline's recommended-models feed and return only its `free[]` tier. + * Returns null on any failure — the free tier is additive, so a dead feed must + * never take the /api/v1/models catalog down with it. + * @param {{accessToken?: string, apiKey?: string}} credentials + * @returns {Promise<{id: string, name: string}[] | null>} + */ +async function fetchClineFreeTierModels() { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(CLINE_RECOMMENDED_MODELS_ENDPOINT, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + + if (!response.ok) return null; + + const json = await response.json(); + const free = Array.isArray(json?.free) ? json.free : []; + if (!free.length) return null; + + return free + .filter((m) => typeof m?.id === "string" && m.id.trim() !== "") + .map((m) => ({ id: m.id, name: m.name || m.id })); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + /** * Fetch Cline live model catalog from Cline's /models endpoint. * Unlike resolveClinepassModels, this returns ALL models (including @@ -91,5 +131,15 @@ export async function resolveClineModels(credentials) { name: m.name || m.id, })); - return models.length ? { models } : null; + // Free tier: /api/v1/models lists no `cline-free/*` ids, so merge the feed's + // free[] in. First writer wins on a shared id, keeping the catalog's entry + // for anything the two sources agree on. + const freeTier = await fetchClineFreeTierModels(); + const byId = new Map(models.map((m) => [m.id, m])); + for (const m of freeTier || []) { + if (!byId.has(m.id)) byId.set(m.id, m); + } + const merged = Array.from(byId.values()); + + return merged.length ? { models: merged } : null; } diff --git a/tests/unit/cline-free-tier-models.test.js b/tests/unit/cline-free-tier-models.test.js new file mode 100644 index 00000000..7dac1858 --- /dev/null +++ b/tests/unit/cline-free-tier-models.test.js @@ -0,0 +1,125 @@ +// Cline's free tier lives in the `cline-free/` namespace and is published only +// by the recommended-models feed, not by /api/v1/models. These tests pin that +// resolveClineModels() merges the feed's `free[]` into its catalog so the free +// models reach /v1/models and the dashboard picker. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const MODELS_URL = "https://api.cline.bot/api/v1/models"; +const FEED_URL = "https://api.cline.bot/api/v1/ai/cline/recommended-models"; + +const MODELS_RESPONSE = [ + { id: "meta/muse-spark-1.3-contributor" }, + { id: "deepseek/deepseek-v4.1-flash" }, + { id: "stealth/space-bunny-alpha" }, +]; + +const FEED_RESPONSE = { + recommended: [{ id: "anthropic/claude-opus-5", name: "Claude Opus 5", description: "", tags: ["NEW"] }], + free: [ + { id: "stealth/space-bunny-alpha", name: "Space Bunny Alpha", description: "", tags: [] }, + { id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", description: "", tags: [] }, + { id: "cline-free/deepseek-v4.1-flash", name: "Deepseek V4.1 Flash", description: "", tags: [] }, + { id: "cline-free/gemini-3.8-flash", name: "Gemini 3.8 Flash", description: "", tags: [] }, + { id: "cline-free/mimo-v2.6-flash", name: "Mimo V2.6 Flash", description: "", tags: [] }, + ], + clinePass: [{ id: "cline-pass/glm-5.3", name: "GLM-5.3", description: "", tags: [] }], +}; + +let fetchMock; + +function jsonResponse(obj) { + return { ok: true, status: 200, json: async () => obj, text: async () => JSON.stringify(obj) }; +} + +beforeEach(() => { + fetchMock = vi.fn(async (url) => { + if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE); + if (String(url) === FEED_URL) return jsonResponse(FEED_RESPONSE); + throw new Error("unexpected fetch: " + url); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("resolveClineModels free-tier merge", () => { + it("includes the cline-free/* models that /api/v1/models omits", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-free/muse-spark-1.3-contributor"); + expect(ids).toContain("cline-free/deepseek-v4.1-flash"); + expect(ids).toContain("cline-free/gemini-3.8-flash"); + expect(ids).toContain("cline-free/mimo-v2.6-flash"); + }); + + it("keeps every /api/v1/models entry (feed is additive)", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("meta/muse-spark-1.3-contributor"); + expect(ids).toContain("deepseek/deepseek-v4.1-flash"); + }); + + it("deduplicates ids present in both sources", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const ids = result.models.map((m) => m.id); + expect(ids.filter((id) => id === "stealth/space-bunny-alpha")).toHaveLength(1); + }); + + it("returns {id, name} for feed entries", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + const entry = result.models.find((m) => m.id === "cline-free/muse-spark-1.3-contributor"); + expect(entry.name).toBe("Muse Spark 1.3 Contributor"); + }); + + it("survives a failing feed and still returns the /models catalog", async () => { + fetchMock.mockImplementation(async (url) => { + if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE); + return { ok: false, status: 503, json: async () => ({}), text: async () => "" }; + }); + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models.map((m) => m.id)).toEqual(MODELS_RESPONSE.map((m) => m.id)); + }); + + it("does not leak the cline-pass/ subscription tier into the cline list", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models.map((m) => m.id)).not.toContain("cline-pass/glm-5.3"); + }); +}); + +describe("cline-free namespace pricing", () => { + it("bills cline-free/* at zero", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash"); + expect(pricing).toMatchObject({ + input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0, + }); + }); + + it("bills cline-free/* muse-spark at zero", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + expect(getPricingForModel("cline", "cline-free/muse-spark-1.3-contributor").input).toBe(0); + }); + + it("still bills the paid twin at its published rate", async () => { + const { getPricingForModel } = await import("../../open-sse/providers/pricing.js"); + expect(getPricingForModel("cline", "deepseek/deepseek-v4.1-flash").input).toBe(0.14); + expect(getPricingForModel("cline", "meta/muse-spark-1.3-contributor")).toBeNull(); + }); + + it("zero price survives cost calculation over a large usage", async () => { + const { getPricingForModel, calculateCostFromTokens } = await import("../../open-sse/providers/pricing.js"); + const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash"); + const cost = calculateCostFromTokens( + { prompt_tokens: 1_000_000, completion_tokens: 1_000_000, reasoning_tokens: 500_000 }, + pricing + ); + expect(cost).toBe(0); + }); +});