feat(providers): add Cline free-tier models and sync Freebuff catalog

- Cline: 6 free models, cline-cli product headers, API-key auth,
  {data} envelope unwrap, workos: prefix handling
- Freebuff: muse-spark 1.3 → 1.2 (upstream withdrawal 2026-09-07),
  DeepSeek V4.1 Flash rename
This commit is contained in:
MUH. IQRAM BAHRING committed 2026-09-11 12:21:35 +08:00
1 parent 8b34cfaa44
commit b0505067b2
16 files changed
+319 -64

No files matched your search

+8 -3
View File
@@ -25,8 +25,13 @@ function setAuth(headers, spec, token) {
// Resolve auth onto headers from a descriptor.
function applyAuth(headers, desc, credentials) {
if (desc.combined) {
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined")
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined") —
// unless the descriptor opts into preserveHookAuth: a hook then owns the
// Authorization value (e.g. Cline needs workos:-prefixed OAuth tokens but
// plain API keys, which a single merged token can't express).
if (!(desc.preserveHookAuth && headers[desc.header])) {
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
}
if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
return;
}
@@ -40,7 +45,7 @@ function applyAuth(headers, desc, credentials) {
const HEADER_HOOKS = {
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken, {}, { isApiKey: !!c.apiKey })),
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
};
+32 -26
View File
@@ -117,10 +117,11 @@ function injectEndTurnTool(body) {
// base2 roots during the transition).
//
// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha,
// google/gemini-3.8-flash) are deliberately absent: no new session can be
// admitted on them, so mapping them would only hide a dead pick behind a
// wrong root. z-ai/glm-5.2 stays mapped (referral-earned accounts can still
// run it) even though it is not a standing picker row.
// google/gemini-3.8-flash, meta/muse-spark-1.3-contributor) are deliberately
// absent: no new session can be admitted on them, so mapping them would only
// hide a dead pick behind a wrong root. z-ai/glm-5.2 stays mapped
// (referral-earned accounts can still run it) even though it is not a
// standing picker row.
const FREE_ROOT_AGENT_BY_MODEL = {
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
"z-ai/glm-5.2": "base3-free-glm",
@@ -128,7 +129,7 @@ const FREE_ROOT_AGENT_BY_MODEL = {
"mimo/mimo-v2.5": "base3-free-mimo",
"openai/gpt-5.6-luna": "base3-free-luna",
"upstage/solar-pro4": "base3-free-solar-pro4",
"meta/muse-spark-1.3-contributor": "base3-free-muse-spark-1-3",
"meta/muse-spark-1.2-contributor": "base3-free-muse-spark",
"anthropic/claude-fable-5": "base3-free-fable",
};
@@ -301,28 +302,7 @@ async function requestSession(token, model, proxyOptions) {
err.status = 401;
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff session request failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`);
err.status = response.status;
throw err;
}
const status = data?.status;
if (status === "active") {
const parsedExp = Date.parse(data.expiresAt || "");
const entry = {
instanceId: data.instanceId,
expiresAt: Number.isFinite(parsedExp) ? parsedExp : Date.now() + SESSION_DEFAULT_TTL_MS,
};
sessionCache.set(sessionCacheKey(token, model), entry);
return { instanceId: data.instanceId, status: "active" };
}
if (status === "none") {
// Not session-gated right now — proceed without an instance id; a 428 on
// chat tells us the admission gate actually requires a session.
return { instanceId: null, status: "none" };
}
const GATE_MESSAGES = {
country_blocked: "Freebuff is not available in your region (country blocked).",
banned: "Your Freebuff account has been banned.",
@@ -333,6 +313,10 @@ async function requestSession(token, model, proxyOptions) {
model_unavailable: "This model is not available on Freebuff right now.",
premium_slot_taken: "Freebuff premium slot is taken — try another model.",
};
// Gate statuses ride BOTH 200 (pre-join refusals) and 4xx — the backend
// sends spend_limited/rate_limited as HTTP 429 with the gate in the body.
// Handle them BEFORE the generic !response.ok throw so exhaustion carries
// resetsAtMs (skip-until-reset) instead of a bare status.
if (GATE_MESSAGES[status]) {
const err = new Error(data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : GATE_MESSAGES[status]);
// Freebucks / session-allowance exhaustion is a hard stop until the daily
@@ -355,6 +339,28 @@ async function requestSession(token, model, proxyOptions) {
}
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff session request failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`);
err.status = response.status;
throw err;
}
if (status === "active") {
const parsedExp = Date.parse(data.expiresAt || "");
const entry = {
instanceId: data.instanceId,
expiresAt: Number.isFinite(parsedExp) ? parsedExp : Date.now() + SESSION_DEFAULT_TTL_MS,
};
sessionCache.set(sessionCacheKey(token, model), entry);
return { instanceId: data.instanceId, status: "active" };
}
if (status === "none") {
// Not session-gated right now — proceed without an instance id; a 428 on
// chat tells us the admission gate actually requires a session.
return { instanceId: null, status: "none" };
}
throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`);
}
+4 -2
View File
@@ -468,9 +468,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
if (log?.errorLine) {
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}`);
log.errorLine(reqTag, "✗", `ERROR ${error?.status || HTTP_STATUS.BAD_GATEWAY} · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}`);
}
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg, error?.resetsAtMs || undefined);
// Carry the executor's own status (429/409 quota gates) when it threw one;
// fall back to 502 for generic throws.
return createErrorResult(error?.status || HTTP_STATUS.BAD_GATEWAY, errMsg, error?.resetsAtMs || undefined);
}
// Handle 401/403 - try token refresh (skip for noAuth providers)
@@ -138,6 +138,21 @@ function openAICompletionToResponses(responseBody, customToolNames = null) {
};
}
/**
* Unwrap gateway envelopes around an OpenAI Chat Completions body.
* Some OpenAI-compatible gateways wrap the body in a `data` envelope
* (Cline: {data, success}) — without this, choices/usage don't resolve at
* top level downstream and surface as "no completion choices".
* Generic guard, no provider hardcode: only fires when the OpenAI body is
* nested under `data`.
*/
export function unwrapDataEnvelope(responseBody) {
if (responseBody && !responseBody.choices && responseBody.data?.choices) {
return responseBody.data;
}
return responseBody;
}
/**
* Translate non-streaming response body from provider format → OpenAI format.
*/
@@ -305,6 +320,9 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
// Unwrap AFTER logging (raw envelope stays in the log for forensics) but
// BEFORE usage extraction/translation so choices/usage resolve downstream.
responseBody = unwrapDataEnvelope(responseBody);
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
+6
View File
@@ -258,6 +258,12 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
// ── Cline free tier: Upstage Solar Pro + LongCat (agentic coding models;
// windows unverified — 200K/32K conservative, same as laguna:free).
// NOTE: placed before the o-series catch-alls — "solar-pro4" contains "o4".
{ pattern: "*solar-pro*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
{ pattern: "*longcat*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
+16
View File
@@ -14,6 +14,9 @@ export default {
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
authHint: "API key dari app.cline.bot → Settings > API Keys, atau login OAuth via browser.",
transport: {
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
headers: {
@@ -26,6 +29,9 @@ export default {
combined: true,
header: "Authorization",
scheme: "bearer",
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
// (a merged token can't express both) — see applyAuth.
preserveHookAuth: true,
hooks: [
"clineHeaders",
],
@@ -40,6 +46,16 @@ export default {
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" },
// Free tier (verified live via GET /api/v1/ai/cline/recommended-models):
// billed $0 on usage, limited quota separate from ClinePass. The
// cline-free/* aliases require Cline product headers (see shared/clineAuth.js)
// or upstream 403s with "only available via Cline product surfaces".
{ id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Free)" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" },
{ id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash (Free)" },
{ id: "cline-free/solar-pro4", name: "Solar Pro 4 (Free)" },
{ id: "cline-free/longcat-2.0", name: "LongCat 2.0 (Free)" },
{ id: "poolside/laguna-s-2.1:free", name: "Laguna S 2.1 (Free)" },
],
oauth: {
appBaseUrl: "https://app.cline.bot",
+3
View File
@@ -26,6 +26,9 @@ export default {
combined: true,
header: "Authorization",
scheme: "bearer",
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
// (a merged token can't express both) — see applyAuth.
preserveHookAuth: true,
hooks: [
"clineHeaders",
],
+10 -3
View File
@@ -63,7 +63,7 @@ export default {
usage: true,
},
// Mirrors the Freebuff waiting-room picker (upstream FREEBUFF_MODELS) as of
// 2026-09-07. NO per-model prices live here: Freebucks pricing is
// 2026-09-11. NO per-model prices live here: Freebucks pricing is
// server-authoritative — the session response's `freebucks` block carries
// `prices` (model → Freebucks/hr) plus an announced `priceChanges` schedule
// (promos like Solar Pro 4's Labor Day run expire server-side; see
@@ -78,13 +78,20 @@ export default {
// only claim while the backend advertises it via limitedModelOffers on the
// session status (the executor auto-checks before claiming); the model is
// otherwise refused.
// meta/muse-spark-1.3-contributor was withdrawn upstream 2026-09-07 (Meta
// returns 404 model_not_found on every key) and replaced in the picker by
// meta/muse-spark-1.2-contributor — same Contributor terms/pool. 1.3 is
// intentionally NOT listed: 9Router has no server-side coercion, so a dead
// pick would only fail.
// deepseek/deepseek-v4-flash was RENAMED upstream 2026-09-10 to DeepSeek
// V4.1 Flash (same undated wire id, new build) — display label follows.
models: [
{ id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4.1 Flash" },
{ id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna" },
{ id: "mimo/mimo-v2.5", name: "MiMo 2.5" },
{ id: "upstage/solar-pro4", name: "Solar Pro 4" },
{ id: "meta/muse-spark-1.3-contributor", name: "Muse Spark 1.3" },
{ id: "meta/muse-spark-1.2-contributor", name: "Muse Spark 1.2" },
{ id: "anthropic/claude-fable-5", name: "Claude Fable 5 (limited offer)" },
],
// Login-flow host — the CLI in freebuff mode logs in via freebuff.com, and
+3 -9
View File
@@ -5,17 +5,11 @@ const FETCH_TIMEOUT_MS = 5000;
/**
* Build request headers for the ClinePass /models endpoint (Cline's upstream API).
* - API keys are sent as plain Bearer tokens.
* - OAuth access tokens must carry the WorkOS `workos:` prefix (handled by buildClineHeaders).
* Auth shape lives in shared/clineAuth: API keys ride plain Bearer, OAuth
* access tokens carry the WorkOS `workos:` prefix.
*/
function buildModelListHeaders(token, isApiKey) {
if (isApiKey) {
return {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
}
return buildClineHeaders(token, { Accept: "application/json" });
return buildClineHeaders(token, { Accept: "application/json" }, { isApiKey });
}
/**
+24 -11
View File
@@ -1,6 +1,10 @@
import pkg from "../../package.json" with { type: "json" };
const APP_VERSION = pkg.version || "0.0.0";
// Cline CLI identity (mirrors apps/cli + sdk/packages/llms request-headers.ts
// in cline/cline). Upstream gates the cline-free/* model aliases to Cline
// product surfaces + recent client versions — requests sent as
// X-CLIENT-TYPE 9router are 403'd with "only available via Cline product
// surfaces". Verified live 2026-09-11: cline-cli/3.0.61 passes the gate.
const CLINE_CLIENT_TYPE = "cline-cli";
const CLINE_CLIENT_VERSION = "3.0.61";
export function getClineAccessToken(token) {
if (typeof token !== "string") return "";
@@ -14,17 +18,26 @@ export function getClineAuthorizationHeader(token) {
return accessToken ? `Bearer ${accessToken}` : "";
}
export function buildClineHeaders(token, extraHeaders = {}) {
const authorization = getClineAuthorizationHeader(token);
export function buildClineHeaders(token, extraHeaders = {}, opts = {}) {
// API keys ride plain Bearer; OAuth access tokens must carry the WorkOS
// `workos:` prefix so the backend routes verification to WorkOS
// (cline/cline: "Prefixed with 'workos:'..."). Verified live 2026-09-11:
// plain sk_* works, workos:sk_* → 401.
const trimmed = typeof token === "string" ? token.trim() : "";
const authorization = !trimmed
? ""
: opts.isApiKey
? `Bearer ${trimmed}`
: getClineAuthorizationHeader(trimmed);
const headers = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"User-Agent": `9Router/${APP_VERSION}`,
"X-PLATFORM": process.platform || "unknown",
"X-PLATFORM-VERSION": process.version || "unknown",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": APP_VERSION,
"X-CORE-VERSION": APP_VERSION,
"User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
"X-PLATFORM": "cli",
"X-PLATFORM-VERSION": CLINE_CLIENT_VERSION,
"X-CLIENT-TYPE": CLINE_CLIENT_TYPE,
"X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
"X-CORE-VERSION": CLINE_CLIENT_VERSION,
"X-IS-MULTIROOT": "false",
...extraHeaders,
};