fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592)

This commit is contained in:
Markus Hartung
2026-08-20 20:30:40 -03:00
parent bc9090ba65
commit 018badc3b3
4 changed files with 189 additions and 2 deletions

View File

@@ -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)

View File

@@ -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();

View File

@@ -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 "";
}

View File

@@ -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();
});
});