mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
MaxAI joins as a first-class signed provider: 13 chat models discovered live from /models/get_config plus 6 image models, routed through the standard /v1 endpoints with per-request X-Authorization signing, browserless onboarding, prompted tool-calling, vision input, image generation and document RAG. Reconciled on merge — worth reading, because the branch forked 227 commits back and 77 files conflicted. Only five carried MaxAI content; the rest was drift from the older release line and took the tip's side, taking the diff from 113 files to 37 (then 93 as counted against the current base). - executors/index.ts: the tip has since refactored the executor map to lazy dynamic imports, so MaxAI is registered in that shape rather than the branch's static import. - imageRegistry.ts: kept only the maxai block. The branch still carried microsoft-designer-web, which #11754 retired. - models/route.ts: the conflicting hunk was an unrelated Vertex/Anthropic URL change, not MaxAI — tip's side. - volcengine agent-plan/coding-plan registries: git auto-merged both sides and produced a duplicated supportsVision key, which TypeScript rejects (TS1117). Removed. One real integration break that only the combined state shows: the MaxAI entry declared no serviceKinds, which #11392 made required a few hours ago. Provider validation threw at load time and check:provider-consistency crashed outright. Declared ["llm"] — the image kinds derive from imageRegistry, per the convention in that PR's backfill. Every count was measured rather than taken from the branch, and each would have been wrong: reserved prefixes are 402, not the 397 the branch computed from its stale 395 base; providers are 353, not 354. 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 those files is a digit substitution and nothing else, verified by masking digits and comparing the removed and added sets (90 lines, identical). The executor-map golden snapshot was regenerated: keyCount 133 -> 134. The branch's file-size-baseline.json predates #12411's ratchet re-tightening, so it was discarded rather than merged — taking it would have silently undone that. The three files this PR grows (proxyFetch.ts +20 for the Windows/firefox_150 TLS profile, imageGeneration.ts +12, models/route.ts +48) were entered against the current baseline under one _rebaseline annotation; no other cap moves. Verified: typecheck:core clean, check:provider-consistency OK (269 REGISTRY entries, 353 canonical providers), check:docs-counts exit 0, check-file-size OK, check:cycles OK, and 79/79 across the MaxAI suites plus 21/21 reserved-prefix and 2/2 executor-map-golden. Thanks @arminanton — the provider work itself is thorough; it was the 227 commits of base that needed the attention.
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
|
* MaxAI doc-RAG — inline document parts → /app/upload_document → doc_list.
|
|
*
|
|
* OmniRoute delivers attached documents INLINE in the chat request as base64
|
|
* `file_data` content parts (OpenAI `{type:"file",file:{filename,file_data}}` /
|
|
* Responses `{type:"input_file",file_data}` / Claude `{type:"document",source}`).
|
|
* MaxAI's `/gpt/cwc/chat` cannot take binary docs inline; instead it references
|
|
* uploaded documents by a content-addressed `doc_id`. This module bridges the
|
|
* two: it detects inline base64 doc parts on the current turn, uploads each via
|
|
* the multipart `/app/upload_document` endpoint (signed like every MaxAI call),
|
|
* and returns the `doc_list` entries to attach to the chat body.
|
|
*
|
|
* doc_id is NOT random — MaxAI requires `doc_id = HMAC-SHA1(file_bytes, IT)` hex
|
|
* (createDocId/qM in the extension). A random id is rejected with a 400
|
|
* "Inconsistency between server doc_id and request doc_id". The IT key is a
|
|
* public web-app constant (ships in the bundle), same class as the signing
|
|
* constants; kept here as a named constant (not a secret).
|
|
*
|
|
* The doc_list item shape is exactly what the live web app sends
|
|
* (site chunk 41068): `{ doc_id, doc_type, file_name }`.
|
|
*/
|
|
import { createHmac } from "node:crypto";
|
|
import { buildMaxaiSignedHeaders } from "./signing.ts";
|
|
import { ensureMaxaiConstants } from "./constantsStore.ts";
|
|
import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts";
|
|
|
|
export const MAXAI_UPLOAD_PATH = "/app/upload_document";
|
|
|
|
export interface MaxaiDocListEntry {
|
|
doc_id: string;
|
|
doc_type: string;
|
|
file_name: string;
|
|
}
|
|
|
|
/** An inline document extracted from an OpenAI/Responses/Claude content part. */
|
|
export interface InlineDoc {
|
|
filename: string;
|
|
mimeType: string;
|
|
bytes: Buffer;
|
|
}
|
|
|
|
/** doc_id = HMAC-SHA1(file_bytes, docIdKey) hex. Content-addressed; MaxAI verifies it. */
|
|
export function computeMaxaiDocId(bytes: Buffer, key: string): string {
|
|
if (!key) throw new Error("computeMaxaiDocId: missing docIdKey");
|
|
return createHmac("sha1", key).update(bytes).digest("hex");
|
|
}
|
|
|
|
const TEXTUAL_EXT = /\.(txt|md|markdown|csv|json|log|xml|yaml|yml|tsv)$/i;
|
|
const CODE_EXT =
|
|
/\.(py|ipynb|js|jsx|ts|tsx|html?|css|java|cs|php|c|cpp|cxx|h|hpp|go|rs|rb|swift|kt|sh|sql)$/i;
|
|
|
|
/** Classify the MaxAI doc_type from the filename/mime (extension taxonomy). */
|
|
export function maxaiDocType(filename: string, mimeType: string): string {
|
|
const f = filename.toLowerCase();
|
|
if (/\.pdf$/i.test(f) || mimeType === "application/pdf") return "page_content__pdf";
|
|
if (CODE_EXT.test(f)) return "chat_file_code";
|
|
return "chat_file"; // text / generic
|
|
}
|
|
|
|
/** Whether a doc_type requires the pure_text field (text-extractable docs). */
|
|
function requiresPureText(docType: string): boolean {
|
|
return docType === "chat_file" || docType === "chat_file_code";
|
|
}
|
|
|
|
/**
|
|
* Parse an OpenAI/Responses/Claude data-URL into raw bytes + mime. Returns null
|
|
* for anything that isn't an inline base64 payload (e.g. a remote URL or an
|
|
* already-uploaded file_id reference, which this bridge does not handle).
|
|
*/
|
|
export function parseInlineDataUrl(dataUrl: unknown): { mimeType: string; bytes: Buffer } | null {
|
|
if (typeof dataUrl !== "string") return null;
|
|
const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl);
|
|
if (!m) return null;
|
|
const mimeType = m[1] || "application/octet-stream";
|
|
const isBase64 = !!m[2];
|
|
try {
|
|
const bytes = isBase64
|
|
? Buffer.from(m[3], "base64")
|
|
: Buffer.from(decodeURIComponent(m[3]), "utf8");
|
|
if (bytes.length === 0) return null;
|
|
return { mimeType, bytes };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract inline documents from the CURRENT (last user) turn of an OpenAI
|
|
* messages[] array. Recognizes the three OmniRoute-delivered shapes:
|
|
* OpenAI Chat: {type:"file", file:{filename, file_data|data}}
|
|
* Responses: {type:"input_file", filename, file_data}
|
|
* Claude: {type:"document", source:{type:"base64", media_type, data}}
|
|
* Only base64/data-URL payloads are handled (a bridge upload needs the bytes).
|
|
*/
|
|
export function extractCurrentTurnDocs(
|
|
messages: Array<{ role?: string; content?: unknown }>
|
|
): InlineDoc[] {
|
|
let content: unknown;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i]?.role === "user") {
|
|
content = messages[i]?.content;
|
|
break;
|
|
}
|
|
}
|
|
if (!Array.isArray(content)) return [];
|
|
const docs: InlineDoc[] = [];
|
|
for (const part of content) {
|
|
if (!part || typeof part !== "object") continue;
|
|
const p = part as Record<string, unknown>;
|
|
const type = p.type;
|
|
|
|
if (type === "file" && p.file && typeof p.file === "object") {
|
|
const file = p.file as Record<string, unknown>;
|
|
const filename = typeof file.filename === "string" ? file.filename : "upload.bin";
|
|
const raw = (file.file_data ?? file.data) as unknown;
|
|
const parsed = parseInlineDataUrl(raw);
|
|
if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes });
|
|
} else if (type === "input_file") {
|
|
const filename = typeof p.filename === "string" ? p.filename : "upload.bin";
|
|
const parsed = parseInlineDataUrl(p.file_data);
|
|
if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes });
|
|
} else if (type === "document" && p.source && typeof p.source === "object") {
|
|
const source = p.source as Record<string, unknown>;
|
|
if (source.type === "base64" && typeof source.data === "string") {
|
|
const mimeType =
|
|
typeof source.media_type === "string" ? source.media_type : "application/octet-stream";
|
|
try {
|
|
const bytes = Buffer.from(source.data, "base64");
|
|
if (bytes.length > 0) {
|
|
const filename =
|
|
typeof p.title === "string" && p.title ? p.title : `document.${mimeExt(mimeType)}`;
|
|
docs.push({ filename, mimeType, bytes });
|
|
}
|
|
} catch {
|
|
/* skip malformed base64 */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return docs;
|
|
}
|
|
|
|
function mimeExt(mime: string): string {
|
|
if (mime === "application/pdf") return "pdf";
|
|
if (mime.startsWith("text/")) return "txt";
|
|
return "bin";
|
|
}
|
|
|
|
/** Rough ~4-chars/token estimate; ceil, never 0 for non-empty text. */
|
|
function estimateTokens(text: string): number {
|
|
return text ? Math.max(1, Math.ceil(text.length / 4)) : 0;
|
|
}
|
|
|
|
/** Build the multipart/form-data body for /app/upload_document (fixed boundary). */
|
|
export function buildUploadMultipart(
|
|
doc: InlineDoc,
|
|
docId: string,
|
|
docType: string,
|
|
boundary: string
|
|
): Buffer {
|
|
const isTextual =
|
|
requiresPureText(docType) &&
|
|
(TEXTUAL_EXT.test(doc.filename) ||
|
|
CODE_EXT.test(doc.filename) ||
|
|
doc.mimeType.startsWith("text/"));
|
|
const pureText = isTextual ? doc.bytes.toString("utf8") : "";
|
|
const tokens = String(estimateTokens(pureText));
|
|
|
|
const parts: Buffer[] = [];
|
|
const dash = `--${boundary}\r\n`;
|
|
const field = (name: string, value: string): void => {
|
|
parts.push(
|
|
Buffer.from(`${dash}Content-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`)
|
|
);
|
|
};
|
|
field("doc_id", docId);
|
|
field("doc_type", docType);
|
|
field("pure_text", pureText);
|
|
field("tokens", tokens);
|
|
field("doc_type_dependent_data", "{}");
|
|
// The file part carries the raw bytes with a content-type.
|
|
parts.push(
|
|
Buffer.from(
|
|
`${dash}Content-Disposition: form-data; name="file"; filename="${doc.filename.replace(/"/g, "")}"\r\n` +
|
|
`Content-Type: ${doc.mimeType}\r\n\r\n`
|
|
)
|
|
);
|
|
parts.push(doc.bytes);
|
|
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
/** True if any SSE frame in the response is the terminal upload_done event. */
|
|
export function sawUploadDone(text: string): boolean {
|
|
return /"event"\s*:\s*"upload_done"/.test(text) || text.includes("upload_done");
|
|
}
|
|
|
|
/**
|
|
* Upload one inline document to MaxAI and return its doc_list entry, or null on
|
|
* failure (upload failures are non-fatal: the chat proceeds without the doc).
|
|
*/
|
|
export async function uploadMaxaiDocument(
|
|
doc: InlineDoc,
|
|
auth: { accessToken: string; userId: string; deviceId: string },
|
|
opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }
|
|
): Promise<MaxaiDocListEntry | null> {
|
|
const fetchImpl = opts?.fetchImpl ?? fetch;
|
|
const constants = await ensureMaxaiConstants({ fetchImpl, signal: opts?.signal });
|
|
if (!constants) return null;
|
|
const docId = computeMaxaiDocId(doc.bytes, constants.docIdKey);
|
|
const docType = maxaiDocType(doc.filename, doc.mimeType);
|
|
const boundary = `----maxai${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
|
|
const bodyBuf = buildUploadMultipart(doc, docId, docType, boundary);
|
|
|
|
// Sign like any request, but DROP the JSON content-type so we can set the
|
|
// multipart boundary content-type ourselves (v3h.signed_headers pattern).
|
|
const { "Content-Type": _drop, ...staticHeaders } = maxaiStaticHeaders();
|
|
const headers: Record<string, string> = {
|
|
...staticHeaders,
|
|
...buildMaxaiSignedHeaders(
|
|
{ path: MAXAI_UPLOAD_PATH, userId: auth.userId, deviceId: auth.deviceId },
|
|
constants
|
|
),
|
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
};
|
|
|
|
// Copy the multipart bytes into a fresh Uint8Array backed by a plain
|
|
// (non-shared) ArrayBuffer. `Buffer.buffer` is typed ArrayBufferLike
|
|
// (ArrayBuffer | SharedArrayBuffer) which isn't assignable to fetch's
|
|
// BodyInit; a freshly-allocated Uint8Array is the BodyInit shape the rest of
|
|
// the codebase uses for binary bodies (kimi-web.ts:397, conol-web.ts:529).
|
|
const bodyBytes = new Uint8Array(bodyBuf.byteLength);
|
|
bodyBytes.set(bodyBuf);
|
|
|
|
try {
|
|
const resp = await fetchImpl(MAXAI_BASE_URL + MAXAI_UPLOAD_PATH, {
|
|
method: "POST",
|
|
headers,
|
|
body: bodyBytes,
|
|
signal: opts?.signal,
|
|
});
|
|
if (!resp.ok) return null;
|
|
const text = await resp.text().catch(() => "");
|
|
if (!sawUploadDone(text)) return null;
|
|
return { doc_id: docId, doc_type: docType, file_name: doc.filename };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload every inline document on the current turn and return the doc_list to
|
|
* attach to the chat body. Failures are skipped (best-effort); the chat still
|
|
* proceeds. Empty array when there are no inline docs.
|
|
*/
|
|
export async function resolveMaxaiDocList(
|
|
messages: Array<{ role?: string; content?: unknown }>,
|
|
auth: { accessToken: string; userId: string; deviceId: string },
|
|
opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }
|
|
): Promise<MaxaiDocListEntry[]> {
|
|
const docs = extractCurrentTurnDocs(messages);
|
|
if (docs.length === 0) return [];
|
|
const results = await Promise.all(docs.map((d) => uploadMaxaiDocument(d, auth, opts)));
|
|
return results.filter((r): r is MaxaiDocListEntry => r !== null);
|
|
}
|