Files
9router/src/app/api/models/test/ping.js
Nick Nyanjui 122f23eebc 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.
2026-09-10 22:48:28 +07:00

218 lines
8.2 KiB
JavaScript

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";
const CLI_TOKEN_SALT = "9r-cli-auth";
function createSilentWavFile() {
const sampleRate = 16000;
const channels = 1;
const bitsPerSample = 16;
const durationMs = 250;
const sampleCount = Math.max(1, Math.floor((sampleRate * durationMs) / 1000));
const dataSize = sampleCount * channels * (bitsPerSample / 8);
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeAscii = (offset, value) => {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i));
}
};
writeAscii(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true);
view.setUint16(32, channels * (bitsPerSample / 8), true);
view.setUint16(34, bitsPerSample, true);
writeAscii(36, "data");
view.setUint32(40, dataSize, true);
return new Blob([buffer], { type: "audio/wav" });
}
async function getInternalHeaders() {
let apiKey = null;
try {
const keys = await getApiKeys();
apiKey = keys.find((k) => k.isActive !== false)?.key || null;
} catch {}
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT);
return headers;
}
export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`) {
const headers = await getInternalHeaders();
const start = Date.now();
if (kind === "embedding") {
const res = await fetch(`${baseUrl}/api/v1/embeddings`, {
method: "POST",
headers,
body: JSON.stringify({ model, input: "test" }),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
if (!res.ok) {
const detail = parsed?.error?.message || parsed?.error || rawText;
return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status };
}
const hasEmbedding = Array.isArray(parsed?.data) && parsed.data.length > 0 && Array.isArray(parsed.data[0]?.embedding);
if (!hasEmbedding) {
return { ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" };
}
return { ok: true, latencyMs, error: null, status: res.status };
}
if (kind === "image") {
const res = await fetch(`${baseUrl}/api/v1/images/generations`, {
method: "POST",
headers,
body: JSON.stringify({ model, prompt: "test" }),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
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 };
}
const hasImages = Array.isArray(parsed?.data) && parsed.data.length > 0;
if (!hasImages) {
return { ok: false, latencyMs, status: res.status, error: "Provider returned no image data for this model" };
}
return { ok: true, latencyMs, error: null, status: res.status };
}
if (kind === "stt") {
const form = new FormData();
const sampleAudio = createSilentWavFile();
form.append("file", sampleAudio, "test.wav");
form.append("model", model);
const res = await fetch(`${baseUrl}/api/v1/audio/transcriptions`, {
method: "POST",
headers: Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "content-type")),
body: form,
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
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 };
}
const text = typeof parsed?.text === "string" ? parsed.text : "";
if (!text.trim()) {
return { ok: false, latencyMs, status: res.status, error: "Provider returned no transcription text for this model" };
}
return { ok: true, latencyMs, error: null, status: res.status };
}
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
// 1024 tokens: reasoning models (ClinePass/kimi-k3, deepseek-v4-pro, etc.) spend
// their budget on chain-of-thought before emitting an answer. A tiny probe like
// max_tokens:16 starves the answer and yields a false "no choices" failure.
// See issue #3010.
max_tokens: 1024,
stream: false,
messages: [{ role: "user", content: "hi" }],
}),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
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 };
}
const providerStatus = parsed?.status;
const providerMsg = parsed?.msg || parsed?.message;
const hasProviderErrorStatus = providerStatus !== undefined
&& providerStatus !== null
&& String(providerStatus) !== "200"
&& String(providerStatus) !== "0";
if (hasProviderErrorStatus && providerMsg) {
return {
ok: false,
latencyMs,
status: res.status,
error: `Provider status ${providerStatus}: ${String(providerMsg).slice(0, 240)}`,
};
}
if (parsed?.error) {
const providerError = parsed?.error?.message || parsed?.error || "Provider returned an error";
return {
ok: false,
latencyMs,
status: res.status,
error: String(providerError).slice(0, 240),
};
}
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
// Soft-pass (issue #3010): a reasoning model may burn its whole budget on
// chain-of-thought and return finish_reason:"length" with empty content but
// non-empty reasoning/thinking. That's a successful connection, not a failure.
const firstChoice = parsed?.choices?.[0] || {};
const hasReasoning =
firstChoice.message?.reasoning ||
firstChoice.message?.reasoning_content ||
firstChoice.message?.thinking ||
firstChoice.message?.thinking_content;
const contentEmpty = !String(firstChoice.message?.content || "").trim();
if (hasChoices && firstChoice.finish_reason === "length" && contentEmpty && hasReasoning) {
return { ok: true, latencyMs, error: null, status: res.status, note: "reasoning-only response (length-limited)" };
}
if (!hasChoices) {
return {
ok: false,
latencyMs,
status: res.status,
error: "Provider returned no completion choices for this model",
};
}
return { ok: true, latencyMs, error: null, status: res.status };
}