diff --git a/jsconfig.json b/jsconfig.json index e8d49426..f1731d92 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 92c78e92..dcb98eb1 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -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; }, }; diff --git a/open-sse/executors/freebuff.js b/open-sse/executors/freebuff.js index 83a16519..f801be79 100644 --- a/open-sse/executors/freebuff.js +++ b/open-sse/executors/freebuff.js @@ -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)}`); } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 5b1d4024..60c1cee6 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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) diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index becc0842..1b4e262c 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5a0b7271..a69d9bbf 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -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 } }, diff --git a/open-sse/providers/registry/cline.js b/open-sse/providers/registry/cline.js index cfa788c4..6e40d6cf 100644 --- a/open-sse/providers/registry/cline.js +++ b/open-sse/providers/registry/cline.js @@ -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", diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index 702054ac..8f458b1f 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -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", ], diff --git a/open-sse/providers/registry/freebuff.js b/open-sse/providers/registry/freebuff.js index 21324ee6..fffee205 100644 --- a/open-sse/providers/registry/freebuff.js +++ b/open-sse/providers/registry/freebuff.js @@ -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 diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 4208a4b6..8543999c 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -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 }); } /** diff --git a/open-sse/shared/clineAuth.js b/open-sse/shared/clineAuth.js index 1b2b7df6..a7e7e407 100644 --- a/open-sse/shared/clineAuth.js +++ b/open-sse/shared/clineAuth.js @@ -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, }; diff --git a/package.json b/package.json index 3c8a83dd..f15d49a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "1.0.12", + "version": "1.0.13", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index db0b8bad..9d9122b6 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -137,6 +137,7 @@ "combined": true, "header": "Authorization", "scheme": "bearer", + "preserveHookAuth": true, "hooks": [ "clineHeaders" ] @@ -153,6 +154,7 @@ "combined": true, "header": "Authorization", "scheme": "bearer", + "preserveHookAuth": true, "hooks": [ "clineHeaders" ] @@ -276,6 +278,26 @@ "validateUrl": "https://api.fireworks.ai/inference/v1/models", "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": { "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", "format": "gemini-cli", @@ -437,6 +459,9 @@ "groq": { "baseUrl": "https://api.groq.com/openai/v1/chat/completions", "validateUrl": "https://api.groq.com/openai/v1/models", + "usage": { + "url": "https://api.groq.com/openai/v1/models" + }, "format": "openai" }, "hyperbolic": { @@ -924,6 +949,32 @@ "format": "openai", "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": { "baseUrl": "https://cloud.zed.dev/completions", "format": "openai", @@ -990,6 +1041,25 @@ "validateUrl": "https://api.morphllm.com/v1/models", "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": { "baseUrl": "https://inference.poolside.ai/v1/chat/completions", "validateUrl": "https://inference.poolside.ai/v1/models", @@ -1009,4 +1079,4 @@ }, "format": "openai" } -} +} \ No newline at end of file diff --git a/tests/unit/cline-data-envelope.test.js b/tests/unit/cline-data-envelope.test.js new file mode 100644 index 00000000..cecbac36 --- /dev/null +++ b/tests/unit/cline-data-envelope.test.js @@ -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\//); + }); +}); diff --git a/tests/unit/freebuff-provider.test.js b/tests/unit/freebuff-provider.test.js index 8489572f..e937cd3e 100644 --- a/tests/unit/freebuff-provider.test.js +++ b/tests/unit/freebuff-provider.test.js @@ -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 () => { fetchMock.mockResolvedValue(jsonResponse({ status: "rate_limited", message: "busy" })); try { @@ -473,10 +520,11 @@ describe("freebuff run registration", () => { expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo"); expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna"); 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"); // Withdrawn upstream models are unmapped — they fall back, and the backend // 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("minimax/minimax-m3")).toBe("base2-free"); expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free"); diff --git a/tests/unit/freebuff-usage.test.js b/tests/unit/freebuff-usage.test.js index fbf99c84..efa02aed 100644 --- a/tests/unit/freebuff-usage.test.js +++ b/tests/unit/freebuff-usage.test.js @@ -73,7 +73,7 @@ describe("getUsageForProvider(freebuff)", () => { resetAt: "2026-08-06T07:00:00.000Z", recurring: true, unlimited: false, - displayName: "DeepSeek V4 Flash", + displayName: "DeepSeek V4.1 Flash", }); expect(usage.quotas["openai/gpt-5.6-luna"]).toMatchObject({ used: 1, @@ -94,7 +94,7 @@ describe("getUsageForProvider(freebuff)", () => { status: "active", accessTier: "full", instanceId: "inst-1", - model: "meta/muse-spark-1.3-contributor", + model: "meta/muse-spark-1.2-contributor", expiresAt: new Date(Date.now() + 3600000).toISOString(), rateLimit: { limit: 6, @@ -112,10 +112,10 @@ describe("getUsageForProvider(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, total: 6, - displayName: "Muse Spark 1.3", + displayName: "Muse Spark 1.2", }); }); @@ -150,7 +150,7 @@ describe("getUsageForProvider(freebuff)", () => { recurring: true, unlimited: false, price: 15, - displayName: "DeepSeek V4 Flash", + displayName: "DeepSeek V4.1 Flash", }); expect(usage.quotas["z-ai/glm-5.3-flash"]).toMatchObject({ used: 15, @@ -297,14 +297,14 @@ describe("parseQuotaData(freebuff)", () => { used: 4.1, total: 6, resetAt: "2026-08-06T07:00:00.000Z", - displayName: "DeepSeek V4 Flash", + displayName: "DeepSeek V4.1 Flash", }, }, }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ - name: "DeepSeek V4 Flash", + name: "DeepSeek V4.1 Flash", modelKey: "deepseek/deepseek-v4-flash", used: 4.1, total: 6,