Skip to content
Closed
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
41 changes: 34 additions & 7 deletions open-sse/executors/duckduckgo-web.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { serializeToolsToPrompt, parseToolCallsFromText } from "../translator/webTools.ts";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the Repository Style Guide (Rule 9), you must always include tests when changing production code under open-sse/. Please add unit/integration tests for the new tool-call translation logic in the tests/ directory.

References
  1. Always include tests when changing production code (src/, open-sse/, electron/, bin/). (link)


export const DUCKDUCKGO_BASE = "https://duckduckgo.com";
const STATUS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/status`;
Expand Down Expand Up @@ -64,7 +65,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}

async execute(input: ExecuteInput) {
const { model, messages, stream, signal, upstreamHeaders } = input;
const { model, messages, stream, signal, upstreamHeaders, body } = input;

if (!messages || messages.length === 0) {
return new Response(
Expand All @@ -73,6 +74,14 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
);
}

const bodyObj = (body || {}) as Record<string, unknown>;
const requestedTools = bodyObj.tools;
const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0;
const toolSystemPrompt = hasTools ? serializeToolsToPrompt(requestedTools) : "";
const effectiveMessages = toolSystemPrompt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unconditionally prepending a system message can collide with an existing system message in the user's messages array. If messages[0].role === "system", the tool instructions will precede the user's intended system prompt, potentially causing the upstream model to process them in an unintended order or ignore the user's system message entirely. Consider merging tool instructions into the existing system message if one is present, or appending after any existing system message.

? [{ role: "system", content: toolSystemPrompt }, ...messages]
: messages;

// Acquire session from pool for fingerprint rotation
const pool = this.getPool();
let session;
Expand Down Expand Up @@ -117,7 +126,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
},
body: JSON.stringify({
model,
messages,
messages: effectiveMessages,
stream: stream !== false,
}),
signal: mergedSignal,
Expand Down Expand Up @@ -145,13 +154,13 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
},
body: JSON.stringify({
model,
messages,
messages: effectiveMessages,
stream: stream !== false,
}),
signal: mergedSignal,
});

return this.processResponse(retryResponse, stream !== false);
return this.processResponse(retryResponse, stream !== false, hasTools, requestedTools);
}
return new Response(
JSON.stringify({ error: { message: "Service unavailable" } }),
Expand All @@ -167,7 +176,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
);
}

const result = this.processResponse(chatResponse, stream !== false);
const result = this.processResponse(chatResponse, stream !== false, hasTools, requestedTools);

// Report pool status based on response
if (pool && session) {
Expand Down Expand Up @@ -222,7 +231,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
}

private async processResponse(response: Response, streaming: boolean): Promise<Response> {
private async processResponse(response: Response, streaming: boolean, hasTools?: boolean, requestedTools?: unknown): Promise<Response> {
if (!response.ok) {
const body = await response.text();
return new Response(body, {
Expand All @@ -231,7 +240,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
});
}

if (streaming) {
if (streaming && !hasTools) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: When stream !== false and tools are present, this condition bypasses the SSE streaming path entirely and returns a buffered non-streaming JSON response. The client's stream=true request is silently downgraded to a single JSON payload, breaking the streaming contract for clients that always expect SSE chunks. Tool calls should either be streamed as SSE delta events or the executor should reject stream=true with tools rather than silently changing the response format.

const reader = response.body?.getReader();
if (!reader) {
return new Response(
Expand Down Expand Up @@ -304,6 +313,24 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
}
}

if (hasTools) {
const { content: cleanedContent, toolCalls } = parseToolCallsFromText(
fullContent, `call-${Date.now()}`, requestedTools
);
const choice: Record<string, unknown> = {
index: 0,
message: { role: "assistant", content: cleanedContent },
finish_reason: toolCalls ? "tool_calls" : "stop",
};
if (toolCalls) {
(choice.message as Record<string, unknown>).tool_calls = toolCalls;
(choice.message as Record<string, unknown>).content = null;
}
return new Response(JSON.stringify({ choices: [choice] }), {
headers: { "Content-Type": "application/json" },
});
}
Comment on lines +316 to +332

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When streaming is true and hasTools is true, returning a standard JSON response with Content-Type: application/json will break clients that expect an SSE stream (text/event-stream). Instead, if streaming is true, we should format the tool calls (or plain text) as SSE chunks and return a text/event-stream response.

      if (hasTools) {
        const { content: cleanedContent, toolCalls } = parseToolCallsFromText(
          fullContent,
          "call-" + Date.now(),
          requestedTools
        );
        if (streaming) {
          const chunks: string[] = [];
          if (toolCalls) {
            chunks.push(JSON.stringify({
              choices: [{
                index: 0,
                delta: { role: "assistant", content: null, tool_calls: toolCalls },
                finish_reason: null
              }]
            }));
            chunks.push(JSON.stringify({
              choices: [{
                index: 0,
                delta: {},
                finish_reason: "tool_calls"
              }]
            }));
          } else {
            if (cleanedContent) {
              chunks.push(JSON.stringify({
                choices: [{
                  index: 0,
                  delta: { role: "assistant", content: cleanedContent },
                  finish_reason: null
                }]
              }));
            }
            chunks.push(JSON.stringify({
              choices: [{
                index: 0,
                delta: {},
                finish_reason: "stop"
              }]
            }));
          }
          const sseData = chunks.map(function(c) { return "data: " + c + "\n\n"; }).join("") + "data: [DONE]\n\n";
          return new Response(sseData, {
            headers: { "Content-Type": "text/event-stream" },
          });
        } else {
          const choice: Record<string, unknown> = {
            index: 0,
            message: { role: "assistant", content: cleanedContent },
            finish_reason: toolCalls ? "tool_calls" : "stop",
          };
          if (toolCalls) {
            (choice.message as Record<string, unknown>).tool_calls = toolCalls;
            (choice.message as Record<string, unknown>).content = null;
          }
          return new Response(JSON.stringify({ choices: [choice] }), {
            headers: { "Content-Type": "application/json" },
          });
        }
      }


const openaiResponse = {
choices: [
{
Expand Down