feat(web-cookie): add tool-call translation to 8 executors via shared webTools helpers (#3259)

Add tool-call translation to 8 web-cookie executors via shared webTools helpers.

Integrated into release/v3.8.12. Thanks @oyi77.
This commit is contained in:
Paijo
2026-06-06 10:22:27 +07:00
committed by GitHub
parent 925d838d3b
commit e364764dc7
10 changed files with 432 additions and 38 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>)?.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({

View File

@@ -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<string, unknown>).messages as
const bodyObj = (body || {}) as Record<string, unknown>;
const messages = bodyObj.messages as
| Array<Record<string, unknown>>
| 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,

View File

@@ -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<string, unknown>;
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<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, {
@@ -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<string, unknown> = { 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: [
{

View File

@@ -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<Record<string, unknown>>;
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, rawMessages);
const messages = effectiveMessages as Array<Record<string, unknown>>;
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({

View File

@@ -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<string, string>,
transformedBody: unknown
): MuseSparkExecuteResult {
transformedBody: unknown,
hasTools?: boolean,
requestedTools?: unknown
): Promise<MuseSparkExecuteResult> {
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<string, unknown>;
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);
}
}

View File

@@ -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<string, unknown>).messages as
const bodyObj = (body || {}) as Record<string, unknown>;
const rawMessages = bodyObj.messages as
| Array<Record<string, unknown>>
| 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<string, unknown>;
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,

View File

@@ -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<string, unknown>;
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<string, unknown> = { 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();

View File

@@ -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<string, unknown>;
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<string, unknown> = {
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",
},
],

View File

@@ -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<string, unknown>,
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 `<tool>` 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",
};
}

View File

@@ -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 <tool> 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("<tool>"), "must teach the <tool> wrapper contract");
});
});
describe("webTools — parseToolCallsFromText", () => {
test("parses a <tool> block into OpenAI tool_calls and strips it from content", () => {
const text =
'Sure, let me check.\n<tool>{"name": "get_weather", "arguments": {"city": "SP"}}</tool>';
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("<tool>"), "the <tool> 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(
'<tool>{"name": "get_weather", "arguments": {}}</tool>',
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");
});
});