mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Merge PR #1628: fix(model-test) route image and STT probes to their real endpoints
Route image model tests to /api/v1/images/generations and STT to /api/v1/audio/transcriptions instead of forcing all non-embedding models through chat completions. Adds kind-aware pingModelByKind, hf->huggingface alias, and silent WAV sample for STT reachability. Scoped to dashboard/internal model testing only; runtime inference routing is unchanged. Author: yicone <yicone@gmail.com> Closes #1628
This commit is contained in:
@@ -29,6 +29,8 @@ const ALIAS_TO_PROVIDER_ID = {
|
||||
kimi: "kimi",
|
||||
minimax: "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
hf: "huggingface",
|
||||
huggingface: "huggingface",
|
||||
ds: "deepseek",
|
||||
deepseek: "deepseek",
|
||||
cmc: "commandcode",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
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,
|
||||
max_tokens: 1,
|
||||
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 {}
|
||||
|
||||
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;
|
||||
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 };
|
||||
}
|
||||
@@ -1,119 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
import { pingModelByKind } from "./ping";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { model, kind } = await request.json();
|
||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`;
|
||||
|
||||
// Get an active internal API key for auth (if requireApiKey is enabled)
|
||||
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}`;
|
||||
// Bypass dashboardGuard for internal self-call via CLI token (machineId-based)
|
||||
headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT);
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// Route to appropriate endpoint based on kind
|
||||
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 NextResponse.json({ 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 NextResponse.json({ ok: false, latencyMs, status: res.status, error: "Provider returned no embedding data" });
|
||||
}
|
||||
return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status });
|
||||
}
|
||||
|
||||
// Default: chat completions
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
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 {}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText;
|
||||
const error = `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`;
|
||||
return NextResponse.json({ ok: false, latencyMs, error, status: res.status });
|
||||
}
|
||||
|
||||
// Some providers may return HTTP 200 but not a real completion for invalid models.
|
||||
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 NextResponse.json({
|
||||
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 NextResponse.json({
|
||||
ok: false,
|
||||
latencyMs,
|
||||
status: res.status,
|
||||
error: String(providerError).slice(0, 240),
|
||||
});
|
||||
}
|
||||
|
||||
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
|
||||
if (!hasChoices) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
latencyMs,
|
||||
status: res.status,
|
||||
error: "Provider returned no completion choices for this model",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status });
|
||||
const result = await pingModelByKind(model, kind || "llm");
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,59 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
|
||||
/**
|
||||
* Get an active API key to pass through auth when requireApiKey is enabled.
|
||||
*/
|
||||
async function getInternalApiKey() {
|
||||
const keys = await getApiKeys();
|
||||
return keys.find((k) => k.isActive !== false)?.key || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping a single model via internal completions endpoint (OpenAI format).
|
||||
* open-sse handles all provider translation automatically.
|
||||
*/
|
||||
async function pingModel(modelId, baseUrl, apiKey, cliToken) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
if (cliToken) headers["x-9r-cli-token"] = cliToken;
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
// 200 = working; 400 = bad request but auth passed (model reachable)
|
||||
const ok = res.status === 200 || res.status === 400;
|
||||
let error = null;
|
||||
if (!ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`;
|
||||
}
|
||||
return { ok, latencyMs, error };
|
||||
} catch (err) {
|
||||
return { ok: false, latencyMs: Date.now() - start, error: err.message };
|
||||
}
|
||||
}
|
||||
import { pingModelByKind } from "@/app/api/models/test/ping";
|
||||
|
||||
/**
|
||||
* POST /api/providers/[id]/test-models
|
||||
* id = connectionId — used only to resolve provider + model list.
|
||||
* Actual requests go through /api/v1/chat/completions (open-sse handles everything).
|
||||
* Actual requests go through the internal endpoint that matches each model kind.
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
@@ -86,20 +41,17 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "No models configured for this provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = await getInternalApiKey();
|
||||
// Bypass dashboardGuard for internal self-call via CLI token (machineId-based)
|
||||
const cliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
// This prevents race condition where multiple requests concurrently refresh the same token.
|
||||
const [first, ...rest] = models;
|
||||
const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey, cliToken);
|
||||
const firstKind = first.type || "llm";
|
||||
const firstResult = await pingModelByKind(`${alias}/${first.id}`, firstKind, baseUrl);
|
||||
const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }];
|
||||
|
||||
if (rest.length > 0) {
|
||||
const restResults = await Promise.all(
|
||||
rest.map(async (model) => {
|
||||
const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey, cliToken);
|
||||
const result = await pingModelByKind(`${alias}/${model.id}`, model.type || "llm", baseUrl);
|
||||
return { modelId: model.id, name: model.name || model.id, ...result };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseModel } from "../../open-sse/services/model.js";
|
||||
|
||||
describe("HuggingFace model alias parsing", () => {
|
||||
it("resolves hf alias to huggingface provider", () => {
|
||||
expect(parseModel("hf/black-forest-labs/FLUX.1-schnell")).toMatchObject({
|
||||
provider: "huggingface",
|
||||
model: "black-forest-labs/FLUX.1-schnell",
|
||||
providerAlias: "hf",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getApiKeys: vi.fn(),
|
||||
getConsistentMachineId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getApiKeys: mocks.getApiKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/machineId", () => ({
|
||||
getConsistentMachineId: mocks.getConsistentMachineId,
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status || 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("model test route kind routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getApiKeys.mockResolvedValue([{ key: "sk-internal", isActive: true }]);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
created: 1,
|
||||
data: [{ b64_json: "abc" }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("routes image model tests to /api/v1/images/generations", async () => {
|
||||
const { POST } = await import("../../src/app/api/models/test/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "hf/black-forest-labs/FLUX.1-schnell",
|
||||
kind: "image",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/images/generations"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "hf/black-forest-labs/FLUX.1-schnell",
|
||||
prompt: "test",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("routes embedding model tests to /api/v1/embeddings", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: [{ embedding: [0.1, 0.2] }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
|
||||
const { POST } = await import("../../src/app/api/models/test/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "voyage/voyage-3-large",
|
||||
kind: "embedding",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/embeddings"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "voyage/voyage-3-large",
|
||||
input: "test",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("fails embedding model tests when provider returns no embedding data", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: [{ embedding: null }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
|
||||
const { POST } = await import("../../src/app/api/models/test/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "voyage/voyage-3-large",
|
||||
kind: "embedding",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBe("Provider returned no embedding data");
|
||||
});
|
||||
|
||||
it("routes stt model tests to /api/v1/audio/transcriptions", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
text: "test",
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
|
||||
const { POST } = await import("../../src/app/api/models/test/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "hf/openai/whisper-small",
|
||||
kind: "stt",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/audio/transcriptions"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(FormData),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns formatted HTTP errors for non-2xx embedding responses", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
error: { message: "bad upstream" },
|
||||
}), {
|
||||
status: 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
|
||||
const { POST } = await import("../../src/app/api/models/test/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "voyage/voyage-3-large",
|
||||
kind: "embedding",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.status).toBe(502);
|
||||
expect(body.error).toBe("HTTP 502: bad upstream");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProviderConnectionById: vi.fn(),
|
||||
getApiKeys: vi.fn(),
|
||||
getConsistentMachineId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getProviderConnectionById: mocks.getProviderConnectionById,
|
||||
getApiKeys: mocks.getApiKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/machineId", () => ({
|
||||
getConsistentMachineId: mocks.getConsistentMachineId,
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status || 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("provider test-models route kind routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getProviderConnectionById.mockResolvedValue({
|
||||
id: "conn-hf",
|
||||
provider: "huggingface",
|
||||
});
|
||||
mocks.getApiKeys.mockResolvedValue([{ key: "sk-internal", isActive: true }]);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes("/api/v1/images/generations")) {
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
created: 1,
|
||||
data: [{ b64_json: "abc" }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
choices: [{ message: { role: "assistant", content: "ok" } }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("routes huggingface image models to /api/v1/images/generations", async () => {
|
||||
const { POST } = await import("../../src/app/api/providers/[id]/test-models/route.js");
|
||||
|
||||
const req = new Request("http://localhost/api/providers/conn-hf/test-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "conn-hf" }) });
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.provider).toBe("huggingface");
|
||||
expect(body.results.some((r) => r.modelId === "black-forest-labs/FLUX.1-schnell" && r.ok)).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/images/generations"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user