diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 2bd66e63..d2dd3c81 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -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", diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js new file mode 100644 index 00000000..0e915af9 --- /dev/null +++ b/src/app/api/models/test/ping.js @@ -0,0 +1,129 @@ +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"; + +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 }; + } + + 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 }; +} diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js index bad3fe74..0b35c9f3 100644 --- a/src/app/api/models/test/route.js +++ b/src/app/api/models/test/route.js @@ -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 }); } diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index f126727f..23aacabb 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -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 }; }) ); diff --git a/tests/unit/hf-model-routing.test.js b/tests/unit/hf-model-routing.test.js new file mode 100644 index 00000000..55d95ef9 --- /dev/null +++ b/tests/unit/hf-model-routing.test.js @@ -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", + }); + }); +}); diff --git a/tests/unit/model-test-routing.test.js b/tests/unit/model-test-routing.test.js new file mode 100644 index 00000000..b655fe56 --- /dev/null +++ b/tests/unit/model-test-routing.test.js @@ -0,0 +1,74 @@ +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", + }), + }) + ); + }); +}); diff --git a/tests/unit/provider-test-models-routing.test.js b/tests/unit/provider-test-models-routing.test.js new file mode 100644 index 00000000..00f3a735 --- /dev/null +++ b/tests/unit/provider-test-models-routing.test.js @@ -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", + }) + ); + }); +});