mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 14:42:20 +03:00
Compare commits
1 Commits
fix/7346-m
...
fix/10592-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018badc3b3 |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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