Files
OmniRoute/open-sse/executors/conol-web.ts
Diego Rodrigues de Sa e Souza d259d9fcba fix(ci): clear base-reds on release/v3.8.50 (round 3) (#10213)
* fix(ci): clear base-reds on release/v3.8.50 (round 3)

- CHANGELOG.md: restore the top [Unreleased] section dropped by the #10189
  reconcile (docs-sync gate: first section must be Unreleased)
- env-doc-sync: document CONDUCTOR_ORCHESTRATOR_TOKEN + CONDUCTOR_SPOKESPERSON_URL
  in .env.example/ENVIRONMENT.md; allowlist the CI-only GITHUB_STEP_SUMMARY and
  TS7_BASE_REF (ts7 ratchet signals); drop a stray merge artifact line
- providers: restore the audited chatanywhere metadata entry that base-reds
  round 2 dropped together with its duplicate — the provider was half-wired
  (registry+endpoint without APIKEY metadata), which is what the wave3 test
  catches; re-pin providers-constants-split at the measured 228
- docs counts: 338 -> 339 (today's +2 void-ai/helixmind, -1 Puter) via
  gen:provider-reference + README/AGENTS/llm.txt/package.json/diagrams/i18n mirrors
- file-size ratchet: annotated rebaseline for the two pre-existing drifts
  (ModelSelectModal 1138, gateways 1250) following the 2026-08-11 precedent

Refs #9985

* fix(ci): base-reds round 3b — stale sibling tests + mode-pack weight contract

- check-docs-counts-sync.test.ts: drop the imports/subtests of the four helpers
  #10196 removed from the gate script (readMcpFactsFromSource, listLocalizedDocs,
  makeRequiredCountsValidator, checkFreeTierInventory) — the new-API tests that
  #10196 added stay; the file now loads again under the node runner
- quota-connection-recovery.test.ts: convert from vitest APIs to node:test —
  the file lives in tests/unit/*.test.ts (node-runner glob) and the vitest
  runtime crashes when imported outside vitest, killing the whole shard entry
- modePacks.ts: re-normalize all six mode packs to sum 1.0 — #8940 added
  sessionAvailability: 0.05 to every pack without rebalancing (1.05 total);
  ratios preserved exactly (÷1.05), so post-normalizeScoringWeights behavior
  is unchanged; restores the declared sum-to-1.0 contract the 4235 test pins

Refs #9985

* fix(ci): base-reds round 3c — vitest siblings, weights default, secrets FP, mutation tap

- DistributeProxiesButton.test.tsx: wrap renders in NextIntlClientProvider —
  #9245 localized the component (useTranslations) and left the test without
  the intl context, failing all 14 cases
- scoring.ts: re-normalize DEFAULT_WEIGHTS to sum 1.0 (same #8940 class as the
  mode packs — sessionAvailability added without rebalancing; ratios preserved)
- .gitleaks.toml: generalize the kimi sponsor-banner localStorage-key allowlist
  to -v\d+ — #10200 bumped v1→v2 and the stale regex regressed the secrets
  ratchet with a false positive
- stryker.conf.json: register 6 covering unit tests in tap.testFiles (4 modules)
  so their mutant kills count — unblocks check:mutation-test-coverage --strict

Refs #9985

* fix(ci): base-reds round 3d — inspector factor gap, stale registry/gap tests, i18n key sync

- comboScoringInspector: add cacheAffinity/sessionAvailability/connectionDensity
  to FACTOR_KEYS + the factor-key type — calculateScore() weighs them but the
  breakdown omitted them, so the explained contributions never summed to the
  reported score (inspector bug, red on the pure tip)
- combo-scoring-inspector.test: make the explicit-weights override sum-neutral
  (±0.05 shift) so it stays valid for any DEFAULT_WEIGHTS values — the hardcoded
  override only summed to 1.0 against the pre-#8940 defaults, which is also why
  explicit weights silently fell back to 'default' on the tip
- unorouter-registry.test: align to the canonical .com host (api.unorouter.ai
  301-redirects there, verified live) and to wave4's live model discovery
  (passthrough, no static seed) — the .ai/auto-model expectations were stale
- check-migration-numbering.test: 147 left KNOWN_GAPS when
  147_api_keys_model_access_mode.sql landed — assert absent (same as 143)
- i18n: sync-ui pass — 35,914 missing UI keys stamped as __MISSING__ placeholders
  across 42 locales (mechanical; greens the pt-BR key-presence integrity test;
  coverage pct unchanged by design — translation is a separate workstream)

Refs #9985

* fix(ci): base-reds round 3e — 2 real defects + 14 stale sibling tests (waves A-E)

Real defects fixed:
- src/lib/db/apiKeys.ts: #9313's empty-allowlist early return bypassed the group
  permission check, silently disabling group deny rules (#8817) for every key
  without a per-key allowlist; fall-through restored, restricted+[] deny-all kept
- open-sse/utils/proxyFetch.ts: #10032 re-appended the raw transport error to the
  propagated message, reintroducing the proxy user:password leak #9837 closed;
  new redactProxyDetailsInMessage() keeps the reason, redacts URL/credentials
- .github/workflows/quality.yml: #10134 added the TS7 ratchet as a separate
  blocking step AFTER the aggregated gates — the exact #8542 masking mechanism;
  folded into the non-fail-fast loop (still blocking, still PR-only) ⚠️ CI edit,
  gate-strengthening — explicit owner sign-off requested on the PR
- src/i18n/messages/ko.json: 3 machine-mistranslation regressions caught by the
  #8244 glossary checker (장애인→비활성화됨, 양말5://→socks5://, 비클로드→Claude가 아닌)

Stale sibling tests aligned to deliberately-moved contracts (each cites its mover):
request-log-detail-layout + -stream (#9245 intl provider), repro-8542 pin update,
quality-rail-gate-membership (#10134 shape), agentSkills-routes 45→46 (#9058),
cloudflare-ai-catalog-8717 (#8804 supersedes #8808), executor-xai (#9994),
vision-bridge-claude-wire (#9463 minimax→openai), sse-auth forced-pin (#8893),
tls-proxy-context (strengthened leak guards), rate-limit-local-error-classification
(#9164/#9342), minimax-thinking-signature (#9463), codebuddy-cn (#9723 +1 test),
github-copilot-custom-model (#9050), providers-g4f-batch3 (#9584),
synced-capability-warmup (#9199, stricter), sidebar-tools-group (#8221),
oauth-modal-grok-cli-paste (#9245); agentSkills/catalog.ts comment 45→46;
file-size rebaseline for proxyFetch (+19, annotated)

Refs #9985

* fix(ci): base-reds round 3f — waves F-J: 9 more real defects + stale sibling sweep

Real production defects fixed (all red on the pure tip, each with its origin):
- routeGuard.ts: #8949 accidentally DELETED the /api/providers/[id]/login
  local-only pattern — the route spawns a browser, so the loopback gate for a
  process-spawning route was gone (Hard Rules #15/#17); restored (314 guard
  tests green)
- agentSkills generator: #9058's category dispatch gave the config category an
  empty body, wiping skills/config-codex-cli/SKILL.md at the #10131 sync;
  fixed + SKILL.md regenerated via the official generator
- imageRegistry: #9982 broke same-provider bare aliasing (antigravity preview
  id sent upstream unresolved); new resolveSameProviderBareAlias() keeps the
  fal cross-provider fix intact
- imageRegistry: #9982's prefix strip handed the bare nano-banana ids to fal-ai,
  violating the pinned 2026-07-31 operator decision (adobe-firefly owns them);
  fal entries made prefix-only (dispatch already re-prefixes)
- mediaGeneration/fal.ts: the missing-credential 401 guard was lost when #10198
  deleted the superseded falHandler — tests were hitting the live network
- bottleneckPatch/rateLimitManager: #9041's merge clobbered #9604, resurrecting
  the Bottleneck v2.19.5 heartbeat bug (reservoir never refills); patched the
  library defect at the root and re-aligned chat-rate-limit-body-lock to the
  working reservoir contract
- processSupervisor.mjs: #9761 regressed the Node spawn to bare "node" (the
  #9156 launchd bug) and dropped #9209's ipv4first args; both restored
- openai-responses/pureHelpers: #9423's Agent null-sentinel was unreachable on
  the schemaless JSON-string path; gate extended
- i18n en.json: #8222's regen reverted the #9976 unclosed-tag fix and #8559's
  combo-cooldown copy; #9038 shipped 40 t() calls with no messages (runtime
  MISSING_MESSAGE); all restored/added + official sync-ui stamps, and vi's
  zero-marker policy re-established via the sanctioned translation backend

Stale sibling tests aligned (movers cited inline): chat-helpers (#9447),
executor-antigravity (#9351), video-fal-grok (#9982), visionBridge (#9759),
web-session-credentials (#8974), production-build-module-integrity (positive
anchor added), agentSkills-generator/skillManifestsLint/skills-injection/
agentSkillTools-mcp/listCapabilities-a2a (#9058), memory-settings (#10010),
model-catalog-policy-invalidation (#8906), model-alias-seed (#9485),
reactive-context-compaction (#8949), combo-provider-wildcard (broken upsert
helper), oauth-google-loopback (43-locale resurrected-key removal)

Validation: 501/501 across the 47 touched test files; typecheck:core, lint,
file-size, docs-sync all green.

Refs #9985

* fix(ci): base-reds round 3g — wave K/L: 4 more real defects + stale alignments

Real defects:
- base/reasoningEffort.ts: the stale duplicate cherry-pick #9612 re-added the
  codex minimal→low rewrite that #9883 had deliberately removed (OMP minimal
  passthrough); block removed again
- cursorImages.ts: #9840 wired prepareCursorImageForWire (sharp re-encode,
  fail-closed) into the SHARED resolveCursorImages, breaking zai-web and
  conol-web image uploads (HTTP 400 'undecodable'); new prepareForWire opt-out,
  Cursor default path unchanged (8 cursor suites green)
- modelCapabilities/snapshot: catalog prepare still issued 323 per-model reads
  of model_context_overrides + max_input_tokens overrides, violating #9199's
  bulk-load contract; both now resolve from the snapshot single pass
- v1-models-discovery-conformance: re-pinned to the bounded 30s SWR window
  (#9199/#10198) — the old 'stale-first regardless of age' contract is gone

Stale tests aligned (movers cited inline): codex-tools-strict-default (#9828
redundant-oneOf strip), devin-providers (#9245 i18n), db-migrationrunner-
constants-split (147→151 renumber #8228), gitlab-duo-oauth-setup (#9245),
chatcore-extracted-modules (#9161 outbound-protocol keying)

compression-api CI failures were cascade artifacts of codex-tools-strict-default
failing in the same force-exit shard process — no own defect (171/171 local).

Refs #9985

* fix(test): compression-api — register both describes before the runner starts

The DATA_DIR setup + route/db top-level awaits sat BETWEEN the two describes;
under --test-force-exit (the CI unit-runner flag) the process exits once the
already-registered tests finish, so on slow CI machines the whole second
describe died as 'Promise resolution is still pending' — the recurring
CI-only shard-2 failure that never reproduced locally without the flag.
Moved to the top of the file; 10/10 under --test-force-exit locally.

Refs #9985

* fix(quality): freeze modelCapabilities.ts at 1006 (annotated) — snapshot routing growth

Refs #9985

* fix(quality): move the modelCapabilities freeze into the frozen map (nested schema)

Refs #9985

* fix(i18n): translate all 39,718 pending UI keys across 42 locales (owner-approved)

Mass-translated every __MISSING__ placeholder via the official i18n:sync-ui
--translate-markers pipeline (operator backend), restoring i18nUiCoverage to the
100 baseline (was 89.9 after the merge-storm UI landings + the 42 keys #9038
never shipped).

Post-pass repairs, all caught by the existing gates:
- glossary: retired renderings the machine reintroduced normalized again
  (提供商→提供者 zh-CN/zh-TW, 鏈接→連結, 文檔→文件, 調用→呼叫, 供應商→提供者,
  響應→回應, 不活躍→未啟用 zh-TW; 클로드→Claude, 옴니루트→OmniRoute ko);
  DATA_DIR forbidden rendering avoided via 数据文件夹 rephrase
- ICU integrity: 120 values with renamed/dropped {params} repaired (39
  positional renames, 81 reset to the en source — functional over fluent)

Validation: glossary/pt-BR/vi/deno-relay/settings-keys/value-drift/google-
loopback suites 76/76; placeholder diff en×42 locales = 0; worst-locale
coverage = 100.0%.

Refs #9985

---------

Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-13 00:02:25 -03:00

894 lines
30 KiB
TypeScript

/**
* 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[]> {
// Conol's asset endpoint stores the original bytes (no Cursor wire prep).
const images = await resolveCursorImages(imageUrls, { prepareForWire: false });
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: new Uint8Array(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);
}
}
}