Files
OmniRoute/open-sse/executors/adapta-web.ts
Diego Rodrigues de Sa e Souza bf8b56b29f Release v3.8.20 (#3547)
* chore(release): open v3.8.20 development cycle

* fix(images): prefer bare combos over image aliases (#3527)

Integrated into release/v3.8.20

* fix(translator): map Codex local_shell tool (#3534)

Integrated into release/v3.8.20

* fix(usage): make opencode-go quota fetcher fail-open instead of throwing 500 (#3522)

Integrated into release/v3.8.20

* Fix Runtime page breaker state rendering (#3533)

Integrated into release/v3.8.20

* Expose provider breaker degradation threshold setting (#3535)

Integrated into release/v3.8.20

* fix(executor): strip provider prefix from versioned built-in tool model field (#3532)

Integrated into release/v3.8.20

* feat(providers): add Claude Fable 5 support (#3524)

Integrated into release/v3.8.20

* feat(resilience): add global provider cooldown tracking to prevent combo re-walking (#3556)

Integrated into release/v3.8.20 (default OFF, opt-in)

* fix(translator): scope thoughtSignature bypass to Antigravity/CLI only (#3560)

Integrated into release/v3.8.20. Co-authored-by: Six7Day <six7day@gmail.com>

* fix(routing): normalize thinking:disabled for combo-substituted models that reject it (#3554) (#3563)

Integrated into release/v3.8.20

* fix(usage): accept 0/empty budget limits so the dashboard can save and clear (#3537) (#3564)

Integrated into release/v3.8.20

* docs(changelog): credit @Six7Day for #3560 thoughtSignature fix (#3414)

The #3560 squash co-author trailer landed inline (unparsed by GitHub), so add
an explicit CHANGELOG credit ensuring @Six7Day (original #3414) and @oyi77 are
on the public record for the Gemini thoughtSignature fix.

* fix(gamification): dedup badge unlock via user_badges so events don't re-fire every request (#3472) (#3565)

Integrated into release/v3.8.20

* fix(routing): pass through 'auto' keyword on codex /v1/responses instead of rewriting to codex/auto (#3509) (#3566)

Integrated into release/v3.8.20

* fix(cli-tools): normalize apiKey null in guide-settings schema so cloud-mode config saves (#3552) (#3567)

Integrated into release/v3.8.20

* fix(catalog): reclassify PublicAI from keyless to one-time-initial (requires API key) (#3558) (#3568)

Integrated into release/v3.8.20

* fix(gemini-web): surface missing Playwright browser as actionable 503 + cooldown hint, not a retryable 500 loop (#3516) (#3570)

Integrated into release/v3.8.20

* fix(security): sanitize raw err.message in web executors + embeddings/search response bodies (Rule #12) (#3494, #3495) (#3573)

Integrated into release/v3.8.20

* fix(dashboard): point CustomHostsManager + FeatureFlagsGrid at real routes (#3486, #3487) (#3574)

Integrated into release/v3.8.20

* chore(providers): remove dead krutrim entry (#3483) + docs(api): fix agent-bridge per-agent state route (#3489) (#3575)

Integrated into release/v3.8.20

* docs(api): correct API_REFERENCE.md paths for skills/plugins/admin/cache/acp/system-info (#3497) (#3577)

Integrated into release/v3.8.20

* fix(proxy): drive SOCKS5 UI option from runtime ENABLE_SOCKS5_PROXY, not build-time NEXT_PUBLIC (#3508) (#3579)

Integrated into release/v3.8.20

* fix(playground): filter playground models by node prefix so custom-endpoint models appear (#3505) (#3581)

Integrated into release/v3.8.20

* fix(usage): show an informative message instead of a blank Kiro quota card when no usage breakdown (#3506) (#3582)

Integrated into release/v3.8.20

* docs(changelog): add the #3506 Kiro quota entry (missed in #3582 due to a stale-base CHANGELOG anchor) (#3583)

Integrated into release/v3.8.20

* fix(auto-update): use stable PROJECT_ROOT walker, not frozen process.cwd() (#3561)

Integrated into release/v3.8.20. Auto-update PROJECT_ROOT now uses a stable __dirname-anchored upward walker instead of the no-op process.cwd() resolver.

* fix: address PR #3518 review comments (lifecycle hooks, regex, indentation, route params) (#3562)

Integrated into release/v3.8.20. Addresses #3518 review: regex literals, logs/[id] route params (Next 16), indentation, and wires plugin lifecycle hooks (onInstall/onActivate/onDeactivate/onUninstall) in the loader so manager.ts can register them. Adds Rule #18 regression test.

* docs(changelog): credit @ViFigueiredo (#3423) for PROJECT_ROOT + log #3561/#3562 (v3.8.20)

* fix: openai to gemini incorrectly translates historical tool calls into text (#3569)

Integrated into release/v3.8.20. Standard Gemini direct path now maps historical tool calls to native functionCall/functionResponse parts (signaturelessToolCallMode: native) instead of inert text — validated against the real Gemini API (gemini-2.5-flash returns 200 for signatureless native functionCall, even with tools+thinking; Hard Rule #18). Eliminates the text-serialization leak. Antigravity/CLI sentinel path (#3560) untouched.

* docs(changelog)+test: reconcile standard-Gemini native mode (#3569) — update round-2 rationale comment + log VPS validation

* docs(changelog): reconcile v3.8.20 — add 9 missing bullets + move [Unreleased] to versioned section

* docs(changelog): complete v3.8.20 reconciliation — 27 bullets, 11 contributors

---------

Co-authored-by: Alexander Averyanov <alex@averyan.ru>
Co-authored-by: Hakan Kurşun <bykamaka@gmail.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Giorgos Giakoumettis <giorgos@yiakoumettis.gr>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-06-10 13:49:08 -03:00

547 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/131.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,
};
}
}