fix(cline,airforce): unwrap {success,data} envelope, add live catalog, and refresh airforce free models
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.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
open-sse/shared/clineEnvelope.js
Normal file
19
open-sse/shared/clineEnvelope.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
36
tests/unit/api-airforce-free-models.test.js
Normal file
36
tests/unit/api-airforce-free-models.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
297
tests/unit/cline-free-models-envelope.test.js
Normal file
297
tests/unit/cline-free-models-envelope.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user