Skip to content

Commit 9a77ce2

Browse files
authored
Merge pull request #13 from gitcommit90/fix/bound-preoutput-inspection
Bound pre-output stream inspection
2 parents 9f98767 + d778155 commit 9a77ce2

5 files changed

Lines changed: 115 additions & 12 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The router supports:
8585
- `fallback`: members are attempted in their configured order.
8686
- `round-robin`: each request rotates the starting member, then retains fallback behavior through the remaining members.
8787

88-
Named routes and OAuth account pools continue through every untried target until an upstream returns a usable `2xx` response. Any non-`2xx` status advances fallback regardless of error type. A `2xx` response that contains an immediate stream error, ends without usable output, has no response body, contains invalid JSON, or carries an explicit error payload also advances fallback. Failure classification controls account cooldown locks and diagnostics; it never stops routing. Capability failures do not create account cooldown locks. The per-member timeout defaults to 60 seconds. Caller cancellation stops immediately, and a stream cannot be transparently rerouted after output has already reached the client.
88+
Named routes and OAuth account pools continue through every untried target until an upstream returns a usable `2xx` response. Any non-`2xx` status advances fallback regardless of error type. A `2xx` response that contains an immediate stream error, ends without usable output, exceeds the bounded pre-output inspection budget, has no response body, contains invalid JSON, or carries an explicit error payload also advances fallback. Failure classification controls account cooldown locks and diagnostics; it never stops routing. Capability failures do not create account cooldown locks. The per-member timeout defaults to 60 seconds. Caller cancellation stops immediately, and a stream cannot be transparently rerouted after output has already reached the client.
8989

9090
OAuth providers add an account-pool layer beneath model routing. Accounts receive monotonic, never-reused aliases (`oauth1`, `oauth2`, ...). Model discovery advertises one canonical pooled id such as `chatgpt/gpt-5.4`; account-qualified ids such as `chatgpt/oauth2/gpt-5.4` and legacy stored-account ids remain resolvable but are not advertised. Quota failures create an account-wide lock using provider reset hints when available; authentication and transient failures use shorter model-scoped cooldowns. Early streaming quota events are inspected before the client stream starts so fallback can still occur. Selection, failure, fallback, locked-account skips, and terminal exhaustion are written as structured logs.
9191

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@gitcommit90/rerouted",
33
"productName": "ReRouted",
4-
"version": "0.5.1",
4+
"version": "0.5.2",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

src/lib/router.js

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const COOLDOWN_MS = {
2323
auth: 2 * 60_000,
2424
transient: 30_000,
2525
};
26+
const PREOUTPUT_INSPECTION_BYTES = 64 * 1024;
2627

2728
function createRequestLog(max = 50) {
2829
const items = [];
@@ -578,7 +579,7 @@ function rebuildResponseWithPrelude(response, chunks, reader) {
578579
});
579580
}
580581

