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

565 lines
18 KiB
TypeScript

import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
const ADAPTA_APP_URL = "https://agent.adapta.one";
const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one";
const ADAPTA_STREAM_URL = `${ADAPTA_APP_URL}/api/chat/stream/v1`;
// Default model ID in Adapta's internal system (corresponds to "ONE" / auto-select)
const DEFAULT_AI_MODEL_ID = 14;
// Map from OmniRoute model IDs to Adapta internal model IDs.
// 14 = "ONE" (auto), values confirmed via chat history inspection.
// Additional IDs can be added as they are discovered.
const MODEL_ID_MAP: Record<string, number> = {
"adapta-one": 14,
"adapta-gpt": 14,
"adapta-claude": 14,
"adapta-gemini": 14,
"adapta-grok": 14,
"adapta-deepseek": 14,
"adapta-llama": 14,
};
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// ── In-memory session cache ───────────────────────────────────────────────────
interface CachedSession {
sessionId: string;
jwt: string;
jwtExpiresAt: number; // unix ms
}
// Keyed by the first 32 chars of the stored __client JWT
const sessionCache = new Map<string, CachedSession>();
function cacheKey(clientJwt: string): string {
return clientJwt.slice(0, 32);
}
function cachedJwt(clientJwt: string): string | null {
const entry = sessionCache.get(cacheKey(clientJwt));
if (!entry) return null;
// Keep a 30-second buffer before expiry
if (Date.now() >= entry.jwtExpiresAt - 30_000) return null;
return entry.jwt;
}
function storeSession(clientJwt: string, sessionId: string, jwt: string, expMs: number): void {
sessionCache.set(cacheKey(clientJwt), { sessionId, jwt, jwtExpiresAt: expMs });
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function extractClientJwt(rawApiKey: string): string {
// Users may paste the full "name=value" form or just the JWT value
const trimmed = rawApiKey.trim();
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0 && !trimmed.startsWith("eyJ")) {
return trimmed.slice(eqIdx + 1).trim();
}
return trimmed;
}
/** Decode the exp claim from a JWT without verifying signature. */
function jwtExpMs(jwt: string): number {
try {
const parts = jwt.split(".");
if (parts.length < 2) return 0;
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const payload = JSON.parse(atob(b64));
return typeof payload.exp === "number" ? payload.exp * 1000 : 0;
} catch {
return 0;
}
}
function makeErrorResponse(status: number, message: string): Response {
return new Response(
JSON.stringify({ error: { message, type: "upstream_error", code: `HTTP_${status}` } }),
{ status, headers: { "Content-Type": "application/json" } }
);
}
// ── Clerk auth flow ───────────────────────────────────────────────────────────
/** Step 1: GET /v1/client → returns active session ID. */
async function getSessionId(
clientJwt: string,
signal?: AbortSignal | null,
log?: ExecuteInput["log"]
): Promise<string> {
const resp = await fetch(`${ADAPTA_CLERK_URL}/v1/client`, {
headers: {
Cookie: `__client=${clientJwt}`,
"User-Agent": USER_AGENT,
Origin: ADAPTA_APP_URL,
},
signal: signal ?? undefined,
});
if (!resp.ok) {
throw new Error(`Clerk /v1/client returned HTTP ${resp.status} — check your __client cookie`);
}
const body = await resp.json();
const sessions: Array<{ id: string; status: string }> = body?.response?.sessions ?? [];
const active = sessions.find((s) => s.status === "active");
if (!active?.id) {
throw new Error(
"No active Clerk session found — your __client cookie may be expired or invalid"
);
}
log?.info?.("ADAPTA-WEB", `Got session ID: ${active.id}`);
return active.id;
}
/** Step 2: POST /v1/client/sessions/{id}/tokens → fresh short-lived JWT. */
async function refreshSessionJwt(
clientJwt: string,
sessionId: string,
signal?: AbortSignal | null,
log?: ExecuteInput["log"]
): Promise<string> {
const resp = await fetch(`${ADAPTA_CLERK_URL}/v1/client/sessions/${sessionId}/tokens`, {
method: "POST",
headers: {
Cookie: `__client=${clientJwt}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
Origin: ADAPTA_APP_URL,
},
signal: signal ?? undefined,
});
if (!resp.ok) {
throw new Error(`Clerk token refresh returned HTTP ${resp.status}`);
}
const body = await resp.json();
const jwt = body?.jwt;
if (typeof jwt !== "string" || !jwt.startsWith("eyJ")) {
throw new Error("Clerk token refresh did not return a valid JWT");
}
log?.info?.("ADAPTA-WEB", `Got fresh session JWT (${jwt.length} chars)`);
return jwt;
}
/** Returns a valid (non-expired) session JWT, refreshing if necessary. */
async function getSessionJwt(
clientJwt: string,
signal?: AbortSignal | null,
log?: ExecuteInput["log"]
): Promise<string> {
const cached = cachedJwt(clientJwt);
if (cached) {
log?.info?.("ADAPTA-WEB", "Using cached session JWT");
return cached;
}
const sessionId = await getSessionId(clientJwt, signal, log);
const jwt = await refreshSessionJwt(clientJwt, sessionId, signal, log);
storeSession(clientJwt, sessionId, jwt, jwtExpMs(jwt) || Date.now() + 55_000);
return jwt;
}
// ── Request/response translation ──────────────────────────────────────────────
interface OpenAIMessage {
role: string;
content: unknown;
}
interface AdaptaPart {
type: "text";
text: string;
}
interface AdaptaMessage {
role: string;
parts: AdaptaPart[];
}
/** Extract plain text from an OpenAI message content (string or content-parts array). */
function extractText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c: Record<string, unknown>) => c.type === "text")
.map((c: Record<string, unknown>) => String(c.text ?? ""))
.join("");
}
return String(content ?? "");
}
/** Build the final Adapta messages array, injecting system prompt into first user message. */
function buildAdaptaMessages(messages: OpenAIMessage[]): AdaptaMessage[] {
let systemText = "";
const rest: OpenAIMessage[] = [];
for (const msg of messages) {
const role = msg.role === "developer" ? "system" : msg.role;
if (role === "system") {
systemText += (systemText ? "\n" : "") + extractText(msg.content);
} else {
rest.push(msg);
}
}
const adapted: AdaptaMessage[] = [];
let systemInjected = false;
for (const msg of rest) {
if (msg.role !== "user" && msg.role !== "assistant") continue;
const text = extractText(msg.content);
if (!text.trim()) continue;
if (!systemInjected && systemText && msg.role === "user") {
adapted.push({
role: "user",
parts: [{ type: "text", text: `${systemText}\n\n${text}` }],
});
systemInjected = true;
} else {
adapted.push({ role: msg.role, parts: [{ type: "text", text }] });
}
}
return adapted;
}
// ── SSE transform: Adapta → OpenAI ───────────────────────────────────────────
function transformStream(adaptaStream: ReadableStream, model: string): ReadableStream {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const id = `chatcmpl-adp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const created = Math.floor(Date.now() / 1000);
let roleEmitted = false;
return new ReadableStream({
async start(controller) {
const reader = adaptaStream.getReader();
let buffer = "";
const emit = (obj: object) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
const chunk = (delta: object, finish?: string) =>
emit({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta, finish_reason: finish ?? null }],
});
const ensureRole = () => {
if (!roleEmitted) {
roleEmitted = true;
chunk({ role: "assistant", content: "" });
}
};
const finalize = () => {
ensureRole();
chunk({}, "stop");
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let event: Record<string, unknown>;
try {
event = JSON.parse(payload);
} catch {
continue;
}
const type = event.type as string;
if (type === "text-delta") {
const delta = event.delta as string;
// Suppress the "quick-response" loading placeholder
if (event.id === "quick-response") continue;
if (typeof delta === "string" && delta.length > 0) {
ensureRole();
chunk({ content: delta });
}
} else if (type === "text-end") {
if (event.id === "quick-response") continue;
// Real text ended — stream will send more events or close
} else if (type === "error") {
const errText = String(event.errorText ?? "Adapta upstream error");
ensureRole();
// Emit the error as content so the user sees it
chunk({ content: `\n\n[Erro: ${errText}]` });
finalize();
return;
} else if (type === "done" || type === "end") {
finalize();
return;
}
}
}
} catch {
// Stream aborted or network error — emit what we have
}
finalize();
},
});
}
// ── Executor ──────────────────────────────────────────────────────────────────
export class AdaptaWebExecutor extends BaseExecutor {
constructor() {
super("adapta-web", { baseUrl: ADAPTA_STREAM_URL });
}
async testConnection(
credentials: Record<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
try {
const rawKey = String((credentials as Record<string, unknown>)?.apiKey ?? "");
if (!rawKey) return false;
const clientJwt = extractClientJwt(rawKey);
const sessionId = await getSessionId(clientJwt, signal);
return !!sessionId;
} catch {
return false;
}
}
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 ?? "");
if (!rawKey) {
return {
response: makeErrorResponse(
401,
"Missing Adapta credentials — paste your __client cookie from .clerk.agent.adapta.one"
),
url: ADAPTA_STREAM_URL,
headers: {},
transformedBody: body,
};
}
const clientJwt = extractClientJwt(rawKey);
let sessionJwt: string;
try {
log?.info?.("ADAPTA-WEB", "Obtaining session JWT via Clerk...");
sessionJwt = await getSessionJwt(clientJwt, signal, log);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.warn?.("ADAPTA-WEB", `Auth failed: ${msg}`);
return {
response: makeErrorResponse(401, `Adapta auth failed: ${sanitizeErrorMessage(msg)}`),
url: ADAPTA_STREAM_URL,
headers: {},
transformedBody: body,
};
}
// 2. Build Adapta request body
const aiModelId = MODEL_ID_MAP[model] ?? DEFAULT_AI_MODEL_ID;
const adaptaMessages = buildAdaptaMessages(effectiveMessages);
if (adaptaMessages.length === 0) {
return {
response: makeErrorResponse(400, "No messages provided"),
url: ADAPTA_STREAM_URL,
headers: {},
transformedBody: body,
};
}
const requestPayload = {
messages: adaptaMessages,
aiModelId,
};
const headers: Record<string, string> = {
Authorization: `Bearer ${sessionJwt}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"User-Agent": USER_AGENT,
Origin: ADAPTA_APP_URL,
Referer: `${ADAPTA_APP_URL}/agentic-chat`,
};
log?.info?.(
"ADAPTA-WEB",
`POST ${ADAPTA_STREAM_URL} | model=${model} aiModelId=${aiModelId} msgs=${adaptaMessages.length}`
);
// 3. Fire request
const resp = await fetch(ADAPTA_STREAM_URL, {
method: "POST",
headers,
body: JSON.stringify(requestPayload),
signal: signal ?? undefined,
});
if (!resp.ok) {
let errMsg = `Adapta error HTTP ${resp.status}`;
if (resp.status === 401 || resp.status === 403) {
errMsg =
"Adapta session expired or invalid — re-paste your __client cookie from .clerk.agent.adapta.one";
// Evict cached JWT so next request re-authenticates
sessionCache.delete(cacheKey(clientJwt));
} else if (resp.status === 429) {
errMsg = "Adapta rate limited — wait and retry";
}
log?.warn?.("ADAPTA-WEB", errMsg);
return {
response: makeErrorResponse(resp.status, errMsg),
url: ADAPTA_STREAM_URL,
headers,
transformedBody: requestPayload,
};
}
// 4. Stream or collect
if (stream !== false) {
return {
response: new Response(transformStream(resp.body!, model), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: ADAPTA_STREAM_URL,
headers,
transformedBody: requestPayload,
};
}
// Non-streaming: collect all text-delta events
const decoder = new TextDecoder();
const reader = resp.body!.getReader();
let buf = "";
let fullText = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "text-delta" && ev.id !== "quick-response") {
fullText += String(ev.delta ?? "");
}
} catch {
// skip
}
}
}
} finally {
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({
id: `chatcmpl-adp-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: { role: "assistant", content: fullText },
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,
};
}
}