Files
OmniRoute/open-sse/executors/maxai/stream.ts
Armin Anton” ∴ cabbbe410a feat(providers): add MaxAI — signed OpenAI-compatible provider (chat, tools, vision, image-gen, doc-RAG) (#11461)
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.
2026-09-02 01:55:42 -03:00

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));
}