diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 4ac036411..c454d2ed9 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -38,8 +38,16 @@ export default { { id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", params: ["n","size","quality","response_format"], kind: "image" }, { id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", params: ["n","size"], kind: "image" }, { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", params: ["n","size"], kind: "image" }, + // OpenRouter exposes a separate, Cohere-compatible POST /api/v1/rerank endpoint + // (not surfaced by its live /v1/models feed). Model IDs keep their vendor slash + // (e.g. "cohere/rerank-4-pro"); the model parser splits on the first slash, so + // 3-segment ids resolve safely. Seeded by hand as OpenRouter adds rerank models. + { id: "cohere/rerank-4-pro", name: "Cohere Rerank 4 Pro (via OpenRouter)", kind: "rerank" }, + { id: "cohere/rerank-4-fast", name: "Cohere Rerank 4 Fast (via OpenRouter)", kind: "rerank" }, + { id: "cohere/rerank-v3.5", name: "Cohere Rerank v3.5 (via OpenRouter)", kind: "rerank" }, + { id: "nvidia/llama-nemotron-rerank-vl-1b-v2:free", name: "Llama Nemotron Rerank VL 1B v2 (free, via OpenRouter)", kind: "rerank" }, ], - serviceKinds: ["llm","embedding","tts","imageToText"], + serviceKinds: ["llm","embedding","tts","imageToText","rerank"], ttsConfig: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", defaultModel: "openai/gpt-4o-mini-tts", diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 0a2cc9830..80ae927d6 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -122,6 +122,37 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ return { ok: true, latencyMs, error: null, status: res.status }; } + if (kind === "rerank") { + const res = await fetch(`${baseUrl}/api/v1/rerank`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + query: "ping", + documents: ["hello world"], + top_n: 1, + }), + 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 results = Array.isArray(parsed?.results) ? parsed.results + : Array.isArray(parsed?.data) ? parsed.data + : null; + if (!results) { + return { ok: false, latencyMs, status: res.status, error: "Provider returned no rerank results for this model" }; + } + return { ok: true, latencyMs, error: null, status: res.status }; + } + const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { method: "POST", headers, diff --git a/src/app/api/v1/models/[kind]/route.js b/src/app/api/v1/models/[kind]/route.js index d6a649a5c..c015278ca 100644 --- a/src/app/api/v1/models/[kind]/route.js +++ b/src/app/api/v1/models/[kind]/route.js @@ -10,6 +10,7 @@ const KIND_SLUG_MAP = { "embedding": ["embedding"], "image-to-text": ["imageToText"], "web": ["webSearch", "webFetch"], + "rerank": ["rerank"], }; export async function OPTIONS() { @@ -24,7 +25,7 @@ export async function OPTIONS() { /** * GET /v1/models/{kind} - OpenAI-compatible models list filtered by capability. - * Supported kinds: image, tts, stt, embedding, image-to-text, web. + * Supported kinds: image, tts, stt, embedding, image-to-text, web, rerank. */ export async function GET(request, { params }) { try { diff --git a/src/app/api/v1/models/buildModelsList.js b/src/app/api/v1/models/buildModelsList.js index 6e4c773eb..fb031bd51 100644 --- a/src/app/api/v1/models/buildModelsList.js +++ b/src/app/api/v1/models/buildModelsList.js @@ -187,6 +187,7 @@ const MODEL_TYPE_TO_KIND = { embedding: "embedding", stt: "stt", imageToText: "imageToText", + rerank: "rerank", }; function modelKind(model) { diff --git a/src/app/api/v1/models/info/route.js b/src/app/api/v1/models/info/route.js index f659fa357..9c4971894 100644 --- a/src/app/api/v1/models/info/route.js +++ b/src/app/api/v1/models/info/route.js @@ -12,6 +12,7 @@ const KIND_ENDPOINT = { imageToText: "/v1/chat/completions", webSearch: "/v1/search", webFetch: "/v1/fetch", + rerank: "/v1/rerank", }; const TTS_VOICES_API = new Set(["elevenlabs", "edge-tts", "deepgram", "inworld", "local-device", "minimax", "minimax-cn"]); diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 8678e5bf2..d42a1077d 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -75,6 +75,7 @@ export const WEB_COOKIE_PROVIDERS = byCategory("webCookie"); // Media provider kinds — each kind maps to a route and endpoint config export const MEDIA_PROVIDER_KINDS = [ { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } }, + { id: "rerank", label: "Rerank", icon: "sort", endpoint: { method: "POST", path: "/v1/rerank" } }, { id: "image", label: "Text to Image", icon: "brush", endpoint: { method: "POST", path: "/v1/images/generations" } }, { id: "imageToText", label: "Image to Text", icon: "image_search", endpoint: { method: "POST", path: "/v1/images/understanding" } }, { id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } }, diff --git a/tests/unit/models-info-rerank-endpoint.test.js b/tests/unit/models-info-rerank-endpoint.test.js new file mode 100644 index 000000000..efb3e8638 --- /dev/null +++ b/tests/unit/models-info-rerank-endpoint.test.js @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +// PR #139 review (Codex): /v1/models/rerank advertises openrouter/cohere/rerank-* +// but GET /v1/models/info built endpoint metadata from KIND_ENDPOINT which had +// no `rerank` entry, so the info response returned endpoint: null and clients +// could not route the newly-discoverable rerank models. Pin the endpoint. +describe("GET /v1/models/info rerank endpoint metadata", () => { + it("returns endpoint /v1/rerank for a rerank model", async () => { + const { GET } = await import("../../src/app/api/v1/models/info/route.js"); + const res = await GET( + new Request("http://local.test/v1/models/info?id=openrouter/cohere/rerank-4-pro"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.kind).toBe("rerank"); + expect(body.endpoint).toBe("/v1/rerank"); + }); +}); diff --git a/tests/unit/openrouter-rerank-buildModelsList.test.js b/tests/unit/openrouter-rerank-buildModelsList.test.js new file mode 100644 index 000000000..8724cc128 --- /dev/null +++ b/tests/unit/openrouter-rerank-buildModelsList.test.js @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// PR #139 review (Codex): static OpenRouter rerank rows carry `kind: "rerank"`. +// Without `buildModelsList` mapping that kind, the LLM filter would advertise +// rerank-only models as chat models. This test pins both halves of the fix: +// - /v1/models (LLM filter) MUST NOT surface openrouter rerank models +// - /v1/models/rerank (rerank filter) MUST surface them +const mocks = vi.hoisted(() => ({ + getProviderConnections: vi.fn(), + getCombos: vi.fn(), + getCustomModels: vi.fn(), + getModelAliases: vi.fn(), + getDisabledModels: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: mocks.getProviderConnections, + getCombos: mocks.getCombos, + getCustomModels: mocks.getCustomModels, + getModelAliases: mocks.getModelAliases, +})); + +vi.mock("@/lib/disabledModelsDb", () => ({ + getDisabledModels: mocks.getDisabledModels, +})); + +describe("buildModelsList rerank kind handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getProviderConnections.mockResolvedValue([ + { provider: "openrouter", isActive: true, apiKey: "sk-test" }, + ]); + mocks.getCombos.mockResolvedValue([]); + mocks.getCustomModels.mockResolvedValue([]); + mocks.getModelAliases.mockResolvedValue({}); + mocks.getDisabledModels.mockResolvedValue({}); + }); + + it("excludes rerank-only models from the chat-completions (llm) list", async () => { + const { buildModelsList, LLM_KIND } = await import( + "../../src/app/api/v1/models/buildModelsList.js" + ); + const models = await buildModelsList([LLM_KIND]); + const ids = new Set(models.map((m) => m.id)); + + expect(ids.has("openrouter/cohere/rerank-4-pro")).toBe(false); + expect(ids.has("openrouter/cohere/rerank-4-fast")).toBe(false); + expect(ids.has("openrouter/cohere/rerank-v3.5")).toBe(false); + expect(ids.has("openrouter/nvidia/llama-nemotron-rerank-vl-1b-v2:free")).toBe(false); + }); + + it("surfaces rerank-only models under the rerank kind filter", async () => { + const { buildModelsList } = await import( + "../../src/app/api/v1/models/buildModelsList.js" + ); + const models = await buildModelsList(["rerank"]); + const ids = new Set(models.map((m) => m.id)); + + expect(ids.has("openrouter/cohere/rerank-4-pro")).toBe(true); + expect(ids.has("openrouter/nvidia/llama-nemotron-rerank-vl-1b-v2:free")).toBe(true); + }); +}); diff --git a/tests/unit/ping-rerank-endpoint.test.js b/tests/unit/ping-rerank-endpoint.test.js new file mode 100644 index 000000000..917667ce3 --- /dev/null +++ b/tests/unit/ping-rerank-endpoint.test.js @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// PR #139 review (Codex): pingModelByKind used to fall every non-embedding/ +// image/stt kind through to POST /api/v1/chat/completions, so testing a +// rerank-only model reported a chat-completion failure. Pin the new rerank +// branch: it MUST hit /api/v1/rerank and MUST NOT hit chat completions. +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, +})); + +const originalFetch = global.fetch; + +describe("pingModelByKind rerank endpoint", () => { + let calls; + + beforeEach(() => { + vi.resetModules(); + mocks.getApiKeys.mockResolvedValue([]); + mocks.getConsistentMachineId.mockResolvedValue("machine-id-test"); + calls = []; + global.fetch = vi.fn(async (url) => { + calls.push(String(url)); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), + }; + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("calls POST /api/v1/rerank, not /api/v1/chat/completions", async () => { + const { pingModelByKind } = await import( + "../../src/app/api/models/test/ping.js" + ); + const result = await pingModelByKind( + "openrouter/cohere/rerank-4-pro", + "rerank", + "http://local.test", + ); + + expect(result.ok).toBe(true); + expect(calls.some((u) => u === "http://local.test/api/v1/rerank")).toBe(true); + expect(calls.some((u) => u.endsWith("/api/v1/chat/completions"))).toBe(false); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("http://local.test/api/v1/rerank"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body).toMatchObject({ + model: "openrouter/cohere/rerank-4-pro", + query: "ping", + documents: ["hello world"], + top_n: 1, + }); + }); +}); diff --git a/tests/unit/rerank-openrouter-6574.test.js b/tests/unit/rerank-openrouter-6574.test.js new file mode 100644 index 000000000..4c7bd0820 --- /dev/null +++ b/tests/unit/rerank-openrouter-6574.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + PROVIDERS, + PROVIDER_MEDIA, + PROVIDER_MODELS, +} from "open-sse/providers/index.js"; +import { deriveRerankUrl } from "open-sse/handlers/rerankCore.js"; +import { parseModel } from "open-sse/services/model.js"; +import openrouter from "open-sse/providers/registry/openrouter.js"; + +// #6574 — OpenRouter exposes a Cohere-compatible POST /api/v1/rerank endpoint +// (confirmed live: openrouter.ai/cohere/rerank-4-pro). The capability/model catalog +// missed rerank registration, so clients could not discover or route OpenRouter rerank +// models reliably. +describe("openrouter rerank provider registration", () => { + it("parseModel resolves openrouter multi-slash rerank model id", () => { + expect(parseModel("openrouter/cohere/rerank-4-pro")).toEqual({ + provider: "openrouter", + model: "cohere/rerank-4-pro", + isAlias: false, + providerAlias: "openrouter", + }); + }); + it("openrouter registry includes rerank service kind", () => { + expect(openrouter.serviceKinds).toContain("rerank"); + }); + + it("PROVIDER_MODELS.openrouter contains the rerank model ids", () => { + const ids = new Set(PROVIDER_MODELS.openrouter.map((m) => m.id)); + expect(ids.has("cohere/rerank-4-pro")).toBe(true); + expect(ids.has("cohere/rerank-4-fast")).toBe(true); + expect(ids.has("cohere/rerank-v3.5")).toBe(true); + + const rerank = PROVIDER_MODELS.openrouter.find((m) => m.id === "cohere/rerank-4-pro"); + expect(rerank?.kind).toBe("rerank"); + }); + + it("deriveRerankUrl resolves openrouter to its Cohere-compatible /rerank endpoint", () => { + const url = deriveRerankUrl(PROVIDERS.openrouter, PROVIDER_MEDIA.openrouter); + expect(url).toBe("https://openrouter.ai/api/v1/rerank"); + }); +});