From 56f52d42f02ff53fe08ee2edd7ba648cc7390671 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 2 Jul 2026 14:17:20 -0300 Subject: [PATCH] fix(providers): Perplexity Web emits real tool_calls in streaming mode (mirror chatgpt-web toolMode) (#5927) Perplexity Web (Pro/Max) only converted {...} text into OpenAI tool_calls for non-streaming requests (hasTools && !stream). Streaming requests -- the default for agentic coding clients -- got the raw text as plain delta.content and never emitted a tool_calls SSE delta, so clients could not execute tools. Reuses the provider-agnostic buildToolModeResponse()/ toolCompletionToSseStream() helpers already shipped for chatgpt-web (#5240): when tools are requested, buffer the full completion and convert it into either a JSON completion or a terminal SSE replay carrying delta.tool_calls + finish_reason: tool_calls, regardless of the caller's stream flag. Extended buildToolModeResponse()'s idSeed to be caller-supplied (default 'cgpt', perplexity-web passes 'pplx') so tool_call ids stay provider-specific without duplicating the helper. Non-tool streaming is unchanged (still lives token-by-token via buildStreamingResponse). --- open-sse/executors/chatgptWebTools.ts | 32 ++-- open-sse/executors/perplexity-web.ts | 51 +++--- ...erplexity-web-streaming-tools-5927.test.ts | 167 ++++++++++++++++++ 3 files changed, 211 insertions(+), 39 deletions(-) create mode 100644 tests/unit/perplexity-web-streaming-tools-5927.test.ts diff --git a/open-sse/executors/chatgptWebTools.ts b/open-sse/executors/chatgptWebTools.ts index 4a52506b33f5..55a1c5be911b 100644 --- a/open-sse/executors/chatgptWebTools.ts +++ b/open-sse/executors/chatgptWebTools.ts @@ -1,13 +1,16 @@ -// Tool-call emulation helpers for the ChatGPT Web executor (#5240). +// Tool-call emulation helpers for web-cookie executors (#5240, #5927). // -// chatgpt.com has no native function calling. When the OpenAI request carries -// `tools`, the prompt-side shim (`prepareToolMessages` in -// ../translator/webTools.ts) injects a `` contract; on the response side -// we parse `{...}` blocks back into OpenAI `tool_calls` — -// mirroring the sibling web-session executors (qwen-web, perplexity-web, ...). +// Web-cookie providers (chatgpt-web, perplexity-web, ...) have no native +// function calling. When the OpenAI request carries `tools`, the prompt-side +// shim (`prepareToolMessages` in ../translator/webTools.ts) injects a `` +// contract; on the response side we parse `{...}` blocks back +// into OpenAI `tool_calls`. // -// The whole tool-mode orchestration lives here so the (frozen) chatgpt-web.ts -// only gains an import + a single delegating call. +// The whole tool-mode orchestration lives here — provider-agnostic — so each +// (frozen) executor only gains an import + a single delegating call. Despite +// the filename (kept for git-blame continuity from #5240, the first caller), +// this module is shared: `buildToolModeResponse()` accepts an `idSeed` so +// every provider gets its own `tool_calls[].id` prefix. import { buildToolAwareResult } from "../translator/webTools.ts"; @@ -28,7 +31,8 @@ function sseChunk(data: unknown): string { */ async function applyToolCallsToJsonResponse( response: Response, - requestedTools: unknown + requestedTools: unknown, + idSeed: string ): Promise { const bodyText = await response.text(); try { @@ -37,7 +41,7 @@ async function applyToolCallsToJsonResponse( const { content, toolCalls, finishReason } = buildToolAwareResult( rawContent, requestedTools, - "cgpt" + idSeed ); if (toolCalls) { json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; @@ -107,9 +111,13 @@ export async function buildToolModeResponse( bufferedJson: Response, requestedTools: unknown, stream: boolean, - meta: { cid: string; created: number; model: string } + meta: { cid: string; created: number; model: string; idSeed?: string } ): Promise { - const jsonResponse = await applyToolCallsToJsonResponse(bufferedJson, requestedTools); + const jsonResponse = await applyToolCallsToJsonResponse( + bufferedJson, + requestedTools, + meta.idSeed ?? "cgpt" + ); if (!stream) return jsonResponse; const completion = await jsonResponse.json(); return new Response(toolCompletionToSseStream(completion, meta.cid, meta.created, meta.model), { diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 9b4397fae401..2e3ea7dea595 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -13,7 +13,8 @@ import { TlsClientUnavailableError, type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; -import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { prepareToolMessages } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; @@ -965,8 +966,29 @@ export class PerplexityWebExecutor extends BaseExecutor { const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); + // Tool mode buffers the full completion (no live token streaming) and + // converts text into real tool_calls — even when the caller asked + // for a streaming response — mirroring chatgpt-web's toolMode (#5240, + // #5927). Without this, streaming requests (the default for agentic + // coding clients) never emitted a tool_calls SSE delta. let finalResponse: Response; - if (stream) { + if (hasTools) { + const bufferedJson = await buildNonStreamingResponse( + response.body, + model, + cid, + created, + parsed.history, + parsed.currentMsg, + signal + ); + finalResponse = await buildToolModeResponse(bufferedJson, requestedTools, stream, { + cid, + created, + model, + idSeed: "pplx", + }); + } else if (stream) { const sseStream = buildStreamingResponse( response.body, model, @@ -996,31 +1018,6 @@ export class PerplexityWebExecutor extends BaseExecutor { ); } - if (hasTools && !stream) { - const bodyText = await (finalResponse as Response).text(); - try { - const json = JSON.parse(bodyText); - const rawContent = json?.choices?.[0]?.message?.content || ""; - const { content, toolCalls, finishReason } = buildToolAwareResult( - rawContent, - requestedTools, - "pplx" - ); - if (toolCalls) { - json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls }; - json.choices[0].finish_reason = finishReason; - } else { - json.choices[0].message.content = content; - } - finalResponse = new Response(JSON.stringify(json), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch { - /* keep original response */ - } - } - return { response: finalResponse, url: PPLX_SSE_ENDPOINT, diff --git a/tests/unit/perplexity-web-streaming-tools-5927.test.ts b/tests/unit/perplexity-web-streaming-tools-5927.test.ts new file mode 100644 index 000000000000..8e867e6262c0 --- /dev/null +++ b/tests/unit/perplexity-web-streaming-tools-5927.test.ts @@ -0,0 +1,167 @@ +// Tool-call emulation for the Perplexity Web executor in STREAMING mode (#5927). +// +// perplexity-web.ts converts {...} text into real OpenAI tool_calls +// only for non-streaming requests (the `hasTools && !stream` gate). Streaming +// requests — the default for agentic coding clients — got the raw text +// as plain delta.content and never emitted a tool_calls SSE delta, so clients +// could not execute tools. These tests live in a dedicated file mirroring +// tests/unit/chatgpt-web-tools-5240.test.ts (the reference fix for chatgpt-web). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts"); +const { __setTlsFetchOverrideForTesting } = await import( + "../../open-sse/services/perplexityTlsClient.ts" +); + +// ─── Helper: Build a mock SSE stream from Perplexity events ───────────────── + +function mockPplxStream(events: unknown[]) { + const encoder = new TextEncoder(); + const chunks: string[] = []; + for (const evt of events) { + chunks.push(`event: message\r\ndata: ${JSON.stringify(evt)}\r\n\r\n`); + } + chunks.push("event: end_of_stream\r\n\r\n"); + const body = chunks.join(""); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +function installMockFetch(streamEvents: unknown[]) { + __setTlsFetchOverrideForTesting(async () => { + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: mockPplxStream(streamEvents), + }; + }); + return () => __setTlsFetchOverrideForTesting(null); +} + +const WEATHER_TOOL = { + type: "function", + function: { + name: "write_file", + description: "Write a file to disk", + parameters: { + type: "object", + properties: { path: { type: "string" }, content: { type: "string" } }, + required: ["path", "content"], + }, + }, +}; + +const TOOL_CALL_TEXT = + '{"name":"write_file","arguments":{"path":"a.ts","content":"x"}}'; + +function toolEvents(text: string) { + return [ + { + backend_uuid: "tool-uuid-1", + blocks: [ + { + intended_usage: "markdown", + markdown_block: { chunks: [text], progress: "DONE" }, + }, + ], + status: "COMPLETED", + }, + ]; +} + +test("Tools stream: text becomes delta.tool_calls + finish_reason tool_calls, NOT raw content (#5927)", async () => { + const restore = installMockFetch(toolEvents(TOOL_CALL_TEXT)); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { + messages: [{ role: "user", content: "write a file" }], + tools: [WEATHER_TOOL], + stream: true, + }, + stream: true, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + } as any); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + + const text = await result.response.text(); + const chunks = text + .split("\n") + .filter((l) => l.startsWith("data: ") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice(6))); + + // Must NOT leak raw text as plain content. + assert.ok( + chunks.every((c) => { + const content = c.choices?.[0]?.delta?.content; + return typeof content !== "string" || !content.includes(""); + }), + "no chunk contains raw text in delta.content" + ); + + const toolChunk = chunks.find((c) => c.choices[0].delta && c.choices[0].delta.tool_calls); + assert.ok(toolChunk, "a chunk carries delta.tool_calls"); + assert.equal(toolChunk.choices[0].finish_reason, "tool_calls"); + const tc = toolChunk.choices[0].delta.tool_calls; + assert.ok(Array.isArray(tc) && tc.length === 1); + assert.equal(tc[0].type, "function"); + assert.equal(tc[0].function.name, "write_file"); + assert.equal(typeof tc[0].function.arguments, "string", "arguments is a JSON string"); + assert.deepEqual(JSON.parse(tc[0].function.arguments), { path: "a.ts", content: "x" }); + + const lastLine = text.trim().split("\n").filter(Boolean).pop(); + assert.equal(lastLine, "data: [DONE]"); + } finally { + restore(); + } +}); + +test("Tools regression: streaming request with NO tools still streams plain content unchanged (#5927)", async () => { + const restore = installMockFetch(toolEvents("Just plain text, no tools.")); + try { + const executor = new PerplexityWebExecutor(); + const result = await executor.execute({ + model: "pplx-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: true }, + stream: true, + credentials: { apiKey: "test-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + } as any); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + const chunks = text + .split("\n") + .filter((l) => l.startsWith("data: ") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice(6))); + + let assembled = ""; + for (const c of chunks) { + const content = c.choices?.[0]?.delta?.content; + if (content) assembled += content; + } + assert.equal(assembled, "Just plain text, no tools."); + + assert.ok( + chunks.every((c) => !(c.choices[0].delta && c.choices[0].delta.tool_calls)), + "no tool_calls emitted without a tools array" + ); + const finishChunk = chunks.find((c) => c.choices[0].finish_reason); + assert.equal(finishChunk.choices[0].finish_reason, "stop"); + } finally { + restore(); + } +});