Skip to content

Commit b6ccf8c

Browse files
committed
Merge OAuth identity and image input fixes
2 parents 3761cec + f0755b2 commit b6ccf8c

24 files changed

Lines changed: 1198 additions & 75 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ ReRouted is an independent project and is not affiliated with or endorsed by any
9595

9696
### 1. Install
9797

98-
[Download ReRouted 0.3.1 for Apple Silicon](https://github.com/gitcommit90/rerouted/releases/download/v0.3.1/ReRouted-0.3.1-arm64.dmg), open the DMG, and drag ReRouted to Applications.
98+
[Download ReRouted 0.4.1 for Apple Silicon](https://github.com/gitcommit90/rerouted/releases/download/v0.4.1/ReRouted-0.4.1-arm64.dmg), open the DMG, and drag ReRouted to Applications.
9999

100100
The macOS release is Developer ID signed, notarized by Apple, and stapled for a normal Gatekeeper launch.
101101

@@ -149,7 +149,7 @@ The gateway continues running when the panel is hidden. Quitting ReRouted stops
149149
| `GET /v1/models` | Enabled direct models and named routes |
150150
| `POST /v1/chat/completions` | Streaming or non-streaming routed completions |
151151

152-
Requests require a generated bearer key except for `/` and `/health`. ReRouted currently targets clients that use OpenAI-style chat completions; embeddings, images, audio, and the rest of the OpenAI platform API are outside its scope.
152+
Requests require a generated bearer key except for `/` and `/health`. OpenAI-style image inputs inside chat-completion messages are supported when the selected upstream model accepts them. The separate `/v1/images` generation API, embeddings, audio, and the rest of the OpenAI platform API are outside ReRouted's scope.
153153

154154
## Local-first, with the boundaries stated plainly
155155

@@ -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.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.
188+
ReRouted `0.4.1` ships for Apple Silicon macOS with masked OAuth account identities, image inputs in chat completions, 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

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.4.0",
3+
"version": "0.4.1",
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: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ function seedOnboarded() {
140140
id: "prov_chatgpt_demo",
141141
type: "chatgpt",
142142
name: "ChatGPT Plus",
143-
email: "demo@example.com",
143+
email: "fantasticfox@gmail.com",
144144
enabled: true,
145145
models: OAUTH.chatgpt.models,
146146
accessToken: "x",
@@ -150,12 +150,31 @@ function seedOnboarded() {
150150
id: "prov_claude_demo",
151151
type: "claude",
152152
name: "Claude Pro",
153-
email: "route@example.com",
153+
profileName: "Route Fox",
154154
enabled: true,
155155
models: OAUTH.claude.models,
156156
accessToken: "x",
157157
createdAt: now - 43_200_000,
158158
},
159+
{
160+
id: "prov_antigravity_demo",
161+
type: "antigravity",
162+
name: "Antigravity (gravitypilot@example.com)",
163+
email: "gravitypilot@example.com",
164+
enabled: true,
165+
models: OAUTH.antigravity.models,
166+
accessToken: "x",
167+
createdAt: now - 21_600_000,
168+
},
169+
{
170+
id: "prov_xai_demo",
171+
type: "xai",
172+
name: "xAI (Grok)",
173+
enabled: true,
174+
models: OAUTH.xai.models,
175+
accessToken: "x",
176+
createdAt: now - 10_800_000,
177+
},
159178
],
160179
combos: [
161180
{
@@ -181,6 +200,7 @@ function registerIpc() {
181200
name: p.name,
182201
accountAlias: p.accountAlias || null,
183202
email: p.email,
203+
profileName: p.profileName,
184204
enabled: p.enabled !== false,
185205
hasToken: !!(p.accessToken || p.apiKey),
186206
models: p.models || defaultModelsForType(p.type),
@@ -288,9 +308,9 @@ function registerIpc() {
288308
{
289309
providerId: "demo",
290310
type: "chatgpt",
291-
name: "ChatGPT",
311+
name: "ChatGPT (fantasticfox@gmail.com)",
292312
accountAlias: "oauth1",
293-
email: "demo@example.com",
313+
email: "fantasticfox@gmail.com",
294314
status: "ok",
295315
source: "ChatGPT quota API",
296316
plan: "plus",
@@ -476,6 +496,13 @@ app.whenReady().then(async () => {
476496
(async () => {
477497
const b = document.getElementById("btn-scan");
478498
if (b) { b.click(); await new Promise(r => setTimeout(r, 400)); }
499+
const results = document.getElementById("detect-results");
500+
if (!results?.textContent.includes("use*@example.com")) {
501+
throw new Error("Detected account email was not privacy masked");
502+
}
503+
if (results.outerHTML.includes("user@example.com")) {
504+
throw new Error("Raw detected account email leaked into onboarding markup");
505+
}
479506
return true;
480507
})()
481508
`);
@@ -516,6 +543,65 @@ app.whenReady().then(async () => {
516543
settings: ".settings-group",
517544
}[p] || "#view > *";
518545
await capture(`app-${p}.png`, selector);
546+
if (p === "providers") {
547+
await win.webContents.executeJavaScript(`
548+
(() => {
549+
const chatgpt = document.querySelector('[data-prov-card="prov_chatgpt_demo"]');
550+
const claude = document.querySelector('[data-prov-card="prov_claude_demo"]');
551+
const antigravity = document.querySelector('[data-prov-card="prov_antigravity_demo"]');
552+
const xai = document.querySelector('[data-prov-card="prov_xai_demo"]');
553+
if (!chatgpt || !claude || !antigravity || !xai) {
554+
throw new Error("Identity fixtures did not render");
555+
}
556+
if (!chatgpt.querySelector(".row-sub")?.textContent.includes("fant********@gmail.com")) {
557+
throw new Error("Account email was not privacy masked");
558+
}
559+
if (antigravity.querySelector(".row-title")?.textContent.trim() !== "Antigravity") {
560+
throw new Error("Email suffix was not removed from Antigravity account name");
561+
}
562+
if (!antigravity.querySelector(".row-sub")?.textContent.includes("grav********@example.com")) {
563+
throw new Error("Antigravity account email was not privacy masked");
564+
}
565+
if (!claude.querySelector(".row-sub")?.textContent.includes("Route Fox")) {
566+
throw new Error("Profile name was not used when account email was unavailable");
567+
}
568+
const markup = chatgpt.outerHTML + antigravity.outerHTML;
569+
if (markup.includes("fantasticfox@gmail.com") || markup.includes("gravitypilot@example.com")) {
570+
throw new Error("Raw account email leaked into provider markup");
571+
}
572+
if (!chatgpt.querySelector(".alias-badge")?.textContent.includes("Account 1")) {
573+
throw new Error("OAuth account alias was not preserved");
574+
}
575+
if (!xai.querySelector(".row-sub")?.textContent.includes("Account 1")) {
576+
throw new Error("OAuth alias was not used when account identity was unavailable");
577+
}
578+
if (xai.querySelector(".account-copy")?.textContent.includes("prov_")) {
579+
throw new Error("Internal provider id leaked into account copy");
580+
}
581+
return true;
582+
})()
583+
`);
584+
}
585+
if (p === "quota") {
586+
await win.webContents.executeJavaScript(`
587+
(() => {
588+
const card = document.querySelector(".quota-card");
589+
if (!card?.textContent.includes("fant********@gmail.com")) {
590+
throw new Error("Quota account email was not privacy masked");
591+
}
592+
if (card.querySelector(".row-title")?.textContent.trim() !== "ChatGPT") {
593+
throw new Error("Email suffix was not removed from quota account name");
594+
}
595+
if (card.outerHTML.includes("fantasticfox@gmail.com")) {
596+
throw new Error("Raw account email leaked into quota markup");
597+
}
598+
if (!card.querySelector(".alias-badge")?.textContent.includes("Account 1")) {
599+
throw new Error("Quota OAuth account alias was not preserved");
600+
}
601+
return true;
602+
})()
603+
`);
604+
}
519605
}
520606

521607
await win.webContents.executeJavaScript(`

src/lib/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const OAUTH = {
6565
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
6666
authorizeUrl: "https://claude.ai/oauth/authorize",
6767
tokenUrl: "https://api.anthropic.com/v1/oauth/token",
68+
profileUrl: "https://api.anthropic.com/api/oauth/profile",
6869
scopes: ["org:create_api_key", "user:profile", "user:inference"],
6970
codeChallengeMethod: "S256",
7071
// Use a dedicated loopback callback. The full URL can be pasted if needed.

src/lib/detect.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ const { execFile } = require("node:child_process");
77
const { promisify } = require("node:util");
88
const { OAUTH } = require("./constants");
99
const { generateId } = require("./password");
10+
const {
11+
applyIdentity,
12+
identityFromTokens,
13+
mergeIdentity,
14+
} = require("./oauth-identity");
1015

1116
const execFileAsync = promisify(execFile);
1217

@@ -63,7 +68,7 @@ function detectCodex() {
6368
source: "codex-file",
6469
path: p,
6570
type: "chatgpt",
66-
name: `ChatGPT (${data.email || path.basename(p, ".json")})`,
71+
name: "ChatGPT",
6772
email: data.email,
6873
accessToken: access,
6974
refreshToken: refresh,
@@ -156,7 +161,7 @@ function detectClaudeFiles() {
156161
source: "claude-file",
157162
path: p,
158163
type: "claude",
159-
name: `Claude (${data.email || path.basename(p, ".json")})`,
164+
name: "Claude",
160165
email: data.email,
161166
accessToken: access,
162167
refreshToken: refresh,
@@ -211,7 +216,7 @@ function detectAntigravity() {
211216
source: "antigravity-file",
212217
path: p,
213218
type: "antigravity",
214-
name: `Antigravity (${data.email || path.basename(p, ".json")})`,
219+
name: "Antigravity",
215220
email: data.email,
216221
accessToken: access,
217222
refreshToken: refresh,
@@ -233,6 +238,52 @@ function detectAntigravity() {
233238
return found;
234239
}
235240

241+
function localXaiIdentity(provider, authData) {
242+
const stored = identityFromTokens("xai", provider || {});
243+
const entries = Object.values(authData || {}).filter(
244+
(entry) => entry && typeof entry === "object" && !Array.isArray(entry)
245+
);
246+
247+
for (const entry of entries) {
248+
const identity = mergeIdentity(
249+
{
250+
email: entry.email,
251+
profileName:
252+
entry.name || [entry.first_name, entry.last_name].filter(Boolean).join(" "),
253+
accountId: entry.principal_id || entry.user_id,
254+
},
255+
identityFromTokens("xai", {
256+
accessToken: entry.key || entry.access_token || entry.accessToken,
257+
idToken: entry.id_token || entry.idToken,
258+
})
259+
);
260+
const sameAccount =
261+
(stored.accountId && identity.accountId && stored.accountId === identity.accountId) ||
262+
(provider?.accessToken &&
263+
provider.accessToken === (entry.key || entry.access_token || entry.accessToken));
264+
if (sameAccount) return identity;
265+
}
266+
return {};
267+
}
268+
269+
function backfillLocalOAuthIdentities(providers, { xaiAuthData } = {}) {
270+
let authData = xaiAuthData;
271+
if (authData === undefined) {
272+
try {
273+
authData = JSON.parse(fs.readFileSync(path.join(home(), ".grok", "auth.json"), "utf8"));
274+
} catch {
275+
authData = null;
276+
}
277+
}
278+
279+
let changed = false;
280+
for (const provider of providers || []) {
281+
if (provider?.type !== "xai" || (provider.email && provider.profileName)) continue;
282+
changed = applyIdentity(provider, localXaiIdentity(provider, authData)) || changed;
283+
}
284+
return changed;
285+
}
286+
236287
/**
237288
* Run all detectors. Returns flat list of importable accounts (no secrets stripped —
238289
* caller imports selected ones into store).
@@ -277,6 +328,8 @@ module.exports = {
277328
detectCodex,
278329
detectClaudeKeychain,
279330
detectAntigravity,
331+
localXaiIdentity,
332+
backfillLocalOAuthIdentities,
280333
detectAll,
281334
summarizeDetected,
282335
};

src/lib/gateway.js

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ const http = require("node:http");
44
const { DEFAULT_PORT } = require("./constants");
55
const logger = require("./logger");
66

7+
const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
8+
79
/**
810
* OpenAI-compatible HTTP gateway.
911
* Auth: Authorization: Bearer <apiKey>
1012
* Routes: GET /v1/models, POST /v1/chat/completions, GET /health
1113
*/
12-
function createGateway({ store, router, port = DEFAULT_PORT, host = "127.0.0.1" } = {}) {
14+
function createGateway({
15+
store,
16+
router,
17+
port = DEFAULT_PORT,
18+
host = "127.0.0.1",
19+
maxBodyBytes = MAX_JSON_BODY_BYTES,
20+
} = {}) {
1321
let server = null;
1422
let listeningPort = null;
1523
let listeningHost = null;
@@ -50,8 +58,33 @@ function createGateway({ store, router, port = DEFAULT_PORT, host = "127.0.0.1"
5058
function readBody(req) {
5159
return new Promise((resolve, reject) => {
5260
const chunks = [];
53-
req.on("data", (c) => chunks.push(c));
61+
let size = 0;
62+
let settled = false;
63+
const tooLarge = () => {
64+
const error = new Error("Request body is too large");
65+
error.code = "REQUEST_BODY_TOO_LARGE";
66+
return error;
67+
};
68+
const declaredLength = Number(req.headers["content-length"]);
69+
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
70+
req.resume();
71+
reject(tooLarge());
72+
return;
73+
}
74+
req.on("data", (c) => {
75+
if (settled) return;
76+
size += c.length;
77+
if (size > maxBodyBytes) {
78+
settled = true;
79+
chunks.length = 0;
80+
reject(tooLarge());
81+
return;
82+
}
83+
chunks.push(c);
84+
});
5485
req.on("end", () => {
86+
if (settled) return;
87+
settled = true;
5588
const raw = Buffer.concat(chunks).toString("utf8");
5689
if (!raw) return resolve({});
5790
try {
@@ -60,7 +93,11 @@ function createGateway({ store, router, port = DEFAULT_PORT, host = "127.0.0.1"
6093
reject(e);
6194
}
6295
});
63-
req.on("error", reject);
96+
req.on("error", (error) => {
97+
if (settled) return;
98+
settled = true;
99+
reject(error);
100+
});
64101
});
65102
}
66103

@@ -124,7 +161,21 @@ function createGateway({ store, router, port = DEFAULT_PORT, host = "127.0.0.1"
124161
let body;
125162
try {
126163
body = await readBody(req);
127-
} catch {
164+
} catch (error) {
165+
if (error?.code === "REQUEST_BODY_TOO_LARGE") {
166+
logger.warn("chat/completions: request body too large");
167+
res.writeHead(413, { "Content-Type": "application/json" });
168+
res.end(
169+
JSON.stringify({
170+
error: {
171+
message: `Request body exceeds the ${Math.floor(maxBodyBytes / (1024 * 1024))} MiB limit`,
172+
type: "invalid_request_error",
173+
code: "request_body_too_large",
174+
},
175+
})
176+
);
177+
return;
178+
}
128179
logger.warn("chat/completions: invalid JSON body");
129180
res.writeHead(400, { "Content-Type": "application/json" });
130181
res.end(
@@ -273,4 +324,4 @@ function createGateway({ store, router, port = DEFAULT_PORT, host = "127.0.0.1"
273324
return { start, stop, restart, isListening, getAddress, checkAuth, handle, validKeys };
274325
}
275326

276-
module.exports = { createGateway };
327+
module.exports = { createGateway, MAX_JSON_BODY_BYTES };

0 commit comments

Comments
 (0)