mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +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.
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
/**
|
|
* MaxAI SSE stream handling — frame parsing, incremental `<think>` split, and
|
|
* token estimation. Ported from the MaxAI v3 Python client (translation/sse.py,
|
|
* translation/stream.py, translation/think_split.py, translation/token_usage.py).
|
|
*
|
|
* MaxAI's `/gpt/cwc/chat` response is `text/event-stream`: `data: {json}` frames
|
|
* separated by blank lines. A text delta is a frame with
|
|
* `data_key === "text" && need_merge` truthy; its content is `frame.text`.
|
|
* Reasoning is emitted inline wrapped in `<think>…</think>`; everything inside is
|
|
* reasoning, everything after the close tag is the visible answer. MaxAI returns
|
|
* no usage frame, so tokens are estimated (~4 chars/token).
|
|
*/
|
|
|
|
/** Parse the text deltas out of a raw SSE body (batch). */
|
|
export function parseMaxaiSseText(raw: string): string {
|
|
let out = "";
|
|
for (const line of raw.split("\n")) {
|
|
const s = line.trim();
|
|
if (!s.startsWith("data:")) continue;
|
|
const js = s.slice(5).trim();
|
|
if (!js || js === "[DONE]") continue;
|
|
try {
|
|
const frame = JSON.parse(js) as { data_key?: unknown; need_merge?: unknown; text?: unknown };
|
|
if (frame.data_key === "text" && frame.need_merge) {
|
|
out += typeof frame.text === "string" ? frame.text : "";
|
|
}
|
|
} catch {
|
|
/* ignore non-JSON keepalive frames */
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** True when a decoded SSE frame is a mergeable text delta. */
|
|
export function isMaxaiTextFrame(
|
|
frame: unknown
|
|
): frame is { data_key: "text"; need_merge: true; text: string } {
|
|
const f = frame as { data_key?: unknown; need_merge?: unknown; text?: unknown };
|
|
return f?.data_key === "text" && Boolean(f?.need_merge) && typeof f?.text === "string";
|
|
}
|
|
|
|
const OPEN = "<think>";
|
|
const CLOSE = "</think>";
|
|
const HOLD = Math.max(OPEN.length, CLOSE.length) - 1;
|
|
|
|
/**
|
|
* Stateful streaming classifier of text into (reasoning, answer). Handles a tag
|
|
* split across frames by holding a short tail. Before `<think>` opens, text is
|
|
* answer; if no `<think>` ever appears the whole stream is answer.
|
|
*/
|
|
export class ThinkSplitter {
|
|
private buf = "";
|
|
private inThink = false;
|
|
|
|
feed(delta: string): { reasoning: string; answer: string } {
|
|
this.buf += delta;
|
|
let reasoning = "";
|
|
let answer = "";
|
|
for (;;) {
|
|
const tag = this.inThink ? CLOSE : OPEN;
|
|
const idx = this.buf.indexOf(tag);
|
|
if (idx === -1) break;
|
|
const before = this.buf.slice(0, idx);
|
|
if (this.inThink) reasoning += before;
|
|
else answer += before;
|
|
this.buf = this.buf.slice(idx + tag.length);
|
|
this.inThink = !this.inThink;
|
|
}
|
|
// Emit everything except a short tail that might begin a tag.
|
|
const safe = this.buf.length > HOLD ? this.buf.slice(0, this.buf.length - HOLD) : "";
|
|
if (safe) {
|
|
this.buf = this.buf.slice(safe.length);
|
|
if (this.inThink) reasoning += safe;
|
|
else answer += safe;
|
|
}
|
|
return { reasoning, answer };
|
|
}
|
|
|
|
flush(): { reasoning: string; answer: string } {
|
|
const tail = this.buf;
|
|
this.buf = "";
|
|
if (!tail) return { reasoning: "", answer: "" };
|
|
return this.inThink ? { reasoning: tail, answer: "" } : { reasoning: "", answer: tail };
|
|
}
|
|
}
|
|
|
|
/** Split a fully-collected answer into { reasoning, answer } (batch/non-stream). */
|
|
export function splitThink(full: string): { reasoning: string; answer: string } {
|
|
const splitter = new ThinkSplitter();
|
|
const a = splitter.feed(full);
|
|
const b = splitter.flush();
|
|
return {
|
|
reasoning: a.reasoning + b.reasoning,
|
|
answer: a.answer + b.answer,
|
|
};
|
|
}
|
|
|
|
/** MaxAI returns no token counts; estimate ~4 chars/token. */
|
|
export function estimateMaxaiTokens(text: string): number {
|
|
return Math.max(0, Math.ceil((text?.length ?? 0) / 4));
|
|
}
|