Files
OmniRoute/open-sse/executors/uc/ws.ts
Armin Anton” ∴ 530096a3be feat(providers): add UC (uncensored.com) — persona (un-metered) + direct (metered) (#11513)
Adds uncensored.com as two OpenAI-compatible providers mirroring UC's own surfaces: uc, the persona/subscription side over WebSocket with a durable Clerk credential minting a short-lived per-connect token (no API key, un-metered), as a full multimodal port — chat, tools, vision, doc-RAG, image, video, TTS; and uc-direct, the metered Developer API over REST with X-api-key. Same underlying models, two billing surfaces.

Reconciled on merge. 57 files conflicted; only seven carried UC content, the rest was drift from the older release line and took the tip's side.

- executors/index.ts: the tip has since refactored the executor map to lazy dynamic imports, so uc is registered in that shape. uc-direct needs no entry — it routes through the default OpenAI-compatible executor.
- imageGeneration.ts: the branch still carried the retired designerWeb import alongside ucImage; kept only the UC one.
- config/providers/index.ts, webSessionCredentials.ts and web-cookie.ts resolved additively against the MaxAI entries #11461 put on the tip an hour earlier.
- web-cookie.ts: the uc entry declared no serviceKinds, required since #11392, so provider validation would have thrown at load. Declared ["llm"]. uc-direct already declared it at the end of its own entry — an earlier pass of mine added a second one after id and TypeScript caught the duplicate (TS1117); the author's placement is what shipped.

Every count was measured against the merged tree rather than taken from the branch, and all three would have been wrong: reserved prefixes are 406, not 399; APIKEY_PROVIDERS is 237, not 234; providers are 355. PROVIDER_REFERENCE.md regenerated, the count updated across README/AGENTS.md/llm.txt and its 42 mirrors, package.json and 6 SVGs — every changed line in the protected surfaces is a digit substitution and nothing else, verified by masking digits and comparing the removed and added sets (90 lines each, identical). The executor-map golden snapshot went 134 -> 135.

The branch's file-size-baseline.json predates #12411's ratchet re-tightening and was discarded rather than merged; imageGeneration.ts (+12 for the uc-image format branch) was entered against the current baseline under a _rebaseline annotation, and no other cap moves.

Verified: typecheck:core clean, check:provider-consistency OK (271 REGISTRY entries, 355 canonical providers), check:docs-counts exit 0, check-file-size OK, check:cycles OK, 119/119 across the PR's test files, and 2/2 executor-map-golden.

Thanks @arminanton — two providers for two real billing surfaces, rather than one entry pretending to be both, is the right modelling.
2026-09-02 02:23:33 -03:00

180 lines
6.1 KiB
TypeScript

/**
* UC (uncensored.com) PERSONA WebSocket driver.
*
* Opens one socket per turn (connect → send the persona frame → stream frames →
* close), mirroring the reference client and the muse-spark-web WS executor. Auth
* is 100% the `?token=` query param (a 60s Clerk JWT); the ONLY required
* handshake header is `Origin: https://uncensored.com` (the backend checks it —
* NO Cookie, NO Authorization on the upgrade).
*
* The driver is transport-only: it classifies frames via UcFrameParser and hands
* each event to an `onEvent` callback, so the executor can drive both a live
* OpenAI SSE stream and a buffered non-streaming response from the same path. The
* module-level constructor + `__setUcWebSocketForTesting` hook let tests inject a
* fake socket (same pattern as muse-spark-web).
*/
import WebSocket from "ws";
import { UC_ORIGIN, UC_WS_HOST, UC_WS_TIMEOUT_MS } from "./constants.ts";
import { buildPersonaFrame, type UcHistoryEntry } from "./protocol.ts";
import { UcFrameParser, type UcEvent } from "./stream.ts";
let WebSocketCtor: typeof WebSocket = WebSocket;
/** Inject a fake WebSocket constructor for tests. Returns a restore fn. */
export function __setUcWebSocketForTesting(ctor: typeof WebSocket): () => void {
const previous = WebSocketCtor;
WebSocketCtor = ctor;
return () => {
WebSocketCtor = previous;
};
}
/** Build the persona WS URL: wss://.../ws/{uid}?token={jwt}&_t={epochms}. */
export function buildUcWsUrl(uid: string, jwt: string): string {
return `${UC_WS_HOST}/${encodeURIComponent(uid)}?token=${encodeURIComponent(jwt)}&_t=${Date.now()}`;
}
export interface UcTurnInput {
jwt: string;
uid: string;
model: string;
text: string;
history: UcHistoryEntry[];
/** Uploaded input-media blobs (images/docs) for the current turn. */
media?: Array<{ blobName: string; contentType: string }>;
timeoutMs?: number;
signal?: AbortSignal | null;
/** Called for each classified event (delta/reasoning/status/done/error). */
onEvent?: (evt: UcEvent) => void;
}
export interface UcTurnResult {
/** The final answer text (raw_text authoritative, else concatenated deltas). */
content: string;
/** Reasoning text accumulated from intermediary_message frames. */
reasoning: string;
/** Set when the turn failed (error frame, transport failure, or timeout). */
error?: string;
}
/**
* Drive one persona turn to completion. Never rejects — a transport/timeout/error
* failure resolves with `{ error }` set (and any partial content). The caller
* decides whether a partial is usable or should surface the error.
*/
export function runUcTurn(input: UcTurnInput): Promise<UcTurnResult> {
const timeoutMs = input.timeoutMs ?? UC_WS_TIMEOUT_MS;
const url = buildUcWsUrl(input.uid, input.jwt);
const parser = new UcFrameParser();
const reasoningParts: string[] = [];
return new Promise<UcTurnResult>((resolve) => {
let ws: WebSocket;
try {
ws = new WebSocketCtor(url, {
headers: { Origin: UC_ORIGIN },
// The persona frame + long answers can exceed the default 100MB cap only
// in pathological cases; leave the library default. permessage-deflate is
// negotiated by the server and handled by `ws` transparently.
});
} catch (err) {
resolve({
content: "",
reasoning: "",
error: `ws connect failed: ${err instanceof Error ? err.message : String(err)}`,
});
return;
}
let settled = false;
let errorText: string | undefined;
let timeout: ReturnType<typeof setTimeout> | null = null;
let abortHandler: (() => void) | null = null;
const finish = (result: UcTurnResult) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (input.signal && abortHandler) input.signal.removeEventListener("abort", abortHandler);
try {
ws.close();
} catch {
/* ignore */
}
resolve(result);
};
const fail = (error: string) =>
finish({ content: parser.accumulated.trim(), reasoning: reasoningParts.join(""), error });
timeout = setTimeout(
() => fail(`UC persona WS timed out (readyState=${ws.readyState})`),
timeoutMs
);
abortHandler = () => fail("Request aborted");
input.signal?.addEventListener("abort", abortHandler, { once: true });
ws.onopen = () => {
try {
const frame = buildPersonaFrame({
model: input.model,
text: input.text,
history: input.history,
uid: input.uid,
media: input.media,
});
ws.send(JSON.stringify(frame));
} catch (err) {
fail(`ws send failed: ${err instanceof Error ? err.message : String(err)}`);
}
};
ws.onmessage = (event: WebSocket.MessageEvent) => {
let raw = "";
const data = event.data as unknown;
if (typeof data === "string") {
raw = data;
} else if (Buffer.isBuffer(data)) {
raw = data.toString("utf-8");
} else if (data instanceof ArrayBuffer) {
raw = new TextDecoder().decode(data);
} else if (ArrayBuffer.isView(data as ArrayBufferView)) {
raw = new TextDecoder().decode(data as ArrayBufferView);
}
if (!raw) return;
for (const evt of parser.feed(raw)) {
input.onEvent?.(evt);
if (evt.kind === "reasoning") {
reasoningParts.push(evt.text);
} else if (evt.kind === "error") {
errorText = evt.text;
} else if (evt.kind === "done") {
finish({ content: evt.text, reasoning: reasoningParts.join("") });
return;
}
}
if (parser.done) {
// Terminal error frame consumed by the parser.
finish({
content: parser.accumulated.trim(),
reasoning: reasoningParts.join(""),
error: errorText,
});
}
};
ws.onerror = () => fail("UC persona WebSocket connection error");
ws.onclose = () => {
if (settled) return;
// Closed without an explicit end_of_stream: use whatever we accumulated.
finish({
content: parser.finalText(),
reasoning: reasoningParts.join(""),
error: errorText,
});
};
});
}