diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index 898b659220..b144bd8267 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -1,4 +1,5 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; const ADAPTA_APP_URL = "https://agent.adapta.one"; const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one"; @@ -352,6 +353,7 @@ export class AdaptaWebExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { const bodyObj = (body ?? {}) as Record; const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as OpenAIMessage[]; + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages); // 1. Extract and validate credentials const rawKey = String((credentials as Record)?.apiKey ?? ""); @@ -385,7 +387,7 @@ export class AdaptaWebExecutor extends BaseExecutor { // 2. Build Adapta request body const aiModelId = MODEL_ID_MAP[model] ?? DEFAULT_AI_MODEL_ID; - const adaptaMessages = buildAdaptaMessages(messages); + const adaptaMessages = buildAdaptaMessages(effectiveMessages); if (adaptaMessages.length === 0) { return { @@ -488,6 +490,35 @@ export class AdaptaWebExecutor extends BaseExecutor { reader.releaseLock(); } + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult(fullText, requestedTools, "adp"); + if (toolCalls) { + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-adp-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model, + choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: ADAPTA_STREAM_URL, headers, transformedBody: requestPayload, + }; + } + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-adp-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: ADAPTA_STREAM_URL, headers, transformedBody: requestPayload, + }; + } + return { response: new Response( JSON.stringify({ diff --git a/open-sse/executors/blackbox-web.ts b/open-sse/executors/blackbox-web.ts index cecd84fa80..3a96aa6773 100644 --- a/open-sse/executors/blackbox-web.ts +++ b/open-sse/executors/blackbox-web.ts @@ -6,6 +6,7 @@ import { } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; const BLACKBOX_CHAT_API = "https://app.blackbox.ai/api/chat"; const BLACKBOX_DEFAULT_COOKIE = "next-auth.session-token"; @@ -302,7 +303,8 @@ export class BlackboxWebExecutor extends BaseExecutor { log, upstreamExtraHeaders, }: ExecuteInput) { - const messages = (body as Record).messages as + const bodyObj = (body || {}) as Record; + const messages = bodyObj.messages as | Array> | undefined; if (!messages || !Array.isArray(messages) || messages.length === 0) { @@ -323,8 +325,9 @@ export class BlackboxWebExecutor extends BaseExecutor { }; } + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages as Array<{ role: string; content: unknown }>); const chatId = crypto.randomUUID().slice(0, 7); - const parsedMessages = parseOpenAIMessages(messages, chatId); + const parsedMessages = parseOpenAIMessages(effectiveMessages, chatId); if (parsedMessages.length === 0) { const errorResponse = new Response( JSON.stringify({ @@ -645,6 +648,27 @@ export class BlackboxWebExecutor extends BaseExecutor { const id = `chatcmpl-blackbox-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult(responseText, requestedTools, "bbx"); + if (toolCalls) { + const toolResponse = new Response( + JSON.stringify({ + id, object: "chat.completion", created, model, + choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + return { response: toolResponse, url: BLACKBOX_CHAT_API, headers, transformedBody }; + } + const finalResponse = stream + ? new Response(buildStreamingResponse(content, model, id, created), { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + }) + : buildNonStreamingResponse(content, model, id, created); + return { response: finalResponse, url: BLACKBOX_CHAT_API, headers, transformedBody }; + } + const finalResponse = stream ? new Response(buildStreamingResponse(responseText, model, id, created), { status: 200, diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 0cea817dec..0986753db3 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -1,5 +1,6 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; export const DUCKDUCKGO_BASE = "https://duckduckgo.com"; const STATUS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/status`; @@ -64,7 +65,16 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - const { model, messages, stream, signal, upstreamHeaders } = input; + const { model, body, stream, signal } = input; + const bodyObj = (body || {}) as Record; + const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; + + if (signal?.aborted) { + return new Response( + JSON.stringify({ error: { message: "Request cancelled" } }), + { status: 499, headers: { "Content-Type": "application/json" } } + ); + } if (!messages || messages.length === 0) { return new Response( @@ -73,6 +83,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { ); } + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages); + // Acquire session from pool for fingerprint rotation const pool = this.getPool(); let session; @@ -117,7 +129,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { }, body: JSON.stringify({ model, - messages, + messages: effectiveMessages, stream: stream !== false, }), signal: mergedSignal, @@ -145,13 +157,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" } }), @@ -167,7 +179,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) { @@ -222,7 +234,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } } - private async processResponse(response: Response, streaming: boolean): Promise { + private async processResponse(response: Response, streaming: boolean, hasTools?: boolean, requestedTools?: unknown): Promise { if (!response.ok) { const body = await response.text(); return new Response(body, { @@ -231,7 +243,7 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { }); } - if (streaming) { + if (streaming && !hasTools) { const reader = response.body?.getReader(); if (!reader) { return new Response( @@ -304,6 +316,15 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } } + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult(fullContent, requestedTools, "ddg"); + const message: Record = { role: "assistant", content }; + if (toolCalls) { message.tool_calls = toolCalls; message.content = null; } + return new Response(JSON.stringify({ choices: [{ index: 0, message, finish_reason: finishReason }] }), { + headers: { "Content-Type": "application/json" }, + }); + } + const openaiResponse = { choices: [ { diff --git a/open-sse/executors/inner-ai.ts b/open-sse/executors/inner-ai.ts index 04437de585..c6ad4ee6da 100644 --- a/open-sse/executors/inner-ai.ts +++ b/open-sse/executors/inner-ai.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; const INNER_AI_CHAT_URL = "https://chatapi.innerai.com/chat"; @@ -599,7 +600,8 @@ export class InnerAiExecutor extends BaseExecutor { // Build message content from OpenAI messages array const rawMessages = Array.isArray(bodyObj.messages) ? bodyObj.messages : []; - const messages = rawMessages as Array>; + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages); + const messages = effectiveMessages as Array>; const messageContent = buildMessageContent(messages); if (!messageContent.trim()) { return makeErrorResult(400, "No message content to send", body); @@ -693,6 +695,25 @@ export class InnerAiExecutor extends BaseExecutor { throw err; } const completionId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + if (hasTools) { + const { content: cleaned, toolCalls, finishReason } = buildToolAwareResult(content, requestedTools, "inner"); + if (toolCalls) { + return { + response: new Response( + JSON.stringify({ + id: completionId, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model: resolvedModel, + choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: INNER_AI_CHAT_URL, headers: reqHeaders, transformedBody: innerAiBody, + }; + } + content = cleaned; + } + return { response: new Response( JSON.stringify({ diff --git a/open-sse/executors/muse-spark-web.ts b/open-sse/executors/muse-spark-web.ts index dc283c2e62..ec9a7118b0 100644 --- a/open-sse/executors/muse-spark-web.ts +++ b/open-sse/executors/muse-spark-web.ts @@ -8,6 +8,7 @@ import { } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; import { normalizeSessionCookieHeader, normalizeSessionCookieHeaders, @@ -1144,18 +1145,20 @@ function rememberAssistantTurn( }); } -function buildSuccessResult( +async function buildSuccessResult( parsed: ParsedMetaAiResponse, stream: boolean, model: string, headers: Record, - transformedBody: unknown -): MuseSparkExecuteResult { + transformedBody: unknown, + hasTools?: boolean, + requestedTools?: unknown +): Promise { const id = `chatcmpl-meta-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); const deltas = parsed.deltas.length > 0 ? parsed.deltas : [parsed.content]; const reasoningDeltas = parsed.reasoningDeltas; - const response = stream + let response = stream ? new Response(buildStreamingResponse(deltas, reasoningDeltas, model, id, created), { status: 200, headers: { @@ -1166,6 +1169,24 @@ function buildSuccessResult( }) : buildNonStreamingResponse(parsed.content, parsed.reasoningContent, model, id, created); + if (hasTools && !stream) { + const bodyText = await (response as Response).text(); + try { + const json = JSON.parse(bodyText); + const rawContent = json?.choices?.[0]?.message?.content || ""; + const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "muse"); + 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; + } + response = new Response(JSON.stringify(json), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + } catch { /* keep original response */ } + } + return resultWithResponse(response, headers, transformedBody); } @@ -1183,12 +1204,14 @@ export class MuseSparkWebExecutor extends BaseExecutor { log, upstreamExtraHeaders, }: ExecuteInput) { - const messages = getOpenAiMessages(body); - if (!messages) { + const bodyObj = (body || {}) as Record; + const rawMessages = getOpenAiMessages(body); + if (!rawMessages) { return errorResult(400, "Missing or empty messages array", "invalid_request", {}, body); } - const parsedHistory = parseOpenAIMessages(messages); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages as Array<{ role: string; content: unknown }>); + const parsedHistory = parseOpenAIMessages(effectiveMessages); if (!parsedHistory.foldedPrompt) { return errorResult(400, "Empty query after processing messages", "invalid_request", {}, body); } @@ -1222,7 +1245,10 @@ export class MuseSparkWebExecutor extends BaseExecutor { const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; const fetchResult = await postMetaAiRequest(headers, transformedBody, combinedSignal, log); - if (!fetchResult.ok) return fetchResult.result; + if (!fetchResult.ok) { + const err = fetchResult as { ok: false; result: MuseSparkExecuteResult }; + return err.result; + } const upstreamResponse = fetchResult.response; if (!upstreamResponse.ok) { @@ -1252,6 +1278,6 @@ export class MuseSparkWebExecutor extends BaseExecutor { } rememberAssistantTurn(parsed, credentials, model, parsedHistory, conversationContext); - return buildSuccessResult(parsed, stream, model, headers, transformedBody); + return buildSuccessResult(parsed, stream, model, headers, transformedBody, hasTools, requestedTools); } } diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 787dcf0014..6d694ac59d 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -13,6 +13,7 @@ import { TlsClientUnavailableError, type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; const PPLX_API_VERSION = "client-1.11.0"; @@ -657,10 +658,11 @@ export class PerplexityWebExecutor extends BaseExecutor { } async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { - const messages = (body as Record).messages as + const bodyObj = (body || {}) as Record; + const rawMessages = bodyObj.messages as | Array> | undefined; - if (!messages || !Array.isArray(messages) || messages.length === 0) { + if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) { const errResp = new Response( JSON.stringify({ error: { message: "Missing or empty messages array", type: "invalid_request" }, @@ -670,8 +672,9 @@ export class PerplexityWebExecutor extends BaseExecutor { return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; } + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages as Array<{ role: string; content: unknown }>); + // Resolve thinking mode - const bodyObj = body as Record; const thinking = bodyObj.thinking === true || (bodyObj.reasoning_effort != null && bodyObj.reasoning_effort !== "none"); @@ -691,7 +694,7 @@ export class PerplexityWebExecutor extends BaseExecutor { } // Parse messages and check session continuity - const parsed = parseOpenAIMessages(messages); + const parsed = parseOpenAIMessages(effectiveMessages); const followUpUuid = sessionLookup(parsed.history); if (followUpUuid) { log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); @@ -835,6 +838,24 @@ 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/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 66f8aec0db..67c7f50c1e 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -10,6 +10,7 @@ */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; const BASE_URL = "https://chat.qwen.ai"; const CHAT_URL = `${BASE_URL}/api/chat/completions`; @@ -29,8 +30,10 @@ export class QwenWebExecutor extends BaseExecutor { const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; const modelId = (bodyObj.model as string) || "qwen-plus"; + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages); + const reqBody = { - messages: messages.map((m) => ({ role: m.role, content: m.content })), + messages: effectiveMessages.map((m) => ({ role: m.role, content: String(m.content ?? "") })), model: modelId, stream: wantStream, max_tokens: (bodyObj.max_tokens as number) || 4096, @@ -80,30 +83,86 @@ export class QwenWebExecutor extends BaseExecutor { if (!wantStream) { const data = (await upstream.json()) as Record; - const content = + const rawContent = (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || (data?.content as string) || ""; + + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "qwen"); + const message: Record = { role: "assistant", content }; + if (toolCalls) { message.tool_calls = toolCalls; message.content = null; } + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-qwen-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model: modelId, + choices: [{ index: 0, message, finish_reason: finishReason }], + }), + { headers: { "Content-Type": "application/json" } } + ), + url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, + }; + } + return { response: new Response( JSON.stringify({ - id: `chatcmpl-qwen-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + id: `chatcmpl-qwen-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model: modelId, + choices: [{ index: 0, message: { role: "assistant", content: rawContent }, finish_reason: "stop" }], }), { headers: { "Content-Type": "application/json" } } ), - url: CHAT_URL, - headers: reqHeaders, - transformedBody: reqBody, + url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, }; } // Streaming const encoder = new TextEncoder(); const decoder = new TextDecoder(); + + if (hasTools) { + let fullContent = ""; + const reader = upstream.body?.getReader(); + if (reader) { + let buf = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + for (const line of buf.split("\n")) { + if (!line.startsWith("data:")) continue; + const d = line.slice(5).trim(); + if (d === "[DONE]") continue; + try { fullContent += JSON.parse(d).choices?.[0]?.delta?.content || ""; } catch {} + } + buf = buf.split("\n").pop() || ""; + } + } catch {} + } + + const { content, toolCalls, finishReason } = buildToolAwareResult(fullContent, requestedTools, "qwen"); + const stream = new ReadableStream({ + start(controller) { + const id = `chatcmpl-qwen-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const delta = toolCalls + ? { role: "assistant", content: null, tool_calls: toolCalls } + : { role: "assistant", content }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: modelId, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: modelId, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] })}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return { + response: new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } }), + url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, + }; + } + const stream = new ReadableStream({ async start(controller) { const reader = upstream.body?.getReader(); diff --git a/open-sse/executors/t3-chat-web.ts b/open-sse/executors/t3-chat-web.ts index 027e5e75d5..f01bb6a124 100644 --- a/open-sse/executors/t3-chat-web.ts +++ b/open-sse/executors/t3-chat-web.ts @@ -15,6 +15,7 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -344,10 +345,11 @@ export class T3ChatWebExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { const bodyObj = (body || {}) as Record; - const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{ + const rawMessages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{ role: string; content: string | unknown; }>; + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages); // 1. Parse + validate credentials. The credential pipeline stores the single // pasted string as `apiKey` (fallback `accessToken`); parse out the Cookie // header + convex-session-id (#3007) instead of expecting pre-structured fields. @@ -374,7 +376,7 @@ export class T3ChatWebExecutor extends BaseExecutor { // fields (model, messages, stream) in the request body. const requestPayload: Record = { model, - messages, + messages: effectiveMessages, stream: stream !== false, }; @@ -487,7 +489,37 @@ export class T3ChatWebExecutor extends BaseExecutor { } // Non-streaming: collect all content and return OpenAI JSON - const content = await collectStreamContent(resp.body); + const rawContent = await collectStreamContent(resp.body); + + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "t3"); + if (toolCalls) { + return { + response: new Response( + JSON.stringify({ + id: `chatcmpl-t3-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model: model || "unknown", + choices: [{ index: 0, message: { role: "assistant", content: null, tool_calls: toolCalls }, finish_reason: finishReason }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: completionUrl, headers, transformedBody: requestPayload, + }; + } + const openaiResponse = { + id: `chatcmpl-t3-${Date.now()}`, object: "chat.completion", + created: Math.floor(Date.now() / 1000), model: model || "unknown", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; + return { + response: new Response(JSON.stringify(openaiResponse), { + status: 200, headers: { "Content-Type": "application/json" }, + }), + url: completionUrl, headers, transformedBody: requestPayload, + }; + } + const openaiResponse = { id: `chatcmpl-t3-${Date.now()}`, object: "chat.completion", @@ -496,7 +528,7 @@ export class T3ChatWebExecutor extends BaseExecutor { choices: [ { index: 0, - message: { role: "assistant", content }, + message: { role: "assistant", content: rawContent }, finish_reason: "stop", }, ], diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 406ddaa490..43897927f4 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -435,3 +435,58 @@ export function parseToolCallsFromText( const content = stripRanges(text, acceptedRanges); return { content, toolCalls }; } + +// ── Shared helpers for web-cookie executors ──────────────────────────────── + +interface ToolPrepResult { + hasTools: boolean; + requestedTools: unknown; + effectiveMessages: Array<{ role: string; content: unknown }>; +} + +/** + * Extract tools from an OpenAI request body and prepend a tool-system-prompt + * to the messages array when tools are present. Every web-cookie executor + * that wants tool-call support calls this once before building its upstream + * request body. + */ +export function prepareToolMessages( + bodyObj: Record, + messages: Array<{ role: string; content: unknown }>, +): ToolPrepResult { + const requestedTools = bodyObj.tools; + const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; + if (!hasTools) return { hasTools: false, requestedTools, effectiveMessages: messages }; + + const toolPrompt = serializeToolsToPrompt(requestedTools); + return { + hasTools: true, + requestedTools, + effectiveMessages: [{ role: "system", content: toolPrompt }, ...messages], + }; +} + +interface ToolCompletionResult { + content: string; + toolCalls: OpenAIToolCall[] | null; + finishReason: string; +} + +/** + * Parse tool calls from a model's text response. Returns the cleaned content + * (with `` blocks stripped), the parsed tool calls (or null), and the + * appropriate finish_reason. Every web-cookie executor calls this on the + * collected response text when `hasTools` is true. + */ +export function buildToolAwareResult( + rawContent: string, + requestedTools: unknown, + idSeed = "call", +): ToolCompletionResult { + const { content, toolCalls } = parseToolCallsFromText(rawContent, `${idSeed}-${Date.now()}`, requestedTools); + return { + content, + toolCalls, + finishReason: toolCalls ? "tool_calls" : "stop", + }; +} diff --git a/tests/unit/web-tools-translation.test.ts b/tests/unit/web-tools-translation.test.ts new file mode 100644 index 0000000000..c4c7fdf522 --- /dev/null +++ b/tests/unit/web-tools-translation.test.ts @@ -0,0 +1,104 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + serializeToolsToPrompt, + parseToolCallsFromText, + prepareToolMessages, + buildToolAwareResult, +} from "../../open-sse/translator/webTools.ts"; + +// Regression coverage for the shared web-cookie tool-call translation helpers +// (#3259). These functions back tool-calling for the 8 pure-API web executors +// (adapta-web, blackbox-web, duckduckgo-web, inner-ai, muse-spark-web, +// perplexity-web, qwen-web, t3-chat-web), so the translation contract must hold. + +const WEATHER_TOOL = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +describe("webTools — serializeToolsToPrompt", () => { + test("returns empty string when there are no tools", () => { + assert.equal(serializeToolsToPrompt([]), ""); + assert.equal(serializeToolsToPrompt(undefined), ""); + }); + + test("lists each tool and explains the block contract", () => { + const prompt = serializeToolsToPrompt(WEATHER_TOOL); + assert.ok(prompt.includes("Available tools:")); + assert.ok(prompt.includes("- get_weather: Get the weather for a city")); + assert.ok(prompt.includes(""), "must teach the wrapper contract"); + }); +}); + +describe("webTools — parseToolCallsFromText", () => { + test("parses a block into OpenAI tool_calls and strips it from content", () => { + const text = + 'Sure, let me check.\n{"name": "get_weather", "arguments": {"city": "SP"}}'; + const { content, toolCalls } = parseToolCallsFromText(text, "call", WEATHER_TOOL); + + assert.ok(toolCalls && toolCalls.length === 1, "one tool call expected"); + assert.equal(toolCalls[0].function.name, "get_weather"); + assert.equal(typeof toolCalls[0].function.arguments, "string", "arguments must be a JSON string"); + assert.deepEqual(JSON.parse(toolCalls[0].function.arguments), { city: "SP" }); + assert.ok(!content.includes(""), "the block must be stripped from content"); + }); + + test("returns null tool calls for plain text with no tool block", () => { + const { content, toolCalls } = parseToolCallsFromText("just a normal answer", "call", WEATHER_TOOL); + assert.equal(toolCalls, null); + assert.equal(content, "just a normal answer"); + }); + + test("accepts bare JSON tool calls only when a requested tool set is provided", () => { + const bare = '{"name": "get_weather", "arguments": {"city": "RJ"}}'; + + const withTools = parseToolCallsFromText(bare, "call", WEATHER_TOOL); + assert.ok(withTools.toolCalls && withTools.toolCalls[0].function.name === "get_weather"); + + const withoutTools = parseToolCallsFromText(bare, "call"); + assert.equal(withoutTools.toolCalls, null, "bare JSON must not be parsed without a tools[] set"); + }); +}); + +describe("webTools — prepareToolMessages", () => { + test("prepends a tool system prompt when tools are present", () => { + const messages = [{ role: "user", content: "weather in SP?" }]; + const result = prepareToolMessages({ tools: WEATHER_TOOL }, messages); + + assert.equal(result.hasTools, true); + assert.equal(result.effectiveMessages[0].role, "system"); + assert.ok(String(result.effectiveMessages[0].content).includes("get_weather")); + assert.equal(result.effectiveMessages.length, messages.length + 1); + }); + + test("passes messages through untouched when there are no tools", () => { + const messages = [{ role: "user", content: "hi" }]; + const result = prepareToolMessages({}, messages); + + assert.equal(result.hasTools, false); + assert.equal(result.effectiveMessages, messages); + }); +}); + +describe("webTools — buildToolAwareResult", () => { + test("finish_reason is tool_calls when a call is parsed, else stop", () => { + const called = buildToolAwareResult( + '{"name": "get_weather", "arguments": {}}', + WEATHER_TOOL + ); + assert.equal(called.finishReason, "tool_calls"); + assert.ok(called.toolCalls && called.toolCalls.length === 1); + + const plain = buildToolAwareResult("no tools here", WEATHER_TOOL); + assert.equal(plain.finishReason, "stop"); + assert.equal(plain.toolCalls, null); + assert.equal(plain.content, "no tools here"); + }); +});