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:
Nick Nyanjui
2026-09-10 22:46:07 +07:00
committed by decolua
parent f6e7cabe60
commit 122f23eebc
14 changed files with 540 additions and 63 deletions

View File

@@ -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 };

View File

@@ -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) => {

View File

@@ -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))),
};

View File

@@ -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({

View File

@@ -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(() => {

View File

@@ -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",
},