581-
async function inspectEarlyResponsesSse(response) {
582+
async function inspectEarlyResponsesSse(response, maxBytes = PREOUTPUT_INSPECTION_BYTES) {
582583
if (!response?.ok || !response.body?.getReader) return { response, failure: null };
583584
const contentType = String(response.headers?.get?.("content-type") || "").toLowerCase();
584585
if (contentType && !contentType.includes("text/event-stream")) {
@@ -598,24 +599,55 @@ async function inspectEarlyResponsesSse(response) {
598599
const decoder = new TextDecoder();
599600
const chunks = [];
600601
let text = "";
602+
let pendingEvents = "";
603+
let bytes = 0;
601604
let done = false;
602605
while (true) {
603606
const next = await reader.read();
604607
done = next.done;
605608
if (done) break;
606609
chunks.push(next.value);
607-
text += decoder.decode(next.value, { stream: true });
608-
const failure = parseEarlyResponsesFailure(text, response);
609-
if (failure) {
610-
await reader.cancel().catch(() => {});
611-
return { response: null, failure };
610+
bytes += next.value?.byteLength || 0;
611+
const decoded = decoder.decode(next.value, { stream: true });
612+
text += decoded;
613+
pendingEvents += decoded;
614+
615+
let completeEnd = 0;
616+
const boundary = /\r?\n\r?\n/g;
617+
for (let match = boundary.exec(pendingEvents); match; match = boundary.exec(pendingEvents)) {
618+
completeEnd = boundary.lastIndex;
619+
}
620+
if (completeEnd) {
621+
const completeEvents = pendingEvents.slice(0, completeEnd);
622+
pendingEvents = pendingEvents.slice(completeEnd);
623+
const failure = parseEarlyResponsesFailure(completeEvents, response);
624+
if (failure) {
625+
await reader.cancel().catch(() => {});
626+
return { response: null, failure };
627+
}
628+
if (hasProductiveResponsesEvent(completeEvents)) break;
612629
}
613630
// Metadata events such as response.created can precede a quota error.
614631
// Hold the stream until actual output (or completion) proves the account usable.
615-
if (hasProductiveResponsesEvent(text)) break;
632+
if (bytes >= maxBytes) {
633+
await reader.cancel().catch(() => {});
634+
return {
635+
response: null,
636+
failure: {
637+
status: 502,
638+
error: `Upstream exceeded the ${maxBytes}-byte inspection budget before producing a usable response`,
639+
resetAt: null,
640+
},
641+
};
642+
}
616643
}
617644

618645
if (done) {
646+
const trailing = decoder.decode();
647+
text += trailing;
648+
pendingEvents += trailing;
649+
const trailingFailure = parseEarlyResponsesFailure(pendingEvents, response);
650+
if (trailingFailure) return { response: null, failure: trailingFailure };
619651
try {
620652
const payload = JSON.parse(text);
621653
if (isUsableChatCompletion(payload)) {
@@ -624,7 +656,7 @@ async function inspectEarlyResponsesSse(response) {
624656
} catch {
625657
/* the completed body may be SSE */
626658
}
627-
if (!hasProductiveResponsesEvent(text)) {
659+
if (!hasProductiveResponsesEvent(pendingEvents)) {
628660
return {
629661
response: null,
630662
failure: {

tests/router-fallback.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,77 @@ describe("same-provider OAuth account fallback", () => {
13651365
assert.deepEqual(calls, ["stream-1.test", "stream-2.test", "stream-3.test"]);
13661366
});
13671367

1368+
it("bounds pre-output stream inspection and reroutes an oversized metadata preamble", async () => {
1369+
const store = createStore(tmpConfig());
1370+
const providers = ["Slow preamble", "Backup"].map((name, index) => ({
1371+
id: `prov_bounded_${index + 1}`,
1372+
type: "openai-compat",
1373+
name,
1374+
baseUrl: `https://bounded-${index + 1}.test/v1`,
1375+
apiKey: `bounded-key-${index + 1}`,
1376+
enabled: true,
1377+
models: [{ id: `bounded-model-${index + 1}`, name, enabled: true }],
1378+
}));
1379+
store.seed({
1380+
providers,
1381+
combos: [
1382+
{
1383+
id: "combo_bounded",
1384+
strategy: "fallback",
1385+
members: providers.map((provider, index) => ({
1386+
providerId: provider.id,
1387+
model: `bounded-model-${index + 1}`,
1388+
})),
1389+
},
1390+
],
1391+
});
1392+
const calls = [];
1393+
let canceled = false;
1394+
const router = createRouter({
1395+
store,
1396+
logger: captureLogger(),
1397+
fetchImpl: async (url) => {
1398+
calls.push(new URL(url).hostname);
1399+
if (url.includes("bounded-1")) {
1400+
return new Response(
1401+
new ReadableStream({
1402+
start(controller) {
1403+
controller.enqueue(
1404+
new TextEncoder().encode(
1405+
`data: ${JSON.stringify({ type: "response.created", padding: "x".repeat(70 * 1024) })}\n\n`
1406+
)
1407+
);
1408+
},
1409+
cancel() {
1410+
canceled = true;
1411+
},
1412+
}),
1413+
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
1414+
);
1415+
}
1416+
return new Response(
1417+
`data: ${JSON.stringify({ choices: [{ delta: { content: "bounded fallback" } }] })}\n\ndata: [DONE]\n\n`,
1418+
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
1419+
);
1420+
},
1421+
});
1422+
1423+
const result = await router.chatCompletions({
1424+
body: {
1425+
model: "combo_bounded",
1426+
messages: [{ role: "user", content: "hello" }],
1427+
stream: true,
1428+
},
1429+
});
1430+
const chunks = [];
1431+
await result.streamPipe({ write: (chunk) => chunks.push(String(chunk)) });
1432+
1433+
assert.equal(result.ok, true, JSON.stringify(result.error));
1434+
assert.equal(canceled, true);
1435+
assert.match(chunks.join(""), /bounded fallback/);
1436+
assert.deepEqual(calls, ["bounded-1.test", "bounded-2.test"]);
1437+
});
1438+
13681439
it("records streaming usage after the final SSE event instead of an early zero-token success", async () => {
13691440
const store = createStore(tmpConfig());
13701441
store.seed({ providers: [oauthAccount("prov_a", "token-a", 100)] });

0 commit comments

Comments
 (0)