diff --git a/open-sse/utils/streamErrorPatterns.js b/open-sse/utils/streamErrorPatterns.js new file mode 100644 index 00000000..084d6815 --- /dev/null +++ b/open-sse/utils/streamErrorPatterns.js @@ -0,0 +1,47 @@ +/** + * Config-driven in-stream error pattern matching. + * See docs/2026-08-04-stream-error-patterns-design.md. + * + * Settings shape: streamErrorPatterns: { [provider]: string[] } + * Pattern syntax per entry: + * - "plain text" → case-insensitive substring match + * - "/regex/flags" → RegExp match (flags after the last "/") + */ + +export function parsePatterns(patterns) { + if (!Array.isArray(patterns)) return []; + const out = []; + for (const entry of patterns) { + if (typeof entry !== "string" || !entry.trim()) continue; + const raw = entry.trim(); + const m = raw.match(/^\/(.*)\/([a-z]*)$/s); + if (m) { + try { + out.push({ regex: new RegExp(m[1], m[2]), raw }); + } catch { + // invalid regex → skip; config errors must never break requests + } + continue; + } + out.push({ text: raw.toLowerCase(), raw }); + } + return out; +} + +export function matchStreamErrorPatterns(patterns, text) { + if (!Array.isArray(patterns) || patterns.length === 0) return null; + if (typeof text !== "string" || !text) return null; + const lower = text.toLowerCase(); + for (const p of parsePatterns(patterns)) { + if (p.text && lower.includes(p.text)) return p.raw; + if (p.regex) { + p.regex.lastIndex = 0; // stateless even with /g + if (p.regex.test(text)) return p.raw; + } + } + return null; +} + +export function streamStatusForContent(patterns, content) { + return matchStreamErrorPatterns(patterns, content) ? "error" : "success"; +} diff --git a/tests/unit/streamErrorPatterns.test.js b/tests/unit/streamErrorPatterns.test.js new file mode 100644 index 00000000..4ecde02c --- /dev/null +++ b/tests/unit/streamErrorPatterns.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { parsePatterns, matchStreamErrorPatterns, streamStatusForContent } from "../../open-sse/utils/streamErrorPatterns.js"; + +describe("streamErrorPatterns util", () => { + it("matches plain text as case-insensitive substring", () => { + expect(matchStreamErrorPatterns(["Network connection lost"], "network CONNECTION LOST.")).toBe("Network connection lost"); + expect(matchStreamErrorPatterns(["server_error"], "some normal content")).toBeNull(); + }); + + it("matches /regex/flags", () => { + expect(matchStreamErrorPatterns(["/generation failed.*retry/i"], "GENERATION FAILED. please RETRY")).toBe("/generation failed.*retry/i"); + expect(matchStreamErrorPatterns(["/\\d+ tokens/"], "used 123 tokens")).toBe("/\\d+ tokens/"); + }); + + it("skips invalid regex and empty entries", () => { + expect(matchStreamErrorPatterns(["/[unclosed/", "", " "], "anything")).toBeNull(); + }); + + it("returns null for empty patterns or text", () => { + expect(matchStreamErrorPatterns([], "x")).toBeNull(); + expect(matchStreamErrorPatterns(["x"], "")).toBeNull(); + expect(matchStreamErrorPatterns(null, "x")).toBeNull(); + expect(matchStreamErrorPatterns(undefined, "x")).toBeNull(); + }); + + it("regex matching is stateless across calls", () => { + const pats = ["/error/i"]; + expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i"); + expect(matchStreamErrorPatterns(pats, "ERROR")).toBe("/error/i"); + }); + + it("parsePatterns normalizes entries", () => { + const parsed = parsePatterns(["Plain", "/re/g", "", "/bad["]); + expect(parsed.length).toBe(3); + expect(parsed[0]).toEqual({ text: "plain", raw: "Plain" }); + expect(parsed[1].regex).toBeInstanceOf(RegExp); + expect(parsed[2]).toEqual({ text: "/bad[", raw: "/bad[" }); + }); + + it("skips entries that look like regex but fail to compile", () => { + const parsed = parsePatterns(["/[unclosed/", "/ok/g"]); + expect(parsed.length).toBe(1); + expect(parsed[0].raw).toBe("/ok/g"); + }); + + it("streamStatusForContent maps match to error status", () => { + expect(streamStatusForContent(["boom"], "a boom happened")).toBe("error"); + expect(streamStatusForContent(["boom"], "all good")).toBe("success"); + }); +});