Compare commits

..

1 Commits

Author SHA1 Message Date
Markus Hartung
018badc3b3 fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592) 2026-08-20 20:30:40 -03:00
7 changed files with 198 additions and 77 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

@@ -1 +0,0 @@
- fix(cli): repair hollow externalized package dirs in the nested `<distDir>/node_modules` bundle location too, not just the top-level one, fixing macOS/Linux Electron `ERR_MODULE_NOT_FOUND` on Turbopack-externalized packages (#7346)

View File

@@ -628,11 +628,12 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
* This keeps the fix narrowly scoped to packages the standalone already expects.
*
* @param {string} projectRoot
* @param {string} bundleNodeModules
* @param {string} resolvedOutDir
* @returns {{repaired: number, packages: string[]}}
*/
function repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules) {
function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) {
const summary = { repaired: 0, packages: [] };
const bundleNodeModules = path.join(resolvedOutDir, "node_modules");
const sourceNodeModules = path.join(projectRoot, "node_modules");
if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) {
return summary;
@@ -898,23 +899,12 @@ export function assembleStandalone({
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
// Repair hollow externalized package dirs in BOTH locations Turbopack's standalone
// tracer can populate: the top-level bundle node_modules, and — for projects with a
// custom distDir (see next.config.mjs) — the nested <relDistDir>/node_modules mirrored
// alongside the traced server chunks. materializeBundledSymlinks (step 7 below) already
// treats these as two distinct targets; #9913 only covered the top-level one, which left
// the nested location's hollow dirs unrepaired (#7346).
for (const bundleNodeModules of [
path.join(resolvedOutDir, "node_modules"),
path.join(resolvedOutDir, relDistDir, "node_modules"),
]) {
const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, bundleNodeModules);
if (emptyPkgRepair.repaired > 0) {
console.log(
`[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s) in ` +
`${path.relative(resolvedOutDir, bundleNodeModules) || "."}: ${emptyPkgRepair.packages.join(", ")}`
);
}
const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir);
if (emptyPkgRepair.repaired > 0) {
console.log(
`[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s): ` +
emptyPkgRepair.packages.join(", ")
);
}
// #9166: dynamically imported LLMLingua packages are not reliably traced

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

@@ -1,55 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { assembleStandalone } from "../../../scripts/build/assembleStandalone.mjs";
// #7346: on macOS (and Linux AppImage) Electron builds, Turbopack's standalone tracer can leave
// a hollow (directory exists, contains zero files) externalized-package directory behind. #9913
// added `repairEmptyExternalPackageDirs` to overlay the real source package on top of a hollow
// bundle dir — but it only scans the TOP-LEVEL `<outDir>/node_modules`. This project builds with
// a custom, non-default `distDir` (".build/next", see next.config.mjs), and Next's standalone
// tracer also emits a SECOND, nested `node_modules` under `<outDir>/<relDistDir>/node_modules`
// (the same location `materializeBundledSymlinks` already treats as a distinct target — see
// assembleStandalone() step 7). A hollow externalized package dir landing in that nested
// location is never repaired, which reproduces the exact ERR_MODULE_NOT_FOUND class reported on
// #7346 even after #6794/#7353/#9913 all landed.
test("assembleStandalone repairs a hollow externalized package dir in the nested <distDir> node_modules, not just the top-level one", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repair-nested-empty-pkg-"));
const projectRoot = path.join(tmp, "project");
const relDistDir = ".build/next";
const distDir = path.join(projectRoot, relDistDir);
const outDir = path.join(tmp, "dist");
// Real source package the repair should copy from.
const sourcePkgDir = path.join(projectRoot, "node_modules", "some-nested-pkg");
fs.mkdirSync(sourcePkgDir, { recursive: true });
fs.writeFileSync(path.join(sourcePkgDir, "package.json"), '{"name":"some-nested-pkg"}');
fs.writeFileSync(path.join(sourcePkgDir, "index.js"), "module.exports = {};");
// Fake standalone tree with a hollow externalized package dir under the NESTED
// <relDistDir>/node_modules (directory exists but contains zero files — the exact
// "hollow" shape repairEmptyExternalPackageDirs already repairs at the top level).
const standaloneDir = path.join(distDir, "standalone");
fs.mkdirSync(standaloneDir, { recursive: true });
fs.writeFileSync(path.join(standaloneDir, "server.js"), "// server");
const hollowNestedPkgDir = path.join(standaloneDir, relDistDir, "node_modules", "some-nested-pkg");
fs.mkdirSync(hollowNestedPkgDir, { recursive: true });
assembleStandalone({
distDir,
outDir,
projectRoot,
copyNatives: true,
});
const repairedIndexPath = path.join(outDir, relDistDir, "node_modules", "some-nested-pkg", "index.js");
assert.ok(
fs.existsSync(repairedIndexPath),
"hollow nested externalized package dir must be repaired with the real source package (index.js present)"
);
fs.rmSync(tmp, { recursive: true, force: true });
});

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