Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car

## [Unreleased]

## [0.5.10] - 2026-07-29

### Fixed

- Antigravity Flash and Pro streaming responses now accept Google's CRLF-delimited SSE frames instead of completing with empty output.
- Claude Code's canonical Fable and Claude Opus 4.8 model IDs now resolve through matching `fable` and `opus-4.8` named routes, preserving configured cross-provider fallback.
- Anthropic overload responses now create short model-scoped transient cooldowns instead of misleading quota-wide account locks.

## [0.5.9] - 2026-07-23

### Fixed
Expand Down Expand Up @@ -100,7 +108,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car

See [GitHub Releases](https://github.com/gitcommit90/rerouted/releases) for artifact digests and notes prior to the Keep a Changelog narrative. Notable themes in late 0.4.x included signed/notarized distribution, in-app updates, named routes, OAuth account pools, OpenAI chat completions and Responses routing, and launch hardening.

[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.9...HEAD
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.10...HEAD
[0.5.10]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.10
[0.5.9]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.9
[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8
[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@gitcommit90/rerouted",
"productName": "ReRouted",
"version": "0.5.9",
"version": "0.5.10",
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
"author": "gitcommit90",
"license": "MIT",
Expand Down
29 changes: 26 additions & 3 deletions src/lib/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const COOLDOWN_MS = {
transient: 30_000,
};
const PREOUTPUT_INSPECTION_BYTES = 64 * 1024;
const CLAUDE_CODE_CANONICAL_ROUTES = new Map([
["claude-fable-5", "fable"],
["claude-opus-4-8", "opus-4.8"],
]);

function createRequestLog(max = 50) {
const items = [];
Expand Down Expand Up @@ -94,8 +98,26 @@ function makeMember(cfg, provider, upstreamModel, opts) {
};
}

function canonicalRouteModelId(cfg, requestedModelId) {
const requested = String(requestedModelId || "").trim();
const upstreamModel = requested.startsWith("claude/")
? requested.slice("claude/".length)
: requested;

// Account-qualified IDs must continue to address that exact account.
if (requested !== upstreamModel && upstreamModel.includes("/")) return requested;

const routeName = CLAUDE_CODE_CANONICAL_ROUTES.get(upstreamModel.toLowerCase());
if (!routeName) return requested;
const combo = (cfg.combos || []).find(
(entry) => publicComboId(entry).trim().toLowerCase() === routeName
);
return combo ? publicComboId(combo) : requested;
}

function resolveTargets(cfg, modelId) {
const combo = (cfg.combos || []).find((c) => comboMatchesId(c, modelId));
const routeModelId = canonicalRouteModelId(cfg, modelId);
const combo = (cfg.combos || []).find((c) => comboMatchesId(c, routeModelId));
if (combo) {
const members = (combo.members || [])
.map((m) => {
Expand Down Expand Up @@ -134,7 +156,7 @@ function resolveTargets(cfg, modelId) {
members,
};
}
const single = resolveSingle(cfg, modelId);
const single = resolveSingle(cfg, routeModelId);
if (!single) return null;
return { kind: "single", members: [single], strategy: "fallback" };
}
Expand Down Expand Up @@ -243,7 +265,7 @@ function classifyFailure(status, errorText) {
const text = String(errorText || "").toLowerCase();
const quota =
status === 429 ||
/rate[ _-]?limit|too many requests|quota|usage[ _-]?limit|resource[ _-]?exhaust|capacity|overload/.test(text);
/rate[ _-]?limit|too many requests|quota|usage[ _-]?limit|resource[ _-]?exhaust|capacity/.test(text);
if (quota) return { eligible: true, kind: "quota", defaultCooldownMs: COOLDOWN_MS.quota };
const request =
/context[ _-]?window|context[ _-]?length[ _-]?exceed|maximum[ _-]?context[ _-]?length|input.{0,80}(?:too[ _-]?long|too[ _-]?large|exceed.{0,40}(?:context|token))|request[ _-]?too[ _-]?large/.test(
Expand Down Expand Up @@ -1438,6 +1460,7 @@ module.exports = {
resolveTargets,
resolveSingle,
accountCandidatesFor,
canonicalRouteModelId,
compareAccounts,
orderMembers,
isRetryableStatus,
Expand Down
10 changes: 5 additions & 5 deletions src/lib/sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@ function createSseParser() {
push(chunk) {
buf += chunkToString(chunk);
const events = [];
let idx;
while ((idx = buf.indexOf("\n\n")) !== -1) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let boundary;
while ((boundary = /\r?\n\r?\n/.exec(buf))) {
const block = buf.slice(0, boundary.index);
buf = buf.slice(boundary.index + boundary[0].length);
let event = "message";
let data = "";
for (const line of block.split("\n")) {
for (const line of block.split(/\r?\n/)) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trimStart();
}
Expand Down
44 changes: 44 additions & 0 deletions tests/antigravity-tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,50 @@ describe("Antigravity tool calling", () => {
assert.equal(body.request.contents[1].parts[1].thoughtSignature, undefined);
});

it("parses CRLF-delimited Gemini SSE text, finish state, and usage", async () => {
const frames = [
{
response: {
candidates: [
{
content: { role: "model", parts: [{ text: "OK" }] },
finishReason: "STOP",
},
],
usageMetadata: {
promptTokenCount: 3,
candidatesTokenCount: 1,
totalTokenCount: 4,
cachedContentTokenCount: 2,
},
},
},
].map((payload) => `data: ${JSON.stringify(payload)}\r\n\r\n`);
const writes = [];

const usage = await antigravity.pipeGeminiSse(
Readable.from(frames),
{ write(chunk) { writes.push(chunk); } },
"gemini-3-flash-agent"
);

const chunks = parseSseWrites(writes);
assert.equal(
chunks
.map((chunk) => chunk.choices[0].delta.content)
.filter(Boolean)
.join(""),
"OK"
);
assert.equal(chunks.at(-1).choices[0].finish_reason, "stop");
assert.deepEqual(usage, {
prompt_tokens: 3,
completion_tokens: 1,
total_tokens: 4,
prompt_tokens_details: { cached_tokens: 2 },
});
});

it("deduplicates cumulative Gemini SSE text and buffers the final signed tool call", async () => {
const events = [
`data: ${JSON.stringify({
Expand Down
72 changes: 72 additions & 0 deletions tests/gateway.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,78 @@ describe("gateway Responses API", () => {
await new Promise((resolve) => server.close(resolve));
}
});

it("streams visible Antigravity text through Responses from CRLF Gemini SSE", async () => {
const store = createStore(tmpConfig());
store.seed({
providers: [
{
id: "prov_antigravity",
type: "antigravity",
name: "Antigravity",
accessToken: "antigravity-token",
projectId: "test-project",
enabled: true,
createdAt: 100,
models: [
{ id: "gemini-3-flash-agent", name: "Gemini 3 Flash", enabled: true },
],
},
],
});
const apiKey = store.load().apiKey;
let upstreamCalls = 0;
const router = createRouter({
store,
fetchImpl: async () => {
upstreamCalls += 1;
return new Response(
`data: ${JSON.stringify({
response: {
candidates: [
{
content: { role: "model", parts: [{ text: "OK" }] },
finishReason: "STOP",
},
],
usageMetadata: {
promptTokenCount: 3,
candidatesTokenCount: 1,
totalTokenCount: 4,
},
},
})}\r\n\r\n`,
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
},
});
const gateway = createGateway({ store, router });
const server = http.createServer((req, res) => gateway.handle(req, res));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/responses`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: "antigravity/gemini-3-flash-agent",
input: "Reply with OK",
stream: true,
}),
});
const text = await response.text();

assert.equal(response.status, 200);
assert.equal(upstreamCalls, 1);
assert.match(text, /event: response\.output_text\.delta/);
assert.match(text, /"delta":"OK"/);
assert.match(text, /event: response\.completed/);
assert.match(text, /"input_tokens":3/);
assert.match(text, /"output_tokens":1/);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
});

describe("gateway request limits", () => {
Expand Down
Loading
Loading