diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index f1c556b3f0..b70a22f6fc 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -101,6 +101,7 @@ import { sensenovaProvider } from "./registry/sensenova/index.ts"; import { hyperbolicProvider } from "./registry/hyperbolic/index.ts"; import { lambda_aiProvider } from "./registry/lambda-ai/index.ts"; import { t3_webProvider } from "./registry/t3-web/index.ts"; +import { conol_webProvider } from "./registry/conol-web/index.ts"; import { iflytekProvider } from "./registry/iflytek/index.ts"; import { crofProvider } from "./registry/crof/index.ts"; import { moonshotProvider } from "./registry/moonshot/index.ts"; @@ -331,6 +332,7 @@ export const REGISTRY: Record = { hyperbolic: hyperbolicProvider, "lambda-ai": lambda_aiProvider, "t3-web": t3_webProvider, + "conol-web": conol_webProvider, iflytek: iflytekProvider, crof: crofProvider, moonshot: moonshotProvider, diff --git a/open-sse/config/providers/registry/conol-web/index.ts b/open-sse/config/providers/registry/conol-web/index.ts new file mode 100644 index 0000000000..e49760792d --- /dev/null +++ b/open-sse/config/providers/registry/conol-web/index.ts @@ -0,0 +1,14 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { CONOL_FALLBACK_MODELS } from "../../../services/conolModels.ts"; + +export const conol_webProvider: RegistryEntry = { + id: "conol-web", + alias: "cnl", + format: "openai", + executor: "conol-web", + baseUrl: "https://conol.ai/api/sessions", + authType: "apikey", + authHeader: "cookie", + passthroughModels: true, + models: CONOL_FALLBACK_MODELS, +}; diff --git a/open-sse/executors/conol-web.ts b/open-sse/executors/conol-web.ts new file mode 100644 index 0000000000..7ba409a7da --- /dev/null +++ b/open-sse/executors/conol-web.ts @@ -0,0 +1,893 @@ +/** + * ConolExecutor — conol.ai browser-session chat (Unofficial/Experimental). + * + * Protocol verified against the web client on 2026-07-30: + * - POST /api/assets for raw image uploads + * - POST /api/sessions to create a session + * - POST /api/sessions/{id}/model to pin preset, then model, then effort + * - POST /api/sessions/{id}/messages to submit a turn + * - GET /api/sessions/{id}/messages?logDeltas=1 for cumulative NDJSON updates + * - Cookie authentication via __Secure-better-auth.session_token + * + * Session creation ignores agentModel/agentEffort and answers with + * `modelDowngraded: true` on the account default, so the session is always + * created empty and configured via /model before the first turn is submitted. + */ +import { createHash } from "node:crypto"; + +import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { CursorImageError, extractImageUrls, resolveCursorImages } from "../utils/cursorImages.ts"; +import { normalizeConolCookie, resolveConolCredentials } from "../services/conolAuth.ts"; +import { resolveConolModelSelection, type ConolEffort } from "../services/conolModels.ts"; +import { + applyConolSessionModel, + buildConolSessionModelPlan, +} from "../services/conolSessionModel.ts"; + +export { normalizeConolCookie, resolveConolCredentials }; + +const CONOL_ORIGIN = "https://conol.ai"; +const CONOL_SESSION_URL = `${CONOL_ORIGIN}/api/sessions`; +const CONOL_REQUEST_TIMEOUT_MS = 300_000; +const CONOL_MAX_STREAM_BYTES = 16 * 1024 * 1024; +const CONOL_SESSION_TTL_MS = 6 * 60 * 60 * 1000; +const CONOL_MAX_SESSION_BINDINGS = 500; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; + +interface ChatMessage { + role: string; + content: unknown; +} + +interface ConolRequestBody { + messages?: ChatMessage[]; + model?: string; + timezone?: string; + metadata?: unknown; + conversation_id?: unknown; + conversationId?: unknown; + session_id?: unknown; + sessionId?: unknown; + prompt_cache_key?: unknown; + promptCacheKey?: unknown; +} + +interface ConolMessagePart { + type: "text" | "image"; + content: string; + mediaType?: string; +} + +interface ConolUserTurn { + text: string; + imageUrls: string[]; +} + +interface ConolSessionBinding { + upstreamSessionId: string; + lastUsedAt: number; + /** Model preset already primed on this session — sent once, not per turn. */ + presetApplied: boolean; + /** Model/effort currently pinned upstream, so we only re-pin on an actual switch. */ + appliedModel: string; + appliedEffort: ConolEffort | null; + /** Conol wants `hasImageHistory` sticky once the session has seen an image. */ + hasImageHistory: boolean; +} + +export interface ParsedConolStream { + text: string; + usedTokens: number | null; + contextWindow: number | null; + modelId: string; + done: boolean; +} + +const conolSessionBindings = new Map(); +const conolSessionLocks = new Map>(); + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function extractText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) { + return value + .map((item) => extractText(item)) + .filter(Boolean) + .join("\n"); + } + if (typeof value !== "object") return ""; + const record = value as Record; + const type = readString(record.type).toLowerCase(); + if (type === "image_url" || type === "input_image" || type === "image") return ""; + return ( + readString(record.text) || + (typeof record.content === "string" ? record.content : extractText(record.content)) || + extractText(record.output) || + extractText(record.result) + ); +} + +function extractUserText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) { + return value + .map((item) => extractUserText(item)) + .filter(Boolean) + .join("\n"); + } + if (typeof value !== "object") return ""; + + const record = value as Record; + const type = readString(record.type).toLowerCase(); + if (type === "text" || type === "input_text" || type === "output_text") { + return readString(record.text) || readString(record.content); + } + if (type) { + // Conol owns the agent loop. Do not flatten tool calls/results, images, or + // other agentic protocol blocks into the user's text prompt. + return ""; + } + return readString(record.text) || extractUserText(record.content); +} + +function stripGeneratedImageMarkers(value: string): string { + return value + .replace(/^\s*\[Image\s+\d+\]:\s*\(unavailable\)\s*$/gim, "") + .replace(/^\s*\[Image:\s*source:\s*[^\]\r\n]+\]\s*$/gim, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function buildConolUserTurn(messages: ChatMessage[]): ConolUserTurn { + const latestUserMessage = [...messages] + .reverse() + .find((message) => readString(message.role).toLowerCase() === "user"); + if (!latestUserMessage) return { text: "", imageUrls: [] }; + + return { + text: stripGeneratedImageMarkers(extractUserText(latestUserMessage.content)), + imageUrls: extractImageUrls(latestUserMessage.content), + }; +} + +export function buildConolPromptText(messages: ChatMessage[]): string { + return buildConolUserTurn(messages).text; +} + +function readHeader(headers: Record | null | undefined, name: string): string { + if (!headers) return ""; + const direct = readString(headers[name]); + if (direct) return direct; + const normalizedName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === normalizedName) return readString(value); + } + return ""; +} + +function readMetadataSessionId(metadata: unknown): string { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return ""; + const record = metadata as Record; + const direct = readString(record.session_id) || readString(record.sessionId); + if (direct) return direct; + + const userId = record.user_id; + if (userId && typeof userId === "object" && !Array.isArray(userId)) { + return readString((userId as Record).session_id); + } + if (typeof userId !== "string" || userId.length > 4096) return ""; + try { + const parsed = JSON.parse(userId) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? readString((parsed as Record).session_id) + : ""; + } catch { + return ""; + } +} + +function hashKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function resolveConolClientSessionKey( + body: ConolRequestBody, + clientHeaders?: Record | null +): string | null { + const candidates = [ + readHeader(clientHeaders, "x-claude-code-session-id"), + readHeader(clientHeaders, "x-codex-session-id"), + readHeader(clientHeaders, "x-session-id"), + readHeader(clientHeaders, "x_session_id"), + readHeader(clientHeaders, "session-id"), + readHeader(clientHeaders, "session_id"), + readHeader(clientHeaders, "x-omniroute-session-id"), + readHeader(clientHeaders, "x-omniroute-session"), + readMetadataSessionId(body.metadata), + readString(body.conversation_id), + readString(body.conversationId), + readString(body.session_id), + readString(body.sessionId), + readString(body.prompt_cache_key), + readString(body.promptCacheKey), + ]; + const candidate = candidates.find((value) => value.length > 0 && value.length <= 4096); + return candidate ? hashKey(candidate) : null; +} + +function sweepConolSessionBindings(now = Date.now()): void { + for (const [key, binding] of conolSessionBindings) { + if (now - binding.lastUsedAt > CONOL_SESSION_TTL_MS) { + conolSessionBindings.delete(key); + } + } + while (conolSessionBindings.size > CONOL_MAX_SESSION_BINDINGS) { + let oldestKey = ""; + let oldestTime = Number.POSITIVE_INFINITY; + for (const [key, binding] of conolSessionBindings) { + if (binding.lastUsedAt < oldestTime) { + oldestKey = key; + oldestTime = binding.lastUsedAt; + } + } + if (!oldestKey) break; + conolSessionBindings.delete(oldestKey); + } +} + +function getConolSessionBinding(key: string): ConolSessionBinding | null { + sweepConolSessionBindings(); + const binding = conolSessionBindings.get(key); + if (!binding) return null; + binding.lastUsedAt = Date.now(); + return binding; +} + +function setConolSessionBinding( + key: string, + binding: Omit +): void { + conolSessionBindings.set(key, { ...binding, lastUsedAt: Date.now() }); + sweepConolSessionBindings(); +} + +/** + * Model/effort are deliberately excluded: switching models must re-pin the + * existing Conol session (POST /model) rather than stranding it and losing the + * conversation history. + */ +function buildConolSessionBindingKey( + input: ExecuteInput, + cookie: string, + clientSessionKey: string +): string { + const accountKey = input.credentials.connectionId + ? `connection:${hashKey(input.credentials.connectionId)}` + : `cookie:${hashKey(cookie)}`; + return hashKey(`${accountKey}:${clientSessionKey}`); +} + +async function withConolSessionLock( + key: string | null, + operation: () => Promise +): Promise { + if (!key) return operation(); + + const previous = conolSessionLocks.get(key) ?? Promise.resolve(); + let releaseCurrent!: () => void; + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const current = previous.catch(() => undefined).then(() => currentGate); + conolSessionLocks.set(key, current); + await previous.catch(() => undefined); + + try { + return await operation(); + } finally { + releaseCurrent(); + if (conolSessionLocks.get(key) === current) { + conolSessionLocks.delete(key); + } + } +} + +export function clearConolSessionBindingsForTests(): void { + conolSessionBindings.clear(); + conolSessionLocks.clear(); +} + +/** True when this turn continues a session we created on an earlier request. */ +function reusedSessionCandidate( + cachedBinding: ConolSessionBinding | null, + sessionId: string +): boolean { + return !!cachedBinding && cachedBinding.upstreamSessionId === sessionId; +} + +function messageText(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const message = value as Record; + if (readString(message.role).toLowerCase() !== "assistant") return ""; + return extractText(message.content).trim(); +} + +function stageAssistantText(stages: unknown, field: "logs" | "preview"): string { + if (!Array.isArray(stages)) return ""; + let result = ""; + for (const stage of stages) { + if (!stage || typeof stage !== "object" || Array.isArray(stage)) continue; + const entries = (stage as Record)[field]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + const text = messageText(entry); + if (text) result = text; + } + } + return result; +} + +function parseEventLine(originalLine: string): unknown | null { + let line = originalLine.trim(); + if (!line || line.startsWith(":") || line.startsWith("event:")) return null; + if (line.startsWith("data:")) line = line.slice(5).trim(); + if (line.startsWith("message\t")) line = line.slice("message\t".length); + if (!line) return null; + if (line === "[DONE]") return { type: "done" }; + try { + return JSON.parse(line); + } catch { + // Ignore non-JSON keepalive and timestamp lines. + return null; + } +} + +function isDoneEvent(value: unknown): boolean { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + readString((value as Record).type) === "done" + ); +} + +function parseEventLines(raw: string): unknown[] { + const events: unknown[] = []; + for (const line of raw.replace(/\r\n/g, "\n").split("\n")) { + const event = parseEventLine(line); + if (event) events.push(event); + } + return events; +} + +/** + * Conol emits a terminal `done` event but keeps the HTTP stream open. Reading + * `response.text()` therefore waits until the request timeout even though the + * assistant answer is already complete. Consume complete lines and cancel the + * reader as soon as `done` arrives. + */ +export async function collectConolMessageStream(response: Response): Promise { + if (!response.body) return response.text(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const lines: string[] = []; + let pending = ""; + let totalBytes = 0; + let doneEventReceived = false; + + try { + while (!doneEventReceived) { + const chunk = await reader.read(); + if (chunk.done) { + pending += decoder.decode(); + break; + } + + totalBytes += chunk.value.byteLength; + if (totalBytes > CONOL_MAX_STREAM_BYTES) { + throw new Error("Conol message stream exceeded the safety limit"); + } + pending += decoder.decode(chunk.value, { stream: true }); + const completeLines = pending.split(/\r?\n/); + pending = completeLines.pop() ?? ""; + for (const line of completeLines) { + lines.push(line); + if (isDoneEvent(parseEventLine(line))) { + doneEventReceived = true; + break; + } + } + } + + if (!doneEventReceived && pending) lines.push(pending); + } finally { + if (doneEventReceived) { + try { + await reader.cancel(); + } catch { + // The upstream may close at the same instant as its done event. + } + } else { + reader.releaseLock(); + } + } + + return lines.join("\n"); +} + +export function parseConolMessageStream(raw: string): ParsedConolStream { + let finalizedText = ""; + let previewText = ""; + let streamedText = ""; + let usedTokens: number | null = null; + let contextWindow: number | null = null; + let modelId = ""; + let done = false; + + for (const value of parseEventLines(raw)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const event = value as Record; + const type = readString(event.type); + if (type === "done") { + done = true; + continue; + } + + const finalCandidate = stageAssistantText(event.stages, "logs"); + const previewCandidate = stageAssistantText(event.stages, "preview"); + if (finalCandidate) finalizedText = finalCandidate; + if (previewCandidate) previewText = previewCandidate; + + if (type === "assistant") { + const direct = extractText(event.content ?? event.message ?? event.text).trim(); + if (direct) finalizedText = direct; + } else if (type === "stream_event") { + const delta = extractText(event.delta ?? event.content ?? event.text); + if (delta) streamedText += delta; + } + + const context = + event.contextUsage && + typeof event.contextUsage === "object" && + !Array.isArray(event.contextUsage) + ? (event.contextUsage as Record) + : null; + if (context) { + const used = Number(context.usedTokens); + const window = Number(context.contextWindow); + if (Number.isFinite(used)) usedTokens = used; + if (Number.isFinite(window)) contextWindow = window; + modelId = readString(context.modelId) || modelId; + } + } + + return { + text: finalizedText || previewText || streamedText, + usedTokens, + contextWindow, + modelId, + done, + }; +} + +function conolHeaders( + cookie: string, + extra?: Record, + sessionId?: string +): Record { + return { + accept: "application/json", + "accept-language": "en-US,en;q=0.9", + cookie, + origin: CONOL_ORIGIN, + referer: sessionId + ? `${CONOL_ORIGIN}/home?chat_session=${encodeURIComponent(sessionId)}` + : `${CONOL_ORIGIN}/home`, + "user-agent": USER_AGENT, + ...extra, + }; +} + +function safeTimezone(value: unknown): string { + const explicit = readString(value); + if (/^[A-Za-z_+-]+(?:\/[A-Za-z0-9_+-]+)*$/.test(explicit)) return explicit; + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +async function uploadConolImages( + cookie: string, + imageUrls: string[], + signal?: AbortSignal | null, + sessionId?: string +): Promise { + const images = await resolveCursorImages(imageUrls); + const parts: ConolMessagePart[] = []; + for (const image of images) { + const response = await fetch(`${CONOL_ORIGIN}/api/assets`, { + method: "POST", + headers: conolHeaders( + cookie, + { + accept: "application/json", + "content-type": image.mimeType, + }, + sessionId + ), + body: image.data, + signal: signal ?? undefined, + }); + if (!response.ok) { + throw new Error(`Conol image upload failed (HTTP ${response.status})`); + } + const payload = (await response.json()) as Record; + const id = readString(payload.id); + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error("Conol image upload returned an invalid asset ID"); + } + parts.push({ + type: "image", + content: `/api/assets/${id}`, + mediaType: readString(payload.mediaType) || image.mimeType, + }); + } + return parts; +} + +function estimateTokens(text: string): number { + return Math.max(0, Math.ceil(text.length / 4)); +} + +function completionResponse( + text: string, + model: string, + sessionId: string, + prompt: string +): Response { + const promptTokens = estimateTokens(prompt); + const completionTokens = estimateTokens(text); + return new Response( + JSON.stringify({ + id: `chatcmpl-conol-${sessionId}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: text }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +function streamResponse(text: string, model: string, sessionId: string): Response { + const encoder = new TextEncoder(); + const id = `chatcmpl-conol-${sessionId}`; + const created = Math.floor(Date.now() / 1000); + const readable = new ReadableStream({ + start(controller) { + const chunks = [ + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ]; + for (const chunk of chunks) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(readable, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +} + +export class ConolWebExecutor extends BaseExecutor { + constructor() { + super("conol-web", { id: "conol-web", baseUrl: CONOL_SESSION_URL }); + } + + async execute(input: ExecuteInput) { + const requestBody = (input.body || {}) as ConolRequestBody; + const messages = Array.isArray(requestBody.messages) ? requestBody.messages : []; + const userTurn = buildConolUserTurn(messages); + const prompt = userTurn.text; + const imageUrls = userTurn.imageUrls; + if (!prompt && imageUrls.length === 0) { + return makeErrorResult( + 400, + "No user message found", + { model: input.model }, + CONOL_SESSION_URL + ); + } + + const { cookie } = resolveConolCredentials(input.credentials); + if (!cookie) { + return makeErrorResult( + 401, + "Missing Conol session cookie — sign in with the browser or paste the Cookie header", + { model: input.model }, + CONOL_SESSION_URL + ); + } + + const { model, effort, effortExplicit } = resolveConolModelSelection( + input.model || requestBody.model + ); + const clientSessionKey = resolveConolClientSessionKey(requestBody, input.clientHeaders); + const sessionBindingKey = clientSessionKey + ? buildConolSessionBindingKey(input, cookie, clientSessionKey) + : null; + const timeoutSignal = AbortSignal.timeout(CONOL_REQUEST_TIMEOUT_MS); + const upstreamSignal = input.signal + ? mergeAbortSignals(input.signal, timeoutSignal) + : timeoutSignal; + try { + return await withConolSessionLock(sessionBindingKey, async () => { + if (upstreamSignal.aborted) { + throw upstreamSignal.reason ?? new DOMException("Aborted", "AbortError"); + } + + const cachedBinding = sessionBindingKey ? getConolSessionBinding(sessionBindingKey) : null; + let sessionId = cachedBinding?.upstreamSessionId || ""; + let reusedSession = false; + let presetApplied = cachedBinding?.presetApplied ?? false; + let appliedModel = cachedBinding?.appliedModel ?? ""; + let appliedEffort: ConolEffort | null = cachedBinding?.appliedEffort ?? null; + const imageParts = await uploadConolImages( + cookie, + imageUrls, + upstreamSignal, + sessionId || undefined + ); + const parts: ConolMessagePart[] = [...imageParts]; + if (prompt) parts.push({ type: "text", content: prompt }); + const timezone = safeTimezone(requestBody.timezone); + // Sticky: once a session has carried an image, Conol keeps treating it as + // multimodal, which drives preset text/multimodal model resolution. + const hasImageHistory = + (cachedBinding?.hasImageHistory ?? false) || imageParts.length > 0; + + // Conol ignores agentModel/agentEffort on session creation, so create the + // session empty and configure it before any turn is submitted. Otherwise the + // very first turn silently runs on the downgraded account default. + if (!sessionId) { + const createResponse = await fetch(CONOL_SESSION_URL, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ source: { type: "home" }, messages: [], timezone }), + signal: upstreamSignal, + }); + if (createResponse.status === 401 || createResponse.status === 403) { + return makeErrorResult( + createResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + CONOL_SESSION_URL + ); + } + if (!createResponse.ok) { + return makeErrorResult( + createResponse.status, + `Conol session creation failed (HTTP ${createResponse.status})`, + { model }, + CONOL_SESSION_URL + ); + } + + const created = (await createResponse.json()) as Record; + sessionId = readString(created.sessionId); + if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) { + return makeErrorResult( + 502, + "Conol returned an invalid session identifier", + { model }, + CONOL_SESSION_URL + ); + } + presetApplied = false; + appliedModel = ""; + appliedEffort = null; + } + + const plan = buildConolSessionModelPlan({ model, effort, hasImageHistory }); + const desiredEffort = plan.effort?.agentEffort ?? null; + // Re-pin only on a real change: a new session, a model switch, or an + // effort switch. Steady-state follow-ups cost no extra round trips. + const needsModelUpdate = + !presetApplied || appliedModel !== model || appliedEffort !== desiredEffort; + if (needsModelUpdate) { + const configured = await applyConolSessionModel({ + sessionId, + plan, + skipPreset: presetApplied, + buildHeaders: (id) => conolHeaders(cookie, undefined, id), + signal: upstreamSignal, + onWarning: (message) => input.log?.warn?.("conol-web", message), + }); + presetApplied = presetApplied || configured.presetApplied; + if (configured.modelApplied) { + appliedModel = model; + appliedEffort = configured.effortApplied; + } + } + + if (reusedSessionCandidate(cachedBinding, sessionId)) { + const followUpUrl = `${CONOL_SESSION_URL}/${sessionId}/messages`; + const followUpResponse = await fetch(followUpUrl, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }, sessionId), + body: JSON.stringify({ messages: parts, timezone }), + signal: upstreamSignal, + }); + if (followUpResponse.status === 401 || followUpResponse.status === 403) { + return makeErrorResult( + followUpResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + followUpUrl + ); + } + if (followUpResponse.status === 404 || followUpResponse.status === 410) { + if (sessionBindingKey) conolSessionBindings.delete(sessionBindingKey); + return makeErrorResult( + followUpResponse.status, + "Conol session no longer exists — retry to start a new session", + { model, sessionId }, + followUpUrl + ); + } + if (!followUpResponse.ok) { + return makeErrorResult( + followUpResponse.status, + `Conol follow-up submission failed (HTTP ${followUpResponse.status})`, + { model, sessionId }, + followUpUrl + ); + } + reusedSession = true; + await followUpResponse.body?.cancel().catch(() => undefined); + } else { + const firstTurnUrl = `${CONOL_SESSION_URL}/${sessionId}/messages`; + const firstTurnResponse = await fetch(firstTurnUrl, { + method: "POST", + headers: conolHeaders(cookie, { "content-type": "application/json" }, sessionId), + body: JSON.stringify({ messages: parts, timezone }), + signal: upstreamSignal, + }); + if (firstTurnResponse.status === 401 || firstTurnResponse.status === 403) { + return makeErrorResult( + firstTurnResponse.status, + "Conol session expired or is invalid — sign in again", + { model }, + firstTurnUrl + ); + } + if (!firstTurnResponse.ok) { + return makeErrorResult( + firstTurnResponse.status, + `Conol message submission failed (HTTP ${firstTurnResponse.status})`, + { model, sessionId }, + firstTurnUrl + ); + } + await firstTurnResponse.body?.cancel().catch(() => undefined); + } + + if (sessionBindingKey) { + setConolSessionBinding(sessionBindingKey, { + upstreamSessionId: sessionId, + presetApplied, + appliedModel, + appliedEffort, + hasImageHistory, + }); + } + + const messagesUrl = `${CONOL_SESSION_URL}/${sessionId}/messages?logDeltas=1`; + const messageResponse = await fetch(messagesUrl, { + method: "GET", + headers: conolHeaders( + cookie, + { accept: "text/event-stream, application/x-ndjson" }, + sessionId + ), + signal: upstreamSignal, + }); + if (!messageResponse.ok) { + if ( + sessionBindingKey && + (messageResponse.status === 404 || messageResponse.status === 410) + ) { + conolSessionBindings.delete(sessionBindingKey); + } + return makeErrorResult( + messageResponse.status, + `Conol message stream failed (HTTP ${messageResponse.status})`, + { model, sessionId }, + messagesUrl + ); + } + + const parsed = parseConolMessageStream(await collectConolMessageStream(messageResponse)); + if (!parsed.text) { + return makeErrorResult( + 502, + "Conol returned no assistant response", + { model, sessionId }, + messagesUrl + ); + } + const response = input.stream + ? streamResponse(parsed.text, model, sessionId) + : completionResponse(parsed.text, model, sessionId, prompt); + + return { + response, + url: messagesUrl, + headers: { cookie: "***" }, + transformedBody: { + model, + ...(appliedEffort ? { effort: appliedEffort } : {}), + effortRequested: effort, + effortExplicit, + sessionId, + reusedSession, + clientSessionBound: sessionBindingKey !== null, + imageCount: imageParts.length, + }, + }; + }); + } catch (error) { + const isTimeout = error instanceof Error && error.name === "TimeoutError"; + const status = error instanceof CursorImageError ? error.status : isTimeout ? 504 : 502; + const message = + error instanceof CursorImageError + ? error.message + : isTimeout + ? "Conol request timed out" + : error instanceof Error && error.name === "AbortError" + ? "Conol request was cancelled" + : "Conol request failed"; + return makeErrorResult(status, message, { model }, CONOL_SESSION_URL); + } + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 1a1d5140ea..a42abc80d1 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -73,6 +73,7 @@ import { TinyCmsExecutor } from "./tinycms.ts"; import { HyperAgentExecutor } from "./hyperagent.ts"; import { XaiExecutor } from "./xai.ts"; import { PromptQlExecutor } from "./promptql.ts"; +import { ConolWebExecutor } from "./conol-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -209,6 +210,9 @@ const executors = { xai: new XaiExecutor(), "xai-oauth": new XaiExecutor("xai-oauth"), xao: new XaiExecutor("xai-oauth"), + qw: new QwenWebExecutor(), // Alias + "conol-web": new ConolWebExecutor(), + cnl: new ConolWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -306,3 +310,4 @@ export { XaiExecutor } from "./xai.ts"; export { MoonshotExecutor } from "./moonshot.ts"; export { CheaperInferenceExecutor } from "./cheaperinference.ts"; export { PromptQlExecutor } from "./promptql.ts"; +export { ConolWebExecutor } from "./conol-web.ts"; diff --git a/open-sse/services/conolAuth.ts b/open-sse/services/conolAuth.ts new file mode 100644 index 0000000000..9e44a5ff96 --- /dev/null +++ b/open-sse/services/conolAuth.ts @@ -0,0 +1,55 @@ +export const CONOL_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"; + +export interface ConolCredentialInput { + apiKey?: unknown; + accessToken?: unknown; + cookie?: unknown; + providerSpecificData?: unknown; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readStoredValue(value: unknown): string { + const raw = readString(value); + if (!raw || !raw.startsWith("{")) return raw; + try { + const parsed = JSON.parse(raw) as Record; + return ( + readString(parsed.cookie) || + readString(parsed[CONOL_SESSION_COOKIE_NAME]) || + readString(parsed.sessionToken) + ); + } catch { + return raw; + } +} + +export function normalizeConolCookie(rawValue: string): string { + const raw = readStoredValue(rawValue).replace(/^Cookie:\s*/i, "").trim(); + if (!raw) return ""; + if (raw.includes("=")) return raw; + return `${CONOL_SESSION_COOKIE_NAME}=${raw}`; +} + +export function resolveConolCredentials(credentials?: ConolCredentialInput): { + cookie: string; +} { + const providerData = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record) + : {}; + + const raw = + readStoredValue(providerData.cookie) || + readStoredValue(providerData[CONOL_SESSION_COOKIE_NAME]) || + readStoredValue(providerData.sessionToken) || + readStoredValue(credentials?.cookie) || + readStoredValue(credentials?.apiKey) || + readStoredValue(credentials?.accessToken); + + return { cookie: normalizeConolCookie(raw) }; +} diff --git a/open-sse/services/conolBrowserLogin.ts b/open-sse/services/conolBrowserLogin.ts new file mode 100644 index 0000000000..7444591c64 --- /dev/null +++ b/open-sse/services/conolBrowserLogin.ts @@ -0,0 +1,120 @@ +import { CONOL_SESSION_COOKIE_NAME } from "./conolAuth.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +const CONOL_HOME_URL = "https://conol.ai/home"; +const DEFAULT_LOGIN_TIMEOUT_MS = 300_000; +const MIN_LOGIN_TIMEOUT_MS = 15_000; +const MAX_LOGIN_TIMEOUT_MS = 600_000; +const POLL_INTERVAL_MS = 1_000; + +interface BrowserCookieLike { + name: string; + value: string; + domain?: string; +} + +export interface ConolBrowserLoginResult { + success: boolean; + credentials?: { cookie: string }; + error?: string; +} + +type BrowserLauncher = Pick; + +function clampTimeout(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LOGIN_TIMEOUT_MS; + return Math.max(MIN_LOGIN_TIMEOUT_MS, Math.min(MAX_LOGIN_TIMEOUT_MS, Math.trunc(value))); +} + +export function extractConolBrowserCredentials( + cookies: BrowserCookieLike[] +): { cookie: string } | null { + const session = cookies.find( + (candidate) => + candidate.name === CONOL_SESSION_COOKIE_NAME && + (!candidate.domain || candidate.domain === "conol.ai" || candidate.domain.endsWith(".conol.ai")) + ); + const value = session?.value?.trim() || ""; + if (!value || /[\r\n;]/.test(value)) return null; + return { cookie: `${CONOL_SESSION_COOKIE_NAME}=${value}` }; +} + +export async function launchConolLoginBrowser( + playwright: BrowserLauncher +): Promise { + const configuredPath = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim(); + const attempts: Array> = [ + ...(configuredPath ? [{ headless: false, executablePath: configuredPath }] : []), + { headless: false, channel: "chrome" }, + { headless: false, channel: "msedge" }, + { headless: false }, + ]; + + let lastError: unknown; + for (const options of attempts) { + try { + return await playwright.chromium.launch(options); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new Error("No compatible browser is available for sign-in"); +} + +export async function startConolBrowserLogin( + requestedTimeout?: unknown +): Promise { + const timeout = clampTimeout(requestedTimeout); + let playwright: typeof import("playwright"); + try { + playwright = await import("playwright"); + } catch { + return { + success: false, + error: "Browser sign-in is unavailable. Paste the Conol Cookie header instead.", + }; + } + + let browser: import("playwright").Browser | null = null; + try { + browser = await launchConolLoginBrowser(playwright); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "en-US", + }); + const page = await context.newPage(); + await page.goto(CONOL_HOME_URL, { + waitUntil: "domcontentloaded", + timeout: Math.min(timeout, 60_000), + }); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const credentials = extractConolBrowserCredentials( + await context.cookies(["https://conol.ai"]) + ); + if (credentials) return { success: true, credentials }; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return { + success: false, + error: "Conol sign-in timed out. Complete login in the opened browser and try again.", + }; + } catch (error) { + return { + success: false, + error: sanitizeErrorMessage(error instanceof Error ? error.message : error), + }; + } finally { + if (browser) { + try { + await browser.close(); + } catch { + // The user may close the login window before extraction completes. + } + } + } +} diff --git a/open-sse/services/conolModels.ts b/open-sse/services/conolModels.ts new file mode 100644 index 0000000000..47d87e5c15 --- /dev/null +++ b/open-sse/services/conolModels.ts @@ -0,0 +1,310 @@ +import { CONOL_SESSION_COOKIE_NAME, normalizeConolCookie } from "./conolAuth.ts"; + +export type ConolEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** Ordered weakest → strongest. Used to clamp a requested effort onto a model. */ +export const CONOL_EFFORT_ORDER: readonly ConolEffort[] = [ + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + +export interface ConolModel { + id: string; + name: string; + supportsVision?: boolean; + /** Efforts the upstream advertises for this model. Empty means "not tunable". */ + efforts?: ConolEffort[]; +} + +export interface ConolModelDiscovery { + agentServerId: string; + defaultModel: string; + models: ConolModel[]; + modelPresets: ConolModelPreset[]; +} + +export interface ConolModelPreset { + id: string; + text?: string; + multimodal?: string; +} + +/** Effort ladders observed on https://conol.ai/api/agent-servers (2026-07-30). */ +const EFFORTS_XHIGH: ConolEffort[] = ["low", "medium", "high", "xhigh"]; +const EFFORTS_STANDARD: ConolEffort[] = ["minimal", "low", "medium", "high"]; +const EFFORTS_NO_XHIGH: ConolEffort[] = ["low", "medium", "high"]; +const EFFORTS_HIGH_ONLY: ConolEffort[] = ["high", "xhigh"]; +const EFFORTS_PRO: ConolEffort[] = ["medium", "high", "xhigh"]; + +interface FallbackModelSeed { + id: string; + vision: boolean; + efforts: ConolEffort[]; +} + +const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [ + { id: "claude-opus-5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-opus-4-8", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-fable-5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-opus-4-7", vision: true, efforts: EFFORTS_XHIGH }, + { id: "claude-sonnet-5", vision: true, efforts: EFFORTS_NO_XHIGH }, + { id: "claude-sonnet-4-6", vision: true, efforts: EFFORTS_NO_XHIGH }, + { id: "claude-haiku-4-5", vision: true, efforts: EFFORTS_STANDARD }, + { id: "gpt-5.5", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.5-pro", vision: true, efforts: EFFORTS_PRO }, + { id: "gpt-5.6-sol", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.6-terra", vision: true, efforts: EFFORTS_XHIGH }, + { id: "gpt-5.6-luna", vision: true, efforts: EFFORTS_XHIGH }, + { id: "deepseek/deepseek-v4-pro", vision: false, efforts: EFFORTS_HIGH_ONLY }, + { id: "openrouter/fusion", vision: false, efforts: [] }, + { id: "z-ai/glm-5.2", vision: false, efforts: EFFORTS_STANDARD }, + { id: "z-ai/glm-5.1", vision: false, efforts: EFFORTS_STANDARD }, + { id: "tencent/hy3", vision: false, efforts: EFFORTS_STANDARD }, + { id: "moonshotai/kimi-k3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "moonshotai/kimi-k2.7-code", vision: true, efforts: EFFORTS_STANDARD }, + { id: "qwen/qwen3.7-plus", vision: true, efforts: EFFORTS_STANDARD }, + { id: "qwen/qwen3.7-max", vision: false, efforts: EFFORTS_STANDARD }, + { id: "minimax/minimax-m3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "stepfun/step-3.7-flash", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.5-flash", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.1-pro-preview", vision: true, efforts: EFFORTS_STANDARD }, + { id: "google/gemini-3.1-flash-lite", vision: true, efforts: EFFORTS_STANDARD }, + { id: "x-ai/grok-4.3", vision: true, efforts: EFFORTS_STANDARD }, + { id: "deepseek/deepseek-v4-flash", vision: false, efforts: EFFORTS_HIGH_ONLY }, + { id: "xiaomi/mimo-v2.5", vision: true, efforts: EFFORTS_STANDARD }, + { id: "xiaomi/mimo-v2.5-pro", vision: false, efforts: EFFORTS_STANDARD }, +]; + +/** Presets exposed by the web client's model picker (id → text/multimodal model). */ +export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [ + { id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" }, + { id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" }, + { id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" }, + { id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" }, +]; + +function modelName(id: string): string { + return id + .split("/") + .pop()! + .split("-") + .map((part) => { + const lower = part.toLowerCase(); + if (["gpt", "ai", "glm"].includes(lower)) return lower.toUpperCase(); + return part.length ? part[0]!.toUpperCase() + part.slice(1) : part; + }) + .join(" "); +} + +export const CONOL_FALLBACK_MODELS: ConolModel[] = FALLBACK_MODEL_SEEDS.map((seed) => ({ + id: seed.id, + name: modelName(seed.id), + supportsVision: seed.vision, + efforts: [...seed.efforts], +})); + +const CONOL_FALLBACK_EFFORTS = new Map( + FALLBACK_MODEL_SEEDS.map((seed) => [seed.id, seed.efforts]) +); + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toEfforts(value: unknown): ConolEffort[] | null { + if (!Array.isArray(value)) return null; + const efforts = value + .map((entry) => readString(entry).toLowerCase()) + .filter((entry): entry is ConolEffort => + (CONOL_EFFORT_ORDER as readonly string[]).includes(entry) + ); + // Normalize to the canonical weakest→strongest order and de-duplicate. + return CONOL_EFFORT_ORDER.filter((effort) => efforts.includes(effort)); +} + +function toModel(value: unknown): ConolModel | null { + if (typeof value === "string") { + const id = value.trim(); + return id ? { id, name: modelName(id) } : null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + const id = + readString(item.id) || + readString(item.modelId) || + readString(item.value) || + readString(item.name); + if (!id) return null; + const inputModalities = Array.isArray(item.inputModalities) + ? item.inputModalities.filter((modality): modality is string => typeof modality === "string") + : null; + const efforts = toEfforts(item.efforts); + return { + id, + name: readString(item.displayName) || readString(item.name) || modelName(id), + ...(inputModalities + ? { supportsVision: inputModalities.some((modality) => modality.toLowerCase() === "image") } + : {}), + ...(efforts ? { efforts } : {}), + }; +} + +function toModelPreset(value: unknown): ConolModelPreset | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + const id = readString(item.id); + if (!id) return null; + const text = readString(item.text); + const multimodal = readString(item.multimodal); + return { id, ...(text ? { text } : {}), ...(multimodal ? { multimodal } : {}) }; +} + +/** + * Clamp a requested effort onto the ladder a model actually advertises. + * Returns `null` when the model exposes no effort control at all. + */ +export function clampConolEffort( + requested: ConolEffort, + supported: readonly ConolEffort[] | undefined +): ConolEffort | null { + const ladder = + supported && supported.length + ? CONOL_EFFORT_ORDER.filter((effort) => supported.includes(effort)) + : []; + if (!ladder.length) return null; + if (ladder.includes(requested)) return requested; + + const requestedRank = CONOL_EFFORT_ORDER.indexOf(requested); + // Prefer the strongest supported effort at or below the request; otherwise the weakest above. + let below: ConolEffort | null = null; + for (const effort of ladder) { + if (CONOL_EFFORT_ORDER.indexOf(effort) <= requestedRank) below = effort; + } + return below ?? ladder[0]!; +} + +/** Effort ladder for a model id, using discovery data when available. */ +export function conolEffortsForModel( + modelId: string, + discovered?: readonly ConolModel[] +): ConolEffort[] { + const fromDiscovery = discovered?.find((model) => model.id === modelId)?.efforts; + if (fromDiscovery) return [...fromDiscovery]; + return [...(CONOL_FALLBACK_EFFORTS.get(modelId) ?? [])]; +} + +export function parseConolAgentServers(payload: unknown): ConolModelDiscovery { + const root = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" + ? ((payload as Record).agentServers ?? + (payload as Record).servers ?? + []) + : []; + const servers = Array.isArray(root) ? root : []; + const server = servers.find( + (value) => value && typeof value === "object" && !Array.isArray(value) + ) as Record | undefined; + const capabilities = + server?.capabilities && + typeof server.capabilities === "object" && + !Array.isArray(server.capabilities) + ? (server.capabilities as Record) + : null; + const agents = Array.isArray(capabilities?.agents) ? capabilities.agents : []; + const defaultAgent = readString(capabilities?.defaultAgent); + const agent = (agents.find((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return readString((value as Record).name) === defaultAgent; + }) ?? agents[0]) as Record | undefined; + + const seen = new Set(); + const rawModels = Array.isArray(agent?.models) + ? agent.models + : Array.isArray(server?.models) + ? server.models + : []; + const models = rawModels.map(toModel).filter((model): model is ConolModel => { + if (!model || seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); + + const rawPresets = Array.isArray(agent?.modelPresets) ? agent.modelPresets : []; + const seenPresets = new Set(); + const modelPresets = rawPresets + .map(toModelPreset) + .filter((preset): preset is ConolModelPreset => { + if (!preset || seenPresets.has(preset.id)) return false; + seenPresets.add(preset.id); + return true; + }); + + return { + agentServerId: readString(server?.id), + defaultModel: readString(agent?.defaultModel) || readString(server?.defaultModel), + models, + modelPresets, + }; +} + +/** + * Effort applied when the caller does not pin one via the `-` model suffix. + * Clamped per-model, so models without an `xhigh` rung fall back to their strongest rung. + */ +export const CONOL_DEFAULT_EFFORT: ConolEffort = "xhigh"; + +export function resolveConolModelSelection(value: unknown): { + model: string; + effort: ConolEffort; + /** True when the effort came from an explicit `-` suffix rather than the default. */ + effortExplicit: boolean; +} { + let model = readString(value); + if (model.startsWith("conol-web/")) model = model.slice("conol-web/".length); + else if (model.startsWith("conol/")) model = model.slice("conol/".length); + else if (model.startsWith("cnl/")) model = model.slice("cnl/".length); + model ||= "claude-sonnet-5"; + + const effortMatch = model.match(/-(xhigh|high|medium|low|minimal)$/); + if (!effortMatch) return { model, effort: CONOL_DEFAULT_EFFORT, effortExplicit: false }; + return { + model: model.slice(0, -effortMatch[0].length), + effort: effortMatch[1] as ConolEffort, + effortExplicit: true, + }; +} + +export function resolveConolModelId(value: unknown): string { + return resolveConolModelSelection(value).model; +} + +export async function discoverConolModels(options: { + cookie: string; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +}): Promise { + const cookie = normalizeConolCookie(options.cookie); + if (!cookie) throw new Error(`Missing ${CONOL_SESSION_COOKIE_NAME} cookie`); + + const response = await (options.fetchImpl ?? fetch)("https://conol.ai/api/agent-servers", { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: options.signal, + }); + if (!response.ok) { + throw new Error(`Conol model discovery returned HTTP ${response.status}`); + } + const discovered = parseConolAgentServers(await response.json()); + if (!discovered.models.length) { + throw new Error("Conol model discovery returned an empty catalog"); + } + return discovered; +} diff --git a/open-sse/services/conolSessionModel.ts b/open-sse/services/conolSessionModel.ts new file mode 100644 index 0000000000..90371e03ff --- /dev/null +++ b/open-sse/services/conolSessionModel.ts @@ -0,0 +1,148 @@ +/** + * Conol session model/effort configuration. + * + * `POST /api/sessions` ignores `agentModel`/`agentEffort` in its body — a freshly + * created session always starts on the account default and Conol reports the + * downgrade via `modelDowngraded` / `effectiveModel`. The web client therefore + * configures the session out-of-band against `POST /api/sessions/{id}/model`, + * which accepts three distinct payload shapes (verified 2026-07-30): + * + * 1. `{"modelPreset":"pro","hasImageHistory":false}` — picker preset + * 2. `{"agentModel":"claude-fable-5","agentEffort":null}` — pin an explicit model + * 3. `{"agentEffort":"xhigh"}` — pin the effort + * + * Shape 2 resets `agentEffort` to `null`, so the effort call must always follow + * the model call. All three return `{"ok":true}`. + */ +import { + clampConolEffort, + conolEffortsForModel, + type ConolEffort, + type ConolModel, +} from "./conolModels.ts"; + +export const CONOL_ORIGIN = "https://conol.ai"; + +/** Preset the web client sends on every new session before pinning a model. */ +export const CONOL_DEFAULT_MODEL_PRESET = "pro"; + +export type ConolModelPresetId = "flash" | "moderate" | "pro" | "ultra"; + +const KNOWN_PRESETS = new Set(["flash", "moderate", "pro", "ultra"]); + +export function isConolModelPreset(value: string): value is ConolModelPresetId { + return KNOWN_PRESETS.has(value as ConolModelPresetId); +} + +export interface ConolSessionModelPlan { + /** Preset priming call, sent once per session. */ + preset: { modelPreset: string; hasImageHistory: boolean }; + /** Explicit model pin. Always clears effort so the effort call can apply cleanly. */ + model: { agentModel: string; agentEffort: null }; + /** Effort pin, omitted when the model exposes no effort ladder. */ + effort: { agentEffort: ConolEffort } | null; +} + +export interface BuildConolSessionModelPlanOptions { + model: string; + effort: ConolEffort; + hasImageHistory: boolean; + /** Discovery catalog, when available, so effort ladders stay accurate. */ + catalog?: readonly ConolModel[]; + /** Overrides the default `pro` priming preset. */ + modelPreset?: string; +} + +/** + * Build the ordered preset → model → effort payloads for a session. + * Effort is clamped onto the ladder the target model actually advertises, so a + * default of `xhigh` degrades to `high` on models such as `claude-sonnet-5`. + */ +export function buildConolSessionModelPlan( + options: BuildConolSessionModelPlanOptions +): ConolSessionModelPlan { + const supported = conolEffortsForModel(options.model, options.catalog); + const effort = clampConolEffort(options.effort, supported); + return { + preset: { + modelPreset: options.modelPreset || CONOL_DEFAULT_MODEL_PRESET, + hasImageHistory: options.hasImageHistory, + }, + model: { agentModel: options.model, agentEffort: null }, + effort: effort ? { agentEffort: effort } : null, + }; +} + +export function conolSessionModelUrl(sessionId: string): string { + return `${CONOL_ORIGIN}/api/sessions/${encodeURIComponent(sessionId)}/model`; +} + +export interface ApplyConolSessionModelOptions { + sessionId: string; + plan: ConolSessionModelPlan; + /** Skip the preset priming call when the session was already primed. */ + skipPreset?: boolean; + buildHeaders: (sessionId: string) => Record; + fetchImpl?: typeof fetch; + signal?: AbortSignal | null; + onWarning?: (message: string) => void; +} + +export interface AppliedConolSessionModel { + presetApplied: boolean; + modelApplied: boolean; + effortApplied: ConolEffort | null; +} + +async function postSessionModel( + url: string, + body: unknown, + options: ApplyConolSessionModelOptions +): Promise { + const response = await (options.fetchImpl ?? fetch)(url, { + method: "POST", + headers: { ...options.buildHeaders(options.sessionId), "content-type": "application/json" }, + body: JSON.stringify(body), + signal: options.signal ?? undefined, + }); + // Drain so the socket can be reused; the payload is only `{"ok":true}`. + await response.body?.cancel().catch(() => undefined); + if (!response.ok) { + options.onWarning?.( + `Conol session model update failed (HTTP ${response.status}) for ${JSON.stringify(body)}` + ); + return false; + } + return true; +} + +/** + * Apply preset → model → effort in order. Ordering is load-bearing: the model + * call nulls the effort, so applying effort first would silently drop it. + * Failures are reported but non-fatal — the turn still runs on Conol's default. + */ +export async function applyConolSessionModel( + options: ApplyConolSessionModelOptions +): Promise { + const url = conolSessionModelUrl(options.sessionId); + const applied: AppliedConolSessionModel = { + presetApplied: false, + modelApplied: false, + effortApplied: null, + }; + + if (!options.skipPreset) { + applied.presetApplied = await postSessionModel(url, options.plan.preset, options); + } + + applied.modelApplied = await postSessionModel(url, options.plan.model, options); + + // Only pin effort if the model pin landed; otherwise the session is on an + // unknown model whose effort ladder we cannot reason about. + if (applied.modelApplied && options.plan.effort) { + const ok = await postSessionModel(url, options.plan.effort, options); + if (ok) applied.effortApplied = options.plan.effort.agentEffort; + } + + return applied; +} diff --git a/open-sse/services/conolUsage.ts b/open-sse/services/conolUsage.ts new file mode 100644 index 0000000000..1e3302a7a4 --- /dev/null +++ b/open-sse/services/conolUsage.ts @@ -0,0 +1,111 @@ +import { normalizeConolCookie } from "./conolAuth.ts"; + +interface UsageQuota { + used: number; + total: number; + remaining: number; + remainingPercentage: number; + resetAt: null; + unlimited: boolean; +} + +interface ConolBalance { + dailyCredits?: unknown; + subscriptionCredits?: unknown; + subscriptionAmount?: unknown; + extraCredits?: unknown; + total?: unknown; +} + +interface ConolUsageResult { + plan: string; + quotas: Record<"credits" | "daily" | "subscription" | "extra", UsageQuota>; + message: string | null; +} + +function numberValue(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function remainingQuota(remaining: number, total = remaining): UsageQuota { + const boundedTotal = Math.max(total, remaining); + const used = Math.max(0, boundedTotal - remaining); + return { + used, + total: boundedTotal, + remaining, + remainingPercentage: + boundedTotal > 0 ? Math.round((remaining / boundedTotal) * 1000) / 10 : 0, + resetAt: null, + unlimited: false, + }; +} + +export function buildConolUsageResult(balance: ConolBalance): ConolUsageResult { + const daily = numberValue(balance.dailyCredits); + const subscription = numberValue(balance.subscriptionCredits); + const subscriptionAmount = numberValue(balance.subscriptionAmount); + const extra = numberValue(balance.extraCredits); + const aggregate = numberValue(balance.total) || daily + subscription + extra; + + return { + plan: subscriptionAmount > 0 ? "Subscription" : "Free", + quotas: { + credits: remainingQuota(aggregate), + daily: remainingQuota(daily), + subscription: remainingQuota(subscription, subscriptionAmount || subscription), + extra: remainingQuota(extra), + }, + message: null, + }; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readProviderValue(data: unknown, keys: readonly string[]): string { + if (!data || typeof data !== "object" || Array.isArray(data)) return ""; + const record = data as Record; + for (const key of keys) { + const value = readString(record[key]); + if (value) return value; + } + return ""; +} + +export async function getConolUsage( + apiKey: unknown, + providerSpecificData?: unknown +): Promise { + const raw = + readProviderValue(providerSpecificData, [ + "cookie", + "__Secure-better-auth.session_token", + "sessionToken", + ]) || readString(apiKey); + const cookie = normalizeConolCookie(raw); + if (!cookie) return { message: "Missing Conol session cookie" }; + + try { + const response = await fetch("https://conol.ai/api/billing/balance", { + method: "GET", + headers: { + accept: "application/json", + cookie, + referer: "https://conol.ai/home", + }, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 401 || response.status === 403) { + return { message: "Conol session expired or is invalid" }; + } + if (!response.ok) { + return { message: `Conol balance request failed (HTTP ${response.status})` }; + } + return buildConolUsageResult((await response.json()) as ConolBalance); + } catch { + return { message: "Conol balance request failed" }; + } +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index f7f6d5bdfe..e6979d172b 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -69,6 +69,7 @@ import { getXaiOauthUsage } from "./usage/xaiOauth.ts"; import { getGrokCliUsage } from "./usage/grokCli.ts"; import { getFirecrawlUsage } from "./usage/firecrawl.ts"; import { getCommandCodeUsage } from "./usage/command-code.ts"; +import { getConolUsage } from "./conolUsage.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -133,6 +134,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "firecrawl", // Command Code credits + 5h/weekly windows (GET /alpha/billing/credits) "command-code", + "conol-web", + "cnl", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -234,6 +237,9 @@ export async function getUsageForProvider( return await getFirecrawlUsage(id || "", apiKey, connection); case "command-code": return await getCommandCodeUsage(apiKey || accessToken || ""); + case "conol-web": + case "cnl": + return await getConolUsage(apiKey || accessToken, providerSpecificData); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index 555d503978..3d9294bac6 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -189,6 +189,46 @@ export async function POST( } } + // Conol: unofficial browser-session chat with cookie auth + // (__Secure-better-auth.session_token). Dedicated browser login + credential + // persistence (same shape as the other web-cookie providers). + if (providerSlug === "conol-web" || providerSlug === "cnl") { + try { + const { startConolBrowserLogin } = await import( + "@omniroute/open-sse/services/conolBrowserLogin.ts" + ); + const result = await startConolBrowserLogin( + typeof body.timeout === "number" ? body.timeout : undefined + ); + if (!result.success || !result.credentials) { + return NextResponse.json(result, { status: 400 }); + } + try { + await updateProviderConnection(id, { + apiKey: JSON.stringify(result.credentials), + providerSpecificData: result.credentials, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Extracted but failed to persist: ${msg}` }, + { status: 500 } + ); + } + return NextResponse.json({ + success: true, + credentials: result.credentials, + persisted: true, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Login endpoint error: ${msg}` }, + { status: 500 } + ); + } + } + try { // Generic web-cookie path: pass the provider SLUG (not the DB id) so // TOKEN_EXTRACTION_CONFIGS can find the extraction config. diff --git a/src/app/api/providers/[id]/models/conolDiscovery.ts b/src/app/api/providers/[id]/models/conolDiscovery.ts new file mode 100644 index 0000000000..6de64d5bc5 --- /dev/null +++ b/src/app/api/providers/[id]/models/conolDiscovery.ts @@ -0,0 +1,97 @@ +import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { resolveConolCredentials } from "@omniroute/open-sse/services/conolAuth.ts"; +import { + CONOL_FALLBACK_MODELS, + discoverConolModels, + type ConolModel, +} from "@omniroute/open-sse/services/conolModels.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +interface DiscoveryWarnings { + cacheWarning?: string; + localWarning?: string; +} + +interface ConolDiscoveryRouteOptions { + provider: string; + connectionId: string; + apiKey: unknown; + accessToken: unknown; + providerSpecificData: unknown; + proxy: unknown; + maybeReturnCachedDiscovery: () => Response | null; + maybeReturnAutoFetchDisabled: () => Response | null; + buildDiscoveryFallbackResponse: (warnings: DiscoveryWarnings) => Response | null; + buildResponse: (payload: Record) => Response; + buildApiDiscoveryResponse: (models: ConolModel[]) => Promise; +} + +export async function maybeHandleConolModelDiscovery( + options: ConolDiscoveryRouteOptions +): Promise { + if (options.provider !== "conol-web" && options.provider !== "cnl") return null; + + const cachedResponse = options.maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = options.maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + const { cookie } = resolveConolCredentials({ + apiKey: options.apiKey, + accessToken: options.accessToken, + providerSpecificData: options.providerSpecificData, + }); + const seedModels = CONOL_FALLBACK_MODELS.map((model) => ({ + id: model.id, + name: model.name, + supportsVision: model.supportsVision, + })); + if (!cookie) { + const fallback = options.buildDiscoveryFallbackResponse({ + cacheWarning: "No Conol cookie configured — using cached catalog", + localWarning: "No Conol cookie configured — using local catalog", + }); + if (fallback) return fallback; + return options.buildResponse({ + provider: options.provider, + connectionId: options.connectionId, + models: seedModels, + source: "local_catalog", + intentional: true, + warning: "No Conol session cookie — using seed model list", + }); + } + + try { + const discovery = await discoverConolModels({ + cookie, + fetchImpl: (url, init) => + safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: options.proxy, + ...init, + }), + }); + return options.buildApiDiscoveryResponse(discovery.models); + } catch (error) { + console.log("Error fetching models from conol-web", { + error: sanitizeErrorMessage(error instanceof Error ? error.message : error), + }); + const fallback = options.buildDiscoveryFallbackResponse({ + cacheWarning: "Conol model discovery failed — using cached catalog", + localWarning: "Conol model discovery failed — using seed catalog", + }); + if (fallback) return fallback; + return options.buildResponse({ + provider: options.provider, + connectionId: options.connectionId, + models: seedModels, + source: "local_catalog", + intentional: true, + warning: "API unavailable — using seed Conol model list", + }); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8adc370207..0e8c198de9 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -127,6 +127,7 @@ import { fetchCodexDiscoveryModels, fetchCodexGithubCatalogModels, } from "./discovery/codex"; +import { maybeHandleConolModelDiscovery } from "./conolDiscovery"; function toLiveModel(item: Record): { id: string; name: string } | null { const itemId = typeof item.id === "string" ? item.id.trim() : ""; @@ -667,6 +668,20 @@ export async function GET( }); } } + const conolResponse = await maybeHandleConolModelDiscovery({ + provider, + connectionId, + apiKey, + accessToken, + providerSpecificData: connection.providerSpecificData, + proxy, + maybeReturnCachedDiscovery, + maybeReturnAutoFetchDisabled, + buildDiscoveryFallbackResponse, + buildResponse, + buildApiDiscoveryResponse, + }); + if (conolResponse) return conolResponse; if (provider === "bedrock") { const cachedResponse = maybeReturnCachedDiscovery(); diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index de36dc4d80..cb5aba25af 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -165,7 +165,9 @@ function getRegistryModel(providerIdOrAlias: string | null, modelId: string | nu const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias; const models = PROVIDER_MODELS[providerAlias]; if (!Array.isArray(models)) return null; - return models.find((model) => model?.id === modelId) || null; + const normalizedModelId = + providerAlias === "cnl" ? modelId.replace(/-(?:xhigh|high|medium|low)$/i, "") : modelId; + return models.find((model) => model?.id === normalizedModelId) || null; } function resolveCapabilityInput(input: CapabilityInput) { diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index ad3914b3f9..16f884f9de 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -95,6 +95,8 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "firecrawl", // Command Code API key → /alpha/billing/credits + windowLimits "command-code", + "conol-web", + "cnl", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 84f5b4b83c..9d6b15b824 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -54,6 +54,7 @@ export function supportsDualAuthProvider(providerId: unknown): boolean { // Web / Cookie Providers + // API Key Providers // Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views). @@ -470,6 +471,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "firecrawl", // Command Code credits + 5h/weekly rolling windows "command-code", + "conol-web", + "cnl", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 3b1a7f8837..08876dc917 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -466,6 +466,19 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Paste the full Cookie header from hyperagent.com (DevTools → Network → any request → Request Headers → Cookie). Session cookies power chat + billing usage.", }, + "conol-web": { + id: "conol-web", + alias: "cnl", + name: "Conol (Unofficial/Experimental)", + icon: "auto_awesome", + color: "#F6C945", + textIcon: "CO", + website: "https://conol.ai", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + authHint: + "Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required.", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index a6ec0abb40..9e7f1884fc 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -325,6 +325,14 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "token", "access_token", "accessToken"], }, + "conol-web": { + kind: "cookie", + credentialName: "__Secure-better-auth.session_token", + placeholder: + "__Secure-better-auth.session_token=... or full Cookie header from conol.ai", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "__Secure-better-auth.session_token"], + }, } satisfies Record & Record; diff --git a/tests/unit/conol-web.test.ts b/tests/unit/conol-web.test.ts new file mode 100644 index 0000000000..8ed8f2b9cb --- /dev/null +++ b/tests/unit/conol-web.test.ts @@ -0,0 +1,963 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildConolUserTurn, + buildConolPromptText, + clearConolSessionBindingsForTests, + collectConolMessageStream, + ConolWebExecutor, + normalizeConolCookie, + parseConolMessageStream, + resolveConolClientSessionKey, + resolveConolCredentials, +} from "../../open-sse/executors/conol-web.ts"; +import { + CONOL_FALLBACK_MODELS, + clampConolEffort, + parseConolAgentServers, + resolveConolModelSelection, +} from "../../open-sse/services/conolModels.ts"; +import { buildConolSessionModelPlan } from "../../open-sse/services/conolSessionModel.ts"; +import { buildConolUsageResult } from "../../open-sse/services/conolUsage.ts"; +import { extractConolBrowserCredentials } from "../../open-sse/services/conolBrowserLogin.ts"; +import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.ts"; +import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; + +const SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"; + +describe("Conol web provider", () => { + it("normalizes raw, full-header, JSON, and provider-data credentials", () => { + assert.equal(normalizeConolCookie("token-value"), `${SESSION_COOKIE_NAME}=token-value`); + assert.equal( + normalizeConolCookie(`Cookie: preference=compact; ${SESSION_COOKIE_NAME}=token-value`), + `preference=compact; ${SESSION_COOKIE_NAME}=token-value` + ); + assert.deepEqual( + resolveConolCredentials({ + apiKey: JSON.stringify({ cookie: `${SESSION_COOKIE_NAME}=json-token` }), + }), + { cookie: `${SESSION_COOKIE_NAME}=json-token` } + ); + assert.deepEqual( + resolveConolCredentials({ + providerSpecificData: { [SESSION_COOKIE_NAME]: "provider-token" }, + }), + { cookie: `${SESSION_COOKIE_NAME}=provider-token` } + ); + }); + + it("sends only the latest user turn and strips generated image metadata", () => { + const messages = [ + { role: "system", content: "Be concise." }, + { role: "user", content: "Earlier user turn" }, + { role: "assistant", content: "Ready." }, + { role: "tool", content: "secret tool output" }, + { + role: "user", + content: [ + { + type: "text", + text: + "[Image 1]: (unavailable)\n" + + "[Image: source: C:\\Users\\someone\\.claude\\image-cache\\id\\2.png]\n" + + "Inspect this", + }, + { type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }, + ], + }, + ]; + const turn = buildConolUserTurn(messages); + const prompt = buildConolPromptText(messages); + + assert.equal(prompt, "Inspect this"); + assert.equal(turn.text, "Inspect this"); + assert.deepEqual(turn.imageUrls, ["data:image/png;base64,YQ=="]); + assert.doesNotMatch(prompt, /Be concise|Earlier user turn|Ready|secret tool output/); + assert.doesNotMatch(prompt, /image-cache|unavailable/); + assert.doesNotMatch(prompt, /base64/); + }); + + it("derives stable client session keys without exposing the raw identifier", () => { + const fromHeader = resolveConolClientSessionKey( + {}, + { "x-claude-code-session-id": "client-session-123" } + ); + const repeated = resolveConolClientSessionKey( + {}, + { "X-Claude-Code-Session-Id": "client-session-123" } + ); + const movedToBody = resolveConolClientSessionKey({ + conversation_id: "client-session-123", + }); + const fromMetadata = resolveConolClientSessionKey({ + metadata: { user_id: JSON.stringify({ session_id: "metadata-session-456" }) }, + }); + + assert.equal(fromHeader, repeated); + assert.equal(fromHeader, movedToBody); + assert.match(fromHeader || "", /^[a-f0-9]{64}$/); + assert.doesNotMatch(fromHeader || "", /client-session-123/); + assert.match(fromMetadata || "", /^[a-f0-9]{64}$/); + assert.equal(resolveConolClientSessionKey({}), null); + }); + + it("keeps Claude system/tool data out while preserving its translated image", () => { + const translated = claudeToOpenAIRequest( + "conol-web/claude-fable-5", + { + system: [{ type: "text", text: "Large Claude Code system instructions." }], + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "Read", + input: { path: "private.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Private tool result", + }, + { + type: "text", + text: "Describe this image", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "aW1hZ2U=", + }, + }, + ], + }, + ], + }, + false + ); + const turn = buildConolUserTurn( + translated.messages as Array<{ + role: string; + content: unknown; + }> + ); + + assert.equal(turn.text, "Describe this image"); + assert.deepEqual(turn.imageUrls, ["data:image/png;base64,aW1hZ2U="]); + assert.doesNotMatch(turn.text, /system instructions|Private tool result|private\.txt/); + }); + + it("uses the latest cumulative history snapshot", () => { + const raw = [ + JSON.stringify({ + type: "history_delta", + stages: [ + { + preview: [{ role: "assistant", content: [{ type: "text", text: "Hel" }] }], + }, + ], + }), + JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [{ role: "assistant", content: [{ type: "text", text: "Hello world!" }] }], + }, + ], + contextUsage: { + usedTokens: 42, + contextWindow: 200000, + modelId: "claude-fable-5", + }, + }), + JSON.stringify({ type: "done" }), + ].join("\n"); + + assert.deepEqual(parseConolMessageStream(raw), { + text: "Hello world!", + usedTokens: 42, + contextWindow: 200000, + modelId: "claude-fable-5", + done: true, + }); + }); + + it("stops reading when done arrives even if the upstream never closes", async () => { + const encoder = new TextEncoder(); + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + `message\t${JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [{ role: "assistant", content: [{ type: "text", text: "Finished" }] }], + }, + ], + })}`, + `message\t${JSON.stringify({ type: "done" })}`, + "", + ].join("\n") + ) + ); + }, + cancel() { + cancelled = true; + }, + }); + + const raw = await Promise.race([ + collectConolMessageStream(new Response(body)), + new Promise((_, reject) => + setTimeout(() => reject(new Error("collector did not stop at done")), 1_000) + ), + ]); + assert.equal(parseConolMessageStream(raw).text, "Finished"); + assert.equal(cancelled, true); + }); + + it("parses the live nested agent-server model schema and strips server secrets", () => { + const discovery = parseConolAgentServers([ + { + id: "server-1", + apiKey: "must-not-leak", + capabilities: { + defaultAgent: "conol", + agents: [ + { + name: "conol", + defaultModel: "claude-fable-5", + models: [ + { + name: "claude-fable-5", + displayName: "Claude Fable 5", + efforts: ["low", "xhigh"], + inputModalities: ["text", "image"], + }, + { + name: "deepseek/deepseek-v4-pro", + displayName: "DeepSeek V4 Pro", + inputModalities: ["text"], + }, + ], + }, + ], + }, + }, + ]); + + assert.deepEqual(discovery, { + agentServerId: "server-1", + defaultModel: "claude-fable-5", + models: [ + { + id: "claude-fable-5", + name: "Claude Fable 5", + supportsVision: true, + efforts: ["low", "xhigh"], + }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsVision: false, + }, + ], + modelPresets: [], + }); + assert.equal(JSON.stringify(discovery).includes("must-not-leak"), false); + assert.ok(CONOL_FALLBACK_MODELS.length > 0); + }); + + it("parses model presets from the agent-server payload", () => { + const discovery = parseConolAgentServers([ + { + id: "server-1", + capabilities: { + defaultAgent: "default", + agents: [ + { + name: "default", + models: [{ name: "z-ai/glm-5.2" }], + modelPresets: [ + { id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" }, + { id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" }, + { text: "ignored-without-id" }, + ], + }, + ], + }, + }, + ]); + + assert.deepEqual(discovery.modelPresets, [ + { id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" }, + { id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" }, + ]); + }); + + it("clamps effort onto the ladder each model actually advertises", () => { + // claude-sonnet-5 has no xhigh rung, so the xhigh default degrades to high. + assert.equal(clampConolEffort("xhigh", ["low", "medium", "high"]), "high"); + // Exact matches pass through untouched. + assert.equal(clampConolEffort("xhigh", ["low", "medium", "high", "xhigh"]), "xhigh"); + // deepseek only exposes high/xhigh, so a weak request climbs to the weakest rung. + assert.equal(clampConolEffort("minimal", ["high", "xhigh"]), "high"); + // Models without an effort ladder (openrouter/fusion) must not receive one. + assert.equal(clampConolEffort("xhigh", []), null); + assert.equal(clampConolEffort("xhigh", undefined), null); + }); + + it("orders the session model plan preset -> model -> effort", () => { + const plan = buildConolSessionModelPlan({ + model: "claude-fable-5", + effort: "xhigh", + hasImageHistory: false, + }); + assert.deepEqual(plan.preset, { modelPreset: "pro", hasImageHistory: false }); + // The model call must null the effort, since Conol resets it server-side. + assert.deepEqual(plan.model, { agentModel: "claude-fable-5", agentEffort: null }); + assert.deepEqual(plan.effort, { agentEffort: "xhigh" }); + + // A model without an xhigh rung gets the clamped effort. + assert.deepEqual( + buildConolSessionModelPlan({ + model: "claude-sonnet-5", + effort: "xhigh", + hasImageHistory: true, + }).effort, + { agentEffort: "high" } + ); + // A model with no effort ladder at all skips the effort call. + assert.equal( + buildConolSessionModelPlan({ + model: "openrouter/fusion", + effort: "xhigh", + hasImageHistory: false, + }).effort, + null + ); + }); + + it("reports native vision support for effort variants and preserves text-only models", () => { + assert.equal( + getResolvedModelCapabilities("conol-web/claude-fable-5-xhigh").supportsVision, + true + ); + assert.equal( + getResolvedModelCapabilities("cnl/deepseek/deepseek-v4-pro").supportsVision, + false + ); + }); + + it("separates effort suffixes and defaults to xhigh when none is pinned", () => { + assert.deepEqual(resolveConolModelSelection("conol-web/claude-fable-5-xhigh"), { + model: "claude-fable-5", + effort: "xhigh", + effortExplicit: true, + }); + assert.deepEqual(resolveConolModelSelection("conol-web/claude-haiku-4-5-minimal"), { + model: "claude-haiku-4-5", + effort: "minimal", + effortExplicit: true, + }); + // No suffix -> xhigh by default, per the provider contract. + assert.deepEqual(resolveConolModelSelection("cnl/gpt-5.6-sol"), { + model: "gpt-5.6-sol", + effort: "xhigh", + effortExplicit: false, + }); + }); + + it("extracts only a valid Conol secure browser cookie", () => { + assert.deepEqual( + extractConolBrowserCredentials([ + { name: "other", value: "ignored", domain: ".conol.ai" }, + { name: SESSION_COOKIE_NAME, value: "browser-token", domain: ".conol.ai" }, + ]), + { cookie: `${SESSION_COOKIE_NAME}=browser-token` } + ); + assert.equal( + extractConolBrowserCredentials([ + { name: SESSION_COOKIE_NAME, value: "unsafe;cookie", domain: ".conol.ai" }, + ]), + null + ); + assert.equal( + extractConolBrowserCredentials([ + { name: SESSION_COOKIE_NAME, value: "wrong-domain", domain: ".example.com" }, + ]), + null + ); + }); + + it("maps remaining balances without inventing consumed history", () => { + const usage = buildConolUsageResult({ + dailyCredits: 12.5, + subscriptionCredits: 7, + subscriptionAmount: 20, + extraCredits: 3, + total: 22.5, + }); + + assert.equal(usage.plan, "Subscription"); + assert.equal(usage.quotas.credits.remaining, 22.5); + assert.equal(usage.quotas.credits.used, 0); + assert.equal(usage.quotas.subscription.total, 20); + assert.equal(usage.quotas.subscription.used, 13); + }); + + it("pins preset, model, then effort on a new session before sending the turn", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, init }); + if (url.endsWith("/api/sessions")) { + return new Response(JSON.stringify({ sessionId: "session_123" }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + if (url.endsWith("/api/sessions/session_123/model")) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.includes("/api/sessions/session_123/messages?logDeltas=1")) { + return new Response( + [ + JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [ + { + role: "assistant", + content: [{ type: "text", text: "OK" }], + }, + ], + }, + ], + }), + JSON.stringify({ type: "done" }), + ].join("\n"), + { status: 200, headers: { "content-type": "application/x-ndjson" } } + ); + } + if (url.endsWith("/api/sessions/session_123/messages")) { + return new Response(null, { status: 202 }); + } + throw new Error(`Unexpected test URL: ${url}`); + }) as typeof fetch; + + try { + const executor = new ConolWebExecutor(); + const result = await executor.execute({ + model: "conol-web/claude-fable-5-xhigh", + stream: false, + body: { + messages: [{ role: "user", content: "Reply OK" }], + timezone: "Europe/Chisinau", + }, + credentials: { + providerSpecificData: { cookie: `${SESSION_COOKIE_NAME}=synthetic-token` }, + }, + }); + + const capture = result as { + response: Response; + headers: Record; + transformedBody: Record; + }; + assert.deepEqual(capture.headers, { cookie: "***" }); + assert.equal(JSON.stringify(capture).includes("synthetic-token"), false); + assert.deepEqual(capture.transformedBody, { + model: "claude-fable-5", + effort: "xhigh", + effortRequested: "xhigh", + effortExplicit: true, + sessionId: "session_123", + reusedSession: false, + clientSessionBound: false, + imageCount: 0, + }); + + // The session is created empty — model/effort there would be ignored upstream. + const createBody = JSON.parse(String(calls[0]?.init?.body)); + assert.equal(calls[0]?.url.endsWith("/api/sessions"), true); + assert.deepEqual(createBody.messages, []); + assert.equal("agentModel" in createBody, false); + assert.equal("agentEffort" in createBody, false); + assert.equal(calls[0]?.init?.headers instanceof Headers, false); + + // Then exactly three /model calls, in preset -> model -> effort order. + const modelCalls = calls.filter((call) => call.url.endsWith("/model")); + assert.equal(modelCalls.length, 3); + assert.deepEqual(JSON.parse(String(modelCalls[0]?.init?.body)), { + modelPreset: "pro", + hasImageHistory: false, + }); + assert.deepEqual(JSON.parse(String(modelCalls[1]?.init?.body)), { + agentModel: "claude-fable-5", + agentEffort: null, + }); + assert.deepEqual(JSON.parse(String(modelCalls[2]?.init?.body)), { agentEffort: "xhigh" }); + + // Configuration must complete before the turn is submitted. + const turnIndex = calls.findIndex( + (call) => call.url.endsWith("/api/sessions/session_123/messages") && call.init?.method === "POST" + ); + const lastModelIndex = calls.map((call) => call.url.endsWith("/model")).lastIndexOf(true); + assert.ok(lastModelIndex < turnIndex, "model config must precede the message turn"); + + const responseBody = await capture.response.json(); + assert.equal(responseBody.choices[0].message.content, "OK"); + assert.equal(responseBody.model, "claude-fable-5"); + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }); + + it("defaults to xhigh, clamps it per model, and skips effort when unsupported", async () => { + const runWithModel = async (model: string) => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + const modelBodies: unknown[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/sessions")) { + return new Response(JSON.stringify({ sessionId: "s1" }), { status: 201 }); + } + if (url.endsWith("/api/sessions/s1/model")) { + modelBodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.includes("?logDeltas=1")) { + return new Response( + `${JSON.stringify({ + type: "history_delta", + stages: [{ logs: [{ role: "assistant", content: [{ type: "text", text: "hi" }] }] }], + })}\n${JSON.stringify({ type: "done" })}`, + { status: 200 } + ); + } + return new Response(null, { status: 202 }); + }) as typeof fetch; + + try { + // No effort suffix -> the xhigh default applies. + await new ConolWebExecutor().execute({ + model: `conol-web/${model}`, + stream: false, + body: { messages: [{ role: "user", content: "hi" }] }, + credentials: { apiKey: `${SESSION_COOKIE_NAME}=synthetic-token` }, + }); + return modelBodies; + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }; + + // Supports xhigh -> applied verbatim. + assert.deepEqual((await runWithModel("claude-fable-5")).at(-1), { agentEffort: "xhigh" }); + // No xhigh rung -> clamped down to high. + assert.deepEqual((await runWithModel("claude-sonnet-5")).at(-1), { agentEffort: "high" }); + // No effort ladder -> only preset + model calls, no effort call. + const fusionBodies = await runWithModel("openrouter/fusion"); + assert.equal(fusionBodies.length, 2); + assert.deepEqual(fusionBodies.at(-1), { + agentModel: "openrouter/fusion", + agentEffort: null, + }); + }); + + it("reuses one Conol session for follow-ups and forwards only the newest user turn", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + let streamCount = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, init }); + if (url.endsWith("/api/sessions") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "sticky_session" }), { status: 201 }); + } + if (url.endsWith("/api/sessions/sticky_session/model")) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/api/sessions/sticky_session/messages") && init?.method === "POST") { + return new Response(null, { status: 202 }); + } + if (url.includes("/api/sessions/sticky_session/messages?logDeltas=1")) { + streamCount += 1; + return new Response( + [ + JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [ + { + role: "assistant", + content: [{ type: "text", text: streamCount === 1 ? "First" : "Second" }], + }, + ], + }, + ], + }), + JSON.stringify({ type: "done" }), + ].join("\n"), + { status: 200 } + ); + } + throw new Error(`Unexpected test URL: ${url}`); + }) as typeof fetch; + + const executor = new ConolWebExecutor(); + const sharedInput = { + model: "conol-web/claude-fable-5", + stream: false, + credentials: { + connectionId: "connection-1", + apiKey: `${SESSION_COOKIE_NAME}=synthetic-token`, + }, + clientHeaders: { "x-claude-code-session-id": "logical-chat-1" }, + }; + + try { + const first = await executor.execute({ + ...sharedInput, + body: { + messages: [ + { role: "system", content: "Never forward this system prompt." }, + { role: "user", content: "First user turn" }, + ], + }, + }); + const second = await executor.execute({ + ...sharedInput, + body: { + messages: [ + { role: "system", content: "Never forward this system prompt." }, + { role: "user", content: "First user turn" }, + { role: "assistant", content: "First" }, + { role: "tool", content: "Never forward this tool output." }, + { role: "user", content: "Second user turn" }, + ], + }, + }); + + assert.equal((first as { response: Response }).response.status, 200); + assert.equal((second as { response: Response }).response.status, 200); + const createCalls = calls.filter( + (call) => call.url.endsWith("/api/sessions") && call.init?.method === "POST" + ); + const turnCalls = calls.filter( + (call) => + call.url.endsWith("/api/sessions/sticky_session/messages") && call.init?.method === "POST" + ); + const modelCalls = calls.filter((call) => call.url.endsWith("/model")); + // One session, two turns posted into it. + assert.equal(createCalls.length, 1); + assert.equal(turnCalls.length, 2); + // Preset + model + effort once; the unchanged second turn re-pins nothing. + assert.equal(modelCalls.length, 3); + + const createBody = JSON.parse(String(createCalls[0]?.init?.body)); + const firstTurnBody = JSON.parse(String(turnCalls[0]?.init?.body)); + const followUpBody = JSON.parse(String(turnCalls[1]?.init?.body)); + assert.deepEqual(createBody.messages, []); + assert.deepEqual(firstTurnBody.messages, [{ type: "text", content: "First user turn" }]); + assert.deepEqual(followUpBody.messages, [{ type: "text", content: "Second user turn" }]); + assert.equal("source" in followUpBody, false); + assert.equal("agentModel" in followUpBody, false); + assert.doesNotMatch( + JSON.stringify([createBody, firstTurnBody, followUpBody]), + /system prompt|tool output|"role"/ + ); + assert.equal( + (first as { transformedBody: Record }).transformedBody.reusedSession, + false + ); + assert.equal( + (second as { transformedBody: Record }).transformedBody.reusedSession, + true + ); + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }); + + it("re-pins the same session on a model switch instead of stranding it", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; body: unknown }> = []; + let created = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/sessions") && init?.method === "POST") { + created += 1; + return new Response(JSON.stringify({ sessionId: "switch_session" }), { status: 201 }); + } + if (url.endsWith("/model")) { + calls.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.includes("?logDeltas=1")) { + return new Response( + `${JSON.stringify({ + type: "history_delta", + stages: [{ logs: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] }], + })}\n${JSON.stringify({ type: "done" })}`, + { status: 200 } + ); + } + return new Response(null, { status: 202 }); + }) as typeof fetch; + + const shared = { + stream: false, + credentials: { + connectionId: "connection-1", + apiKey: `${SESSION_COOKIE_NAME}=synthetic-token`, + }, + clientHeaders: { "x-session-id": "same-chat" }, + body: { messages: [{ role: "user", content: "hi" }] }, + }; + + try { + const executor = new ConolWebExecutor(); + await executor.execute({ ...shared, model: "conol-web/claude-fable-5-high" }); + calls.length = 0; + // Same logical chat, different model -> must reuse the session and re-pin. + const switched = await executor.execute({ ...shared, model: "conol-web/gpt-5.6-sol-low" }); + + assert.equal(created, 1, "a model switch must not create a second session"); + assert.equal( + (switched as { transformedBody: Record }).transformedBody.reusedSession, + true + ); + // Preset is already primed, so only model + effort are re-sent. + assert.deepEqual( + calls.map((call) => call.body), + [{ agentModel: "gpt-5.6-sol", agentEffort: null }, { agentEffort: "low" }] + ); + + // A third turn with no change must not re-pin anything. + calls.length = 0; + await executor.execute({ ...shared, model: "conol-web/gpt-5.6-sol-low" }); + assert.deepEqual(calls, []); + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }); + + it("keeps different client session IDs in different Conol sessions", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + let createdCount = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/sessions") && init?.method === "POST") { + createdCount += 1; + return new Response(JSON.stringify({ sessionId: `session_${createdCount}` }), { + status: 201, + }); + } + if (url.endsWith("/model")) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.includes("/messages?logDeltas=1")) { + return new Response( + `${JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [{ role: "assistant", content: [{ type: "text", text: "Isolated" }] }], + }, + ], + })}\n${JSON.stringify({ type: "done" })}\n`, + { status: 200 } + ); + } + if (url.includes("/messages") && init?.method === "POST") { + return new Response(null, { status: 202 }); + } + throw new Error(`Unexpected test URL: ${url}`); + }) as typeof fetch; + + try { + const executor = new ConolWebExecutor(); + for (const sessionId of ["client-a", "client-b"]) { + await executor.execute({ + model: "conol-web/claude-fable-5", + stream: false, + body: { messages: [{ role: "user", content: "Same text" }] }, + credentials: { + connectionId: "connection-1", + apiKey: `${SESSION_COOKIE_NAME}=synthetic-token`, + }, + clientHeaders: { "x-session-id": sessionId }, + }); + } + assert.equal(createdCount, 2); + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }); + + it("uploads the structured image and references it before clean user text", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nGQAAAAASUVORK5CYII=", + "base64" + ); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, init }); + if (url.endsWith("/api/assets")) { + assert.deepEqual(Buffer.from(init?.body as Uint8Array), png); + return new Response( + JSON.stringify({ + id: "asset_1", + url: "/api/assets/asset_1", + mediaType: "image/png", + }), + { status: 201 } + ); + } + if (url.endsWith("/api/sessions")) { + return new Response(JSON.stringify({ sessionId: "image_session" }), { status: 201 }); + } + if (url.endsWith("/api/sessions/image_session/model")) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.includes("/api/sessions/image_session/messages?logDeltas=1")) { + return new Response( + `${JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [{ role: "assistant", content: [{ type: "text", text: "Image received" }] }], + }, + ], + })}\n${JSON.stringify({ type: "done" })}\n`, + { status: 200 } + ); + } + if (url.endsWith("/api/sessions/image_session/messages")) { + return new Response(null, { status: 202 }); + } + throw new Error(`Unexpected test URL: ${url}`); + }) as typeof fetch; + + try { + const result = await new ConolWebExecutor().execute({ + model: "conol-web/claude-fable-5", + stream: false, + body: { + messages: [ + { role: "system", content: "System data must stay local." }, + { + role: "user", + content: [ + { + type: "text", + text: + "[Image 1]: (unavailable)\n" + + "[Image: source: C:\\Users\\someone\\.claude\\image-cache\\id\\2.png]\n" + + "What is on the image?", + }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${png.toString("base64")}` }, + }, + ], + }, + ], + }, + credentials: { apiKey: `${SESSION_COOKIE_NAME}=synthetic-token` }, + }); + + assert.equal((result as { response: Response }).response.status, 200); + const turnCall = calls.find( + (call) => + call.url.endsWith("/api/sessions/image_session/messages") && call.init?.method === "POST" + ); + const turnBody = JSON.parse(String(turnCall?.init?.body)); + assert.deepEqual(turnBody.messages, [ + { + type: "image", + content: "/api/assets/asset_1", + mediaType: "image/png", + }, + { type: "text", content: "What is on the image?" }, + ]); + assert.doesNotMatch(JSON.stringify(turnBody), /unavailable|image-cache|System data/); + + // An image turn must prime the preset as multimodal. + const presetBody = JSON.parse( + String(calls.find((call) => call.url.endsWith("/model"))?.init?.body) + ); + assert.deepEqual(presetBody, { modelPreset: "pro", hasImageHistory: true }); + } finally { + globalThis.fetch = originalFetch; + clearConolSessionBindingsForTests(); + } + }); + + it("emits OpenAI SSE data and a terminal DONE marker", async () => { + clearConolSessionBindingsForTests(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/sessions")) { + return new Response(JSON.stringify({ sessionId: "session_stream" }), { status: 201 }); + } + return new Response( + `${JSON.stringify({ + type: "history_delta", + stages: [ + { + logs: [{ role: "assistant", content: [{ type: "text", text: "Streamed" }] }], + }, + ], + })}\n${JSON.stringify({ type: "done" })}\n`, + { status: 200 } + ); + }) as typeof fetch; + + try { + const result = await new ConolWebExecutor().execute({ + model: "conol-web/claude-haiku-4-5", + stream: true, + body: { messages: [{ role: "user", content: "Test" }] }, + credentials: { apiKey: `${SESSION_COOKIE_NAME}=synthetic-token` }, + }); + const text = await (result as { response: Response }).response.text(); + assert.match(text, /"content":"Streamed"/); + assert.match(text, /data: \[DONE\]/); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/unit/guardrails/visionBridge.test.ts b/tests/unit/guardrails/visionBridge.test.ts index 64a2b3a40b..286af2a3d4 100644 --- a/tests/unit/guardrails/visionBridge.test.ts +++ b/tests/unit/guardrails/visionBridge.test.ts @@ -215,6 +215,62 @@ test("VB-S02b: respects native vision support for GPT-family models", async () = } }); +test("VB-S02c: Conol multimodal models bypass the vision bridge", async () => { + const guardrail = createGuardrail(); + const model = "conol-web/claude-fable-5-xhigh"; + const payload = createPayload({ + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,aW1hZ2U=" }, + }, + ], + }, + ], + }); + visionCallCount = 0; + + const result = await guardrail.preCall(payload, createContext({ model })); + + assert.equal(getResolvedModelCapabilities(model).supportsVision, true); + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); + assert.strictEqual(visionCallCount, 0); +}); + +test("VB-S02d: Conol text-only models remain eligible for the vision bridge", async () => { + const guardrail = createGuardrail(); + const model = "conol-web/deepseek/deepseek-v4-pro"; + const payload = createPayload({ + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,aW1hZ2U=" }, + }, + ], + }, + ], + }); + visionCallCount = 0; + + const result = await guardrail.preCall(payload, createContext({ model })); + + assert.equal(getResolvedModelCapabilities(model).supportsVision, false); + assert.strictEqual(result.block, false); + assert.notStrictEqual(result.modifiedPayload, undefined); + assert.strictEqual(visionCallCount, 1); +}); + test("VB-S02: model capabilities returns supportsVision for known models", () => { const gpt4oCaps = getResolvedModelCapabilities("openai/gpt-4o"); // supportsVision may be true (if sync data exists) or null (if not synced) diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 55d6fe11a9..f6dc1ba019 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -82,6 +82,12 @@ test("web session credential metadata identifies cookie, token, and no-auth prov // #5465 — t3.chat ships a step-by-step DevTools copy hint (localStorage + Cookie header). hintKey: "t3ChatWebCookieHint", }); + assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("conol-web"), { + kind: "cookie", + credentialName: "__Secure-better-auth.session_token", + placeholder: "__Secure-better-auth.session_token=... or full Cookie header from conol.ai", + acceptsFullCookieHeader: true, + }); }); test("web session credential validator requires provider-specific non-empty values", () => {