Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "";
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading