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
32 changes: 20 additions & 12 deletions open-sse/executors/chatgptWebTools.ts
Original file line number Diff line number Diff line change
@@ -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 `<tool>` contract; on the response side
// we parse `<tool>{...}</tool>` 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 `<tool>`
// contract; on the response side we parse `<tool>{...}</tool>` 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";

Expand All @@ -28,7 +31,8 @@ function sseChunk(data: unknown): string {
*/
async function applyToolCallsToJsonResponse(
response: Response,
requestedTools: unknown
requestedTools: unknown,
idSeed: string
): Promise<Response> {
const bodyText = await response.text();
try {
Expand All @@ -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 };
Expand Down Expand Up @@ -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<Response> {
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), {
Expand Down
51 changes: 24 additions & 27 deletions open-sse/executors/perplexity-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <tool> 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,
Expand Down Expand Up @@ -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,
Expand Down
167 changes: 167 additions & 0 deletions tests/unit/perplexity-web-streaming-tools-5927.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Tool-call emulation for the Perplexity Web executor in STREAMING mode (#5927).
//
// perplexity-web.ts converts <tool>{...}</tool> 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 <tool> 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 =
'<tool>{"name":"write_file","arguments":{"path":"a.ts","content":"x"}}</tool>';

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: <tool> text becomes delta.tool_calls + finish_reason tool_calls, NOT raw <tool> 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 <tool> text as plain content.
assert.ok(
chunks.every((c) => {
const content = c.choices?.[0]?.delta?.content;
return typeof content !== "string" || !content.includes("<tool>");
}),
"no chunk contains raw <tool> 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();
}
});
Loading