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:
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ function setAuth(headers, spec, token) {
|
|||||||
// Resolve auth onto headers from a descriptor.
|
// Resolve auth onto headers from a descriptor.
|
||||||
function applyAuth(headers, desc, credentials) {
|
function applyAuth(headers, desc, credentials) {
|
||||||
if (desc.combined) {
|
if (desc.combined) {
|
||||||
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined")
|
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined") —
|
||||||
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
|
// 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;
|
if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,7 @@ function applyAuth(headers, desc, credentials) {
|
|||||||
const HEADER_HOOKS = {
|
const HEADER_HOOKS = {
|
||||||
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
|
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
|
||||||
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.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; },
|
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,11 @@ function injectEndTurnTool(body) {
|
|||||||
// base2 roots during the transition).
|
// base2 roots during the transition).
|
||||||
//
|
//
|
||||||
// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha,
|
// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha,
|
||||||
// google/gemini-3.8-flash) are deliberately absent: no new session can be
|
// google/gemini-3.8-flash, meta/muse-spark-1.3-contributor) are deliberately
|
||||||
// admitted on them, so mapping them would only hide a dead pick behind a
|
// absent: no new session can be admitted on them, so mapping them would only
|
||||||
// wrong root. z-ai/glm-5.2 stays mapped (referral-earned accounts can still
|
// hide a dead pick behind a wrong root. z-ai/glm-5.2 stays mapped
|
||||||
// run it) even though it is not a standing picker row.
|
// (referral-earned accounts can still run it) even though it is not a
|
||||||
|
// standing picker row.
|
||||||
const FREE_ROOT_AGENT_BY_MODEL = {
|
const FREE_ROOT_AGENT_BY_MODEL = {
|
||||||
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
|
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
|
||||||
"z-ai/glm-5.2": "base3-free-glm",
|
"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",
|
"mimo/mimo-v2.5": "base3-free-mimo",
|
||||||
"openai/gpt-5.6-luna": "base3-free-luna",
|
"openai/gpt-5.6-luna": "base3-free-luna",
|
||||||
"upstage/solar-pro4": "base3-free-solar-pro4",
|
"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",
|
"anthropic/claude-fable-5": "base3-free-fable",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,28 +302,7 @@ async function requestSession(token, model, proxyOptions) {
|
|||||||
err.status = 401;
|
err.status = 401;
|
||||||
throw err;
|
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;
|
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 = {
|
const GATE_MESSAGES = {
|
||||||
country_blocked: "Freebuff is not available in your region (country blocked).",
|
country_blocked: "Freebuff is not available in your region (country blocked).",
|
||||||
banned: "Your Freebuff account has been banned.",
|
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.",
|
model_unavailable: "This model is not available on Freebuff right now.",
|
||||||
premium_slot_taken: "Freebuff premium slot is taken — try another model.",
|
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]) {
|
if (GATE_MESSAGES[status]) {
|
||||||
const err = new Error(data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : 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
|
// Freebucks / session-allowance exhaustion is a hard stop until the daily
|
||||||
@@ -355,6 +339,28 @@ async function requestSession(token, model, proxyOptions) {
|
|||||||
}
|
}
|
||||||
throw err;
|
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)}`);
|
throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -468,9 +468,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
}
|
}
|
||||||
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||||
if (log?.errorLine) {
|
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)
|
// 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.
|
* 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);
|
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) {
|
if (onRequestSuccess) {
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(onRequestSuccess)
|
.then(onRequestSuccess)
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ export const PATTERN_CAPABILITIES = [
|
|||||||
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
|
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
|
||||||
{ pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
|
{ 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) ──────────────────────────
|
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
|
||||||
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
|
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
|
||||||
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
|
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "oauth",
|
category: "oauth",
|
||||||
|
authModes: ["oauth", "apikey"],
|
||||||
|
hasOAuth: true,
|
||||||
|
authHint: "API key dari app.cline.bot → Settings > API Keys, atau login OAuth via browser.",
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
|
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -26,6 +29,9 @@ export default {
|
|||||||
combined: true,
|
combined: true,
|
||||||
header: "Authorization",
|
header: "Authorization",
|
||||||
scheme: "bearer",
|
scheme: "bearer",
|
||||||
|
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
|
||||||
|
// (a merged token can't express both) — see applyAuth.
|
||||||
|
preserveHookAuth: true,
|
||||||
hooks: [
|
hooks: [
|
||||||
"clineHeaders",
|
"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-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||||
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite 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" },
|
{ 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: {
|
oauth: {
|
||||||
appBaseUrl: "https://app.cline.bot",
|
appBaseUrl: "https://app.cline.bot",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export default {
|
|||||||
combined: true,
|
combined: true,
|
||||||
header: "Authorization",
|
header: "Authorization",
|
||||||
scheme: "bearer",
|
scheme: "bearer",
|
||||||
|
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
|
||||||
|
// (a merged token can't express both) — see applyAuth.
|
||||||
|
preserveHookAuth: true,
|
||||||
hooks: [
|
hooks: [
|
||||||
"clineHeaders",
|
"clineHeaders",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default {
|
|||||||
usage: true,
|
usage: true,
|
||||||
},
|
},
|
||||||
// Mirrors the Freebuff waiting-room picker (upstream FREEBUFF_MODELS) as of
|
// 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
|
// server-authoritative — the session response's `freebucks` block carries
|
||||||
// `prices` (model → Freebucks/hr) plus an announced `priceChanges` schedule
|
// `prices` (model → Freebucks/hr) plus an announced `priceChanges` schedule
|
||||||
// (promos like Solar Pro 4's Labor Day run expire server-side; see
|
// (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
|
// only claim while the backend advertises it via limitedModelOffers on the
|
||||||
// session status (the executor auto-checks before claiming); the model is
|
// session status (the executor auto-checks before claiming); the model is
|
||||||
// otherwise refused.
|
// 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: [
|
models: [
|
||||||
{ id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" },
|
{ 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: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna" },
|
||||||
{ id: "mimo/mimo-v2.5", name: "MiMo 2.5" },
|
{ id: "mimo/mimo-v2.5", name: "MiMo 2.5" },
|
||||||
{ id: "upstage/solar-pro4", name: "Solar Pro 4" },
|
{ 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)" },
|
{ 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
|
// Login-flow host — the CLI in freebuff mode logs in via freebuff.com, and
|
||||||
|
|||||||
@@ -5,17 +5,11 @@ const FETCH_TIMEOUT_MS = 5000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build request headers for the ClinePass /models endpoint (Cline's upstream API).
|
* Build request headers for the ClinePass /models endpoint (Cline's upstream API).
|
||||||
* - API keys are sent as plain Bearer tokens.
|
* Auth shape lives in shared/clineAuth: API keys ride plain Bearer, OAuth
|
||||||
* - OAuth access tokens must carry the WorkOS `workos:` prefix (handled by buildClineHeaders).
|
* access tokens carry the WorkOS `workos:` prefix.
|
||||||
*/
|
*/
|
||||||
function buildModelListHeaders(token, isApiKey) {
|
function buildModelListHeaders(token, isApiKey) {
|
||||||
if (isApiKey) {
|
return buildClineHeaders(token, { Accept: "application/json" }, { isApiKey });
|
||||||
return {
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return buildClineHeaders(token, { Accept: "application/json" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import pkg from "../../package.json" with { type: "json" };
|
// Cline CLI identity (mirrors apps/cli + sdk/packages/llms request-headers.ts
|
||||||
|
// in cline/cline). Upstream gates the cline-free/* model aliases to Cline
|
||||||
const APP_VERSION = pkg.version || "0.0.0";
|
// 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) {
|
export function getClineAccessToken(token) {
|
||||||
if (typeof token !== "string") return "";
|
if (typeof token !== "string") return "";
|
||||||
@@ -14,17 +18,26 @@ export function getClineAuthorizationHeader(token) {
|
|||||||
return accessToken ? `Bearer ${accessToken}` : "";
|
return accessToken ? `Bearer ${accessToken}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildClineHeaders(token, extraHeaders = {}) {
|
export function buildClineHeaders(token, extraHeaders = {}, opts = {}) {
|
||||||
const authorization = getClineAuthorizationHeader(token);
|
// 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 = {
|
const headers = {
|
||||||
"HTTP-Referer": "https://cline.bot",
|
"HTTP-Referer": "https://cline.bot",
|
||||||
"X-Title": "Cline",
|
"X-Title": "Cline",
|
||||||
"User-Agent": `9Router/${APP_VERSION}`,
|
"User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
|
||||||
"X-PLATFORM": process.platform || "unknown",
|
"X-PLATFORM": "cli",
|
||||||
"X-PLATFORM-VERSION": process.version || "unknown",
|
"X-PLATFORM-VERSION": CLINE_CLIENT_VERSION,
|
||||||
"X-CLIENT-TYPE": "9router",
|
"X-CLIENT-TYPE": CLINE_CLIENT_TYPE,
|
||||||
"X-CLIENT-VERSION": APP_VERSION,
|
"X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
|
||||||
"X-CORE-VERSION": APP_VERSION,
|
"X-CORE-VERSION": CLINE_CLIENT_VERSION,
|
||||||
"X-IS-MULTIROOT": "false",
|
"X-IS-MULTIROOT": "false",
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "1.0.12",
|
"version": "1.0.13",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
"combined": true,
|
"combined": true,
|
||||||
"header": "Authorization",
|
"header": "Authorization",
|
||||||
"scheme": "bearer",
|
"scheme": "bearer",
|
||||||
|
"preserveHookAuth": true,
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"clineHeaders"
|
"clineHeaders"
|
||||||
]
|
]
|
||||||
@@ -153,6 +154,7 @@
|
|||||||
"combined": true,
|
"combined": true,
|
||||||
"header": "Authorization",
|
"header": "Authorization",
|
||||||
"scheme": "bearer",
|
"scheme": "bearer",
|
||||||
|
"preserveHookAuth": true,
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"clineHeaders"
|
"clineHeaders"
|
||||||
]
|
]
|
||||||
@@ -276,6 +278,26 @@
|
|||||||
"validateUrl": "https://api.fireworks.ai/inference/v1/models",
|
"validateUrl": "https://api.fireworks.ai/inference/v1/models",
|
||||||
"format": "openai"
|
"format": "openai"
|
||||||
},
|
},
|
||||||
|
"freebuff": {
|
||||||
|
"baseUrl": "https://www.codebuff.com/api/v1/chat/completions",
|
||||||
|
"format": "openai",
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": "ai-sdk/openai-compatible/1.0/codebuff"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"429": {
|
||||||
|
"attempts": 2,
|
||||||
|
"delayMs": 2000
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"attempts": 2,
|
||||||
|
"delayMs": 1500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"url": "https://www.codebuff.com/api/v1/freebuff/session"
|
||||||
|
}
|
||||||
|
},
|
||||||
"gemini-cli": {
|
"gemini-cli": {
|
||||||
"baseUrl": "https://cloudcode-pa.googleapis.com/v1internal",
|
"baseUrl": "https://cloudcode-pa.googleapis.com/v1internal",
|
||||||
"format": "gemini-cli",
|
"format": "gemini-cli",
|
||||||
@@ -437,6 +459,9 @@
|
|||||||
"groq": {
|
"groq": {
|
||||||
"baseUrl": "https://api.groq.com/openai/v1/chat/completions",
|
"baseUrl": "https://api.groq.com/openai/v1/chat/completions",
|
||||||
"validateUrl": "https://api.groq.com/openai/v1/models",
|
"validateUrl": "https://api.groq.com/openai/v1/models",
|
||||||
|
"usage": {
|
||||||
|
"url": "https://api.groq.com/openai/v1/models"
|
||||||
|
},
|
||||||
"format": "openai"
|
"format": "openai"
|
||||||
},
|
},
|
||||||
"hyperbolic": {
|
"hyperbolic": {
|
||||||
@@ -924,6 +949,32 @@
|
|||||||
"format": "openai",
|
"format": "openai",
|
||||||
"tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token"
|
"tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token"
|
||||||
},
|
},
|
||||||
|
"trae": {
|
||||||
|
"baseUrl": "https://core-normal.trae.ai/api/remote/v1",
|
||||||
|
"format": "openai",
|
||||||
|
"headers": {
|
||||||
|
"X-Trae-Client-Type": "web",
|
||||||
|
"X-Preferenced-Language": "en",
|
||||||
|
"Referer": "https://solo.trae.ai/"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"combined": true,
|
||||||
|
"header": "Authorization",
|
||||||
|
"scheme": "Cloud-IDE-JWT"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"url": "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo"
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"cn": "https://api.marscode.com",
|
||||||
|
"sg": "https://api.trae.ai",
|
||||||
|
"us": "https://www.trae.ai"
|
||||||
|
},
|
||||||
|
"defaultRegion": "cn",
|
||||||
|
"clientId": "ono9krqynydwx5",
|
||||||
|
"clientSecret": "-",
|
||||||
|
"tokenUrl": "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken"
|
||||||
|
},
|
||||||
"zed": {
|
"zed": {
|
||||||
"baseUrl": "https://cloud.zed.dev/completions",
|
"baseUrl": "https://cloud.zed.dev/completions",
|
||||||
"format": "openai",
|
"format": "openai",
|
||||||
@@ -990,6 +1041,25 @@
|
|||||||
"validateUrl": "https://api.morphllm.com/v1/models",
|
"validateUrl": "https://api.morphllm.com/v1/models",
|
||||||
"format": "openai"
|
"format": "openai"
|
||||||
},
|
},
|
||||||
|
"devin-cli": {
|
||||||
|
"baseUrl": "devin://acp/stdio",
|
||||||
|
"format": "openai"
|
||||||
|
},
|
||||||
|
"windsurf": {
|
||||||
|
"baseUrl": "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage",
|
||||||
|
"format": "openai",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/grpc-web+proto",
|
||||||
|
"Accept": "application/grpc-web+proto",
|
||||||
|
"X-Grpc-Web": "1"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"combined": true,
|
||||||
|
"header": "Authorization",
|
||||||
|
"scheme": "Bearer"
|
||||||
|
},
|
||||||
|
"clientId": "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u"
|
||||||
|
},
|
||||||
"poolside": {
|
"poolside": {
|
||||||
"baseUrl": "https://inference.poolside.ai/v1/chat/completions",
|
"baseUrl": "https://inference.poolside.ai/v1/chat/completions",
|
||||||
"validateUrl": "https://inference.poolside.ai/v1/models",
|
"validateUrl": "https://inference.poolside.ai/v1/models",
|
||||||
@@ -1009,4 +1079,4 @@
|
|||||||
},
|
},
|
||||||
"format": "openai"
|
"format": "openai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/usageDb.js", () => ({
|
||||||
|
appendRequestLog: vi.fn(async () => {}),
|
||||||
|
saveRequestDetail: vi.fn(async () => {}),
|
||||||
|
saveRequestUsage: vi.fn(async () => {})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { unwrapDataEnvelope } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
|
||||||
|
const { buildClineHeaders } = await import("../../open-sse/shared/clineAuth.js");
|
||||||
|
|
||||||
|
// Cline gateway wraps non-streaming Chat Completions in {data, success}.
|
||||||
|
// handleNonStreamingResponse calls unwrapDataEnvelope unconditionally (it runs
|
||||||
|
// before the needsTranslation gate, which openai→openai never passes).
|
||||||
|
const ENVELOPED = {
|
||||||
|
data: {
|
||||||
|
id: "gen-123",
|
||||||
|
object: "chat.completion",
|
||||||
|
created: 1789056634,
|
||||||
|
model: "deepseek/deepseek-v4-flash-0731",
|
||||||
|
choices: [{ index: 0, message: { role: "assistant", content: "Hi there!" }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 9, completion_tokens: 29, total_tokens: 38 },
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cline {data} envelope unwrap", () => {
|
||||||
|
it("unwraps choices/usage to top level", () => {
|
||||||
|
const out = unwrapDataEnvelope(structuredClone(ENVELOPED));
|
||||||
|
expect(out.choices?.[0]?.message?.content).toBe("Hi there!");
|
||||||
|
expect(out.usage?.prompt_tokens).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves plain OpenAI bodies untouched", () => {
|
||||||
|
const out = unwrapDataEnvelope(structuredClone(ENVELOPED.data));
|
||||||
|
expect(out.choices?.[0]?.message?.content).toBe("Hi there!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers top-level choices when both exist", () => {
|
||||||
|
const body = { ...structuredClone(ENVELOPED), choices: [{ index: 0, message: { role: "assistant", content: "top" }, finish_reason: "stop" }] };
|
||||||
|
const out = unwrapDataEnvelope(body);
|
||||||
|
expect(out.choices?.[0]?.message?.content).toBe("top");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-envelope bodies", () => {
|
||||||
|
expect(unwrapDataEnvelope(null)).toBe(null);
|
||||||
|
expect(unwrapDataEnvelope({ error: "x" })).toEqual({ error: "x" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cline auth header shape", () => {
|
||||||
|
it("sends API keys as plain Bearer", () => {
|
||||||
|
expect(buildClineHeaders("sk_abc", {}, { isApiKey: true }).Authorization).toBe("Bearer sk_abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes OAuth tokens with workos:", () => {
|
||||||
|
expect(buildClineHeaders("tok123").Authorization).toBe("Bearer workos:tok123");
|
||||||
|
expect(buildClineHeaders("workos:tok123").Authorization).toBe("Bearer workos:tok123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends Cline product identity headers (free-model gate)", () => {
|
||||||
|
const h = buildClineHeaders("tok123");
|
||||||
|
expect(h["X-CLIENT-TYPE"]).toBe("cline-cli");
|
||||||
|
expect(h["User-Agent"]).toMatch(/^Cline\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -327,6 +327,53 @@ describe("freebuff session pre-flight", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles spend_limited arriving as HTTP 429 (the actual wire shape) — still skips until reset", async () => {
|
||||||
|
const resetAt = "2099-01-01T00:00:00.000Z";
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
jsonResponse(
|
||||||
|
{
|
||||||
|
status: "spend_limited",
|
||||||
|
accessTier: "full",
|
||||||
|
upgrade: { url: "https://freebuff.com/plans", message: "Get 150 Freebucks a day from $8/mo." },
|
||||||
|
message: "This account hit today's hard usage cap.",
|
||||||
|
resetAt,
|
||||||
|
},
|
||||||
|
{ status: 429, ok: false },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(requestSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toMatchObject({
|
||||||
|
status: 429,
|
||||||
|
resetsAtMs: Date.parse(resetAt),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rate_limited arriving as HTTP 429 with only retryAfterMs", async () => {
|
||||||
|
const retryAfterMs = 15 * 60 * 1000;
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
jsonResponse({ status: "rate_limited", retryAfterMs, message: "limit" }, { status: 429, ok: false }),
|
||||||
|
);
|
||||||
|
const before = Date.now();
|
||||||
|
try {
|
||||||
|
await requestSession("tok-1", "deepseek/deepseek-v4-flash", null);
|
||||||
|
throw new Error("should have rejected");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.status).toBe(429);
|
||||||
|
expect(error.resetsAtMs).toBeGreaterThanOrEqual(before + retryAfterMs - 1000);
|
||||||
|
expect(error.resetsAtMs).toBeLessThanOrEqual(before + retryAfterMs + 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unknown HTTP 429 as a generic failure (no gate status → no resetsAtMs)", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ error: "nope" }, { status: 429, ok: false }));
|
||||||
|
try {
|
||||||
|
await requestSession("tok-1", "deepseek/deepseek-v4-flash", null);
|
||||||
|
throw new Error("should have rejected");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.status).toBe(429);
|
||||||
|
expect(error.resetsAtMs).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rate_limited without any reset hint stays a plain error (transient cooldown path)", async () => {
|
it("rate_limited without any reset hint stays a plain error (transient cooldown path)", async () => {
|
||||||
fetchMock.mockResolvedValue(jsonResponse({ status: "rate_limited", message: "busy" }));
|
fetchMock.mockResolvedValue(jsonResponse({ status: "rate_limited", message: "busy" }));
|
||||||
try {
|
try {
|
||||||
@@ -473,10 +520,11 @@ describe("freebuff run registration", () => {
|
|||||||
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo");
|
expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo");
|
||||||
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna");
|
expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna");
|
||||||
expect(rootAgentIdForModel("upstage/solar-pro4")).toBe("base3-free-solar-pro4");
|
expect(rootAgentIdForModel("upstage/solar-pro4")).toBe("base3-free-solar-pro4");
|
||||||
expect(rootAgentIdForModel("meta/muse-spark-1.3-contributor")).toBe("base3-free-muse-spark-1-3");
|
expect(rootAgentIdForModel("meta/muse-spark-1.2-contributor")).toBe("base3-free-muse-spark");
|
||||||
expect(rootAgentIdForModel("anthropic/claude-fable-5")).toBe("base3-free-fable");
|
expect(rootAgentIdForModel("anthropic/claude-fable-5")).toBe("base3-free-fable");
|
||||||
// Withdrawn upstream models are unmapped — they fall back, and the backend
|
// Withdrawn upstream models are unmapped — they fall back, and the backend
|
||||||
// refuses their sessions anyway.
|
// refuses their sessions anyway.
|
||||||
|
expect(rootAgentIdForModel("meta/muse-spark-1.3-contributor")).toBe("base2-free");
|
||||||
expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free");
|
expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free");
|
||||||
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free");
|
expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free");
|
||||||
expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free");
|
expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free");
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("getUsageForProvider(freebuff)", () => {
|
|||||||
resetAt: "2026-08-06T07:00:00.000Z",
|
resetAt: "2026-08-06T07:00:00.000Z",
|
||||||
recurring: true,
|
recurring: true,
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
displayName: "DeepSeek V4 Flash",
|
displayName: "DeepSeek V4.1 Flash",
|
||||||
});
|
});
|
||||||
expect(usage.quotas["openai/gpt-5.6-luna"]).toMatchObject({
|
expect(usage.quotas["openai/gpt-5.6-luna"]).toMatchObject({
|
||||||
used: 1,
|
used: 1,
|
||||||
@@ -94,7 +94,7 @@ describe("getUsageForProvider(freebuff)", () => {
|
|||||||
status: "active",
|
status: "active",
|
||||||
accessTier: "full",
|
accessTier: "full",
|
||||||
instanceId: "inst-1",
|
instanceId: "inst-1",
|
||||||
model: "meta/muse-spark-1.3-contributor",
|
model: "meta/muse-spark-1.2-contributor",
|
||||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
limit: 6,
|
limit: 6,
|
||||||
@@ -112,10 +112,10 @@ describe("getUsageForProvider(freebuff)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(usage.plan).toBe("Freebuff");
|
expect(usage.plan).toBe("Freebuff");
|
||||||
expect(usage.quotas["meta/muse-spark-1.3-contributor"]).toMatchObject({
|
expect(usage.quotas["meta/muse-spark-1.2-contributor"]).toMatchObject({
|
||||||
used: 2.4,
|
used: 2.4,
|
||||||
total: 6,
|
total: 6,
|
||||||
displayName: "Muse Spark 1.3",
|
displayName: "Muse Spark 1.2",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ describe("getUsageForProvider(freebuff)", () => {
|
|||||||
recurring: true,
|
recurring: true,
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
price: 15,
|
price: 15,
|
||||||
displayName: "DeepSeek V4 Flash",
|
displayName: "DeepSeek V4.1 Flash",
|
||||||
});
|
});
|
||||||
expect(usage.quotas["z-ai/glm-5.3-flash"]).toMatchObject({
|
expect(usage.quotas["z-ai/glm-5.3-flash"]).toMatchObject({
|
||||||
used: 15,
|
used: 15,
|
||||||
@@ -297,14 +297,14 @@ describe("parseQuotaData(freebuff)", () => {
|
|||||||
used: 4.1,
|
used: 4.1,
|
||||||
total: 6,
|
total: 6,
|
||||||
resetAt: "2026-08-06T07:00:00.000Z",
|
resetAt: "2026-08-06T07:00:00.000Z",
|
||||||
displayName: "DeepSeek V4 Flash",
|
displayName: "DeepSeek V4.1 Flash",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0]).toMatchObject({
|
expect(rows[0]).toMatchObject({
|
||||||
name: "DeepSeek V4 Flash",
|
name: "DeepSeek V4.1 Flash",
|
||||||
modelKey: "deepseek/deepseek-v4-flash",
|
modelKey: "deepseek/deepseek-v4-flash",
|
||||||
used: 4.1,
|
used: 4.1,
|
||||||
total: 6,
|
total: 6,
|
||||||
|
|||||||
Reference in New Issue
Block a user