mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* feat(homolog): scaffolding da suíte de homologação E2E (deps + npm run homolog) * feat(homolog): L0 avaliador de paridade de deploy (TDD) * feat(homolog): L1a ciclo de vida de API key efêmera (login admin -> create -> revoke) * feat(homolog): L1b suite httpYac de API (models, chat, auth de management, health) * feat(homolog): L1c checker SSE de streaming real (TDD no parser) * feat(homolog): L2 smoke de providers reais via promptfoo gerado do catálogo * feat(homolog): L4a Playwright homolog config + login storageState * feat(homolog): L4b smoke de todas as rotas do dashboard (descoberta via fs) * feat(homolog): L4c fluxo criar/revogar API key pela UI * fix(homolog): resiliencia real-environment — stream:false no smoke promptfoo, retry de socket keep-alive, key efemera com sufixo unico * feat(homolog): L5 orquestrador npm run homolog + relatorio CTRF unificado * docs(homolog): guia de operacao da suite + fragment de changelog + allowlist env-doc-sync * fix(homolog): paraleliza o sweep de rotas do dashboard (fullyParallel + 8 workers) * fix(homolog): isola outputs crus em homolog-report/raw para nao quebrar o ctrf merge * fix(homolog): outputDir absoluto do reporter CTRF da UI (path relativo escapava do worktree) * chore(quality): allowlist the 5 homolog-suite devDependencies (ctrf-io trio, httpyac, promptfoo) after registry verification * chore(quality): register the homolog Playwright suite as a test-discovery collector (run.mjs -> tests/homolog/ui)
81 lines
2.9 KiB
JavaScript
81 lines
2.9 KiB
JavaScript
export function parseSseChunk(text) {
|
|
// Itera LINHAS dentro de cada bloco: a VPS emite comment-lines SSE
|
|
// (": x-omniroute-*") no mesmo bloco do "data: [DONE]", então olhar só o
|
|
// início do bloco perde o terminador.
|
|
const events = [];
|
|
for (const block of text.split(/\n\n/)) {
|
|
for (const line of block.split("\n")) {
|
|
const t = line.trim();
|
|
if (t.startsWith("data:")) events.push(t.slice(5).trim());
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
export function summarizeStream(events) {
|
|
let contentDeltas = 0;
|
|
let done = false;
|
|
for (const e of events) {
|
|
if (e === "[DONE]") {
|
|
done = true;
|
|
continue;
|
|
}
|
|
try {
|
|
const j = JSON.parse(e);
|
|
if (j.choices?.[0]?.delta?.content) contentDeltas++;
|
|
} catch {
|
|
/* fragmento parcial — ignorado; o caller acumula buffer */
|
|
}
|
|
}
|
|
const ok = contentDeltas >= 1 && done;
|
|
return { ok, contentDeltas, done };
|
|
}
|
|
|
|
/** Faz 1 chat streaming real e valida o protocolo SSE ponta-a-ponta. */
|
|
export async function checkSse(baseUrl, apiKey, model, { retries = 1 } = {}) {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [{ role: "user", content: "Reply with exactly: OK" }],
|
|
max_tokens: 5,
|
|
stream: true,
|
|
}),
|
|
});
|
|
if (res.status !== 200) return { ok: false, failures: [`HTTP ${res.status}`] };
|
|
const ct = res.headers.get("content-type") || "";
|
|
if (!ct.includes("text/event-stream")) return { ok: false, failures: [`content-type "${ct}"`] };
|
|
|
|
const events = [];
|
|
let buffer = "";
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
const deadline = Date.now() + 60_000;
|
|
while (Date.now() < deadline) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lastSep = buffer.lastIndexOf("\n\n");
|
|
if (lastSep >= 0) {
|
|
events.push(...parseSseChunk(buffer.slice(0, lastSep + 2)));
|
|
buffer = buffer.slice(lastSep + 2);
|
|
}
|
|
}
|
|
// flush do resto do buffer (último bloco pode chegar sem "\n\n" no read final)
|
|
if (buffer.trim()) events.push(...parseSseChunk(buffer));
|
|
const s = summarizeStream(events);
|
|
return { ok: s.ok, failures: s.ok ? [] : [`contentDeltas=${s.contentDeltas} done=${s.done}`] };
|
|
} catch (err) {
|
|
// Socket keep-alive reciclado pelo servidor entre requests é transitório —
|
|
// 1 retry antes de reportar falha. Erro persistente é FALHA da camada,
|
|
// nunca crash do orquestrador.
|
|
if (retries > 0) {
|
|
await new Promise((r) => setTimeout(r, 1_000));
|
|
return checkSse(baseUrl, apiKey, model, { retries: retries - 1 });
|
|
}
|
|
return { ok: false, failures: [`fetch/stream error: ${err?.cause?.message || err.message}`] };
|
|
}
|
|
}
|