mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592)
|
||||
@@ -11,6 +11,13 @@ import { getModelPricing } from "@/lib/playground/types";
|
||||
import type { ConfigState } from "../StudioConfigPane";
|
||||
import type { StreamMetrics } from "@/shared/schemas/playground";
|
||||
import { buildReasoningRequestFields } from "../reasoningControlUtils";
|
||||
import {
|
||||
buildNonChatRequestBody,
|
||||
formatNonChatResponse,
|
||||
isChatCompletionsEndpoint,
|
||||
lastUserContent,
|
||||
resolveChatTabRequestPath,
|
||||
} from "./chatTabEndpointRequest";
|
||||
|
||||
interface Message {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -127,11 +134,19 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
|
||||
|
||||
try {
|
||||
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const chatEndpoint = isChatCompletionsEndpoint(configState.endpoint);
|
||||
const requestBody = chatEndpoint
|
||||
? buildRequestBody(chatMessages)
|
||||
: buildNonChatRequestBody(
|
||||
configState.endpoint,
|
||||
lastUserContent(chatMessages),
|
||||
configState.model
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/chat/completions", {
|
||||
const res = await fetch(resolveChatTabRequestPath(configState.endpoint), {
|
||||
method: "POST",
|
||||
headers: fetchHeaders,
|
||||
body: JSON.stringify(buildRequestBody(chatMessages)),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -150,6 +165,20 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chatEndpoint) {
|
||||
const rawText = await res.text();
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
|
||||
next[idx] = { ...next[idx], content: formatNonChatResponse(rawText) };
|
||||
return next;
|
||||
});
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
setLoading(false);
|
||||
streamMetrics.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
let firstChunk = true;
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts
|
||||
//
|
||||
// #10592 — ChatTab.tsx hardcoded every "Send" click to POST /api/v1/chat/completions,
|
||||
// ignoring configState.endpoint entirely. Selecting a search-only provider (exa-search,
|
||||
// tavily-search, serper-search) in the Endpoint selector still sent a chat.completions
|
||||
// request, which has no notion of search-provider credentials and 404s.
|
||||
//
|
||||
// This module gives ChatTab a small, testable seam for routing non-chat endpoints
|
||||
// (currently "search" and "web.fetch") to their real path with a query-shaped body,
|
||||
// instead of the chat.completions messages/SSE shape.
|
||||
|
||||
import { endpointToPath, type PlaygroundEndpoint } from "@/lib/playground/codeExport";
|
||||
|
||||
/** Chat-shaped endpoints keep the existing messages[] + SSE-delta request/response flow. */
|
||||
export function isChatCompletionsEndpoint(endpoint: PlaygroundEndpoint | undefined): boolean {
|
||||
return !endpoint || endpoint === "chat.completions";
|
||||
}
|
||||
|
||||
/** Resolves the fetch path (mounted under `/api`) for the selected Playground endpoint. */
|
||||
export function resolveChatTabRequestPath(endpoint: PlaygroundEndpoint | undefined): string {
|
||||
return `/api${endpointToPath(endpoint ?? "chat.completions")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the request body for a non-chat endpoint from the user's free-text query.
|
||||
* "search" and "web.fetch" both take a single string field instead of a messages array.
|
||||
*/
|
||||
export function buildNonChatRequestBody(
|
||||
endpoint: PlaygroundEndpoint | undefined,
|
||||
query: string,
|
||||
model: string
|
||||
): Record<string, unknown> {
|
||||
if (endpoint === "web.fetch") {
|
||||
return { url: query };
|
||||
}
|
||||
const body: Record<string, unknown> = { query };
|
||||
if (model) body.model = model;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Renders a non-chat endpoint's raw response text as a chat-bubble-friendly string. */
|
||||
export function formatNonChatResponse(rawText: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as unknown;
|
||||
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
|
||||
} catch {
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the most recent user-authored message content to use as a non-chat query. */
|
||||
export function lastUserContent(
|
||||
chatMessages: Array<{ role: string; content: string }>
|
||||
): string {
|
||||
for (let i = chatMessages.length - 1; i >= 0; i--) {
|
||||
if (chatMessages[i].role === "user") return chatMessages[i].content;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/playground/types", () => ({ getModelPricing: () => null }));
|
||||
vi.mock("@/lib/playground/streamMetrics", () => ({
|
||||
computeMetrics: () => ({ ttftMs: 100, totalMs: 500, tokensIn: 10, tokensOut: 20, tps: 40, costUsd: 0.001 }),
|
||||
}));
|
||||
vi.mock("remark-gfm", () => ({ default: () => {} }));
|
||||
vi.mock("react-markdown", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="markdown-content">{children}</div>,
|
||||
}));
|
||||
if (typeof Element.prototype.scrollIntoView === "undefined") {
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", { value: () => {}, writable: true, configurable: true });
|
||||
}
|
||||
function setInputValue(el: HTMLTextAreaElement | HTMLInputElement, value: string): void {
|
||||
const nativeSetter =
|
||||
el instanceof HTMLTextAreaElement
|
||||
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
|
||||
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeSetter?.call(el, value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
const { DEFAULT_PARAMS } = await import("../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders");
|
||||
const { default: ChatTab } = await import("../../../src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab");
|
||||
function makeSearchProviderConfig() {
|
||||
return {
|
||||
endpoint: "search" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "exa-search/web",
|
||||
provider: "exa-search",
|
||||
systemPrompt: "",
|
||||
params: { ...DEFAULT_PARAMS },
|
||||
};
|
||||
}
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
function renderChatTab(config: ReturnType<typeof makeSearchProviderConfig>): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ChatTab configState={config} />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
async function waitFor(fn: () => boolean, timeout = 3000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
describe("ChatTab — search-provider endpoint routing (#10592)", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("routes to /api/v1/search (not /api/v1/chat/completions) when configState.endpoint is 'search'", async () => {
|
||||
let capturedUrl: string | null = null;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } }
|
||||
);
|
||||
});
|
||||
const el = renderChatTab(makeSearchProviderConfig());
|
||||
const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
|
||||
act(() => {
|
||||
setInputValue(textarea, "latest news India");
|
||||
});
|
||||
const sendBtn = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("Send")
|
||||
) as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
sendBtn?.click();
|
||||
});
|
||||
await waitFor(() => capturedUrl !== null);
|
||||
expect(capturedUrl).toBe("/api/v1/search");
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user