mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
104
tests/unit/web-tools-translation.test.ts
Normal file
104
tests/unit/web-tools-translation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user