Files
OmniRoute/open-sse/executors/uc/protocol.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

199 lines
7.6 KiB
TypeScript

/**
* UC (uncensored.com) PERSONA protocol — WebSocket send-frame assembly and
* OpenAI→persona context mapping. Ported from the proven reference client
* (uc_native_adapter.py: build_uc_turn, _persona_frame) and the wire spec
* (UC-PERSONA-WS-OMNIROUTE-SPEC.md).
*
* Unlike a stateless-full-history HTTP provider, UC persona is single-shot over a
* socket: one JSON frame carrying the CURRENT turn as `text` plus the prior
* conversation as `chat_history` (client-accumulated). Roles in chat_history are
* `human`/`assistant` (NOT `user`), and content is a parts array
* `[{type:"text",text}]`. System prompts, an identity steer, and the tool
* preamble are folded into `text` (persona has no system channel).
*
* CRITICAL persona wire rules (must be enforced at the executor boundary):
* • NO `direct_params`, and `max_tokens`/`max_completion_tokens`/`reasoning`/
* `temperature`/etc. are IGNORED — worse, injecting `max_tokens` ABORTS the
* turn (empty return). This module simply never emits them.
* • NO native `tools[]` — tool schemas are folded into `text` as a prompted
* `<tool_call>` preamble (handled by the shared translator/webTools.ts on the
* executor side); the response side parses `<tool_call>` blocks back out.
*/
import { randomUUID } from "node:crypto";
import { UC_APP_VERSION } from "./constants.ts";
/**
* Gentle identity steer. An aggressive "absolute override" BACKFIRES on UC's
* persona (the model mocks the injected system text); a mild, professional steer
* neutralizes the default "ENI" pet-name persona cleanly. Proven in the
* reference client.
*/
export const UC_IDENTITY_STEER =
"You are operating as a professional technical assistant. Answer plainly and " +
"directly; do not use pet-names or roleplay framing.";
interface OpenAiMessage {
role?: string;
content?: unknown;
name?: string;
tool_calls?: unknown;
tool_call_id?: string;
}
/** A persona chat_history entry. */
export interface UcHistoryEntry {
role: "human" | "assistant";
content: Array<{ type: "text"; text: string }>;
}
/** Flatten OpenAI `content` (string or multipart array) to plain text. */
export function ucContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === "object" && (part as { type?: string }).type === "text"
? String((part as { text?: unknown }).text ?? "")
: ""
)
.filter(Boolean)
.join("\n");
}
return "";
}
/** Wrap a plain string as a persona content-parts array. */
function textParts(text: string): Array<{ type: "text"; text: string }> {
return [{ type: "text", text }];
}
/**
* Assemble the persona `{ text, history }` from an OpenAI messages[] array.
*
* Split point is the LAST assistant message: everything up to and including it
* becomes `chat_history` (roles mapped user→human, assistant→assistant,
* tool→human with a `[tool result]` prefix); everything AFTER it (the trailing
* user/tool turn) is flattened into the single `text` string. System messages
* are collected and prepended to `text` (persona has no system channel),
* followed by the identity steer, separated from the user content by a divider.
*
* Tool schemas are injected UPSTREAM by the shared prepareToolMessages() (the
* executor passes the already-tool-prepared messages here), so this function
* only maps roles + folds systems — it does not itself render a tool preamble.
*/
export function assembleUcTurn(
messages: OpenAiMessage[],
opts: { identitySteer?: boolean } = {}
): { text: string; history: UcHistoryEntry[] } {
const identitySteer = opts.identitySteer !== false;
const systems: string[] = [];
const history: UcHistoryEntry[] = [];
let lastAssistant = -1;
for (let i = 0; i < messages.length; i++) {
if (messages[i]?.role === "assistant") lastAssistant = i;
}
const head = lastAssistant >= 0 ? messages.slice(0, lastAssistant + 1) : [];
const tail = lastAssistant >= 0 ? messages.slice(lastAssistant + 1) : messages;
for (const m of head) {
const role = m.role;
if (role === "system") {
systems.push(ucContentToText(m.content));
} else if (role === "user") {
history.push({ role: "human", content: textParts(ucContentToText(m.content)) });
} else if (role === "assistant") {
history.push({ role: "assistant", content: textParts(ucContentToText(m.content)) });
} else if (role === "tool") {
history.push({
role: "human",
content: textParts(`[tool result] ${ucContentToText(m.content)}`),
});
}
}
const activeParts: string[] = [];
for (const m of tail) {
const role = m.role;
if (role === "system") {
systems.push(ucContentToText(m.content));
} else if (role === "user") {
activeParts.push(ucContentToText(m.content));
} else if (role === "tool") {
const name = m.name || "tool";
activeParts.push(
`The ${name} tool already ran and returned:\n` +
`${ucContentToText(m.content)}\n` +
`Use this result to answer; do NOT call the tool again.`
);
} else if (role === "assistant") {
activeParts.push(ucContentToText(m.content));
}
}
const preamble: string[] = [];
const joinedSystems = systems.filter(Boolean).join("\n\n");
if (joinedSystems) preamble.push(joinedSystems);
if (identitySteer) preamble.push(UC_IDENTITY_STEER);
let active = activeParts.filter(Boolean).join("\n\n").trim();
if (preamble.length) {
active = preamble.join("\n\n") + "\n\n---\n\n" + active;
}
return { text: active, history };
}
/**
* Build the persona (non-direct) WebSocket send frame. Mirrors the reference
* client's `_persona_frame` exactly. Fresh uuids per message; `model` is the UC
* persona SHORTNAME (already the registry id); `user_identifier` is the account
* uid (also the WS URL path segment).
*
* Note the deliberately-absent knobs: no direct_params, no max_tokens, no
* temperature/reasoning — persona ignores them and max_tokens aborts the turn.
*/
export function buildPersonaFrame(opts: {
model: string;
text: string;
history: UcHistoryEntry[];
uid: string;
/** Uploaded input-media blob references (images/docs) for the current turn. */
media?: Array<{ blobName: string; contentType: string }>;
}): Record<string, unknown> {
// UC persona carries ONE media blob per frame (the captured single-file chat
// case); when several were uploaded we attach the first and list the rest under
// `media_blob_names` for forward-compat (the multi-file field is untested but
// harmless if the server ignores it). See UC-FILE-UPLOAD.md.
const media = opts.media ?? [];
const primary = media[0];
return {
message_id: randomUUID(),
client_request_id: randomUUID(),
thread_id: randomUUID(),
app_version: UC_APP_VERSION,
model: opts.model,
text: opts.text,
chat_history: opts.history,
chat_history_truncated: false,
chat_mode: "chat",
use_memory: false,
web_search_enabled: false,
perplexity_search_enabled: false,
is_smartify: false,
is_refresh: false,
is_suggested_input: false,
followups_enabled: false,
free_tier_model_selected: false,
user_identifier: opts.uid,
// no_media_in_chat means "don't render the media inline in the transcript",
// NOT "no media" — it stays true even when a blob is attached (per capture).
no_media_in_chat: true,
media_blob_name: primary?.blobName ?? "",
media_content_type: primary?.contentType ?? "",
...(media.length > 1
? { media_blob_names: media.map((m) => m.blobName), _uc_media_count: media.length }
: {}),
adapty_profile_id: null,
};
}