From 018badc3b38684351ba02de64846c5016cdc04fe Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Thu, 20 Aug 2026 20:30:40 -0300 Subject: [PATCH] fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592) --- ...592-playground-chattab-endpoint-routing.md | 1 + .../playground/components/tabs/ChatTab.tsx | 33 ++++++- .../components/tabs/chatTabEndpointRequest.ts | 59 +++++++++++ ...nd-chat-tab-search-endpoint-10592.test.tsx | 98 +++++++++++++++++++ 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/10592-playground-chattab-endpoint-routing.md create mode 100644 src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts create mode 100644 tests/unit/ui/playground-chat-tab-search-endpoint-10592.test.tsx diff --git a/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md b/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md new file mode 100644 index 0000000000..ca602b9122 --- /dev/null +++ b/changelog.d/fixes/10592-playground-chattab-endpoint-routing.md @@ -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) diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx index 8f048ee02d..565e667cf1 100644 --- a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx @@ -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 = { "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(); diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts b/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts new file mode 100644 index 0000000000..dda2b7825a --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts @@ -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 { + if (endpoint === "web.fetch") { + return { url: query }; + } + const body: Record = { 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 ""; +} diff --git a/tests/unit/ui/playground-chat-tab-search-endpoint-10592.test.tsx b/tests/unit/ui/playground-chat-tab-search-endpoint-10592.test.tsx new file mode 100644 index 0000000000..e38272967d --- /dev/null +++ b/tests/unit/ui/playground-chat-tab-search-endpoint-10592.test.tsx @@ -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 }) =>
{children}
, +})); +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; el: HTMLDivElement }> = []; +function renderChatTab(config: ReturnType): HTMLDivElement { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render(); + }); + containers.push({ root, el }); + return el; +} +async function waitFor(fn: () => boolean, timeout = 3000): Promise { + 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(); + }); +});