mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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.
199 lines
7.6 KiB
TypeScript
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,
|
|
};
|
|
}
|