From 122f23eebc45f43214f0397f28130f8978715386 Mon Sep 17 00:00:00 2001 From: Nick Nyanjui Date: Thu, 10 Sep 2026 22:46:07 +0700 Subject: [PATCH] fix(cline,airforce): unwrap {success,data} envelope, add live catalog, and refresh airforce free models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cline (api.cline.bot) wraps non-stream chat completions in {"success":true,"data":{...choices...}}, which both the dashboard model-test ping and the proxy non-stream path read at top level, producing "Provider returned no completion choices for this model" (#3644). Unwrap the envelope before usage extraction and response translation; the error envelope ({"success":false,...}) never matches and passes through untouched. Scoped through `transport.quirks.clineEnvelope` so only cline/clinepass opt in — no other provider's response body is ever rewritten. Also adds a live Cline catalog: `fetchClineRawModels()` is shared between `resolveClineModels()` (full catalog, including free-tier ids such as z-ai/glm-5.3-flash) and `resolveClinepassModels()` (cline-pass/* only), wired into /v1/models, the per-provider models route, and the combo selector's model picker with the static catalog kept as fallback. Refreshes the dead api-airforce free models (anthropic/claude-3.7-sonnet, moonshot/kimi-k2.6, google/gemini-2.5-flash) with the live gpt-oss-120b, gpt-oss-20b and kimi-k2.7-code, plus passthroughModels, forceStream and a suggested-models filter. --- .../handlers/chatCore/nonStreamingHandler.js | 6 + open-sse/providers/registry/api-airforce.js | 9 +- open-sse/providers/registry/cline.js | 4 + open-sse/providers/registry/clinepass.js | 2 + open-sse/services/clinepassModels.js | 62 +++- open-sse/shared/clineEnvelope.js | 19 ++ src/app/api/models/test/ping.js | 7 + src/app/api/providers/[id]/models/route.js | 32 ++ .../api/providers/suggested-models/filters.js | 6 + src/app/api/v1/models/route.js | 9 +- src/shared/components/ModelSelectModal.js | 112 ++++--- src/shared/constants/cliTools.js | 2 +- tests/unit/api-airforce-free-models.test.js | 36 +++ tests/unit/cline-free-models-envelope.test.js | 297 ++++++++++++++++++ 14 files changed, 540 insertions(+), 63 deletions(-) create mode 100644 open-sse/shared/clineEnvelope.js create mode 100644 tests/unit/api-airforce-free-models.test.js create mode 100644 tests/unit/cline-free-models-envelope.test.js diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 3b6cfc6d..fe94b282 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -6,6 +6,7 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; +import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; @@ -302,6 +303,11 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m } } + // Unwrap before any consumer reads choices/usage so non-stream clients get a + // bare OpenAI body and usage tracking sees data.usage. No-op unless the + // provider opts in via transport.quirks.clineEnvelope. + responseBody = unwrapClineEnvelope(responseBody, provider); + reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody); if (onRequestSuccess) { Promise.resolve() diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js index 16ed8b3e..1f98bc20 100644 --- a/open-sse/providers/registry/api-airforce.js +++ b/open-sse/providers/registry/api-airforce.js @@ -20,6 +20,8 @@ export default { authModes: [ "apikey", ], + passthroughModels: true, + modelsFetcher: { url: "https://api.airforce/v1/models", type: "airforce-free" }, transport: { baseUrl: "https://api.airforce/v1/chat/completions", validateUrl: "https://api.airforce/v1/models", @@ -27,10 +29,11 @@ export default { "HTTP-Referer": "https://endpoint-proxy.local", "X-Title": "Endpoint Proxy", }, + forceStream: true, }, models: [ - { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, - { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, - { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + { id: "gpt-oss-120b", name: "GPT-OSS 120B (Free)", contextLength: 131072 }, + { id: "gpt-oss-20b", name: "GPT-OSS 20B (Free)", contextLength: 131072 }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code (Free)", contextLength: 262144 }, ], }; diff --git a/open-sse/providers/registry/cline.js b/open-sse/providers/registry/cline.js index cfa788c4..90b16c4b 100644 --- a/open-sse/providers/registry/cline.js +++ b/open-sse/providers/registry/cline.js @@ -14,12 +14,16 @@ export default { }, }, category: "oauth", + authModes: ["oauth"], + hasOAuth: true, transport: { baseUrl: "https://api.cline.bot/api/v1/chat/completions", headers: { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", }, + // Non-stream chat completions come back wrapped in {"success":true,"data":{...}} + quirks: { clineEnvelope: true }, tokenUrl: "https://api.cline.bot/api/v1/auth/token", refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", auth: { diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index 1e7d0520..fc21590f 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -25,6 +25,8 @@ export default { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", }, + // Non-stream chat completions come back wrapped in {"success":true,"data":{...}} + quirks: { clineEnvelope: true }, auth: { combined: true, header: "Authorization", diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 4208a4b6..0aa96ffe 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -19,12 +19,10 @@ function buildModelListHeaders(token, isApiKey) { } /** - * Fetch ClinePass live model catalog from Cline's /models endpoint. - * - * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) - * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + * Internal: fetch the raw model list from Cline's /models endpoint. + * Returns the parsed array or null on any failure. */ -export async function resolveClinepassModels(credentials) { +async function fetchClineRawModels(credentials) { const isApiKey = Boolean(credentials?.apiKey); const token = isApiKey ? credentials.apiKey : credentials?.accessToken; if (!token) return null; @@ -45,19 +43,53 @@ export async function resolveClinepassModels(credentials) { const json = await response.json(); const rawList = Array.isArray(json) ? json : json?.data; - if (!Array.isArray(rawList)) return null; - - const models = rawList - .filter((m) => typeof m?.id === "string" && m.id.startsWith("cline-pass/")) - .map((m) => ({ - id: m.id, - name: m.name || m.id, - })); - - return models.length ? { models } : null; + return Array.isArray(rawList) ? rawList : null; } catch { return null; } finally { clearTimeout(timer); } } + +/** + * Fetch ClinePass live model catalog from Cline's /models endpoint. + * Returns only models with the cline-pass/ prefix. + * + * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) + * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + */ +export async function resolveClinepassModels(credentials) { + const rawList = await fetchClineRawModels(credentials); + if (!rawList) return null; + + const models = rawList + .filter((m) => typeof m?.id === "string" && m.id.startsWith("cline-pass/")) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + })); + + return models.length ? { models } : null; +} + +/** + * Fetch Cline live model catalog from Cline's /models endpoint. + * Unlike resolveClinepassModels, this returns ALL models (including + * free-tier models like z-ai/glm-5.3-flash) without the cline-pass/ prefix filter. + * + * @param {object} credentials - Connection credentials ({ accessToken, apiKey }) + * @returns {Promise<{ models: { id: string, name: string }[] } | null>} + */ +export async function resolveClineModels(credentials) { + const rawList = await fetchClineRawModels(credentials); + if (!rawList) return null; + + const models = rawList + .filter((m) => typeof m?.id === "string" && m.id.trim() !== "") + .map((m) => ({ + id: m.id, + name: m.name || m.id, + })); + + return models.length ? { models } : null; +} diff --git a/open-sse/shared/clineEnvelope.js b/open-sse/shared/clineEnvelope.js new file mode 100644 index 00000000..3873b746 --- /dev/null +++ b/open-sse/shared/clineEnvelope.js @@ -0,0 +1,19 @@ +import { PROVIDERS } from "../providers/index.js"; + +/** + * Unwrap Cline's non-stream envelope: {"success":true,"data":{...choices...}}. + * + * Scoped to providers opting in via `transport.quirks.clineEnvelope` so no other + * provider's body is ever rewritten. The error envelope ({"success":false,...}) + * never matches and passes through untouched. + * + * @param {object} body - Parsed upstream response body + * @param {string} provider - Provider id or alias + * @returns {object} The inner `data` object, or `body` unchanged + */ +export function unwrapClineEnvelope(body, provider) { + if (!provider || !PROVIDERS[provider]?.quirks?.clineEnvelope) return body; + const { success, data } = body || {}; + if (success !== true || !data || typeof data !== "object" || Array.isArray(data)) return body; + return data; +} diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 24119eb2..273aff47 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -1,4 +1,6 @@ import { getApiKeys } from "@/lib/localDb"; +import { resolveProviderId } from "@/shared/constants/providers.js"; +import { unwrapClineEnvelope } from "open-sse/shared/clineEnvelope.js"; import { UPDATER_CONFIG } from "@/shared/constants/config"; import { getConsistentMachineId } from "@/shared/utils/machineId"; @@ -151,6 +153,11 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ let parsed = null; try { parsed = rawText ? JSON.parse(rawText) : null; } catch {} + // Unwrap before the choices checks below. No-op for providers that do not + // opt in via transport.quirks.clineEnvelope. + const providerId = resolveProviderId(String(model).split("/")[0]); + parsed = unwrapClineEnvelope(parsed, providerId); + if (!res.ok) { const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status }; diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d73ec11d..605bc5ca 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveClineModels, resolveClinepassModels } from "open-sse/services/clinepassModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -287,6 +288,37 @@ const PROVIDER_MODELS_CONFIG = { }, }, + // Cline/ClinePass share api.cline.bot/api/v1/models. The service layer already + // handles Bearer-vs-`workos:` auth and swallows failures into null, so these follow + // the cursor direct pattern (no refreshFn) and only differ in filtering: + // cline returns the whole catalog verbatim, clinepass keeps cline-pass/* only. + cline: { + customResolver: async (connection) => { + const result = await resolveClineModels({ + accessToken: connection.accessToken, + apiKey: connection.apiKey, + }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("cline"), + warning: "Cline returned no live models; falling back to static catalog.", + }; + }, + }, + clinepass: { + customResolver: async (connection) => { + const result = await resolveClinepassModels({ + accessToken: connection.accessToken, + apiKey: connection.apiKey, + }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("clinepass"), + warning: "ClinePass returned no live models; falling back to static catalog.", + }; + }, + }, + // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { customResolver: async (connection) => { diff --git a/src/app/api/providers/suggested-models/filters.js b/src/app/api/providers/suggested-models/filters.js index 13454757..8babbb9b 100644 --- a/src/app/api/providers/suggested-models/filters.js +++ b/src/app/api/providers/suggested-models/filters.js @@ -26,4 +26,10 @@ export const FILTERS = { (Array.isArray(models) ? models : []) .filter((m) => m.id?.startsWith("mimo") || m.name?.toLowerCase().includes("mimo")) .map((m) => ({ id: m.id, name: m.name || m.id })), + + "airforce-free": (models) => + (Array.isArray(models) ? models : []) + .filter((m) => (m.tier === "free" || m.id?.endsWith(":free")) && m.supports_chat === true && (!m.media_type || m.media_type === "chat" || m.media_type === "text")) + .map((m) => ({ id: m.id, name: m.name || m.id, contextLength: m.context_length })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))), }; diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 173d91c2..9ce571b4 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -11,7 +11,7 @@ import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels, routableQoderModels } from "open-sse/services/qoderModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; -import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; +import { resolveClinepassModels, resolveClineModels } from "open-sse/services/clinepassModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js"; @@ -79,6 +79,13 @@ const LIVE_MODEL_RESOLVERS = { }); return result?.models?.length ? { models: result.models } : null; }, + cline: async (conn) => { + const result = await resolveClineModels({ + accessToken: conn.accessToken, + apiKey: conn.apiKey, + }); + return result?.models?.length ? { models: result.models } : null; + }, "grok-cli": async (conn) => { const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}); const result = await resolveGrokCliModels({ diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 2cbc11a2..20e17d95 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -20,6 +20,54 @@ const PROVIDER_ORDER = [ // Providers that need no auth — always show in model selector const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter(id => FREE_PROVIDERS[id].noAuth); +// Providers with per-account live catalogs via /api/providers/[id]/models. +// Static registry stays as fallback when live fetch fails or is empty. +const LIVE_CATALOG_PROVIDERS = ["cursor", "cline", "clinepass"]; + +// Fetch a provider's account-scoped catalog for every active connection and merge +// the results. Entries collapse by model id on purpose: two connections of the +// same provider produce the same picker value (`alias/id`), so keeping the first +// avoids duplicate rows. There is no per-connection metadata to preserve beyond +// {id,name}. Empty array means "nothing live" so callers keep the static fallback. +function useLiveProviderModels(isOpen, connectionIds, label) { + const [models, setModels] = useState([]); + const idsKey = (connectionIds ?? []).join("|"); + + useEffect(() => { + const ids = idsKey ? idsKey.split("|") : []; + if (!isOpen || ids.length === 0) { + setModels([]); + return undefined; + } + + let cancelled = false; + Promise.all(ids.map(async (connectionId) => { + const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + })) + .then((modelLists) => { + if (cancelled) return; + const seen = new Set(); + setModels(modelLists.flat().filter((model) => { + if (!model?.id || seen.has(model.id)) return false; + seen.add(model.id); + return true; + })); + }) + .catch((error) => { + // Do not hide the static fallback when the account catalog is unavailable. + console.warn(`Unable to load ${label} models for selector:`, error); + if (!cancelled) setModels([]); + }); + + return () => { cancelled = true; }; + }, [isOpen, idsKey, label]); + + return models; +} + export default function ModelSelectModal({ isOpen, onClose, @@ -49,48 +97,25 @@ export default function ModelSelectModal({ const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); const [disabledModels, setDisabledModels] = useState({}); - const [cursorModels, setCursorModels] = useState([]); - - // Cursor exposes the usable catalog per account. Keep the static catalog only - // as a fallback, since it quickly becomes stale and different accounts can - // have different model entitlements. - const cursorConnectionIds = useMemo( - () => activeProviders - .filter((provider) => provider.provider === "cursor" && provider.id) - .map((provider) => provider.id), - [activeProviders], - ); - - useEffect(() => { - if (!isOpen || cursorConnectionIds.length === 0) { - setCursorModels([]); - return undefined; + // Cursor and Cline expose the usable catalog per account, so the static catalog is + // kept only as a fallback: it goes stale quickly and entitlements differ per account. + // Single map driven by LIVE_CATALOG_PROVIDERS so the constant cannot drift + // from the memos below; per-provider arrays stay referentially stable unless + // activeProviders itself changes. + const liveConnectionIdsByProvider = useMemo(() => { + const map = Object.fromEntries(LIVE_CATALOG_PROVIDERS.map((id) => [id, []])); + for (const p of activeProviders) { + if (p?.id && Object.prototype.hasOwnProperty.call(map, p.provider)) map[p.provider].push(p.id); } + return map; + }, [activeProviders]); + const cursorConnectionIds = liveConnectionIdsByProvider.cursor; + const clineConnectionIds = liveConnectionIdsByProvider.cline; + const clinepassConnectionIds = liveConnectionIdsByProvider.clinepass; - let cancelled = false; - Promise.all(cursorConnectionIds.map(async (connectionId) => { - const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); - if (!response.ok) return []; - const data = await response.json(); - return Array.isArray(data.models) ? data.models : []; - })) - .then((modelLists) => { - if (cancelled) return; - const seen = new Set(); - setCursorModels(modelLists.flat().filter((model) => { - if (!model?.id || seen.has(model.id)) return false; - seen.add(model.id); - return true; - })); - }) - .catch((error) => { - // Do not hide the static fallback when the account catalog is unavailable. - console.warn("Unable to load Cursor models for selector:", error); - if (!cancelled) setCursorModels([]); - }); - - return () => { cancelled = true; }; - }, [isOpen, cursorConnectionIds]); + const cursorModels = useLiveProviderModels(isOpen, cursorConnectionIds, "Cursor"); + const clineModels = useLiveProviderModels(isOpen, clineConnectionIds, "Cline"); + const clinepassModels = useLiveProviderModels(isOpen, clinepassConnectionIds, "ClinePass"); const fetchCombos = async () => { try { @@ -323,8 +348,9 @@ export default function ModelSelectModal({ hasModels: mergedModels.length > 0, }; } else { - const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 - ? cursorModels + const liveModels = providerId === "cursor" ? cursorModels : providerId === "cline" ? clineModels : providerId === "clinepass" ? clinepassModels : []; + const hardcodedModels = liveModels.length > 0 + ? liveModels : getModelsByProviderId(providerId); const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); @@ -394,7 +420,7 @@ export default function ModelSelectModal({ }); return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels, clineModels, clinepassModels]); // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 0f7c0236..a6bb4685 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -199,7 +199,7 @@ export const CLI_TOOLS = { id: "cline", name: "Cline", image: "/providers/cline.png", - color: "#00D1B2", + color: "#5B9BD5", description: "Cline AI Coding Assistant", configType: "custom", }, diff --git a/tests/unit/api-airforce-free-models.test.js b/tests/unit/api-airforce-free-models.test.js new file mode 100644 index 00000000..0b435981 --- /dev/null +++ b/tests/unit/api-airforce-free-models.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import airforce from "../../open-sse/providers/registry/api-airforce.js"; +import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; + +describe("api-airforce free models", () => { + const ids = airforce.models.map((m) => m.id); + + it("registers the three live free models", () => { + expect(ids).toContain("gpt-oss-120b"); + expect(ids).toContain("gpt-oss-20b"); + expect(ids).toContain("kimi-k2.7-code"); + }); + + it("drops the dead catalog ids", () => { + expect(ids).not.toContain("anthropic/claude-3.7-sonnet"); + expect(ids).not.toContain("moonshot/kimi-k2.6"); + expect(ids).not.toContain("google/gemini-2.5-flash"); + }); + + it("is passthrough so any live id resolves", () => { + expect(airforce.passthroughModels).toBe(true); + expect(PROVIDERS["api-airforce"].forceStream).toBe(true); + }); + + it("PROVIDER_MODELS['af'] exposes the new ids", () => { + expect(PROVIDER_MODELS.af.map((m) => m.id)).toEqual(expect.arrayContaining([ + "gpt-oss-120b", "gpt-oss-20b", "kimi-k2.7-code", + ])); + }); + + it("caps resolve for the free ids", () => { + expect(getCapabilitiesForModel("api-airforce", "kimi-k2.7-code").reasoning).toBe(true); + expect(getCapabilitiesForModel("api-airforce", "gpt-oss-120b").reasoning).toBe(true); + }); +}); diff --git a/tests/unit/cline-free-models-envelope.test.js b/tests/unit/cline-free-models-envelope.test.js new file mode 100644 index 00000000..8ae0fa89 --- /dev/null +++ b/tests/unit/cline-free-models-envelope.test.js @@ -0,0 +1,297 @@ +// Cline free models (z-ai/glm-5.3-flash, deepseek-v4-flash) wrap non-stream +// chat completions in {"success":true,"data":{...choices...}} on +// https://api.cline.bot/api/v1/chat/completions. Both the UI model-test ping +// (src/app/api/models/test/ping.js) and the proxy non-stream path +// (open-sse/handlers/chatCore/nonStreamingHandler.js) read `choices` at the +// top level, so enveloped choices are invisible ("Provider returned no +// completion choices for this model"). These tests pin the unwrap behavior. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the heavy Next.js-dependent imports BEFORE importing ping.js +// (same pattern as tests/unit/ping-reasoning-models-3010.test.js). +vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) })); +vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } })); +vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") })); +// requestDetail.js imports from @/lib/usageDb.js too, so one mock covers both +// the handler and its usage/detail helpers. +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}), +})); + +const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js"); +const { handleNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js"); + +// The proxy adds a 2000-token headroom buffer to usage before returning it +// to the client (addBufferToUsage), so response-body usage is input + 2000. +// The usage recorded via saveRequestUsage is the unbuffered extraction — +// asserting on it proves the unwrap ran before usage extraction. +const { saveRequestUsage } = await import("@/lib/usageDb.js"); + +describe("cline free-models {success,data} envelope", () => { + let fetchMock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function jsonResponse(obj) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify(obj), + json: async () => obj, + }; + } + + it("ping: enveloped success unwraps to ok:true (regression for reported error)", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } }) + ); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + }); + + it("ping: enveloped reasoning-only response still soft-passes with note", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + success: true, + data: { + choices: [ + { + finish_reason: "length", + message: { content: "", reasoning: "The user said hi" }, + }, + ], + }, + }) + ); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + expect(result.note).toMatch(/reasoning-only/); + }); + + it("ping: error envelope passes through without unwrap", async () => { + const body = { error: "empty response content", success: false }; + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: async () => JSON.stringify(body), + json: async () => body, + }); + const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/empty response content/); + }); + + it("ping: bare (un-enveloped) body still passes", async () => { + fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] })); + const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(true); + }); + + it("ping: does not unwrap for a provider that did not opt in", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } }) + ); + const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127"); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/no completion choices/i); + }); +}); + +describe("cline free-models envelope in nonStreamingHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function stubLogger() { + return { logProviderResponse() {}, logConvertedResponse() {} }; + } + + function callHandler(providerResponse, provider = "cline") { + return handleNonStreamingResponse({ + providerResponse, + provider, + model: "z-ai/glm-5.3-flash", + sourceFormat: "openai", + targetFormat: "openai", + body: { stream: false }, + stream: false, + translatedBody: null, + finalBody: null, + requestStartTime: Date.now(), + connectionId: "c1", + apiKey: "k", + clientRawRequest: null, + onRequestSuccess: () => {}, + reqLogger: stubLogger(), + toolNameMap: null, + customToolNames: null, + trackDone: () => {}, + appendLog: () => {}, + pxpipe: null, + reqTag: "t", + log: null, + }); + } + + it("unwraps the {success,data} envelope before usage extraction and translation", async () => { + const providerResponse = new Response( + JSON.stringify({ + success: true, + data: { + choices: [{ message: { content: "Hi" } }], + usage: { prompt_tokens: 5, completion_tokens: 2 }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse); + expect(result.success).toBe(true); + const body = await result.response.json(); + expect(body.choices).toBeDefined(); + expect(body.choices[0].message.content).toBe("Hi"); + expect(body.success).toBeUndefined(); + expect(saveRequestUsage).toHaveBeenCalledTimes(1); + expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({ + prompt_tokens: 5, + completion_tokens: 2, + }); + expect(body.usage.prompt_tokens).toBe(2005); + }); + + it("passes a bare (non-enveloped) body through unchanged", async () => { + const providerResponse = new Response( + JSON.stringify({ + choices: [{ message: { content: "Hi" } }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse); + expect(result.success).toBe(true); + const body = await result.response.json(); + expect(body.choices[0].message.content).toBe("Hi"); + expect(saveRequestUsage).toHaveBeenCalledTimes(1); + expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({ + prompt_tokens: 3, + completion_tokens: 1, + }); + expect(body.usage.prompt_tokens).toBe(2003); + }); + + // The unwrap is opt-in via transport.quirks.clineEnvelope so it can never + // rewrite another provider's body — including one that happens to return + // {"success":true,"data":...} for its own reasons. + it("leaves an enveloped body untouched for a provider that did not opt in", async () => { + const providerResponse = new Response( + JSON.stringify({ + success: true, + data: { choices: [{ message: { content: "Hi" } }] }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + const result = await callHandler(providerResponse, "openai"); + const body = await result.response.json(); + expect(body.success).toBe(true); + expect(body.data.choices[0].message.content).toBe("Hi"); + expect(body.choices).toBeUndefined(); + }); +}); + +describe("cline /api/v1/models aggregation (resolveClineModels vs resolveClinepassModels)", () => { + const API_MODELS_URL = "https://api.cline.bot/api/v1/models"; + + const API_RESPONSE = [ + { id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "cline-pass/glm-5.2", name: "GLM-5.2" }, + { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash" }, + { id: "z-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" }, + ]; + + let fetchMock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolveClineModels returns all models (including free-tier)", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(4); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-pass/deepseek-v4-flash"); + expect(ids).toContain("z-ai/glm-5.3-flash"); + expect(ids).toContain("z-ai/deepseek-v4-flash"); + }); + + it("resolveClinepassModels returns only cline-pass/ models", async () => { + const { resolveClinepassModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClinepassModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(2); + const ids = result.models.map((m) => m.id); + expect(ids).toContain("cline-pass/deepseek-v4-flash"); + expect(ids).toContain("cline-pass/glm-5.2"); + expect(ids).not.toContain("z-ai/glm-5.3-flash"); + }); + + it("resolveClineModels unwraps {success,data} envelope", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: API_RESPONSE }), + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).not.toBeNull(); + expect(result.models).toHaveLength(4); + }); + + it("resolveClineModels returns null when no token", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + const result = await resolveClineModels({}); + expect(result).toBeNull(); + }); + + it("resolveClineModels returns null on fetch error", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockRejectedValue(new Error("network error")); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result).toBeNull(); + }); + + it("resolveClineModels returns {id,name} shape", async () => { + const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => API_RESPONSE, + }); + const result = await resolveClineModels({ accessToken: "test-token" }); + expect(result.models[0]).toHaveProperty("id"); + expect(result.models[0]).toHaveProperty("name"); + expect(typeof result.models[0].id).toBe("string"); + expect(typeof result.models[0].name).toBe("string"); + }); +});