Skip to content
Open
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
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@ dist
web-dist
.env
web/*.tsbuildinfo
data/accounts.json
data/oauth-state.json
data/requests-trace.jsonl
data
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ Because this is often deployed remotely (Unraid/VPS), onboarding uses a manual r
2. For OpenAI accounts, enter the account email
3. Click **Start OAuth**
4. Complete login in browser
5. Copy the full redirect URL shown after the callback completes
6. Paste that URL in the dashboard and click **Complete OAuth**
5. Wait for the local callback page to open on `localhost:1455`
6. The dashboard should autofill the callback URL, or you can copy it from that page
7. Click **Complete OAuth**

Mistral accounts still use manual token entry in the dashboard.

Expand Down Expand Up @@ -281,6 +282,7 @@ Model alias admin endpoints:
| `OAUTH_TOKEN_URL` | `https://auth.openai.com/oauth/token` | OAuth token endpoint |
| `OAUTH_SCOPE` | `openid profile email offline_access` | OAuth scope |
| `OAUTH_REDIRECT_URI` | `http://localhost:1455/auth/callback` | Redirect URI |
| `OAUTH_CALLBACK_BIND_HOST` | `` | Override bind host for the local OAuth callback helper server (for example `0.0.0.0` in Docker) |
| `MISTRAL_COMPACT_UPSTREAM_PATH` | `/v1/responses/compact` | Mistral upstream path for compact responses |

---
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ services:
build: .
container_name: multivibe
ports:
- "4010:4010"
- "1455:1455"
environment:
- PORT=1455
Expand All @@ -22,6 +23,7 @@ services:
- OAUTH_TOKEN_URL=https://auth.openai.com/oauth/token
- OAUTH_SCOPE=openid profile email offline_access
- OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback
- OAUTH_CALLBACK_BIND_HOST=0.0.0.0
volumes:
- ./data:/data
restart: unless-stopped
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "npm --prefix web run build",
"build": "npm run build:web && npm run build:api",
"start": "node dist/server.js"
"start": "node dist/server.js",
"test": "node --test --test-force-exit test/*.test.js"
},
"dependencies": {
"@foxglove/wasm-zstd": "^1.0.1",
Expand Down
72 changes: 49 additions & 23 deletions src/account-utils.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
import { OAuthConfig } from "./oauth.js";
import { mergeTokenIntoAccount, refreshAccessToken } from "./oauth.js";
import { normalizeProvider, rememberError } from "./quota.js";
import {
clearAuthFailureState,
normalizeProvider,
rememberError,
} from "./quota.js";
import type { Account } from "./types.js";
import {
TOKEN_REFRESH_COOLDOWN_MS,
TOKEN_REFRESH_MARGIN_MS,
} from "./config.js";

const refreshInFlight = new Map<string, Promise<Account>>();

export async function ensureValidToken(
account: Account,
oauthConfig: OAuthConfig,
): Promise<Account> {
if (normalizeProvider(account) !== "openai") return account;
if (!account.expiresAt || Date.now() < account.expiresAt - 5 * 60_000)
if (!account.expiresAt || Date.now() < account.expiresAt - TOKEN_REFRESH_MARGIN_MS)
return account;
if (!account.refreshToken) return account;

try {
const refreshed = await refreshAccessToken(
oauthConfig,
account.refreshToken,
);
const merged = mergeTokenIntoAccount(account, refreshed);
merged.state = {
...merged.state,
needsTokenRefresh: false,
};
return merged;
} catch (err: any) {
rememberError(
account,
`refresh token failed: ${err?.message ?? String(err)}`,
);
account.state = {
...account.state,
needsTokenRefresh: true,
};
const refreshToken = account.refreshToken;
if (
typeof account.state?.refreshBlockedUntil === "number" &&
Date.now() < account.state.refreshBlockedUntil
) {
return account;
}

const existing = refreshInFlight.get(account.id);
if (existing) return existing;

const run = (async () => {
try {
const refreshed = await refreshAccessToken(
oauthConfig,
refreshToken,
);
const merged = mergeTokenIntoAccount(account, refreshed);
clearAuthFailureState(merged);
return merged;
} catch (err: any) {
const message = err?.message ?? String(err);
rememberError(account, `refresh token failed: ${message}`);
const failureCount = (account.state?.refreshFailureCount ?? 0) + 1;
account.state = {
...account.state,
needsTokenRefresh: true,
refreshFailureCount: failureCount,
refreshBlockedUntil:
Date.now() + TOKEN_REFRESH_COOLDOWN_MS * Math.min(failureCount, 6),
};
return account;
} finally {
refreshInFlight.delete(account.id);
}
})();

refreshInFlight.set(account.id, run);
return run;
}
44 changes: 40 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os from "node:os";

export const HOST = process.env.HOST ?? "127.0.0.1";
export const PORT = Number(process.env.PORT ?? 1455);
export const STORE_PATH = process.env.STORE_PATH ?? "/data/accounts.json";
export const OAUTH_STATE_PATH =
Expand Down Expand Up @@ -28,17 +29,19 @@ export const ZAI_UPSTREAM_PATH =
export const ZAI_COMPACT_UPSTREAM_PATH =
process.env.ZAI_COMPACT_UPSTREAM_PATH ?? "/v1/chat/completions";
export const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? "";
export const STORE_ENCRYPTION_KEY =
process.env.STORE_ENCRYPTION_KEY ?? "";
export const MAX_ACCOUNT_RETRY_ATTEMPTS = Math.max(
1,
Number(process.env.MAX_ACCOUNT_RETRY_ATTEMPTS ?? 5),
);
export const MAX_UPSTREAM_RETRIES = Math.max(
export const MAX_GET_RETRIES = Math.max(
0,
Number(process.env.MAX_UPSTREAM_RETRIES ?? 3),
Number(process.env.MAX_GET_RETRIES ?? 2),
);
export const UPSTREAM_BASE_DELAY_MS = Math.max(
export const RETRY_BASE_DELAY_MS = Math.max(
100,
Number(process.env.UPSTREAM_BASE_DELAY_MS ?? 1000),
Number(process.env.RETRY_BASE_DELAY_MS ?? 250),
);
export const PI_USER_AGENT = `pi (${os.platform()} ${os.release()}; ${os.arch()})`;

Expand All @@ -57,6 +60,39 @@ export const MODELS_CACHE_MS = Number(
export const TOKEN_REFRESH_MARGIN_MS = Number(
process.env.TOKEN_REFRESH_MARGIN_MS ?? 60_000,
);
export const TOKEN_REFRESH_COOLDOWN_MS = Number(
process.env.TOKEN_REFRESH_COOLDOWN_MS ?? 5 * 60_000,
);
export const UPSTREAM_REQUEST_TIMEOUT_MS = Number(
process.env.UPSTREAM_REQUEST_TIMEOUT_MS ?? 60_000,
);
export const MODEL_DISCOVERY_TIMEOUT_MS = Number(
process.env.MODEL_DISCOVERY_TIMEOUT_MS ?? 8_000,
);
export const OAUTH_REQUEST_TIMEOUT_MS = Number(
process.env.OAUTH_REQUEST_TIMEOUT_MS ?? 15_000,
);
export const OAUTH_CALLBACK_BIND_HOST =
process.env.OAUTH_CALLBACK_BIND_HOST ?? "";
export const MODEL_COMPATIBILITY_TTL_MS = Number(
process.env.MODEL_COMPATIBILITY_TTL_MS ?? 6 * 60 * 60_000,
);
export const SERVER_HEADERS_TIMEOUT_MS = Number(
process.env.SERVER_HEADERS_TIMEOUT_MS ?? 30_000,
);
export const SERVER_KEEP_ALIVE_TIMEOUT_MS = Number(
process.env.SERVER_KEEP_ALIVE_TIMEOUT_MS ?? 5_000,
);
export const SERVER_REQUEST_TIMEOUT_MS = Number(
process.env.SERVER_REQUEST_TIMEOUT_MS ?? 90_000,
);
export const SHUTDOWN_GRACE_MS = Number(
process.env.SHUTDOWN_GRACE_MS ?? 10_000,
);
export const TRACE_COMPACTION_INTERVAL = Math.max(
1,
Number(process.env.TRACE_COMPACTION_INTERVAL ?? 100),
);

export const ACCOUNT_FLUSH_INTERVAL_MS = Number(
process.env.ACCOUNT_FLUSH_INTERVAL_MS ?? 5_000,
Expand Down
50 changes: 50 additions & 0 deletions src/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";

type Envelope = {
v: 1;
alg: "aes-256-gcm";
iv: string;
tag: string;
data: string;
};

function deriveKey(secret: string): Buffer {
return createHash("sha256").update(secret, "utf8").digest();
}

export function encryptJson<T>(value: T, secret: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
const plaintext = Buffer.from(JSON.stringify(value), "utf8");
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const envelope: Envelope = {
v: 1,
alg: "aes-256-gcm",
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: ciphertext.toString("base64"),
};
return JSON.stringify(envelope, null, 2);
}

export function decryptJson<T>(raw: string, secret: string): T {
const parsed = JSON.parse(raw) as Envelope;
if (!parsed || parsed.v !== 1 || parsed.alg !== "aes-256-gcm") {
throw new Error("unsupported encrypted payload");
}
const decipher = createDecipheriv(
"aes-256-gcm",
deriveKey(secret),
Buffer.from(parsed.iv, "base64"),
);
decipher.setAuthTag(Buffer.from(parsed.tag, "base64"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(parsed.data, "base64")),
decipher.final(),
]);
return JSON.parse(decrypted.toString("utf8")) as T;
}

export function looksEncryptedJson(raw: string): boolean {
return /^\s*\{\s*"v"\s*:\s*1\s*,\s*"alg"\s*:\s*"aes-256-gcm"/.test(raw);
}
Loading