Skip to content

Commit 73ac01c

Browse files
committed
fix: restore Antigravity streams and Claude route fallback
1 parent 9fa3f9b commit 73ac01c

8 files changed

Lines changed: 330 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
99

1010
## [Unreleased]
1111

12+
## [0.5.10] - 2026-07-29
13+
14+
### Fixed
15+
16+
- Antigravity Flash and Pro streaming responses now accept Google's CRLF-delimited SSE frames instead of completing with empty output.
17+
- 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.
18+
- Anthropic overload responses now create short model-scoped transient cooldowns instead of misleading quota-wide account locks.
19+
1220
## [0.5.9] - 2026-07-23
1321

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

101109
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.
102110

103-
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.9...HEAD
111+
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.10...HEAD
112+
[0.5.10]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.10
104113
[0.5.9]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.9
105114
[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8
106115
[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7

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.9",
4+
"version": "0.5.10",
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: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const COOLDOWN_MS = {
2525
transient: 30_000,
2626
};
2727
const PREOUTPUT_INSPECTION_BYTES = 64 * 1024;
28+
const CLAUDE_CODE_CANONICAL_ROUTES = new Map([
29+
["claude-fable-5", "fable"],
30+
["claude-opus-4-8", "opus-4.8"],
31+
]);
2832

2933
function createRequestLog(max = 50) {
3034
const items = [];
@@ -94,8 +98,26 @@ function makeMember(cfg, provider, upstreamModel, opts) {
9498
};
9599
}
96100

101+
function canonicalRouteModelId(cfg, requestedModelId) {
102+
const requested = String(requestedModelId || "").trim();
103+
const upstreamModel = requested.startsWith("claude/")
104+
? requested.slice("claude/".length)
105+
: requested;
106+
107+
// Account-qualified IDs must continue to address that exact account.
108+
if (requested !== upstreamModel && upstreamModel.includes("/")) return requested;
109+
110+
const routeName = CLAUDE_CODE_CANONICAL_ROUTES.get(upstreamModel.toLowerCase());
111+
if (!routeName) return requested;
112+
const combo = (cfg.combos || []).find(
113+
(entry) => publicComboId(entry).trim().toLowerCase() === routeName
114+
);
115+
return combo ? publicComboId(combo) : requested;
116+
}
117+
97118
function resolveTargets(cfg, modelId) {
98-
const combo = (cfg.combos || []).find((c) => comboMatchesId(c, modelId));
119+
const routeModelId = canonicalRouteModelId(cfg, modelId);
120+
const combo = (cfg.combos || []).find((c) => comboMatchesId(c, routeModelId));
99121
if (combo) {
100122
const members = (combo.members || [])
101123
.map((m) => {
@@ -134,7 +156,7 @@ function resolveTargets(cfg, modelId) {
134156
members,
135157
};
136158
}
137-
const single = resolveSingle(cfg, modelId);
159+
const single = resolveSingle(cfg, routeModelId);
138160
if (!single) return null;
139161
return { kind: "single", members: [single], strategy: "fallback" };
140162
}
@@ -243,7 +265,7 @@ function classifyFailure(status, errorText) {
243265
const text = String(errorText || "").toLowerCase();
244266
const quota =
245267
status === 429 ||
246-
/rate[ _-]?limit|too many requests|quota|usage[ _-]?limit|resource[ _-]?exhaust|capacity|overload/.test(text);
268+
/rate[ _-]?limit|too many requests|quota|usage[ _-]?limit|resource[ _-]?exhaust|capacity/.test(text);
247269
if (quota) return { eligible: true, kind: "quota", defaultCooldownMs: COOLDOWN_MS.quota };
248270
const request =
249271
/context[ _-]?window|context[ _-]?length[ _-]?exceed|maximum[ _-]?context[ _-]?length|input.{0,80}(?:too[ _-]?long|too[ _-]?large|exceed.{0,40}(?:context|token))|request[ _-]?too[ _-]?large/.test(
@@ -1438,6 +1460,7 @@ module.exports = {
14381460
resolveTargets,
14391461
resolveSingle,
14401462
accountCandidatesFor,
1463+
canonicalRouteModelId,
14411464
compareAccounts,
14421465
orderMembers,
14431466
isRetryableStatus,

src/lib/sse.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,13 @@ function createSseParser() {
9898
push(chunk) {
9999
buf += chunkToString(chunk);
100100
const events = [];
101-
let idx;
102-
while ((idx = buf.indexOf("\n\n")) !== -1) {
103-
const block = buf.slice(0, idx);
104-
buf = buf.slice(idx + 2);
101+
let boundary;
102+
while ((boundary = /\r?\n\r?\n/.exec(buf))) {
103+
const block = buf.slice(0, boundary.index);
104+
buf = buf.slice(boundary.index + boundary[0].length);
105105
let event = "message";
106106
let data = "";
107-
for (const line of block.split("\n")) {
107+
for (const line of block.split(/\r?\n/)) {
108108
if (line.startsWith("event:")) event = line.slice(6).trim();
109109
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trimStart();
110110
}

tests/antigravity-tools.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,50 @@ describe("Antigravity tool calling", () => {
299299
assert.equal(body.request.contents[1].parts[1].thoughtSignature, undefined);
300300
});
301301

302+
it("parses CRLF-delimited Gemini SSE text, finish state, and usage", async () => {
303+
const frames = [
304+
{
305+
response: {
306+
candidates: [
307+
{
308+
content: { role: "model", parts: [{ text: "OK" }] },
309+
finishReason: "STOP",
310+
},
311+
],
312+
usageMetadata: {
313+
promptTokenCount: 3,
314+
candidatesTokenCount: 1,
315+
totalTokenCount: 4,
316+
cachedContentTokenCount: 2,
317+
},
318+
},
319+
},
320+
].map((payload) => `data: ${JSON.stringify(payload)}\r\n\r\n`);
321+
const writes = [];
322+
323+
const usage = await antigravity.pipeGeminiSse(
324+
Readable.from(frames),
325+
{ write(chunk) { writes.push(chunk); } },
326+
"gemini-3-flash-agent"
327+
);
328+
329+
const chunks = parseSseWrites(writes);
330+
assert.equal(
331+
chunks
332+
.map((chunk) => chunk.choices[0].delta.content)
333+
.filter(Boolean)
334+
.join(""),
335+
"OK"
336+
);
337+
assert.equal(chunks.at(-1).choices[0].finish_reason, "stop");
338+
assert.deepEqual(usage, {
339+
prompt_tokens: 3,
340+
completion_tokens: 1,
341+
total_tokens: 4,
342+
prompt_tokens_details: { cached_tokens: 2 },
343+
});
344+
});
345+
302346
it("deduplicates cumulative Gemini SSE text and buffers the final signed tool call", async () => {
303347
const events = [
304348
`data: ${JSON.stringify({

tests/gateway.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,78 @@ describe("gateway Responses API", () => {
548548
await new Promise((resolve) => server.close(resolve));
549549
}
550550
});
551+
552+
it("streams visible Antigravity text through Responses from CRLF Gemini SSE", async () => {
553+
const store = createStore(tmpConfig());
554+
store.seed({
555+
providers: [
556+
{
557+
id: "prov_antigravity",
558+
type: "antigravity",
559+
name: "Antigravity",
560+
accessToken: "antigravity-token",
561+
projectId: "test-project",
562+
enabled: true,
563+
createdAt: 100,
564+
models: [
565+
{ id: "gemini-3-flash-agent", name: "Gemini 3 Flash", enabled: true },
566+
],
567+
},
568+
],
569+
});
570+
const apiKey = store.load().apiKey;
571+
let upstreamCalls = 0;
572+
const router = createRouter({
573+
store,
574+
fetchImpl: async () => {
575+
upstreamCalls += 1;
576+
return new Response(
577+
`data: ${JSON.stringify({
578+
response: {
579+
candidates: [
580+
{
581+
content: { role: "model", parts: [{ text: "OK" }] },
582+
finishReason: "STOP",
583+
},
584+
],
585+
usageMetadata: {
586+
promptTokenCount: 3,
587+
candidatesTokenCount: 1,
588+
totalTokenCount: 4,
589+
},
590+
},
591+
})}\r\n\r\n`,
592+
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
593+
);
594+
},
595+
});
596+
const gateway = createGateway({ store, router });
597+
const server = http.createServer((req, res) => gateway.handle(req, res));
598+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
599+
600+
try {
601+
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/responses`, {
602+
method: "POST",
603+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
604+
body: JSON.stringify({
605+
model: "antigravity/gemini-3-flash-agent",
606+
input: "Reply with OK",
607+
stream: true,
608+
}),
609+
});
610+
const text = await response.text();
611+
612+
assert.equal(response.status, 200);
613+
assert.equal(upstreamCalls, 1);
614+
assert.match(text, /event: response\.output_text\.delta/);
615+
assert.match(text, /"delta":"OK"/);
616+
assert.match(text, /event: response\.completed/);
617+
assert.match(text, /"input_tokens":3/);
618+
assert.match(text, /"output_tokens":1/);
619+
} finally {
620+
await new Promise((resolve) => server.close(resolve));
621+
}
622+
});
551623
});
552624

553625
describe("gateway request limits", () => {

0 commit comments

Comments
 (0)