Files
2026-07-13 13:39:12 +08:00

929 lines
28 KiB
TypeScript

import { createHash } from "node:crypto";
import {
BaseExecutor,
mergeAbortSignals,
mergeUpstreamExtraHeaders,
type ExecuteInput,
} 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,
} from "@/lib/providers/webCookieAuth";
import {
type ParsedMetaAiResponse,
isRecord,
parseMetaAiResponseText,
} from "./muse-spark-web/response-parser.ts";
const META_AI_GRAPHQL_API = "https://www.meta.ai/api/graphql";
// Meta rebranded the chat product from "Abra" to "Ecto"; the session cookie
// `abra_sess` was replaced by `ecto_1_sess`. `normalizeSessionCookieHeader`
// only uses this constant when the user pastes a bare cookie value with no
// `name=` prefix; full cookie lines (with any cookie names) pass through
// untouched, so users who paste their entire DevTools cookie line still work.
const META_AI_DEFAULT_COOKIE = "ecto_1_sess";
// Persisted-query id and friendly name for the current send-message
// operation. The previous Abra mutation (doc_id 078dfdff...) was retired
// when Meta removed the RewriteOptionsInput type from the schema; it now
// fails server-side validation with `Unknown type "RewriteOptionsInput"`.
// The new operation is a Subscription rather than a Mutation, but Meta's
// GraphQL endpoint still accepts it over POST and streams the response.
const META_AI_SEND_MESSAGE_DOC_ID = "29ae946c82d1f301196c6ca2226400b5";
const META_AI_ROOT_BRANCH_PATH = "0";
const META_AI_ENTRY_POINT = "KADABRA__CHAT__UNIFIED_INPUT_BAR";
const META_AI_FRIENDLY_NAME = "useEctoSendMessageSubscription";
const META_AI_REQUEST_ANALYTICS_TAGS = "graphservice";
const META_AI_ASBD_ID = "129477";
const META_AI_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
type MuseSparkModelInfo = {
mode: string;
isThinking: boolean;
};
const MODEL_MAP: Record<string, MuseSparkModelInfo> = {
"muse-spark": { mode: "mode_fast", isThinking: false },
"muse-spark-thinking": { mode: "mode_thinking", isThinking: true },
"muse-spark-contemplating": { mode: "think_hard", isThinking: true },
};
function extractMessageText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (!isRecord(part)) return "";
if (part.type === "text" && typeof part.text === "string") {
return part.text;
}
if (part.type === "input_text" && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter((part) => part.trim().length > 0)
.join("\n")
.trim();
}
type NormalizedMessage = { role: string; content: string };
type ParsedHistory = {
/** Whole history folded into one string (used when starting a new conversation). */
foldedPrompt: string;
/** Just the last user turn — sent on its own when we're continuing a cached conversation. */
latestUserContent: string;
/**
* Index in `normalized` of the most recent assistant turn, or -1 if none.
* Used to slice the prefix that anchors the continuation cache key (so two
* separate chats with identical assistant responses but different
* preceding history don't collide).
*/
lastAssistantIndex: number;
/**
* The role+content of every non-empty message after normalization, in
* order. The continuation-cache key hashes the prefix of this list ending
* at the last assistant message, so the key is unique to a specific
* (history → response) pair rather than just the response text alone.
*/
normalized: NormalizedMessage[];
};
function parseOpenAIMessages(messages: Array<Record<string, unknown>>): ParsedHistory {
const extracted: NormalizedMessage[] = [];
for (const message of messages) {
let role = String(message.role || "user");
if (role === "developer") role = "system";
const content = extractMessageText(message.content);
if (!content) continue;
extracted.push({ role, content });
}
if (extracted.length === 0) {
return {
foldedPrompt: "",
latestUserContent: "",
lastAssistantIndex: -1,
normalized: [],
};
}
let lastUserIndex = -1;
for (let i = extracted.length - 1; i >= 0; i--) {
if (extracted[i].role === "user") {
lastUserIndex = i;
break;
}
}
let lastAssistantIndex = -1;
for (let i = extracted.length - 1; i >= 0; i--) {
if (extracted[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
const foldedPrompt = extracted
.map((message, index) => {
if (index === lastUserIndex) {
return message.content;
}
return `${message.role}: ${message.content}`;
})
.join("\n\n")
.trim();
const latestUserContent = lastUserIndex >= 0 ? extracted[lastUserIndex].content : "";
return { foldedPrompt, latestUserContent, lastAssistantIndex, normalized: extracted };
}
function estimateTokens(text: string): number {
return Math.max(1, Math.ceil((text || "").length / 4));
}
function encodeBase62(value: bigint, padLength: number): string {
let remaining = value;
let encoded = "";
while (remaining > 0n) {
encoded = BASE62_ALPHABET[Number(remaining % 62n)] + encoded;
remaining /= 62n;
}
return encoded.padStart(padLength, "0");
}
function decodeBase62(value: string): bigint {
let decoded = 0n;
for (const char of value) {
const index = BASE62_ALPHABET.indexOf(char);
if (index < 0) {
throw new Error(`Invalid base62 character: ${char}`);
}
decoded = decoded * 62n + BigInt(index);
}
return decoded;
}
function randomBigInt(byteLength: number): bigint {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
let result = 0n;
for (const byte of bytes) {
result = (result << 8n) | BigInt(byte);
}
return result;
}
function generateMetaConversationId(): string {
const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n);
const random = randomBigInt(8) & ((1n << 64n) - 1n);
const packed = (timestamp << 64n) | random;
return `c.${encodeBase62(packed, 19)}`;
}
function generateMetaEventId(conversationId: string): string | null {
if (!conversationId.startsWith("c.")) {
return null;
}
try {
const packedConversation = decodeBase62(conversationId.slice(2));
const conversationRandom = packedConversation & ((1n << 64n) - 1n);
const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n);
const eventRandom = randomBigInt(4) & ((1n << 32n) - 1n);
const packedEvent = (timestamp << (64n + 32n)) | (conversationRandom << 32n) | eventRandom;
return `e.${encodeBase62(packedEvent, 25)}`;
} catch {
return null;
}
}
function generateNumericMessageId(): string {
return (
BigInt(Date.now()) * 1000n +
BigInt(Math.floor(Math.random() * 1000)) +
(randomBigInt(2) & 0xfffn)
).toString();
}
function normalizeMetaLocale(): string {
const locale =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().locale || "en-US"
: "en-US";
return locale.replace(/-/g, "_");
}
function getMuseSparkModelInfo(model: string): MuseSparkModelInfo {
return MODEL_MAP[model] || MODEL_MAP["muse-spark"];
}
// ─── Conversation continuity cache ──────────────────────────────────────────
// The default behavior of /v1/chat/completions is stateless: the caller passes
// the full message history each turn. Without continuation, every turn would
// open a brand-new meta.ai conversation containing the OpenAI history folded
// into a single user prompt — three real chat turns become three separate
// conversations in the user's meta.ai history, each polluted with the prior
// turns rendered as "user: …" / "assistant: …" text.
//
// To present a clean single growing conversation in meta.ai, we cache the
// conversationId we created on the previous turn keyed by a hash of the
// (connectionId, model, normalized history through the last assistant turn).
// On the next turn, if the incoming OpenAI history's prefix-up-to-the-last-
// assistant-turn matches a cached entry, we reuse the cached conversationId,
// set isNewConversation=false, and send only the latest user turn — Meta
// appends to the existing conversation tree.
//
// Hashing the *full prefix* (not just the assistant text) is important: two
// independent chats from the same connection that happen to land on identical
// assistant text (e.g. a generic refusal or greeting) would otherwise collide
// and route the next turn into the wrong meta.ai conversation, mixing chat
// state across logical sessions. The differing preceding history makes the
// hashes distinct.
//
// TTL is 30 minutes (Meta's web client also expires idle conversations on a
// similar window). Cache cap is generous — entries are tiny (~250 B) so 5000
// entries is ~1.25 MB, plenty of headroom for multi-user setups.
type CachedConversation = {
conversationId: string;
branchPath: string;
expiresAt: number;
};
const MUSE_CONV_CACHE_MAX = 5000;
const MUSE_CONV_CACHE_TTL_MS = 30 * 60 * 1000;
const conversationCache = new Map<string, CachedConversation>();
/**
* Canonical-stringify a normalized message list so the same logical history
* always produces the same hash. Uses ASCII Group Separator / Record
* Separator characters as field delimiters so they can't appear inside
* normal message content.
*/
function canonicalizeNormalizedHistory(messages: NormalizedMessage[]): string {
return messages.map((m) => `${m.role}\x1e${m.content}`).join("\x1f");
}
function makeConversationCacheKey(
connectionId: string,
model: string,
normalizedPrefix: NormalizedMessage[]
): string {
return createHash("sha256")
.update(`${connectionId}\x1f${model}\x1f${canonicalizeNormalizedHistory(normalizedPrefix)}`)
.digest("hex");
}
function lookupCachedConversation(key: string): CachedConversation | null {
const entry = conversationCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
conversationCache.delete(key);
return null;
}
return entry;
}
function rememberConversation(
key: string,
context: { conversationId: string; branchPath: string }
): void {
if (conversationCache.size >= MUSE_CONV_CACHE_MAX && !conversationCache.has(key)) {
// Map iteration is insertion order, so the first key is the oldest.
const oldest = conversationCache.keys().next().value;
if (oldest) conversationCache.delete(oldest);
}
conversationCache.set(key, {
conversationId: context.conversationId,
branchPath: context.branchPath,
expiresAt: Date.now() + MUSE_CONV_CACHE_TTL_MS,
});
}
/** Test hook — exported for unit tests; not wired to runtime callers. */
export function __resetMuseSparkConversationCacheForTesting(): void {
conversationCache.clear();
}
type ConversationContext = {
conversationId: string;
branchPath: string;
isNewConversation: boolean;
};
function buildMetaAiRequestBody(prompt: string, model: string, conversation: ConversationContext) {
const userUniqueMessageId = generateNumericMessageId();
return {
doc_id: META_AI_SEND_MESSAGE_DOC_ID,
variables: {
assistantMessageId: crypto.randomUUID(),
attachments: null,
clientLatitude: null,
clientLongitude: null,
clientTimezone:
typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC",
clippyIp: null,
content: prompt,
conversationId: conversation.conversationId,
conversationStarterId: null,
currentBranchPath: conversation.branchPath,
developerOverridesForMessage: null,
devicePixelRatio: 1,
entryPoint: META_AI_ENTRY_POINT,
imagineOperationRequest: null,
isNewConversation: conversation.isNewConversation,
mentions: null,
mode: getMuseSparkModelInfo(model).mode,
promptEditType: null,
promptSessionId: crypto.randomUUID(),
promptType: null,
qplJoinId: null,
requestedToolCall: null,
// `rewriteOptions` was removed from Meta's GraphQL schema (the
// RewriteOptionsInput type is gone), so sending it — even as null —
// makes the server reject the persisted query with
// `Unknown type "RewriteOptionsInput"`. Omit it entirely; GraphQL
// input fields are nullable-by-omission by default.
turnId: crypto.randomUUID(),
userAgent: META_AI_USER_AGENT,
userEventId: generateMetaEventId(conversation.conversationId),
userLocale: normalizeMetaLocale(),
userMessageId: crypto.randomUUID(),
userUniqueMessageId,
},
};
}
function sseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildStreamingResponse(
deltas: string[],
reasoningDeltas: string[],
model: string,
id: string,
created: number
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream(
{
start(controller) {
controller.enqueue(
encoder.encode(
sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { role: "assistant" },
finish_reason: null,
logprobs: null,
},
],
})
)
);
for (const delta of reasoningDeltas) {
if (!delta) continue;
controller.enqueue(
encoder.encode(
sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { reasoning_content: delta },
finish_reason: null,
logprobs: null,
},
],
})
)
);
}
for (const delta of deltas) {
if (!delta) continue;
controller.enqueue(
encoder.encode(
sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
delta: { content: delta },
finish_reason: null,
logprobs: null,
},
],
})
)
);
}
controller.enqueue(
encoder.encode(
sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
})
)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
},
{ highWaterMark: 16384 }
);
}
function buildNonStreamingResponse(
content: string,
reasoningContent: string,
model: string,
id: string,
created: number
) {
const completionTokens = estimateTokens(content);
const message: Record<string, unknown> = { role: "assistant", content };
if (reasoningContent) {
message.reasoning_content = reasoningContent;
}
return new Response(
JSON.stringify({
id,
object: "chat.completion",
created,
model,
system_fingerprint: null,
choices: [
{
index: 0,
message,
finish_reason: "stop",
logprobs: null,
},
],
usage: {
prompt_tokens: completionTokens,
completion_tokens: completionTokens,
total_tokens: completionTokens * 2,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
function buildErrorResponse(status: number, message: string, code?: string | null) {
return new Response(
JSON.stringify({
error: {
message,
type: "upstream_error",
...(code ? { code } : {}),
},
}),
{ status, headers: { "Content-Type": "application/json" } }
);
}
async function readTextResponse(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal | null
): Promise<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let text = "";
try {
while (true) {
if (signal?.aborted) {
throw signal.reason ?? new DOMException("Aborted", "AbortError");
}
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
} finally {
reader.releaseLock();
}
}
export function normalizeMetaAiCookieHeader(apiKey: string): string {
return normalizeSessionCookieHeader(apiKey, META_AI_DEFAULT_COOKIE);
}
function selectMetaAiCookieHeader(credentials: ExecuteInput["credentials"]): string {
const extraCookieValues = Array.isArray(credentials.providerSpecificData?.extraApiKeys)
? credentials.providerSpecificData.extraApiKeys.filter(
(value): value is string => typeof value === "string" && value.trim().length > 0
)
: [];
const normalizedPool = normalizeSessionCookieHeaders(
[credentials.apiKey || "", ...extraCookieValues],
META_AI_DEFAULT_COOKIE
);
if (normalizedPool.length === 0) {
return "";
}
if (normalizedPool.length === 1 || !credentials.connectionId) {
return normalizedPool[0];
}
return getRotatingApiKey(credentials.connectionId, normalizedPool[0], normalizedPool.slice(1));
}
function buildMetaAiHeaders(cookieHeader: string): Record<string, string> {
return {
Accept: "text/event-stream",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Cookie: cookieHeader,
Origin: "https://www.meta.ai",
Referer: "https://www.meta.ai/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": META_AI_USER_AGENT,
"X-ASBD-ID": META_AI_ASBD_ID,
"X-FB-Friendly-Name": META_AI_FRIENDLY_NAME,
"X-FB-Request-Analytics-Tags": META_AI_REQUEST_ANALYTICS_TAGS,
};
}
type MuseSparkExecuteResult = {
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
};
function resultWithResponse(
response: Response,
headers: Record<string, string>,
transformedBody: unknown
): MuseSparkExecuteResult {
return {
response,
url: META_AI_GRAPHQL_API,
headers,
transformedBody,
};
}
function errorResult(
status: number,
message: string,
code: string,
headers: Record<string, string>,
transformedBody: unknown
): MuseSparkExecuteResult {
return resultWithResponse(buildErrorResponse(status, message, code), headers, transformedBody);
}
function getOpenAiMessages(body: unknown): Array<Record<string, unknown>> | null {
const messages = (body as Record<string, unknown>).messages;
if (!messages || !Array.isArray(messages) || messages.length === 0) return null;
return messages as Array<Record<string, unknown>>;
}
function getContinuationCacheKey(
parsedHistory: ParsedHistory,
credentials: ExecuteInput["credentials"],
model: string
): string | null {
if (
parsedHistory.lastAssistantIndex < 0 ||
!credentials.connectionId ||
parsedHistory.latestUserContent.length === 0
) {
return null;
}
return makeConversationCacheKey(
credentials.connectionId,
model,
parsedHistory.normalized.slice(0, parsedHistory.lastAssistantIndex + 1)
);
}
function getConversationContext(cached: CachedConversation | null): ConversationContext {
if (!cached) {
return {
conversationId: generateMetaConversationId(),
branchPath: META_AI_ROOT_BRANCH_PATH,
isNewConversation: true,
};
}
return {
conversationId: cached.conversationId,
branchPath: cached.branchPath,
isNewConversation: false,
};
}
function evictContinuationIfNeeded(
cached: CachedConversation | null,
cacheKey: string | null
): void {
if (cached && cacheKey) {
conversationCache.delete(cacheKey);
}
}
async function postMetaAiRequest(
headers: Record<string, string>,
transformedBody: unknown,
signal: AbortSignal,
log: ExecuteInput["log"]
): Promise<{ ok: true; response: Response } | { ok: false; result: MuseSparkExecuteResult }> {
try {
const response = await fetch(META_AI_GRAPHQL_API, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal,
});
return { ok: true, response };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log?.error?.("MUSE-SPARK-WEB", `Fetch failed: ${message}`);
return {
ok: false,
result: errorResult(
502,
`Meta AI connection failed: ${message}`,
"meta_ai_fetch_failed",
headers,
transformedBody
),
};
}
}
function buildHttpErrorResult(
upstreamResponse: Response,
headers: Record<string, string>,
transformedBody: unknown,
cached: CachedConversation | null,
cacheKey: string | null
): MuseSparkExecuteResult {
evictContinuationIfNeeded(cached, cacheKey);
let message = `Meta AI returned HTTP ${upstreamResponse.status}`;
if (upstreamResponse.status === 401 || upstreamResponse.status === 403) {
message = "Meta AI auth failed — your meta.ai ecto_1_sess cookie may be missing or expired.";
} else if (upstreamResponse.status === 429) {
message = "Meta AI rate limited the session. Wait a moment and retry.";
}
return errorResult(
upstreamResponse.status,
message,
`HTTP_${upstreamResponse.status}`,
headers,
transformedBody
);
}
function buildParsedErrorResult(
parsed: ParsedMetaAiResponse,
headers: Record<string, string>,
transformedBody: unknown,
cached: CachedConversation | null,
cacheKey: string | null
): MuseSparkExecuteResult {
evictContinuationIfNeeded(cached, cacheKey);
return errorResult(
parsed.status,
parsed.errorMessage || "Meta AI returned an unknown error",
parsed.errorCode || "meta_ai_unknown_error",
headers,
transformedBody
);
}
function rememberAssistantTurn(
parsed: ParsedMetaAiResponse,
credentials: ExecuteInput["credentials"],
model: string,
parsedHistory: ParsedHistory,
conversationContext: ConversationContext
): void {
if (!parsed.content || !credentials.connectionId) return;
const writePrefix: NormalizedMessage[] = [
...parsedHistory.normalized,
{ role: "assistant", content: parsed.content },
];
rememberConversation(makeConversationCacheKey(credentials.connectionId, model, writePrefix), {
conversationId: conversationContext.conversationId,
branchPath: conversationContext.branchPath,
});
}
async function buildSuccessResult(
parsed: ParsedMetaAiResponse,
stream: boolean,
model: string,
headers: Record<string, string>,
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;
let response = stream
? new Response(buildStreamingResponse(deltas, reasoningDeltas, model, id, created), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
})
: 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);
}
export class MuseSparkWebExecutor extends BaseExecutor {
constructor() {
super("muse-spark-web", { id: "muse-spark-web", baseUrl: META_AI_GRAPHQL_API });
}
async execute({
model,
body,
stream,
credentials,
signal,
log,
upstreamExtraHeaders,
}: ExecuteInput) {
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 { 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);
}
// Look up a prior meta.ai conversation we created for this caller +
// model + chat thread. The lookup key is the connection + model + the
// SHA-256 of the normalized history prefix ending at the last assistant
// turn — that prefix is exactly what we hashed when we cached on the
// previous turn, so a real continuation hits and two parallel chats
// with coincidentally-identical assistant text do not.
//
// We also require `latestUserContent` to be non-empty before using a
// cached entry: if the incoming history has no `user` role (e.g. an
// assistant-prefill payload), the cache-hit path would otherwise POST
// empty content with `isNewConversation: false`, an avoidable upstream
// failure. Falling through to the fresh-conversation path uses the
// folded history instead, which contains real content.
const continuationCacheKey = getContinuationCacheKey(parsedHistory, credentials, model);
const cached = continuationCacheKey ? lookupCachedConversation(continuationCacheKey) : null;
const conversationContext = getConversationContext(cached);
const prompt = cached ? parsedHistory.latestUserContent : parsedHistory.foldedPrompt;
const modelInfo = getMuseSparkModelInfo(model);
const transformedBody = buildMetaAiRequestBody(prompt, model, conversationContext);
const cookieHeader = selectMetaAiCookieHeader(credentials);
const headers = buildMetaAiHeaders(cookieHeader);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
const fetchResult = await postMetaAiRequest(headers, transformedBody, combinedSignal, log);
if (!fetchResult.ok) {
const err = fetchResult as { ok: false; result: MuseSparkExecuteResult };
return err.result;
}
const upstreamResponse = fetchResult.response;
if (!upstreamResponse.ok) {
return buildHttpErrorResult(
upstreamResponse,
headers,
transformedBody,
cached,
continuationCacheKey
);
}
if (!upstreamResponse.body) {
return errorResult(
502,
"Meta AI returned an empty response body",
"meta_ai_empty_body",
headers,
transformedBody
);
}
const responseText = await readTextResponse(upstreamResponse.body, signal);
const parsed = parseMetaAiResponseText(responseText, modelInfo.isThinking);
if (parsed.status !== 200 || parsed.errorMessage) {
return buildParsedErrorResult(parsed, headers, transformedBody, cached, continuationCacheKey);
}
rememberAssistantTurn(parsed, credentials, model, parsedHistory, conversationContext);
return buildSuccessResult(
parsed,
stream,
model,
headers,
transformedBody,
hasTools,
requestedTools
);
}
}