Skip to content

Commit 42c5fca

Browse files
committed
Fix provider routing telemetry and account flows
1 parent a0430a6 commit 42c5fca

22 files changed

Lines changed: 1638 additions & 158 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Questions and bug reports are welcome in [GitHub Issues](https://github.com/gitc
185185

186186
## Current release
187187

188-
ReRouted `0.3.1` ships for Apple Silicon macOS with a Developer ID signature, stapled Apple notarization tickets, and in-app updates backed by stable GitHub Releases. The public API is intentionally limited to health, model discovery, and chat completions; a published third-party client compatibility matrix is still forthcoming.
188+
ReRouted `0.4.0` ships for Apple Silicon macOS with a Developer ID signature, stapled Apple notarization tickets, and in-app updates backed by stable GitHub Releases. The public API is intentionally limited to health, model discovery, and chat completions; a published third-party client compatibility matrix is still forthcoming.
189189

190190
ReRouted is released by [Public Bytes](https://publicbytes.org), a nonprofit building practical technology for public good.
191191

docs/architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ The router supports:
7171
- `fallback`: members are attempted in their configured order.
7272
- `round-robin`: each request rotates the starting member, then retains fallback behavior through the remaining members.
7373

74-
Timeouts, `408`, `429`, and `5xx` responses are retryable. The per-member timeout defaults to 60 seconds. A route stops when a member returns a non-retryable failure; a single direct model stops after its only member.
74+
Timeouts, `408`, `429`, and `5xx` responses are retryable. The per-member timeout defaults to 60 seconds. An explicit route stops immediately when any account or member returns a non-retryable failure; a later account lock cannot replace that terminal response.
7575

76-
OAuth providers add an account-pool layer beneath model routing. Accounts receive the lowest available alias (`oauth1`, `oauth2`, ...), and both shared ids (`chatgpt/gpt-5.4`) and account-specific ids (`chatgpt/oauth2/gpt-5.4`) can continue through sibling accounts. 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.
76+
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.
7777

7878
## Provider adapters
7979

@@ -83,7 +83,7 @@ OAuth providers add an account-pool layer beneath model routing. Accounts receiv
8383
- `chatgpt.js` translates chat-completion requests to the ChatGPT Codex Responses surface and normalizes Responses SSE.
8484
- `claude.js` translates OpenAI messages and tools to Anthropic Messages, applies the current OAuth client contract, and converts JSON/SSE back to OpenAI shapes.
8585
- `antigravity.js` translates Gemini-style upstream requests and SSE.
86-
- `xai.js` uses an OpenAI-compatible chat surface with xAI OAuth credentials.
86+
- `xai.js` translates chat-completion requests to the xAI subscription Responses surface and normalizes its forced SSE stream.
8787

8888
OAuth access-token refreshes are persisted back to the provider record when an adapter returns updated tokens.
8989

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,6 +1,6 @@
11
{
22
"name": "rerouted",
3-
"version": "0.3.1",
3+
"version": "0.4.0",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "Public Bytes",
66
"homepage": "https://rerouted.dev",

scripts/capture-ui.js

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ fs.mkdirSync(outDir, { recursive: true });
2626
fs.mkdirSync(userData, { recursive: true });
2727

2828
const store = createStore(path.join(userData, "config.json"));
29+
const demoStartedAt = Date.now();
2930

3031
function demoStats() {
31-
const now = Date.now();
32+
const now = demoStartedAt;
3233
return {
3334
totalRequests: 1284,
3435
sessionRequests: 12,
@@ -111,6 +112,9 @@ let demoLogEntries = [
111112
{ at: Date.now() - 47 * 60_000, level: "info", msg: "Local gateway listening", meta: { host: "127.0.0.1", port: DEFAULT_PORT } },
112113
];
113114
let keyedProviderAdds = [];
115+
let oauthCancels = [];
116+
let oauthStartsInFlight = 0;
117+
let oauthCancelRaces = 0;
114118
let updateState = {
115119
status: "current",
116120
currentVersion: packageInfo.version,
@@ -248,7 +252,17 @@ function registerIpc() {
248252
],
249253
}));
250254
ipcMain.handle("app:import-detected", async () => ({ ok: true }));
251-
ipcMain.handle("app:oauth-start", async () => ({ ok: true, authUrl: "https://example.com" }));
255+
ipcMain.handle("app:oauth-start", async () => {
256+
oauthStartsInFlight += 1;
257+
await new Promise((resolve) => setTimeout(resolve, 150));
258+
oauthStartsInFlight -= 1;
259+
return { ok: true, authUrl: "https://example.com" };
260+
});
261+
ipcMain.handle("app:oauth-cancel", async (_event, type) => {
262+
if (oauthStartsInFlight) oauthCancelRaces += 1;
263+
oauthCancels.push(type);
264+
return { ok: true };
265+
});
252266
ipcMain.handle("app:oauth-status", async () => ({ active: false }));
253267
ipcMain.handle("app:oauth-complete", async () => ({
254268
ok: true,
@@ -259,6 +273,8 @@ function registerIpc() {
259273
return { ok: true };
260274
});
261275
ipcMain.handle("harness:keyed-provider-adds", async () => keyedProviderAdds);
276+
ipcMain.handle("harness:oauth-cancels", async () => oauthCancels);
277+
ipcMain.handle("harness:oauth-cancel-races", async () => oauthCancelRaces);
262278
ipcMain.handle("app:test-keyed-provider", async () => ({
263279
ok: true,
264280
models: [{ id: "test-model", name: "Test" }],
@@ -502,6 +518,50 @@ app.whenReady().then(async () => {
502518
await capture(`app-${p}.png`, selector);
503519
}
504520

521+
await win.webContents.executeJavaScript(`
522+
(() => {
523+
window.__rr_goto_page("home");
524+
return true;
525+
})()
526+
`);
527+
await sleep(1300);
528+
await win.webContents.executeJavaScript(`
529+
(() => {
530+
const details = document.querySelector("[data-home-credentials]");
531+
const routeMap = document.querySelector("[data-home-route-map]");
532+
const track = routeMap?.querySelector(".route-track");
533+
const copyButton = document.getElementById("copy-url");
534+
if (!details || !routeMap || !track || !copyButton) {
535+
throw new Error("Status persistence controls did not render");
536+
}
537+
details.open = true;
538+
copyButton.focus();
539+
window.__rr_home_poll_test = { routeMap, track, copyButton, animationStarts: 0 };
540+
routeMap.addEventListener("animationstart", () => {
541+
window.__rr_home_poll_test.animationStarts += 1;
542+
});
543+
return true;
544+
})()
545+
`);
546+
await sleep(2300);
547+
await win.webContents.executeJavaScript(`
548+
(() => {
549+
const test = window.__rr_home_poll_test;
550+
const details = document.querySelector("[data-home-credentials]");
551+
if (document.querySelector("[data-home-route-map] .route-track") !== test.track) {
552+
throw new Error("Status polling replaced the route animation DOM");
553+
}
554+
if (!details?.open) throw new Error("Status polling collapsed Credentials and network");
555+
if (document.activeElement !== test.copyButton) {
556+
throw new Error("Status polling moved focus away from the endpoint controls");
557+
}
558+
if (test.animationStarts !== 0) {
559+
throw new Error("Status polling restarted the route animation without new traffic");
560+
}
561+
return true;
562+
})()
563+
`);
564+
505565
await win.webContents.executeJavaScript(`
506566
(() => {
507567
window.__rr_goto_page("providers");
@@ -512,27 +572,119 @@ app.whenReady().then(async () => {
512572
await capture("app-providers-expanded.png", ".provider-detail");
513573

514574
await win.webContents.executeJavaScript(`
515-
(() => {
575+
(async () => {
576+
const reconnect = document.querySelector("[data-reauth]");
577+
reconnect?.click();
578+
await new Promise((resolve) => setTimeout(resolve, 500));
579+
const panel = document.querySelector("#add-panel .action-panel");
580+
const viewport = document.getElementById("view").getBoundingClientRect();
581+
const rect = panel?.getBoundingClientRect();
582+
if (!panel || !rect || rect.top < viewport.top || rect.top >= viewport.bottom) {
583+
throw new Error("Reconnect panel was not brought into view");
584+
}
585+
if (document.activeElement !== panel.querySelector("[data-panel-heading]")) {
586+
throw new Error("Reconnect panel did not receive accessible focus");
587+
}
588+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
589+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
590+
if (document.querySelector("#add-panel .action-panel")) {
591+
throw new Error("Escape did not dismiss reconnect panel");
592+
}
593+
if (document.activeElement !== reconnect) {
594+
throw new Error("Reconnect dismissal did not restore focus");
595+
}
596+
const cancels = await window.rerouted.invoke("harness:oauth-cancels");
597+
if (cancels.length !== 1 || cancels[0] !== "chatgpt") {
598+
throw new Error("Reconnect dismissal did not cancel the pending OAuth flow");
599+
}
600+
return true;
601+
})()
602+
`);
603+
604+
await win.webContents.executeJavaScript(`
605+
(async () => {
516606
window.__rr_goto_page("providers");
517607
if (document.querySelector(".provider-detail")) {
518608
document.querySelector("[data-expand]")?.click();
519609
}
520610
document.getElementById("btn-connect")?.click();
611+
await new Promise((resolve) => setTimeout(resolve, 500));
612+
const panel = document.querySelector("#add-panel .action-panel");
613+
const viewport = document.getElementById("view").getBoundingClientRect();
614+
const rect = panel?.getBoundingClientRect();
615+
if (!panel || !rect || rect.top < viewport.top || rect.bottom > viewport.bottom + 1) {
616+
throw new Error("Connect options were not brought into view");
617+
}
618+
if (document.activeElement !== panel.querySelector("[data-panel-heading]")) {
619+
throw new Error("Connect panel did not receive accessible focus");
620+
}
521621
return true;
522622
})()
523623
`);
524624
await capture("app-providers-connect.png", "#add-panel .action-panel", "#add-panel .action-panel");
525625

526626
await win.webContents.executeJavaScript(`
527-
(() => {
627+
(async () => {
628+
const opener = document.getElementById("btn-connect");
629+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
630+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
631+
if (document.querySelector("#add-panel .action-panel")) {
632+
throw new Error("Escape did not dismiss Connect panel");
633+
}
634+
if (document.activeElement !== opener) {
635+
throw new Error("Connect dismissal did not restore focus");
636+
}
637+
opener.click();
638+
await new Promise((resolve) => setTimeout(resolve, 500));
639+
document.querySelector("#add-panel .tile")?.click();
640+
await new Promise((resolve) => setTimeout(resolve, 20));
641+
const oauth = document.querySelector("#add-panel .action-panel");
642+
const cancel = oauth?.querySelector("[data-panel-cancel]");
643+
if (!oauth || !cancel) {
644+
throw new Error("OAuth panel was not dismissible while connection startup was pending");
645+
}
646+
cancel.click();
647+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
648+
if (document.querySelector("#add-panel .action-panel") || document.activeElement !== opener) {
649+
throw new Error("OAuth Cancel did not dismiss and restore focus");
650+
}
651+
const deadline = Date.now() + 1000;
652+
let cancels = await window.rerouted.invoke("harness:oauth-cancels");
653+
while (cancels.length < 2 && Date.now() < deadline) {
654+
await new Promise((resolve) => setTimeout(resolve, 20));
655+
cancels = await window.rerouted.invoke("harness:oauth-cancels");
656+
}
657+
if (cancels.length !== 2 || cancels[1] !== "chatgpt") {
658+
throw new Error("OAuth dismissal did not cancel the pending flow");
659+
}
660+
const cancelRaces = await window.rerouted.invoke("harness:oauth-cancel-races");
661+
if (cancelRaces !== 0) {
662+
throw new Error("OAuth dismissal raced callback-session creation");
663+
}
664+
opener.click();
665+
await new Promise((resolve) => setTimeout(resolve, 500));
666+
return true;
667+
})()
668+
`);
669+
670+
await win.webContents.executeJavaScript(`
671+
(async () => {
528672
document.getElementById("btn-key")?.click();
673+
await new Promise((resolve) => setTimeout(resolve, 500));
529674
const labels = [...document.querySelectorAll("[data-keyed-preset]")].map((button) =>
530675
(button.textContent || "").trim()
531676
);
532677
const expected = ["OpenRouter", "NVIDIA NIM", "Cloudflare", "GLM Coding", "Custom"];
533678
if (JSON.stringify(labels) !== JSON.stringify(expected)) {
534679
throw new Error("Post-onboarding keyed presets did not render: " + labels.join(", "));
535680
}
681+
const panel = document.querySelector("#add-panel .action-panel");
682+
if (!panel?.querySelector("[data-panel-cancel]")) {
683+
throw new Error("API key panel did not render a Cancel action");
684+
}
685+
if (document.activeElement !== panel.querySelector("[data-panel-heading]")) {
686+
throw new Error("API key panel did not receive accessible focus");
687+
}
536688
return labels;
537689
})()
538690
`);

src/lib/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const OAUTH = {
119119
codeChallengeMethod: "S256",
120120
loopbackPort: 56121,
121121
callbackPath: "/callback",
122-
chatUrl: "https://api.x.ai/v1/chat/completions",
122+
chatUrl: "https://cli-chat-proxy.grok.com/v1/responses",
123123
models: [
124124
{ id: "grok-4.5-high", name: "Grok 4.5 (High)" },
125125
{ id: "grok-4.5-medium", name: "Grok 4.5 (Medium)" },

src/lib/providers/antigravity.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function extractTextFromGemini(data) {
6565

6666
function fromGeminiJson(data, model) {
6767
const text = extractTextFromGemini(data);
68+
const usage = data.usageMetadata || data.response?.usageMetadata;
6869
return {
6970
id: `chatcmpl-${Date.now()}`,
7071
object: "chat.completion",
@@ -73,13 +74,28 @@ function fromGeminiJson(data, model) {
7374
choices: [
7475
{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" },
7576
],
77+
...(usage
78+
? {
79+
usage: {
80+
prompt_tokens: usage.promptTokenCount || 0,
81+
completion_tokens: usage.candidatesTokenCount || 0,
82+
total_tokens:
83+
usage.totalTokenCount ||
84+
(usage.promptTokenCount || 0) + (usage.candidatesTokenCount || 0),
85+
prompt_tokens_details: {
86+
cached_tokens: usage.cachedContentTokenCount || 0,
87+
},
88+
},
89+
}
90+
: {}),
7691
};
7792
}
7893

7994
async function pipeGeminiSse(upstreamBody, res, model) {
8095
const parser = createSseParser();
8196
const id = `chatcmpl-${Date.now()}`;
8297
let roleSent = false;
98+
let streamUsage = null;
8399

84100
async function handleEvents(events) {
85101
for (const ev of events) {
@@ -95,6 +111,19 @@ async function pipeGeminiSse(upstreamBody, res, model) {
95111
upstream.message || upstream.code || upstream.status || "Antigravity stream failed"
96112
);
97113
}
114+
const usage = data.usageMetadata || data.response?.usageMetadata;
115+
if (usage) {
116+
streamUsage = {
117+
prompt_tokens: usage.promptTokenCount || 0,
118+
completion_tokens: usage.candidatesTokenCount || 0,
119+
total_tokens:
120+
usage.totalTokenCount ||
121+
(usage.promptTokenCount || 0) + (usage.candidatesTokenCount || 0),
122+
prompt_tokens_details: {
123+
cached_tokens: usage.cachedContentTokenCount || 0,
124+
},
125+
};
126+
}
98127
const text = extractTextFromGemini(data);
99128
// For incremental SSE, parts may only contain the delta in some APIs;
100129
// Antigravity often re-sends full — we treat each event's text as delta if short path.
@@ -127,6 +156,7 @@ async function pipeGeminiSse(upstreamBody, res, model) {
127156
}
128157
res.write(formatSseData(openaiChunk({ id, model, finishReason: "stop" })));
129158
res.write(SSE_DONE);
159+
return streamUsage;
130160
}
131161

132162
async function refreshToken(provider, { fetchImpl = fetch } = {}) {

0 commit comments

Comments
 (0)