mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
* 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>
894 lines
30 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|