mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
feat(providers): add Conol (conol.ai) web session provider (#8974)
* feat(providers): add Conol web support
* fix(conol): preserve sessions and image turns
* fix(conol): pin session model and effort via /model endpoint
Conol ignores agentModel/agentEffort on POST /api/sessions, so every
session silently ran on the downgraded account default (the create
response reports modelDowngraded: true / effectiveModel).
Sessions are now created empty and configured out-of-band against
POST /api/sessions/{id}/model before the first turn is submitted, in the
order the web client uses: modelPreset, then agentModel, then agentEffort.
The ordering is load-bearing because the model call resets agentEffort to
null server-side.
Effort now defaults to xhigh when the caller does not pin one via the
-<effort> model suffix, and is clamped onto the ladder each model actually
advertises, so xhigh degrades to high on claude-sonnet-5 and is skipped
entirely for models without an effort ladder such as openrouter/fusion.
Model and effort are also dropped from the session binding key so switching
models re-pins the existing session instead of stranding it and losing the
conversation history. Re-pinning only happens on an actual change, so
steady-state follow-ups cost no extra round trips.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, RegistryEntry> = {
|
||||
hyperbolic: hyperbolicProvider,
|
||||
"lambda-ai": lambda_aiProvider,
|
||||
"t3-web": t3_webProvider,
|
||||
"conol-web": conol_webProvider,
|
||||
iflytek: iflytekProvider,
|
||||
crof: crofProvider,
|
||||
moonshot: moonshotProvider,
|
||||
|
||||
14
open-sse/config/providers/registry/conol-web/index.ts
Normal file
14
open-sse/config/providers/registry/conol-web/index.ts
Normal file
@@ -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,
|
||||
};
|
||||
893
open-sse/executors/conol-web.ts
Normal file
893
open-sse/executors/conol-web.ts
Normal file
@@ -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<string, ConolSessionBinding>();
|
||||
const conolSessionLocks = new Map<string, Promise<void>>();
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, string> | 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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).session_id)
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hashKey(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function resolveConolClientSessionKey(
|
||||
body: ConolRequestBody,
|
||||
clientHeaders?: Record<string, string> | 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<ConolSessionBinding, "lastUsedAt">
|
||||
): 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<T>(
|
||||
key: string | null,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!key) return operation();
|
||||
|
||||
const previous = conolSessionLocks.get(key) ?? Promise.resolve();
|
||||
let releaseCurrent!: () => void;
|
||||
const currentGate = new Promise<void>((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<string, unknown>;
|
||||
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<string, unknown>)[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<string, unknown>).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<string> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: 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<string, string>,
|
||||
sessionId?: string
|
||||
): Record<string, string> {
|
||||
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<ConolMessagePart[]> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
55
open-sse/services/conolAuth.ts
Normal file
55
open-sse/services/conolAuth.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
|
||||
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) };
|
||||
}
|
||||
120
open-sse/services/conolBrowserLogin.ts
Normal file
120
open-sse/services/conolBrowserLogin.ts
Normal file
@@ -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<typeof import("playwright"), "chromium">;
|
||||
|
||||
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<import("playwright").Browser> {
|
||||
const configuredPath = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim();
|
||||
const attempts: Array<Record<string, unknown>> = [
|
||||
...(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<ConolBrowserLoginResult> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
310
open-sse/services/conolModels.ts
Normal file
310
open-sse/services/conolModels.ts
Normal file
@@ -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<string, ConolEffort[]>(
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).agentServers ??
|
||||
(payload as Record<string, unknown>).servers ??
|
||||
[])
|
||||
: [];
|
||||
const servers = Array.isArray(root) ? root : [];
|
||||
const server = servers.find(
|
||||
(value) => value && typeof value === "object" && !Array.isArray(value)
|
||||
) as Record<string, unknown> | undefined;
|
||||
const capabilities =
|
||||
server?.capabilities &&
|
||||
typeof server.capabilities === "object" &&
|
||||
!Array.isArray(server.capabilities)
|
||||
? (server.capabilities as Record<string, unknown>)
|
||||
: 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<string, unknown>).name) === defaultAgent;
|
||||
}) ?? agents[0]) as Record<string, unknown> | undefined;
|
||||
|
||||
const seen = new Set<string>();
|
||||
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<string>();
|
||||
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 `-<effort>` 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 `-<effort>` 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<ConolModelDiscovery> {
|
||||
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;
|
||||
}
|
||||
148
open-sse/services/conolSessionModel.ts
Normal file
148
open-sse/services/conolSessionModel.ts
Normal file
@@ -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<ConolModelPresetId>(["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<string, string>;
|
||||
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<boolean> {
|
||||
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<AppliedConolSessionModel> {
|
||||
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;
|
||||
}
|
||||
111
open-sse/services/conolUsage.ts
Normal file
111
open-sse/services/conolUsage.ts
Normal file
@@ -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<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = readString(record[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function getConolUsage(
|
||||
apiKey: unknown,
|
||||
providerSpecificData?: unknown
|
||||
): Promise<ConolUsageResult | { message: string }> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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}` };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
97
src/app/api/providers/[id]/models/conolDiscovery.ts
Normal file
97
src/app/api/providers/[id]/models/conolDiscovery.ts
Normal file
@@ -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<string, unknown>) => Response;
|
||||
buildApiDiscoveryResponse: (models: ConolModel[]) => Promise<Response>;
|
||||
}
|
||||
|
||||
export async function maybeHandleConolModelDiscovery(
|
||||
options: ConolDiscoveryRouteOptions
|
||||
): Promise<Response | null> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
fetchCodexDiscoveryModels,
|
||||
fetchCodexGithubCatalogModels,
|
||||
} from "./discovery/codex";
|
||||
import { maybeHandleConolModelDiscovery } from "./conolDiscovery";
|
||||
|
||||
function toLiveModel(item: Record<string, unknown>): { 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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<string, WebSessionCredentialRequirement> &
|
||||
Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;
|
||||
|
||||
|
||||
963
tests/unit/conol-web.test.ts
Normal file
963
tests/unit/conol-web.test.ts
Normal file
@@ -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<Uint8Array>({
|
||||
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<never>((_, 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<string, string>;
|
||||
transformedBody: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown> }).transformedBody.reusedSession,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
(second as { transformedBody: Record<string, unknown> }).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<string, unknown> }).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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user