Compare commits

..
Author SHA1 Message Date
hanzo-dev c9fd1cfc07 ci(data-schemas): isolate native-sqlite specs one-file-per-process; v0.9.16
The 15 specs that instantiate the native better-sqlite3 driver corrupt each
other under a shared jest worker: jest gives each spec file its own JS module
realm while the native addon is process-cached per worker, so cross-file state
breaks sibling in-memory databases (driver-agnostic — reproduces with mainstream
better-sqlite3; a compound json_extract UNIQUE stops firing). Prod is unaffected
(one realm, one shared handle for the process lifetime).

test:ci now runs test/ci.mjs: the normal coverage pass for everything except the
native-driver set (via testPathIgnorePatterns), then each of those 15 specs in
its own jest process, exiting non-zero on any failure. NOT --runInBand /
maxWorkers=1 (a single shared worker still corrupts). No test weakening, no skips.

Bump chat 0.9.15 -> 0.9.16 (patch).
2026-07-04 15:51:44 -07:00
hanzo-dev 5feca644e2 fix(store/sqlite): close shared handle on rekey + test teardown (real leak)
sharedSqliteHandle() reassigned sharedHandle on a collection-key change without
closing the prior connection — a latent native-handle leak on any rekey. Close
it before replacing, and export closeSharedSqliteHandle() so storeRegistry.spec
(rekeys twice) and tenantIsolation.coverage.spec tear the handle down instead of
leaking it past the file.

NOTE: this fixes the real leak the review identified, but does NOT resolve the
cross-file store-spec flake. That flake is a separate, deeper issue (see report):
driver-agnostic (reproduces with mainstream better-sqlite3 too), isolated to the
store's create path, timing/heap-sensitive (any probe masks it), and NOT fixed by
closing handles/GC/statement-cache/wrapper-pinning. Every store suite passes in
its own process; production (single long-lived handle, one module realm) is
unaffected.
2026-07-04 15:35:37 -07:00
hanzo-dev 72a5c71fe3 fix(store/sqlite): swap node:sqlite→better-sqlite3-multiple-ciphers (Node 20) + honor tenant sentinel in debug log
node:sqlite (DatabaseSync) is a Node 22+ builtin; prod runs Node 20 (Alpine),
so require('node:sqlite') threw ERR_UNKNOWN_BUILTIN_MODULE, cascading through
createModels → applySqliteOverrides → openDatabase and breaking the built dist
("createModels is not a function"). Swap to better-sqlite3-multiple-ciphers
12.11.1 (engines include 20.x, synchronous drop-in, SQLCipher AES-256 at rest —
the Node embodiment of the hanzoai/sqlite contract).

- data-schemas dep + root pnpm.onlyBuiltDependencies allowlist (native addon);
  rollup externalizes the driver.
- openDatabase(): lazy require of the driver, identical WAL/synchronous/
  busy_timeout/foreign_keys pragmas. Wire the CHAT_SQLITE_KEY encryption seam
  (SQLCipher cipher/legacy/key pragmas applied before any page-touching
  statement; 64-hex validated, fail-closed; key/path never logged). KMS/CEK
  derivation is Milestone 2.
- DocModel: retype db to the driver's Database instance; binding/return parity
  already correct (booleans→1/0, dates→ISO, TEXT reads only). engine untouched
  (27/27 held).
- parsers: restore the structured-logging-context impl dropped in an upstream
  merge (spec #13110 landed without its parsers.ts) — appendRequestContext for
  non-debug lines and drop the __SYSTEM__ tenant sentinel from debug traversal.
- models: register the SystemGrant model in createModels (schema/model/methods/
  SQLite-spec + coverage guard all existed; only the registration was dangling).

Scrub all node:sqlite/DatabaseSync references from src (comments + spec names).
2026-07-04 14:40:03 -07:00
124 changed files with 2515 additions and 20487 deletions
+13 -12
View File
@@ -12,8 +12,8 @@ const {
loadWebSearchAuth,
buildImageToolContext,
buildWebSearchContext,
resolveTenantBearer,
OPENID_BEARER_SENTINEL,
resolveHanzoCloudKey,
isHanzoPerUserKeyEnabled,
} = require('@hanzochat/api');
const { getMCPServersRegistry } = require('~/config');
const {
@@ -243,17 +243,18 @@ const loadTools = async ({
customConstructors.dalle = async () => {
const authFields = getAuthFields('dalle');
const authValues = await loadAuthValues({ userId: user, authFields });
// Canonical Hanzo Cloud billing (mirrors custom/initialize.ts): when the
// image endpoint is configured to forward the user's IAM bearer
// (DALLE3_API_KEY === the OIDC-token sentinel), resolve the signed-in
// user's own IAM token and forward it so cloud meters THEIR org. Fail
// closed if there is no forwardable bearer — no shared key to spend.
if (authValues.DALLE3_API_KEY === OPENID_BEARER_SENTINEL) {
const bearer = resolveTenantBearer(options.req);
if (!bearer) {
throw new Error('Sign in with Hanzo to generate images — your Hanzo account funds this request.');
const billingUser = options.req?.user;
const isAuthenticatedUser = Boolean(
billingUser && !billingUser.guest && billingUser.email,
);
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
const perUserKey = await resolveHanzoCloudKey(billingUser);
if (!perUserKey) {
throw new Error(
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
);
}
authValues.DALLE3_API_KEY = bearer;
authValues.DALLE3_API_KEY = perUserKey;
}
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
};
+1 -4
View File
@@ -37,10 +37,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
// generation for that role died. roleDefaults[roleName] always carries a
// name, so this guard makes the nameless create structurally impossible.
if (!role && roleDefaults[roleName]) {
// Store-aware: Role resolves to the SQLite DocModel/DualWriteModel under the
// CHAT_STORE_SQLITE flip, where `new Role()` throws "Role is not a constructor".
// `.create()` is the bounded equivalent; it returns the created doc (toObject).
role = await Role.create(roleDefaults[roleName]);
role = await new Role(roleDefaults[roleName]).save();
await cache.set(roleName, role);
return role.toObject();
}
+18 -25
View File
@@ -150,28 +150,6 @@ function calculateTokenValue(txn) {
}
}
/**
* Persists a transaction via the store-aware `Transaction` model (`~/db/models`),
* which resolves to the SQLite DocModel / DualWriteModel under the CHAT_STORE_SQLITE
* flip — where the mongoose-document constructor (`new Transaction()`) + `.save()`
* throws "Transaction is not a constructor". The provided calculator mutates the
* working object with the derived fields (rate/tokenValue/rateDetail) BEFORE the
* insert, then the non-schema calculator inputs (endpointTokenConfig/inputTokenCount)
* and rateDetail are stripped so the persisted document matches the mongoose path
* (mongoose drops non-schema paths on save; the document store does not).
* @param {object} txData
* @param {(txn: object) => void} calculate
* @returns {Promise<object>} the created transaction document
*/
async function persistTransaction(txData, calculate) {
const txn = { ...txData };
calculate(txn);
delete txn.endpointTokenConfig;
delete txn.inputTokenCount;
delete txn.rateDetail;
return Transaction.create(txn);
}
/**
* New static method to create an auto-refill transaction that does NOT trigger a balance update.
* @param {object} txData - Transaction data.
@@ -185,7 +163,11 @@ async function createAutoRefillTransaction(txData) {
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
return;
}
const transaction = await persistTransaction(txData, calculateTokenValue);
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
const balanceResponse = await updateBalance({
user: transaction.user,
@@ -216,7 +198,12 @@ async function createTransaction(_txData) {
return;
}
const transaction = await persistTransaction(txData, calculateTokenValue);
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
if (!balance?.enabled) {
return;
}
@@ -253,7 +240,13 @@ async function createStructuredTransaction(_txData) {
return;
}
const transaction = await persistTransaction(txData, calculateStructuredTokenValue);
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateStructuredTokenValue(transaction);
await transaction.save();
if (!balance?.enabled) {
return;
+20 -5
View File
@@ -1,5 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { billingSubject } = require('@hanzochat/api');
const { createAutoRefillTransaction } = require('./Transaction');
const { logViolation } = require('~/cache');
const { getMultiplier } = require('./tx');
@@ -94,12 +95,26 @@ const checkBalanceRecord = async function ({
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
const tokenCost = amount * multiplier;
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
// two ways, neither of which is an authed user's org balance: (1) the per-IP
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
// balance the cloud gateway debits and 402s when empty. Running them through the
// Commerce/local gate (startBalance:0) would block the free tier entirely.
if (req?.user?.guest === true) {
return { canSpend: true, balance: 0, tokenCost };
}
const commerceClient = getCommerceClient();
// Tenant billing key = the org (the token `owner`). cloud is the authoritative
// meter+gate and bills per-org (see AUTH_BILLING_CONTRACT.md); this optional
// local pre-flight keys on the SAME org so it never diverges from cloud. This
// gate is OFF in production (balance.enabled=false) — cloud is the ONE gate.
const subject = (req?.user?.organization ?? '').toString().trim();
const billingOrg = (req?.user?.organization ?? '').toString().trim();
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
// the authoritative IAM identity; otherwise derive it from organization + email
// (name == email for individual signups). This keys the gate on the SAME
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
// the shared org balance.
const subject =
(req?.user?.billingSubject ?? '').toString().trim() ||
billingSubject(billingOrg, req?.user?.email);
// Commerce-first authoritative gate (per-subject, fail closed).
if (commerceClient && subject) {
@@ -1,6 +1,10 @@
const { getEndpointsConfig } = require('~/server/services/Config');
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
async function endpointController(req, res) {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
const endpointsConfig = await getEndpointsConfig(req);
res.send(JSON.stringify(endpointsConfig));
}
@@ -72,6 +72,9 @@ const updateFavoritesController = async (req, res) => {
const getFavoritesController = async (req, res) => {
try {
if (req.user?.guest === true) {
return res.status(200).json([]);
}
const userId = req.user.id;
const user = await getUserById(userId, 'favorites');
@@ -1,6 +1,7 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
const { getLogStores } = require('~/cache');
/**
@@ -39,6 +40,9 @@ async function loadModels(req) {
async function modelController(req, res) {
try {
if (req.user?.guest === true) {
return res.send(buildGuestModelsConfig());
}
const modelConfig = await loadModels(req);
res.send(modelConfig);
} catch (error) {
-73
View File
@@ -1,73 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { resolveTenantBearer } = require('@hanzochat/api');
/**
* Server-driven auto-routing defaults for the caller's org. Proxies cloud's
* `GET /v1/get-routing-defaults` (authenticated, org-scoped) so ops can enable
* routing per-default in production from admin.hanzo.ai. The user's hanzo.id
* bearer is resolved server-side and forwarded on-behalf-of — cloud validates it
* and scopes to the caller's own org (same trust model as CloudAgentsClient); the
* token never reaches the browser.
*
* Fail-soft is the whole contract: on a missing bearer, an older cloud-api (404),
* a network error, a non-ok wrapper, or a malformed body, we answer 200 with
* `{ available: false }` so the client behaves exactly as today (local preference
* only) with no crash and no spinner block.
*/
/** HTTP timeout (ms) for the metadata read. Fast endpoint; keep it snappy. */
const TIMEOUT = Number(process.env.ROUTING_DEFAULTS_TIMEOUT) || 5000;
/** The "unknown/absent" answer — the client maps this to today's local-only behavior. */
const ABSENT = { available: false };
/** Resolve cloud's base host the same way the rest of chat's proxies do. */
function cloudEndpoint() {
const explicit = (process.env.HANZO_CLOUD_URL || '').trim();
if (explicit) {
return explicit.replace(/\/+$/, '');
}
const base = (process.env.OPENAI_BASE_URL || '').trim();
if (base) {
return base.replace(/\/v1\/?$/, '').replace(/\/+$/, '');
}
return '';
}
async function routingDefaultsController(req, res) {
const endpoint = cloudEndpoint();
const bearer = resolveTenantBearer(req);
if (!endpoint || !bearer) {
return res.status(200).json(ABSENT);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
try {
const resp = await fetch(`${endpoint}/v1/get-routing-defaults`, {
method: 'GET',
headers: { Authorization: `Bearer ${bearer}` },
signal: controller.signal,
});
if (!resp.ok) {
return res.status(200).json(ABSENT);
}
const body = await resp.json();
const data = body?.status === 'ok' ? body?.data : undefined;
if (!data || typeof data !== 'object') {
return res.status(200).json(ABSENT);
}
return res.status(200).json({
available: true,
auto_routing_active: data.auto_routing_active === true,
default_session_routing: data.default_session_routing === true,
});
} catch (err) {
logger.debug('[routingDefaults] fail-soft', { message: err?.message });
return res.status(200).json(ABSENT);
} finally {
clearTimeout(timeoutId);
}
}
module.exports = routingDefaultsController;
-93
View File
@@ -1,93 +0,0 @@
const mongoose = require('mongoose');
const { Transaction } = require('~/db/models');
const { getCommerceClient } = require('~/server/services/CommerceClient');
/** 1e6 tokenCredits == $1 USD (mirrors client creditsToUsd). */
const CREDITS_PER_USD = 1_000_000;
/** Only prompt/completion transactions represent AI usage (spend); `credits` are deposits. */
const SPEND_TYPES = ['prompt', 'completion'];
/** One accumulator over abs(rawAmount) tokens, abs(tokenValue) credits, and request count. */
const spendGroup = (id) => ({
$group: {
_id: id,
tokens: { $sum: { $abs: { $ifNull: ['$rawAmount', 0] } } },
credits: { $sum: { $abs: { $ifNull: ['$tokenValue', 0] } } },
requests: { $sum: 1 },
},
});
/** Shape a facet bucket into the @hanzo/usage window schema (UsageTotals + ProviderCostSnapshot). */
function toWindow(bucket) {
const row = (bucket && bucket[0]) || { tokens: 0, credits: 0, requests: 0 };
return {
totals: { tokens: row.tokens, requests: row.requests },
providerCost: { used: row.credits / CREDITS_PER_USD, currencyCode: 'USD' },
};
}
/**
* Unified per-user AI usage. Aggregates this user's spend (prompt + completion
* Transactions) over today / 7d / 30d plus a per-model breakdown, enriched with
* the org tier from Commerce. Response mirrors @hanzo/usage's UsageSnapshot
* shape (providerId 'hanzo', totals, providerCost) so it is one wire format
* across Hanzo products.
*/
async function usageController(req, res) {
const userId = new mongoose.Types.ObjectId(req.user.id);
const now = new Date();
const since30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const since7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [facet] = await Transaction.aggregate([
{ $match: { user: userId, tokenType: { $in: SPEND_TYPES }, createdAt: { $gte: since30 } } },
{
$facet: {
today: [{ $match: { createdAt: { $gte: startOfToday } } }, spendGroup(null)],
d7: [{ $match: { createdAt: { $gte: since7 } } }, spendGroup(null)],
d30: [spendGroup(null)],
models: [spendGroup('$model'), { $sort: { credits: -1 } }, { $limit: 20 }],
},
},
]);
const models = (facet?.models ?? [])
.filter((m) => m._id)
.map((m) => ({
model: m._id,
tokens: m.tokens,
requests: m.requests,
cost: m.credits / CREDITS_PER_USD,
}));
const payload = {
providerId: 'hanzo',
currencyCode: 'USD',
tier: 'Free',
windows: {
today: toWindow(facet?.today),
'7d': toWindow(facet?.d7),
'30d': toWindow(facet?.d30),
},
models,
updatedAt: now.toISOString(),
};
// Enrich with Commerce tier (fail-open: usage is still authoritative locally).
const commerceClient = getCommerceClient();
if (commerceClient) {
try {
const tier = await commerceClient.getTierConfig(req.user.id);
if (tier) {
payload.tier = tier.displayName || tier.name || payload.tier;
}
} catch {
// ignore — return local usage without tier enrichment
}
}
res.status(200).json(payload);
}
module.exports = usageController;
+4
View File
@@ -40,12 +40,16 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { buildGuestUser } = require('~/server/services/guestConfig');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const getUserController = async (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {IUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
@@ -0,0 +1,45 @@
const { randomUUID } = require('node:crypto');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
/**
* Issues a short-lived guest token for anonymous preview chat.
*
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
* per-token random id, so guest principals are ephemeral and isolated from each
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
* which keeps every non-chat route closed to guests.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
*/
const guestTokenController = async (req, res) => {
const config = getGuestConfig();
if (!config.enabled) {
return res.status(404).json({ message: 'Guest chat is not enabled' });
}
if (!process.env.JWT_SECRET) {
logger.error('[guestTokenController] JWT_SECRET is not configured');
return res.status(500).json({ message: 'Server misconfiguration' });
}
const id = `guest_${randomUUID()}`;
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
expiresIn: expiresInSeconds,
});
return res.status(200).json({
token,
expiresIn: expiresInSeconds,
endpoint: config.endpoint,
model: config.model,
messageMax: config.messageMax,
});
};
module.exports = { guestTokenController };
-2
View File
@@ -188,8 +188,6 @@ const startServer = async () => {
app.use('/v1/chat/categories', routes.categories);
app.use('/v1/chat/endpoints', routes.endpoints);
app.use('/v1/chat/balance', routes.balance);
app.use('/v1/chat/usage', routes.usage);
app.use('/v1/chat/routing-defaults', routes.routingDefaults);
app.use('/v1/chat/models', routes.models);
app.use('/v1/chat/config', routes.config);
app.use('/v1/chat/assistants', routes.assistants);
@@ -0,0 +1,118 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', agents: 'agents' },
}));
jest.mock('~/server/services/guestConfig', () => ({
getGuestConfig: jest.fn(),
}));
const { getGuestConfig } = require('~/server/services/guestConfig');
const enforceGuestScope = require('../enforceGuestScope');
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
describe('enforceGuestScope', () => {
beforeEach(() => {
jest.clearAllMocks();
getGuestConfig.mockReturnValue({
enabled: true,
endpoint: 'Hanzo',
model: 'zen3-nano',
messageMax: 3,
});
});
it('passes non-guest requests through untouched', () => {
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('OpenAI');
expect(req.body.model).toBe('gpt-4o');
});
it('rejects a guest naming a different endpoint (403)', () => {
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('rejects a guest naming a different (paid) model (403)', () => {
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('pins endpoint, type, and model for a compliant guest request', () => {
const req = {
user: { id: 'g1', guest: true },
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
};
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('Hanzo');
expect(req.body.endpointType).toBe('custom');
expect(req.body.model).toBe('zen3-nano');
});
it('pins endpoint/model even when the guest omits them', () => {
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.endpoint).toBe('Hanzo');
expect(req.body.model).toBe('zen3-nano');
});
it('strips every privileged capability from a guest request', () => {
const req = {
user: { id: 'g1', guest: true },
body: {
endpoint: 'Hanzo',
model: 'zen3-nano',
agent_id: 'agent_evil',
spec: 'paid-spec',
preset: { foo: 'bar' },
files: [{ file_id: 'f1' }],
tools: ['execute_code'],
tool_resources: { x: 1 },
resendFiles: true,
promptPrefix: 'jailbreak',
web_search: true,
},
};
const next = jest.fn();
enforceGuestScope(req, mockRes(), next);
expect(next).toHaveBeenCalledWith();
expect(req.body.agent_id).toBeUndefined();
expect(req.body.spec).toBeUndefined();
expect(req.body.preset).toBeUndefined();
expect(req.body.files).toBeUndefined();
expect(req.body.tools).toBeUndefined();
expect(req.body.tool_resources).toBeUndefined();
expect(req.body.resendFiles).toBeUndefined();
expect(req.body.promptPrefix).toBeUndefined();
expect(req.body.web_search).toBeUndefined();
});
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
const req = { user: { id: 'g1', guest: true }, body: {} };
const res = mockRes();
const next = jest.fn();
enforceGuestScope(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,134 @@
/**
* Integration test for the guest chat middleware chain composition:
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
* that keeps management routes (abort/active/...) on the JWT-only parent router.
*/
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
limiterCache: () => undefined,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', agents: 'agents' },
}));
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
}));
jest.mock('../requireJwtAuth', () =>
jest.fn((req, res, next) => {
req.user = { id: 'real-user', role: 'USER' };
next();
}),
);
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
const enforceGuestScope = require('../enforceGuestScope');
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
const JWT_SECRET = 'test-guest-secret';
const buildRouter = () => {
const router = express.Router();
const RESERVED = new Set(['abort', 'active']);
const chatRouter = express.Router();
chatRouter.use((req, res, next) => {
const subpath = req.path.split('/').filter(Boolean)[0];
if (RESERVED.has(subpath)) {
return next('router');
}
return next();
});
chatRouter.use(requireGuestOrJwtAuth);
chatRouter.use(enforceGuestScope);
chatRouter.use(guestMessageLimiter);
chatRouter.post('/', (req, res) =>
res
.status(200)
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
);
router.use('/chat', chatRouter);
// JWT-only management route on the parent (after the guest router)
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
res.status(200).json({ aborted: true, role: req.user.role }),
);
return router;
};
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/v1/chat/agents', buildRouter());
return app;
};
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
describe('guest chat middleware chain', () => {
beforeEach(() => {
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
process.env.GUEST_MESSAGE_MAX = '2';
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen3-nano';
});
afterEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
delete process.env.GUEST_MESSAGE_MAX;
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
const res = await request(buildApp())
.post('/v1/chat/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.1')
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
});
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
const app = buildApp();
const ip = '10.1.0.2';
const send = () =>
request(app)
.post('/v1/chat/agents/chat')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', ip)
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
expect((await send()).status).toBe(200);
expect((await send()).status).toBe(200);
const blocked = await send();
expect(blocked.status).toBe(402);
expect(blocked.body.type).toBe('GUEST_LIMIT');
});
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
const res = await request(buildApp())
.post('/v1/chat/agents/chat/abort')
.set('Authorization', `Bearer ${guestToken()}`)
.set('x-test-ip', '10.1.0.3')
.send({ streamId: 'x' });
// requireJwtAuth mock forces a real USER; guest never reaches abort.
expect(res.status).toBe(200);
expect(res.body).toEqual({ aborted: true, role: 'USER' });
});
});
@@ -0,0 +1,86 @@
const jwt = require('jsonwebtoken');
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
const requireJwtAuth = require('../requireJwtAuth');
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
const JWT_SECRET = 'test-guest-secret';
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
const guestToken = (claims = {}) =>
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
describe('requireGuestOrJwtAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
});
afterEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
});
it('delegates to requireJwtAuth when guest chat is disabled', () => {
process.env.ALLOW_GUEST_CHAT = 'false';
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
const next = jest.fn();
requireGuestOrJwtAuth(req, mockRes(), next);
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
const next = jest.fn();
requireGuestOrJwtAuth(req, mockRes(), next);
expect(requireJwtAuth).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith();
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
});
it('delegates to requireJwtAuth when no bearer token is present', () => {
const req = { headers: {} };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${userToken}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
const req = { headers: { authorization: `Bearer ${forged}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth when guest claim is missing', () => {
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
it('delegates to requireJwtAuth when guest token lacks an id', () => {
const token = jwt.sign({ guest: true }, JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
});
});
@@ -0,0 +1,61 @@
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { getGuestConfig } = require('~/server/services/guestConfig');
/**
* Server-side capability scope for guest principals.
*
* Runs after guest authentication and before `buildEndpointOption`. For guest
* requests it pins the endpoint and model to the configured free Zen endpoint and
* strips every capability a guest must not reach (paid models, agents, tools,
* files, model specs, presets). The client is never trusted: any guest request
* that names a different endpoint/model is rejected rather than silently rewritten.
*
* Non-guest requests pass through untouched.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
* @param {import('express').NextFunction} next
*/
const enforceGuestScope = (req, res, next) => {
if (req.user?.guest !== true) {
return next();
}
const config = getGuestConfig();
if (!config.enabled) {
return res.status(404).json({ message: 'Guest chat is not enabled' });
}
const body = req.body || {};
const { endpoint, model } = body;
if (endpoint != null && endpoint !== config.endpoint) {
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
return res.status(403).json({ message: 'Guests may only use the free preview model' });
}
if (model != null && model !== config.model) {
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
return res.status(403).json({ message: 'Guests may only use the free preview model' });
}
body.endpoint = config.endpoint;
body.endpointType = EModelEndpoint.custom;
body.model = config.model;
delete body.agent_id;
delete body.spec;
delete body.preset;
delete body.files;
delete body.tools;
delete body.tool_resources;
delete body.resendFiles;
delete body.promptPrefix;
delete body.web_search;
req.body = body;
return next();
};
module.exports = enforceGuestScope;
+4
View File
@@ -10,6 +10,8 @@ const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
const enforceGuestScope = require('./enforceGuestScope');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
@@ -36,6 +38,8 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
requireGuestOrJwtAuth,
enforceGuestScope,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
@@ -0,0 +1,41 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = require('~/server/utils');
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
const max = GUEST_TOKEN_MAX;
const windowInMinutes = windowMs / 60000;
const handler = (req, res) => {
return res.status(429).json({
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
});
};
/**
* Per-IP rate limiter for guest token issuance.
*
* Caps how many guest tokens a single client IP can mint per window so nobody can
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`) and backed by the shared
* Redis `limiterCache` so the cap holds across replicas. Note this is defense in
* depth only: even unlimited tokens cannot multiply the message quota, which is
* itself keyed per-IP (see `guestMessageLimiter`).
*/
const guestTokenLimiter = rateLimit({
windowMs,
max,
handler,
keyGenerator: guestClientIp,
store: limiterCache('guest_token_limiter'),
});
module.exports = { guestTokenLimiter };
@@ -0,0 +1,36 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@hanzochat/api');
const { guestClientIp } = require('~/server/utils');
const { getGuestConfig } = require('~/server/services/guestConfig');
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
const handler = (req, res) => {
return res.status(402).json({
type: 'GUEST_LIMIT',
message: 'Guest message limit reached. Log in to continue.',
});
};
/**
* Per-IP quota limiter for anonymous guest chat completions.
*
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
* message limiters), so the count is shared across replicas — a guest cannot
* multiply their quota by round-robining pods. The key is the REAL client IP
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`), NOT the guest token, so
* clearing cookies, opening incognito, or minting a fresh guest token does not
* reset the count. It only counts requests made by an authenticated guest
* principal; logged-in users skip the counter entirely. On exhaustion it returns
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
*/
const guestMessageLimiter = rateLimit({
windowMs: GUEST_LIMIT_WINDOW_MS,
max: () => getGuestConfig().messageMax,
handler,
keyGenerator: guestClientIp,
skip: (req) => req.user?.guest !== true,
store: limiterCache('guest_message_limiter'),
});
module.exports = { guestMessageLimiter };
@@ -0,0 +1,78 @@
const express = require('express');
const request = require('supertest');
jest.mock('@hanzochat/api', () => ({
limiterCache: () => undefined,
}));
jest.mock('~/server/utils', () => ({
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
guestClientIp: (req) =>
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
}));
jest.mock('~/server/services/guestConfig', () => ({
getGuestConfig: jest.fn(),
}));
const { getGuestConfig } = require('~/server/services/guestConfig');
const { guestMessageLimiter } = require('./guestMessageLimiter');
const buildApp = (user) => {
const app = express();
app.use((req, _res, next) => {
req.user = user;
next();
});
app.use(guestMessageLimiter);
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
return app;
};
describe('guestMessageLimiter', () => {
beforeEach(() => {
jest.clearAllMocks();
getGuestConfig.mockReturnValue({
enabled: true,
messageMax: 3,
endpoint: 'Hanzo',
model: 'zen3-nano',
});
});
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
const app = buildApp({ id: 'g1', guest: true });
const ip = '10.0.0.1';
for (let i = 0; i < 3; i++) {
const res = await request(app).post('/chat').set('x-test-ip', ip);
expect(res.status).toBe(200);
}
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
expect(blocked.status).toBe(402);
expect(blocked.body.type).toBe('GUEST_LIMIT');
});
it('counts quota per IP independently', async () => {
const app = buildApp({ id: 'g1', guest: true });
for (let i = 0; i < 3; i++) {
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
}
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
expect(blocked.status).toBe(402);
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
expect(fresh.status).toBe(200);
});
it('never counts or blocks authenticated (non-guest) users', async () => {
const app = buildApp({ id: 'real-user', role: 'USER' });
for (let i = 0; i < 10; i++) {
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
expect(res.status).toBe(200);
}
});
});
+4
View File
@@ -2,6 +2,8 @@ const createTTSLimiters = require('./ttsLimiters');
const createSTTLimiters = require('./sttLimiters');
const loginLimiter = require('./loginLimiter');
const { guestTokenLimiter } = require('./guestLimiters');
const { guestMessageLimiter } = require('./guestMessageLimiter');
const importLimiters = require('./importLimiters');
const uploadLimiters = require('./uploadLimiters');
const forkLimiters = require('./forkLimiters');
@@ -18,6 +20,8 @@ module.exports = {
...messageLimiters,
...forkLimiters,
loginLimiter,
guestTokenLimiter,
guestMessageLimiter,
registerLimiter,
toolCallLimiter,
cloudAgentLimiter,
@@ -0,0 +1,59 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const requireJwtAuth = require('./requireJwtAuth');
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
/**
* Extracts a bearer token from the Authorization header.
* @param {ServerRequest} req
* @returns {string|null}
*/
const getBearerToken = (req) => {
const header = req.headers?.authorization;
if (!header || !header.startsWith('Bearer ')) {
return null;
}
return header.slice('Bearer '.length).trim();
};
/**
* Guest-aware authentication, applied ONLY to chat-completion routes.
*
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
* on `req.user` and the request proceeds. Otherwise the request falls through to
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
* @param {import('express').NextFunction} next
*/
const requireGuestOrJwtAuth = (req, res, next) => {
const config = getGuestConfig();
if (!config.enabled) {
return requireJwtAuth(req, res, next);
}
const token = getBearerToken(req);
if (!token) {
return requireJwtAuth(req, res, next);
}
let payload;
try {
payload = jwt.verify(token, process.env.JWT_SECRET);
} catch (_err) {
return requireJwtAuth(req, res, next);
}
if (payload?.guest !== true || !payload.id) {
return requireJwtAuth(req, res, next);
}
req.user = buildGuestPrincipal(payload.id);
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
return next();
};
module.exports = requireGuestOrJwtAuth;
@@ -0,0 +1,136 @@
/**
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
* a real signed guest JWT.
*
* Proves:
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/convos return guest-scoped data,
* - a non-bootstrap protected route still 401s for a guest.
*/
const jwt = require('jsonwebtoken');
const express = require('express');
const passport = require('passport');
const request = require('supertest');
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
SystemRoles: { USER: 'USER' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
jest.mock('~/models', () => ({
getUserById: jest.fn(async (id) => {
if (String(id).startsWith('guest_')) {
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
}
return null;
}),
updateUser: jest.fn(),
}));
const { getUserById } = require('~/models');
const jwtLogin = require('~/strategies/jwtStrategy');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
const JWT_SECRET = 'integration-guest-secret';
const buildApp = () => {
const app = express();
app.use(express.json());
app.use(passport.initialize());
passport.use(jwtLogin());
// Bootstrap (guest-aware) routes.
app.get('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
}
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
});
app.get('/v1/chat/user', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
return res.status(200).send(req.user);
});
app.get('/v1/chat/convos', requireGuestOrJwtAuth, (req, res) => {
if (req.user?.guest === true) {
return res.status(200).json({ conversations: [], nextCursor: null });
}
return res.status(200).json({ conversations: ['real'] });
});
// Non-bootstrap protected route (JWT-only): must reject guests.
app.get('/v1/chat/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
return app;
};
describe('guest bootstrap auth chain (integration)', () => {
let app;
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
beforeAll(() => {
process.env.JWT_SECRET = JWT_SECRET;
process.env.ALLOW_GUEST_CHAT = 'true';
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen5-mini';
app = buildApp();
});
afterEach(() => jest.clearAllMocks());
it('GET /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
const res = await request(app)
.get('/v1/chat/endpoints')
.set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
const body = JSON.parse(res.text);
expect(Object.keys(body)).toEqual(['Hanzo']);
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
const res = await request(app).get('/v1/chat/user').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
expect(res.body).not.toHaveProperty('email');
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /v1/chat/convos → 200 empty list for a guest', async () => {
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ conversations: [], nextCursor: null });
});
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${guestToken}`);
expect(res.status).toBe(401);
// The guest id must never reach getUserById (that is what caused the CastError → 500).
expect(getUserById).not.toHaveBeenCalled();
});
it('GET /v1/chat/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
expect(res.status).toBe(401);
expect(getUserById).toHaveBeenCalledTimes(1);
});
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
const res = await request(app).get('/v1/chat/prompts');
expect(res.status).toBe(401);
});
});
+75 -7
View File
@@ -1,6 +1,7 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { resolveTenantBearer } = require('@hanzochat/api');
const { requireJwtAuth, cloudAgentLimiter } = require('~/server/middleware');
const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/CloudAgentsClient');
@@ -19,18 +20,85 @@ const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/Cloud
*/
const router = express.Router();
/**
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
* mandatory — the function only ever REMOVES a token from consideration, never
* admits one:
* - Decodable JWT: an opaque/garbage token cannot be principal-bound, so it is
* never forwarded. hanzo.id — the only cloud IdP — always issues JWTs.
* - Principal binding (MANDATORY): the token must NAME the authenticated user
* (`sub === user.openidId`). If we cannot ASSERT the binding — no `openidId`
* on the principal, no `sub` on the token, or a mismatch — we do NOT forward.
* This keeps "forwarded principal == req.user" true by construction, closing
* the credential-mixing confused deputy (a session/cookie carrying a
* different user's token) with no fail-open when binding data is absent.
* - Expiry: never forward a token past its own `exp`.
*
* Decode-only (no signature verification): cloud performs the authoritative
* JWKS + claim validation over the SAME claims, so it runs as exactly this
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
* binding; an intact `sub` is rejected by cloud on signature.
*
* @param {string|undefined} token
* @param {{openidId?: string}} user
* @returns {boolean}
*/
function isForwardableToken(token, user) {
if (!token || !user?.openidId) {
return false;
}
const claims = jwt.decode(token);
if (!claims || typeof claims !== 'object') {
return false;
}
if (claims.sub !== user.openidId) {
return false;
}
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
return false;
}
return true;
}
/**
* Resolve the caller's hanzo.id bearer for the on-behalf-of call to cloud.
* Delegates to the ONE canonical resolver (`resolveTenantBearer`, @hanzochat/api)
* that the chat-completion path also uses — principal-bound to req.user,
* unexpired, id_token preferred with an access_token fallback, session first then
* the httpOnly cookie. Returns null for an honest 401 (never a wrong-principal,
* expired, unbound, or fabricated call).
*
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
* authoritative DB user loaded by requireJwtAuth), NOT any cookie. A local user
* never carries a hanzo.id token, so a stale OpenID session left in the browser
* can never be forwarded under a local identity — the confused deputy is denied
* at the identity layer.
*
* EVERY candidate — session first, then the httpOnly no-session cookie fallback;
* id_token preferred, access_token second — must pass `isForwardableToken`:
* principal-bound to req.user and unexpired. Returns null — for an honest 401,
* never a wrong-principal, expired, unbound, or fabricated call — otherwise.
*
* @param {import('express').Request} req
* @returns {string|null}
*/
function getUserCloudBearer(req) {
return resolveTenantBearer(req);
if (req.user?.provider !== 'openid') {
return null;
}
const parsed = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const session = req.session?.openidTokens;
const idToken = session?.idToken || parsed.openid_id_token;
if (isForwardableToken(idToken, req.user)) {
return idToken;
}
/**
* access_token fallback (rare: the session/cookie carries no id_token).
* hanzo.id issues JWT access tokens too, so this stays principal-bound; an
* opaque or foreign access_token fails the gate and is never forwarded.
*/
const accessToken = session?.accessToken || parsed.openid_access_token;
if (isForwardableToken(accessToken, req.user)) {
return accessToken;
}
return null;
}
router.use(requireJwtAuth);
+28 -6
View File
@@ -8,6 +8,9 @@ const {
messageIpLimiter,
configMiddleware,
messageUserLimiter,
enforceGuestScope,
guestMessageLimiter,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const { saveMessage } = require('~/models');
const openai = require('./openai');
@@ -35,11 +38,15 @@ router.use('/v1/responses', responses);
router.use('/v1', openai);
/**
* Chat completion router. Every request is gated by the strict `requireJwtAuth`
* (there is no guest chat — a signed-in IAM identity is required so the request
* bills to the user's own org via their forwarded bearer). Split from the parent
* router only so the completion middleware chain (ban/uaParser/config/limiters)
* applies to completions but not to management/CRUD routes.
* Guest-capable chat completion router.
*
* Mounted before the JWT-only routes below so anonymous guests (when
* `ALLOW_GUEST_CHAT` is enabled) can reach ONLY the completion endpoints. Auth
* here accepts either a guest token or a real JWT; `enforceGuestScope` then pins
* guests to the free Zen endpoint/model and strips every other capability, and
* `guestMessageLimiter` enforces the per-IP guest quota. Every other agents route
* (management, CRUD, files) remains gated by the strict `requireJwtAuth` below,
* which rejects guest tokens.
*/
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
@@ -56,10 +63,12 @@ chatRouter.use((req, res, next) => {
}
return next();
});
chatRouter.use(requireJwtAuth);
chatRouter.use(requireGuestOrJwtAuth);
chatRouter.use(checkBan);
chatRouter.use(uaParser);
chatRouter.use(configMiddleware);
chatRouter.use(enforceGuestScope);
chatRouter.use(guestMessageLimiter);
if (isEnabled(LIMIT_MESSAGE_IP)) {
chatRouter.use(messageIpLimiter);
@@ -73,6 +82,19 @@ chatRouter.use('/', chat);
router.use('/chat', chatRouter);
/**
* Guest-safe active-jobs poll. Guests never own a generation job, so this
* returns an empty set without a DB/user lookup. Registered BEFORE the strict
* JWT guard below so the composer's bootstrap poll doesn't 401-loop for guests;
* every other agents route stays JWT-only and rejects guest tokens.
*/
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
if (req.user?.guest === true) {
return res.json({ activeJobIds: [] });
}
return next();
});
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
+2
View File
@@ -17,6 +17,7 @@ const {
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
const { getAppConfig } = require('~/server/services/Config');
const middleware = require('~/server/middleware');
const { Balance } = require('~/db/models');
@@ -41,6 +42,7 @@ router.post(
loginController,
);
router.post('/refresh', refreshController);
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
router.post(
'/register',
middleware.registerLimiter,
+5
View File
@@ -5,6 +5,7 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app');
const { getGuestConfig } = require('~/server/services/guestConfig');
const { getProjectByName } = require('~/models/Project');
const { getLogStores } = require('~/cache');
@@ -59,6 +60,8 @@ router.get('/', async function (req, res) {
const balanceConfig = getBalanceConfig(appConfig);
const guestConfig = getGuestConfig();
// Strip server-internal fields from balance config before sending to client.
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
const clientBalanceConfig = balanceConfig
@@ -121,6 +124,8 @@ router.get('/', async function (req, res) {
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens,
allowGuestChat: guestConfig.enabled,
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
+11 -2
View File
@@ -15,6 +15,7 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const { importConversations } = require('~/server/utils/import');
const { deleteToolCalls } = require('~/models/ToolCall');
const getLogStores = require('~/cache/getLogStores');
@@ -26,8 +27,16 @@ const assistantClients = {
const router = express.Router();
/** Conversation list — JWT-only (no guest chat). */
router.get('/', requireJwtAuth, async (req, res) => {
/**
* Guest-accessible conversation list. Guests have no persisted conversations, so
* this returns an empty, well-formed page — enough for the UI to mount the chat
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
*/
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
if (req.user?.guest === true) {
return res.status(200).json({ conversations: [], nextCursor: null });
}
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
+2 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
const endpointController = require('~/server/controllers/EndpointController');
const router = express.Router();
router.get('/', requireJwtAuth, endpointController);
router.get('/', requireGuestOrJwtAuth, endpointController);
module.exports = router;
-4
View File
@@ -9,8 +9,6 @@ const memories = require('./memories');
const presets = require('./presets');
const prompts = require('./prompts');
const balance = require('./balance');
const usage = require('./usage');
const routingDefaults = require('./routingDefaults');
const actions = require('./actions');
const apiKeys = require('./apiKeys');
const banner = require('./banner');
@@ -51,8 +49,6 @@ module.exports = {
actions,
presets,
balance,
usage,
routingDefaults,
messages,
memories,
endpoints,
+2 -2
View File
@@ -1,8 +1,8 @@
const express = require('express');
const { modelController } = require('~/server/controllers/ModelController');
const { requireJwtAuth } = require('~/server/middleware/');
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
const router = express.Router();
router.get('/', requireJwtAuth, modelController);
router.get('/', requireGuestOrJwtAuth, modelController);
module.exports = router;
-8
View File
@@ -1,8 +0,0 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/RoutingDefaults');
const { requireJwtAuth } = require('../middleware/');
router.get('/', requireJwtAuth, controller);
module.exports = router;
+3 -3
View File
@@ -3,12 +3,12 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
const { requireJwtAuth } = require('~/server/middleware');
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
const router = express.Router();
// Favorites — JWT-only (no guest chat).
router.get('/favorites', requireJwtAuth, getFavoritesController);
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
module.exports = router;
-8
View File
@@ -1,8 +0,0 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/Usage');
const { requireJwtAuth } = require('../middleware/');
router.get('/', requireJwtAuth, controller);
module.exports = router;
+2 -1
View File
@@ -13,6 +13,7 @@ const {
configMiddleware,
canDeleteAccount,
requireJwtAuth,
requireGuestOrJwtAuth,
} = require('~/server/middleware');
const settings = require('./settings');
@@ -20,7 +21,7 @@ const settings = require('./settings');
const router = express.Router();
router.use('/settings', settings);
router.get('/', requireJwtAuth, getUserController);
router.get('/', requireGuestOrJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
+118
View File
@@ -0,0 +1,118 @@
const { isEnabled } = require('@hanzochat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const GUEST_ROLE = 'GUEST';
const GUEST_NAME = 'Guest';
const DEFAULT_GUEST_MESSAGE_MAX = 3;
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
const DEFAULT_GUEST_MODEL = 'zen3-nano';
/**
* Resolves the guest-chat configuration from the environment.
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
*
* @returns {{
* enabled: boolean,
* messageMax: number,
* tokenExpiryMs: number,
* endpoint: string,
* model: string,
* }}
*/
const getGuestConfig = () => {
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
return {
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
messageMax:
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
tokenExpiryMs:
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
? tokenExpiryMs
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
};
};
/**
* Builds the ephemeral guest principal for a verified guest token.
*
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
* plain object — never a DB document — so no route ever reads or writes real
* user data on behalf of a guest. No email, no DB id.
*
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
* @returns {{ id: string, role: string, name: string, guest: true }}
*/
const buildGuestPrincipal = (id) => ({
id,
role: GUEST_ROLE,
name: GUEST_NAME,
guest: true,
});
/**
* Builds the guest-scoped `/v1/chat/user` response: the ephemeral principal only.
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
*
* @param {{ id: string }} principal
* @returns {object}
*/
const buildGuestUser = (principal) => ({
id: principal.id,
username: GUEST_NAME,
name: GUEST_NAME,
role: GUEST_ROLE,
provider: 'guest',
emailVerified: false,
guest: true,
});
/**
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
* with no builder/agent/file/preset capabilities. Everything else is omitted so
* the client cannot surface any other endpoint to a guest.
*
* @returns {Record<string, object>}
*/
const buildGuestEndpointsConfig = () => {
const { endpoint } = getGuestConfig();
return {
[endpoint]: {
type: EModelEndpoint.custom,
userProvide: false,
modelDisplayLabel: endpoint,
order: 0,
},
};
};
/**
* Builds the guest-scoped models config: the single configured guest model under
* the guest endpoint. The client pins the composer to exactly this one model.
*
* @returns {Record<string, string[]>}
*/
const buildGuestModelsConfig = () => {
const { endpoint, model } = getGuestConfig();
return {
[endpoint]: [model],
};
};
module.exports = {
getGuestConfig,
buildGuestPrincipal,
buildGuestUser,
buildGuestEndpointsConfig,
buildGuestModelsConfig,
GUEST_ROLE,
GUEST_NAME,
DEFAULT_GUEST_MESSAGE_MAX,
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
DEFAULT_GUEST_ENDPOINT,
DEFAULT_GUEST_MODEL,
};
+120
View File
@@ -0,0 +1,120 @@
jest.mock('@hanzochat/api', () => ({
isEnabled: (value) => value === 'true' || value === true,
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom' },
}));
const {
getGuestConfig,
buildGuestPrincipal,
buildGuestUser,
buildGuestEndpointsConfig,
buildGuestModelsConfig,
GUEST_ROLE,
GUEST_NAME,
DEFAULT_GUEST_MESSAGE_MAX,
DEFAULT_GUEST_ENDPOINT,
DEFAULT_GUEST_MODEL,
} = require('./guestConfig');
describe('getGuestConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
delete process.env.ALLOW_GUEST_CHAT;
delete process.env.GUEST_MESSAGE_MAX;
delete process.env.GUEST_TOKEN_EXPIRY;
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
afterAll(() => {
process.env = originalEnv;
});
it('is disabled by default (fail closed)', () => {
expect(getGuestConfig().enabled).toBe(false);
});
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
process.env.ALLOW_GUEST_CHAT = 'true';
expect(getGuestConfig().enabled).toBe(true);
});
it('uses defaults when env is unset', () => {
const config = getGuestConfig();
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
});
it('honors GUEST_MESSAGE_MAX', () => {
process.env.GUEST_MESSAGE_MAX = '7';
expect(getGuestConfig().messageMax).toBe(7);
});
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
process.env.GUEST_MESSAGE_MAX = 'abc';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
process.env.GUEST_MESSAGE_MAX = '0';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
process.env.GUEST_MESSAGE_MAX = '-5';
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
});
it('honors the configurable guest endpoint and model', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen4-mini';
const config = getGuestConfig();
expect(config.endpoint).toBe('Hanzo');
expect(config.model).toBe('zen4-mini');
});
it('exposes GUEST_ROLE constant', () => {
expect(GUEST_ROLE).toBe('GUEST');
});
});
describe('guest principal + scoped-config builders', () => {
beforeEach(() => {
delete process.env.GUEST_ENDPOINT;
delete process.env.GUEST_MODEL;
});
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
const principal = buildGuestPrincipal('guest_abc');
expect(principal).toEqual({
id: 'guest_abc',
role: GUEST_ROLE,
name: GUEST_NAME,
guest: true,
});
expect(principal).not.toHaveProperty('email');
expect(principal).not.toHaveProperty('_id');
});
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
expect(user.id).toBe('guest_abc');
expect(user.role).toBe(GUEST_ROLE);
expect(user.name).toBe(GUEST_NAME);
expect(user.guest).toBe(true);
expect(user).not.toHaveProperty('email');
expect(user).not.toHaveProperty('password');
});
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
const config = buildGuestEndpointsConfig();
expect(Object.keys(config)).toEqual(['Hanzo']);
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
});
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
process.env.GUEST_ENDPOINT = 'Hanzo';
process.env.GUEST_MODEL = 'zen5-mini';
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
});
});
+1 -4
View File
@@ -71,12 +71,9 @@ const configureSocialLogins = async (app) => {
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) {
passport.use(appleLogin());
}
// PUBLIC PKCE client: registration MUST NOT be gated on a client secret. A
// public client has none (security is PKCE + signed state), and gating on
// OPENID_CLIENT_SECRET is exactly what left the `openid` strategy unregistered
// ("OpenID strategy not registered") and login dead. See AUTH_BILLING_CONTRACT.md.
if (
process.env.OPENID_CLIENT_ID &&
process.env.OPENID_CLIENT_SECRET &&
process.env.OPENID_ISSUER &&
process.env.OPENID_SCOPE &&
process.env.OPENID_SESSION_SECRET
+30
View File
@@ -0,0 +1,30 @@
const removePorts = require('./removePorts');
/**
* Resolves the real client IP for per-IP guest rate limiting.
*
* hanzo.chat is served behind Cloudflare → the DO LB → the ingress. With that
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
* address, which would let anonymous users share/reset their free-message bucket
* (or collapse everyone into one bucket). Cloudflare always sets
* `CF-Connecting-IP` to the true originating client and — unlike a
* client-supplied `X-Forwarded-For` entry — a browser cannot forge it through
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
* request did not transit Cloudflare (e.g. in-cluster/local).
*
* The returned string is the SOLE identity the guest quota keys on, so it must be
* stable across guest tokens, cookie clears, and incognito sessions from the same
* network origin.
*
* @param {import('express').Request} req
* @returns {string}
*/
const guestClientIp = (req) => {
const cf = req.headers?.['cf-connecting-ip'];
if (typeof cf === 'string' && cf.trim()) {
return cf.trim();
}
return removePorts(req);
};
module.exports = guestClientIp;
+2
View File
@@ -1,4 +1,5 @@
const removePorts = require('./removePorts');
const guestClientIp = require('./guestClientIp');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
@@ -7,6 +8,7 @@ const files = require('./files');
module.exports = {
...handleText,
removePorts,
guestClientIp,
sendEmail,
...files,
...queue,
+5 -5
View File
@@ -13,11 +13,11 @@ const jwtLogin = () =>
async (payload, done) => {
try {
/**
* Guest chat is removed (every request requires a signed-in IAM identity).
* A legacy guest token left in a browser after deploy still carries
* `guest: true` and a synthetic `guest_<uuid>` id that is NOT a Mongo
* ObjectId — reject it cleanly here (401) rather than letting `getUserById`
* throw a Mongoose CastError (→ 500). Fail closed.
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
* strategy fails with a 401 instead of letting `getUserById` throw a
* Mongoose CastError (→ 500). Guests reach their scoped routes through
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
*/
if (payload?.guest === true) {
return done(null, false);
+3 -20
View File
@@ -699,30 +699,16 @@ async function setupOpenId() {
try {
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
// PUBLIC PKCE client is the canonical (and only) mode: no client secret is
// sent, and the token endpoint auth method is `none`. Security is PKCE
// (code_challenge S256, OPENID_USE_PKCE=true) + the signed state, not a
// shared secret shipped with a browser-delivered app. A confidential secret
// is honored ONLY if one is still explicitly configured (legacy), never
// required — its absence must never block strategy registration.
const clientSecret = process.env.OPENID_CLIENT_SECRET;
/** @type {ClientMetadata} */
const clientMetadata = {
client_id: process.env.OPENID_CLIENT_ID,
client_secret: process.env.OPENID_CLIENT_SECRET,
};
if (clientSecret) {
clientMetadata.client_secret = clientSecret;
} else {
clientMetadata.token_endpoint_auth_method = 'none';
}
if (shouldGenerateNonce) {
clientMetadata.response_types = ['code'];
clientMetadata.grant_types = ['authorization_code'];
if (clientSecret) {
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
}
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
}
/** @type {Configuration} */
@@ -749,10 +735,7 @@ async function setupOpenId() {
scope: process.env.OPENID_SCOPE,
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
// A public client (no secret) REQUIRES PKCE — force it on regardless of
// the env toggle so a secretless deploy can never fall back to a bare,
// uncertified code exchange.
usePKCE: isEnabled(process.env.OPENID_USE_PKCE) || !clientSecret,
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
},
createOpenIDCallback(),
);
+1 -8
View File
@@ -1,12 +1,5 @@
jest.mock('winston', () => {
// `winston.format(fn)` returns a *factory*; calling that factory yields a
// Format instance whose `.transform` is `fn`. It never invokes `fn` at
// construction (real winston only calls the transform per-log, with a real
// `info`). Returning `fn` directly made any `redactFormat()` call run
// `fn(undefined)` — which crashes at `info.level`. That is exactly what
// `@librechat/data-schemas`'s own `config/winston` does at import time, so
// every backend suite that pulls in `createModels`/`logger` failed to load.
const mockFormatFunction = jest.fn((fn) => jest.fn(() => ({ transform: fn })));
const mockFormatFunction = jest.fn((fn) => fn);
mockFormatFunction.colorize = jest.fn();
mockFormatFunction.combine = jest.fn();
+1 -1
View File
@@ -29,7 +29,7 @@
},
"homepage": "https://hanzo.ai/chat",
"dependencies": {
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
"@hanzo/ui": "^5.3.41",
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10",
@@ -10,7 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel, SMART_ROUTING_MODEL } from '~/utils';
import { cn, getMessageAriaLabel } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers';
import store from '~/store';
@@ -85,22 +85,6 @@ const MessageRender = memo(
],
);
/**
* When smart routing served this conversation (`model === 'auto'`), the
* response echoes the model that actually ran in `msg.model`. Surface it so
* the user sees which model handled their prompt.
*/
const routedModel = useMemo(() => {
if (msg?.isCreatedByUser === true || conversation?.model !== SMART_ROUTING_MODEL) {
return null;
}
const served = msg?.model;
if (!served || served === SMART_ROUTING_MODEL) {
return null;
}
return served;
}, [msg?.isCreatedByUser, msg?.model, conversation?.model]);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
@@ -153,17 +137,7 @@ const MessageRender = memo(
)}
>
{!hasParallelContent && (
<h2 className={cn('flex select-none items-center gap-1.5 font-semibold', fontSize)}>
{messageLabel}
{routedModel != null && (
<span
className="rounded bg-surface-tertiary px-1.5 py-0.5 text-xs font-normal text-text-secondary"
title={localize('com_ui_routed_to', { 0: routedModel })}
>
{routedModel}
</span>
)}
</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
@@ -4,7 +4,6 @@
*/
import React, { useCallback } from 'react';
import { useAuthContext } from '~/hooks';
import NetworkWallet from './NetworkWallet';
// Use require() to bypass Vite CJS static analysis for @hanzo/ui
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -31,7 +30,6 @@ export default function ChatHanzoHeader() {
<HanzoHeaderComponent
currentApp="Chat"
currentAppId="chat"
headerRight={<NetworkWallet />}
user={
user
? {
@@ -1,23 +0,0 @@
/**
* Chat's network + wallet cluster — the shared @hanzo/ui pair over the
* web custody model (injected EIP-1193, non-custodial). ONE adapter
* instance for the surface; selection persists in the shared network
* store so every consumer of `getNetwork()` follows the switcher.
*
* Wrapped in `dark` because the HanzoHeader bar is always monochrome
* dark, independent of the app theme.
*/
import React from 'react';
import { NetworkSwitcher } from '@hanzo/ui/network';
import { injectedEvmAdapter, WalletMenu } from '@hanzo/ui/wallet';
const walletAdapter = injectedEvmAdapter();
export default function NetworkWallet() {
return (
<div className="dark mr-1 hidden items-center gap-1.5 md:flex">
<NetworkSwitcher />
<WalletMenu adapter={walletAdapter} />
</div>
);
}
+2 -15
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign, BarChart3 } from 'lucide-react';
import { MessageSquare, Command, DollarSign } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import {
GearIcon,
@@ -20,7 +20,6 @@ import {
Personalization,
Data,
Balance,
Usage,
Account,
} from './SettingsTabs';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
@@ -44,9 +43,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
SettingsTabValues.SPEECH,
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled
? [SettingsTabValues.BALANCE, SettingsTabValues.USAGE]
: []),
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
SettingsTabValues.ACCOUNT,
];
const currentIndex = tabs.indexOf(activeTab);
@@ -117,11 +114,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
icon: <DollarSign size={18} />,
label: 'com_nav_setting_balance' as TranslationKeys,
},
{
value: SettingsTabValues.USAGE,
icon: <BarChart3 size={18} />,
label: 'com_nav_setting_usage' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
{
@@ -256,11 +248,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Balance />
</Tabs.Content>
)}
{startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.USAGE} tabIndex={-1}>
<Usage />
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
@@ -1,240 +0,0 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { ExternalLink, TrendingUp, Zap, ArrowUpRight, BarChart3, Sparkles } from 'lucide-react';
import { useGetStartupConfig, useGetUserUsage, useGetRoutingDefaults } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import { resolveSmartRouting } from '~/utils';
import store from '~/store';
const CONSOLE_URL = 'https://console.hanzo.ai/ai-accounts';
const ROUTING_DOCS_URL = 'https://docs.hanzo.ai/docs/usage/routing';
/** Smart-routing toggle: default new Hanzo chats to the gateway `auto` model. */
function SmartRoutingToggle() {
const localize = useLocalize();
// The stored value is the user OVERRIDE (null === follow org default).
const [smartRoutingPref, setSmartRouting] = useRecoilState<boolean | null>(store.smartRouting);
// Server-driven org defaults (fail-soft: absent === today's local-only behavior).
const { data: routingDefaults } = useGetRoutingDefaults();
const available = routingDefaults?.available === true;
const orgDefault = available ? routingDefaults?.default_session_routing ?? null : null;
const autoRoutingActive = available ? routingDefaults?.auto_routing_active !== false : true;
const { enabled, toggleDisabled } = resolveSmartRouting(
smartRoutingPref,
orgDefault,
autoRoutingActive,
);
const labelId = 'smartRouting-label';
return (
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-text-secondary" />
<div id={labelId} className="text-sm font-medium text-text-primary">
{localize('com_nav_smart_routing')}
</div>
</div>
<Switch
id="smartRouting"
checked={enabled}
disabled={toggleDisabled}
onCheckedChange={setSmartRouting}
data-testid="smartRouting"
aria-labelledby={labelId}
/>
</div>
<p className="mt-2 text-xs text-text-secondary">
{toggleDisabled
? localize('com_nav_smart_routing_org_disabled')
: localize('com_nav_smart_routing_desc')}{' '}
{!toggleDisabled && (
<a
href={ROUTING_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-text-primary underline hover:opacity-80"
>
{localize('com_nav_smart_routing_learn_more')}
</a>
)}
</p>
</div>
);
}
function formatUsd(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
/** Compact token count formatter */
function formatTokens(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
/** Progress bar component (shared idiom with the Balance tab). */
function UsageBar({
label,
sublabel,
current,
limit,
color = 'bg-blue-500',
}: {
label: string;
sublabel?: string;
current: number;
limit: number;
color?: string;
}) {
const pct = limit > 0 ? Math.min((current / limit) * 100, 100) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-text-primary">{label}</span>
{sublabel && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">{sublabel}</span>
)}
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full transition-all duration-500 ease-out ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function Usage() {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const usageQuery = useGetUserUsage({
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
});
const usage = usageQuery.data;
const windows = usage?.windows;
const today = windows?.today;
const week = windows?.['7d'];
const month = windows?.['30d'];
const models = usage?.models ?? [];
const monthTokens = month?.totals.tokens ?? 0;
const monthSpend = month?.providerCost.used ?? 0;
const tierLabel = usage?.tier && usage.tier !== 'unknown' ? usage.tier : 'Free';
// Scale the per-window token bars against the widest window (30d).
const tokenScale = Math.max(monthTokens, 1);
const topModelCost = models.length > 0 ? Math.max(...models.map((m) => m.cost), 0.0001) : 1;
return (
<div className="flex flex-col gap-5 p-4 text-sm text-text-primary">
{/* Smart routing toggle */}
<SmartRoutingToggle />
{/* Spend header card */}
<div className="rounded-xl border border-border-medium bg-surface-secondary p-4">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-secondary">
{localize('com_nav_usage_spend_30d')}
</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{formatUsd(monthSpend)}</p>
</div>
<div className="flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1">
<Zap className="h-3 w-3 text-text-secondary" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
{tierLabel}
</span>
</div>
</div>
<p className="mt-1 text-xs text-text-secondary">
{formatTokens(monthTokens)} {localize('com_nav_usage_tokens_used_30d')}
</p>
</div>
{/* Token usage by window */}
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-secondary">
<TrendingUp className="h-3.5 w-3.5" />
{localize('com_nav_usage_tokens')}
</h3>
<UsageBar
label={localize('com_nav_usage_today')}
sublabel={`${formatTokens(today?.totals.tokens ?? 0)} · ${formatUsd(
today?.providerCost.used ?? 0,
)}`}
current={today?.totals.tokens ?? 0}
limit={tokenScale}
color="bg-blue-500"
/>
<UsageBar
label={localize('com_nav_usage_last_7d')}
sublabel={`${formatTokens(week?.totals.tokens ?? 0)} · ${formatUsd(
week?.providerCost.used ?? 0,
)}`}
current={week?.totals.tokens ?? 0}
limit={tokenScale}
color="bg-emerald-500"
/>
<UsageBar
label={localize('com_nav_usage_last_30d')}
sublabel={`${formatTokens(monthTokens)} · ${formatUsd(monthSpend)}`}
current={monthTokens}
limit={tokenScale}
color="bg-violet-500"
/>
</div>
{/* Per-model breakdown */}
{models.length > 0 && (
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-secondary">
<BarChart3 className="h-3.5 w-3.5" />
{localize('com_nav_usage_by_model')}
</h3>
{models.slice(0, 8).map((m) => (
<UsageBar
key={m.model}
label={m.model}
sublabel={`${formatTokens(m.tokens)} · ${formatUsd(m.cost)}`}
current={m.cost}
limit={topModelCost}
color="bg-blue-500"
/>
))}
</div>
)}
{/* Console link */}
<div className="space-y-2 border-t border-border-light pt-4">
<a
href={CONSOLE_URL}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border-medium px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover active:scale-[0.98]"
>
<ExternalLink className="h-4 w-4 text-text-secondary" />
{localize('com_nav_usage_all_providers')}
<ArrowUpRight className="h-3.5 w-3.5 opacity-70" />
</a>
</div>
</div>
);
}
export default React.memo(Usage);
@@ -2,7 +2,6 @@ export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as Usage } from './Usage/Usage';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
-32
View File
@@ -31,38 +31,6 @@ export const useGetUserBalance = (
});
};
export const useGetUserUsage = (
config?: UseQueryOptions<t.TUsageResponse>,
): QueryObserverResult<t.TUsageResponse> => {
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
return useQuery<t.TUsageResponse>([QueryKeys.usage], () => dataService.getUserUsage(), {
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
...config,
enabled: (config?.enabled ?? true) === true && queriesEnabled,
});
};
export const useGetRoutingDefaults = (
config?: UseQueryOptions<t.TRoutingDefaultsResponse>,
): QueryObserverResult<t.TRoutingDefaultsResponse> => {
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
return useQuery<t.TRoutingDefaultsResponse>(
[QueryKeys.routingDefaults],
() => dataService.getRoutingDefaults(),
{
// Org defaults change rarely; never block a boot on this fetch.
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000,
...config,
enabled: (config?.enabled ?? true) === true && queriesEnabled,
},
);
};
export const useGetSearchEnabledQuery = (
config?: UseQueryOptions<boolean>,
): QueryObserverResult<boolean> => {
+2 -40
View File
@@ -31,17 +31,9 @@ import {
getDefaultEndpoint,
getModelSpecPreset,
buildDefaultConvo,
isHanzoEndpoint,
SMART_ROUTING_MODEL,
resolveSmartRouting,
logger,
} from '~/utils';
import {
useDeleteFilesMutation,
useGetEndpointsQuery,
useGetStartupConfig,
useGetRoutingDefaults,
} from '~/data-provider';
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useAssistantListMap from './Assistants/useAssistantListMap';
import { useResetChatBadges } from './useChatBadges';
import { useApplyModelSpecEffects } from './Agents';
@@ -59,15 +51,6 @@ const useNewConvo = (index = 0) => {
const { setConversation } = store.useCreateConversationAtom(index);
const [files, setFiles] = useRecoilState(store.filesByIndex(index));
const saveBadgesState = useRecoilValue<boolean>(store.saveBadgesState);
const smartRoutingPref = useRecoilValue<boolean | null>(store.smartRouting);
// Server-driven org defaults (fail-soft: `available:false` when the endpoint is
// absent/older cloud-api — resolveSmartRouting then keeps today's behavior).
const { data: routingDefaults } = useGetRoutingDefaults();
const { enabled: smartRouting } = resolveSmartRouting(
smartRoutingPref,
routingDefaults?.available ? routingDefaults.default_session_routing ?? null : null,
routingDefaults?.available ? routingDefaults.auto_routing_active !== false : true,
);
const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submissionByIndex(index));
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
@@ -126,11 +109,6 @@ const useNewConvo = (index = 0) => {
activePreset.presetId === defaultPreset?.presetId);
if (buildDefaultConversation) {
// Did the user explicitly pick a model for THIS new conversation
// (model menu / preset)? If so, never override it — smart routing only
// sets the *default* model, it doesn't rewrite an explicit choice.
const userSelectedModel = !!(conversation.model ?? activePreset?.model);
let defaultEndpoint = getDefaultEndpoint({
convoSetup: activePreset ?? conversation,
endpointsConfig,
@@ -222,15 +200,6 @@ const useNewConvo = (index = 0) => {
models,
defaultParamsEndpoint,
});
// Smart routing: default a fresh Hanzo-endpoint conversation to the
// gateway's `auto` model (routes to the best/cheapest capable model).
// Scoped to the Hanzo endpoint and only when the user hasn't picked a
// model explicitly — provider families and explicit choices are left
// untouched.
if (smartRouting && !userSelectedModel && isHanzoEndpoint(defaultEndpoint)) {
conversation.model = SMART_ROUTING_MODEL;
}
}
if (disableParams === true) {
@@ -283,14 +252,7 @@ const useNewConvo = (index = 0) => {
state: disableFocus ? {} : { focusChat: true },
});
},
[
endpointsConfig,
defaultPreset,
assistantsListMap,
modelsQuery.data,
hasAgentAccess,
smartRouting,
],
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
);
const newConversation = useCallback(
-14
View File
@@ -584,17 +584,12 @@
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
"com_nav_setting_speech": "Speech",
"com_nav_setting_usage": "Usage",
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
"com_nav_show_code": "Always show code when using code interpreter",
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
"com_nav_smart_routing": "Smart routing",
"com_nav_smart_routing_desc": "Automatically route each new chat to the best, cheapest capable model. You're billed for whichever model serves it.",
"com_nav_smart_routing_org_disabled": "Disabled for your organization.",
"com_nav_smart_routing_learn_more": "Learn more",
"com_nav_speech_to_text": "Speech to Text",
"com_nav_stop_generating": "Stop generating",
"com_nav_text_to_speech": "Text to Speech",
@@ -609,14 +604,6 @@
"com_nav_tool_dialog_mcp_server_tools": "MCP Server Tools",
"com_nav_tool_remove": "Remove",
"com_nav_tool_search": "Search tools",
"com_nav_usage_all_providers": "See all providers in console.hanzo.ai/ai-accounts",
"com_nav_usage_by_model": "By model",
"com_nav_usage_last_30d": "Last 30 days",
"com_nav_usage_last_7d": "Last 7 days",
"com_nav_usage_spend_30d": "Spend (30 days)",
"com_nav_usage_today": "Today",
"com_nav_usage_tokens": "Tokens used",
"com_nav_usage_tokens_used_30d": "tokens used in the last 30 days",
"com_nav_user": "USER",
"com_nav_user_msg_markdown": "Render user messages as markdown",
"com_nav_user_name_display": "Display username in messages",
@@ -1351,7 +1338,6 @@
"com_ui_role_viewer": "Viewer",
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
"com_ui_roleplay": "Roleplay",
"com_ui_routed_to": "Routed to {{0}}",
"com_ui_rotate": "Rotate",
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_run_code": "Run Code",
-6
View File
@@ -17,12 +17,6 @@ const staticAtoms = {
const localStorageAtoms = {
// General settings
autoScroll: atomWithLocalStorage('autoScroll', false),
// Smart routing user OVERRIDE. `null` (never touched) === follow the org's
// server-driven default; `true`/`false` === an explicit user choice that wins.
// When on, new conversations on the Hanzo endpoint default to model "auto" (the
// gateway routes each prompt to the best/cheapest capable model; billed as
// whatever served it). Resolved via resolveSmartRouting (utils/endpoints).
smartRouting: atomWithLocalStorage<boolean | null>('smartRouting', null),
hideSidePanel: atomWithLocalStorage('hideSidePanel', false),
enableUserMsgMarkdown: atomWithLocalStorage<boolean>(
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
+2 -2
View File
@@ -1538,7 +1538,7 @@ html {
.dark body,
.dark html {
--tw-bg-opacity: 1;
background-color: rgba(0, 0, 0, var(--tw-bg-opacity));
background-color: rgba(23, 23, 23, var(--tw-bg-opacity));
}
#__next,
@@ -1803,7 +1803,7 @@ html {
.dark body,
.dark html {
--tw-bg-opacity: 1;
background-color: rgba(0, 0, 0, var(--tw-bg-opacity));
background-color: rgba(33, 33, 33, var(--tw-bg-opacity));
}
#__next,
#root {
+1 -30
View File
@@ -1,11 +1,6 @@
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig, TConfig } from 'librechat-data-provider';
import {
getAvailableEndpoints,
getEndpointsFilter,
mapEndpoints,
resolveSmartRouting,
} from './endpoints';
import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 },
@@ -88,27 +83,3 @@ describe('mapEndpoints', () => {
expect(mapEndpoints(mockEndpointsConfig)).toEqual(expectedOrder);
});
});
describe('resolveSmartRouting', () => {
it('locks off when the org disabled auto-routing, regardless of local/org values', () => {
expect(resolveSmartRouting(true, true, false)).toEqual({ enabled: false, toggleDisabled: true });
expect(resolveSmartRouting(null, true, false)).toEqual({ enabled: false, toggleDisabled: true });
expect(resolveSmartRouting(false, null, false)).toEqual({ enabled: false, toggleDisabled: true });
});
it('honors an explicit user override over the org default', () => {
expect(resolveSmartRouting(true, false, true)).toEqual({ enabled: true, toggleDisabled: false });
expect(resolveSmartRouting(false, true, true)).toEqual({ enabled: false, toggleDisabled: false });
});
it('follows the org default when the user never set an override', () => {
expect(resolveSmartRouting(null, true, true)).toEqual({ enabled: true, toggleDisabled: false });
expect(resolveSmartRouting(null, false, true)).toEqual({ enabled: false, toggleDisabled: false });
});
it('falls back to off (today behavior) when there is no server signal', () => {
// orgDefault null + active true === older cloud-api / fail-soft fetch
expect(resolveSmartRouting(null, null, true)).toEqual({ enabled: false, toggleDisabled: false });
expect(resolveSmartRouting(true, null, true)).toEqual({ enabled: true, toggleDisabled: false });
});
});
-51
View File
@@ -13,57 +13,6 @@ import type * as t from 'librechat-data-provider';
import type { LocalizeFunction, IconsRecord } from '~/common';
import { getTimestampedValue } from './timestamps';
/**
* The house-brand Hanzo endpoint name (custom endpoint in librechat.yaml,
* `api.hanzo.ai/v1`). Canonical everywhere: config, guest scope (`GUEST_ENDPOINT`
* default), and here. Smart routing's `auto` model is scoped to this endpoint
* the explicit provider families (Qwen, Meta Llama, ) are deliberate picks and
* must never be silently re-routed.
*/
export const HANZO_ENDPOINT = 'Hanzo';
/** The gateway model that routes each prompt to the best/cheapest capable model. */
export const SMART_ROUTING_MODEL = 'auto';
/** True when the endpoint is the Hanzo house-brand endpoint (auto-routing home). */
export function isHanzoEndpoint(endpoint?: EModelEndpoint | string | null): boolean {
return endpoint === HANZO_ENDPOINT;
}
/** Effective smart-routing state for a new conversation and its toggle. */
export interface SmartRoutingState {
/** Whether a fresh Hanzo conversation should default to the `auto` model. */
enabled: boolean;
/** Whether the user-facing toggle must be locked (org disabled auto-routing). */
toggleDisabled: boolean;
}
/**
* Resolve smart routing from the user's local override and the org's
* server-driven defaults. The single source of truth for the effective state
* mirrored (not shared as a package) across the Hanzo apps so behavior is uniform.
*
* - `localPref`: the user override. `null` === never touched (follow org default);
* `true`/`false` === an explicit user choice that wins.
* - `orgDefault`: `default_session_routing` from the org (null when the server
* gave no signal older cloud-api or a fail-soft fetch in which case we fall
* back to today's off-by-default behavior).
* - `autoRoutingActive`: `auto_routing_active` for the org. When false the org has
* disabled auto-routing entirely; the toggle is off and locked. When the fetch
* failed soft, callers pass `true` so nothing changes vs. today.
*/
export function resolveSmartRouting(
localPref: boolean | null,
orgDefault: boolean | null,
autoRoutingActive: boolean,
): SmartRoutingState {
if (!autoRoutingActive) {
return { enabled: false, toggleDisabled: true };
}
const enabled = localPref !== null ? localPref : orgDefault === true;
return { enabled, toggleDisabled: false };
}
/**
* Clears model for non-ephemeral agent conversations.
* Agents use their configured model internally, so the conversation model should be undefined.
-10
View File
@@ -24,9 +24,6 @@ module.exports = {
'./src/**/*.{js,jsx,ts,tsx}',
// Include component library files
'../packages/client/src/**/*.{js,jsx,ts,tsx}',
// Shared @hanzo/ui components (network switcher / wallet menu)
'./node_modules/@hanzo/ui/dist/network/**/*.{js,mjs}',
'./node_modules/@hanzo/ui/dist/wallet/**/*.{js,mjs}',
],
// darkMode: 'class',
darkMode: ['class'],
@@ -168,13 +165,6 @@ module.exports = {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover, var(--background)))',
foreground: 'hsl(var(--popover-foreground, var(--foreground)))',
},
destructive: {
DEFAULT: 'var(--text-destructive)',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
+7 -7
View File
@@ -88,7 +88,7 @@ endpoints:
# completion path that carries the per-user hk- key) instead of bare
# `agents`, which requires an explicit agent_id and 400s on first send.
customOrder: 0
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
# zen* now route to DigitalOcean GenAI (do-ai) and return content
# (cloud >= 1.785.11). fetch:false keeps the menu curated to the
@@ -131,7 +131,7 @@ endpoints:
# CM. Zen ("Hanzo") stays house brand + default at order 0.
- name: "Qwen"
customOrder: 1
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
models:
default:
@@ -145,7 +145,7 @@ endpoints:
modelDisplayLabel: "Qwen"
- name: "Meta Llama"
customOrder: 2
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
models:
default:
@@ -158,7 +158,7 @@ endpoints:
modelDisplayLabel: "Meta Llama"
- name: "DeepSeek"
customOrder: 3
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
models:
default:
@@ -172,7 +172,7 @@ endpoints:
modelDisplayLabel: "DeepSeek"
- name: "Mistral"
customOrder: 4
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
iconURL: "/assets/mistral.png"
models:
@@ -186,7 +186,7 @@ endpoints:
modelDisplayLabel: "Mistral"
- name: "Google Gemma"
customOrder: 5
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
models:
default:
@@ -198,7 +198,7 @@ endpoints:
modelDisplayLabel: "Google Gemma"
- name: "OpenAI GPT-OSS"
customOrder: 6
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
apiKey: "${HANZO_API_KEY}"
baseURL: "https://api.hanzo.ai/v1"
models:
default:
-9
View File
@@ -1,9 +0,0 @@
node_modules
dist
*.tsbuildinfo
.env
.env.local
# Rust / Tauri
src-tauri/target
src-tauri/gen
-88
View File
@@ -1,88 +0,0 @@
# Hanzo AI — mobile app
A mobile-first [Tauri v2](https://tauri.app) app (Rust shell) with a
React 19 + Vite + TypeScript webview, styled with [`@hanzo/gui`](https://www.npmjs.com/package/@hanzo/gui)
(a Tamagui fork). It talks to the Hanzo chat backend (this repo's LibreChat
fork) and shows local AI-provider usage via [`@hanzo/usage`](../../usage).
Self-contained: **not** part of the chat pnpm workspace. Install and build from
inside `mobile/`.
## Screens
| Screen | What it does | Backend |
|----------|---------------------------------------------------------------------|---------|
| Chat | Message list + composer | `POST /api/agents/v1/chat/completions` (OpenAI-compatible) |
| Sessions | Recent conversations | `GET /api/convos` |
| Usage | Provider cards (Hanzo / Codex / Claude); connect empty-state on web | `@hanzo/usage` reads `~/.codex`, `~/.claude`, `~/.hanzo` |
Navigation is a bottom tab bar backed by plain React state — no router library.
## Configuration
Copy `.env.example` to `.env`:
- `VITE_CHAT_API_URL` — backend base URL (default `https://hanzo.chat`)
- `VITE_CHAT_MODEL` — agent id / model for completions (default `hanzo`)
- `VITE_CHAT_TOKEN` — optional bearer token; if unset, the app shows a
paste-a-token screen and stores it in `localStorage`.
> **Auth is a placeholder.** The token flow is a stopgap; the real login is
> Hanzo IAM (hanzo.id) OIDC — see the `TODO(iam)` notes in `src/lib/auth.ts`.
## Develop
```sh
npm install
npm run dev # web-only, http://localhost:1420
npm run build # type-check + vite build -> dist/
# Desktop shell (needs the Rust toolchain + Tauri CLI):
npm run tauri dev
npm run tauri build
```
## Mobile targets
The mobile toolchains are **not** initialized in this repo (they generate
platform projects under `src-tauri/gen/`, which is git-ignored). Initialize them
locally when you build for a device:
```sh
# iOS (macOS + Xcode required)
cargo tauri ios init
cargo tauri ios dev
# Android (Android Studio + NDK required)
cargo tauri android init
cargo tauri android dev
```
`#[cfg_attr(mobile, tauri::mobile_entry_point)]` in `src-tauri/src/lib.rs` is the
shared entry the generated iOS/Android launchers call.
## Rust: on-device AI seams
Two cargo features stage on-device AI and stay **OFF by default** so the default
build is dependency-light and `cargo check` is fast:
- `engine` — local inference via [hanzoai/engine](https://github.com/hanzoai/engine)
- `node` — device registration / mesh via [hanzoai/node](https://github.com/hanzoai/node)
The Tauri commands `engine_status` and `device_register` exist in every build;
with the features off they return `"not built with engine"` / `"not built with
node"`. See `src-tauri/src/ai/mod.rs`.
```sh
cargo check # default, no AI deps
cargo check --features engine,node # once the crates are wired in
```
## Capabilities (scoped)
`src-tauri/capabilities/default.json` grants:
- **fs** read on `$HOME/.codex`, `$HOME/.claude`, `$HOME/.hanzo` (usage config);
write/mkdir limited to `$HOME/.hanzo`.
- **http** to `chatgpt.com`, `api.anthropic.com`, `api.hanzo.ai`, `claude.ai`,
and the chat backend origin `hanzo.chat`.
-20
View File
@@ -1,20 +0,0 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
// One GUI config for the whole app (DRY). Spread the canonical @hanzo/gui v5
// default config and hand it to createGui — same pattern as the Hanzo console
// (hanzoai/console/gui.config.ts). No font override here; the default system
// stack renders correctly inside the mobile webview.
export const config = createGui({
...defaultConfig,
})
export default config
export type Conf = typeof config
declare module '@hanzo/gui' {
interface TypeOverride {
conf: Conf
}
}
-25
View File
@@ -1,25 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark light" />
<title>Hanzo AI</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
background: #000;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-10497
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
{
"name": "@hanzo/chat-mobile",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Hanzo AI mobile app (Tauri v2 + React 19 + @hanzo/gui). Self-contained — not part of the chat pnpm workspace.",
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@hanzo/gui": "^7.3.0",
"@hanzo/usage": "file:../../usage/packages/core",
"@hanzogui/config": "^7.3.0",
"@hanzogui/core": "^7.3.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
-4892
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
[package]
name = "hanzo-chat-mobile"
version = "0.1.0"
description = "Hanzo AI mobile app"
authors = ["Hanzo AI Inc"]
edition = "2021"
rust-version = "1.77.2"
[lib]
# Tauri mobile targets build the app as a shared library; a `_lib` suffix keeps
# the name from colliding with the desktop binary crate on Windows.
name = "hanzo_chat_mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
# On-device AI seams — OFF by default so the default build stays dependency-light
# and `cargo check` needs no model runtime. Turning these on is where the
# hanzoai/engine (inference) and hanzoai/node (device mesh) crates get wired in.
default = []
engine = []
node = []
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,43 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capability: usage-config filesystem reads + chat/provider HTTP.",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$HOME/.codex/**" },
{ "path": "$HOME/.claude/**" },
{ "path": "$HOME/.hanzo/**" }
]
},
{
"identifier": "fs:allow-read-dir",
"allow": [
{ "path": "$HOME/.codex/**" },
{ "path": "$HOME/.claude/**" },
{ "path": "$HOME/.hanzo/**" }
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$HOME/.hanzo/**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "$HOME/.hanzo/**" }]
},
{
"identifier": "http:default",
"allow": [
{ "url": "https://chatgpt.com/*" },
{ "url": "https://api.anthropic.com/*" },
{ "url": "https://api.hanzo.ai/*" },
{ "url": "https://claude.ai/*" },
{ "url": "https://hanzo.chat/*" }
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

-68
View File
@@ -1,68 +0,0 @@
// On-device AI seams.
//
// This module stages two integration points that stay OFF by default (see the
// `engine` / `node` cargo features). Keeping them cfg-gated means the default
// build pulls in no model runtime and `cargo check` is fast and dependency-light.
//
// When ready, wire these to the real crates:
// - feature `engine` -> hanzoai/engine (local inference: GGUF/MLX/Metal, the
// same engine that powers Hanzo Engine on desktop).
// - feature `node` -> hanzoai/node (device registration + peer mesh so a
// phone can join a user's Hanzo compute fabric).
//
// The command surface is identical whether or not the features are enabled, so
// the JS side can always call them; disabled builds return a clear status.
use serde::Serialize;
#[derive(Serialize)]
pub struct EngineStatus {
pub enabled: bool,
pub detail: String,
}
#[derive(Serialize)]
pub struct DeviceRegistration {
pub registered: bool,
pub detail: String,
}
/// Report whether the local inference engine is compiled in and ready.
#[tauri::command]
pub fn engine_status() -> EngineStatus {
#[cfg(feature = "engine")]
{
// TODO(engine): probe hanzoai/engine — loaded model, backend, VRAM.
EngineStatus {
enabled: true,
detail: "engine feature enabled (runtime not yet wired)".into(),
}
}
#[cfg(not(feature = "engine"))]
{
EngineStatus {
enabled: false,
detail: "not built with engine".into(),
}
}
}
/// Register this device with the user's Hanzo node mesh.
#[tauri::command]
pub fn device_register(_name: Option<String>) -> DeviceRegistration {
#[cfg(feature = "node")]
{
// TODO(node): register with hanzoai/node — keypair, enroll, join mesh.
DeviceRegistration {
registered: true,
detail: "node feature enabled (mesh join not yet wired)".into(),
}
}
#[cfg(not(feature = "node"))]
{
DeviceRegistration {
registered: false,
detail: "not built with node".into(),
}
}
}
-18
View File
@@ -1,18 +0,0 @@
mod ai;
// Shared entry point for desktop and mobile (iOS/Android call this from their
// generated launchers). Registers the fs + http plugins the webview uses for
// provider-usage reads and cross-origin API calls (scopes live in
// capabilities/default.json), plus the on-device AI command stubs.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.invoke_handler(tauri::generate_handler![
ai::engine_status,
ai::device_register
])
.run(tauri::generate_context!())
.expect("error while running Hanzo AI");
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hanzo_chat_mobile_lib::run()
}
-38
View File
@@ -1,38 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hanzo AI",
"version": "0.1.0",
"identifier": "ai.hanzo.chat",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Hanzo AI",
"width": 420,
"height": 900,
"minWidth": 320,
"minHeight": 480,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
-66
View File
@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Nav, type Screen } from './components/Nav'
import { TokenGate } from './components/TokenGate'
import { Chat } from './screens/Chat'
import { Sessions } from './screens/Sessions'
import { Usage } from './screens/Usage'
import { fetchRoutingDefaults } from './lib/api'
import { cacheRoutingDefaults, type RoutingDefaults } from './lib/routing'
// App shell: fixed header, active screen, bottom nav. State-based routing only.
// The authed shell is split out so the org routing-defaults boot fetch runs only
// once we hold a token (TokenGate renders children after auth).
export function App() {
return (
<TokenGate>
<AppShell />
</TokenGate>
)
}
function AppShell() {
const [screen, setScreen] = useState<Screen>('chat')
const [routingDefaults, setRoutingDefaults] = useState<RoutingDefaults | null>(null)
// On boot (authed), fetch the server-driven auto-routing defaults once. The
// fetch is fail-soft (never throws → { available: false }); we cache the result
// for the non-React api layer AND hold it in state for the Usage toggle.
useEffect(() => {
let live = true
const controller = new AbortController()
fetchRoutingDefaults({ signal: controller.signal }).then((defaults) => {
if (!live) return
cacheRoutingDefaults(defaults)
setRoutingDefaults(defaults)
})
return () => {
live = false
controller.abort()
}
}, [])
return (
<YStack flex={1} backgroundColor="$background">
<XStack
paddingHorizontal="$4"
paddingVertical="$3"
borderBottomWidth={1}
borderColor="$borderColor"
alignItems="center"
>
<Text fontSize="$6" fontWeight="700" color="$color">
Hanzo AI
</Text>
</XStack>
<YStack flex={1}>
{screen === 'chat' && <Chat />}
{screen === 'sessions' && <Sessions />}
{screen === 'usage' && <Usage routingDefaults={routingDefaults} />}
</YStack>
<Nav active={screen} onChange={setScreen} />
</YStack>
)
}
-68
View File
@@ -1,68 +0,0 @@
import type { ReactElement } from 'react'
import { Text, YStack } from '@hanzo/gui'
export type Screen = 'chat' | 'sessions' | 'usage'
// Inline SVG glyphs — the @hanzo/gui web output renders to the DOM, so a plain
// <svg> is the lightest icon (avoids react-native-svg, whose web build wants a
// react-native-web internal that RNW 0.21 no longer ships).
type Glyph = (p: { active: boolean }) => ReactElement
const stroke = (active: boolean) => (active ? '#fff' : '#888')
const ChatIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const ListIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const GaugeIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M12 14l4-4M20.66 17A9 9 0 1 0 3.34 17" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const TABS: Array<{ id: Screen; label: string; Icon: Glyph }> = [
{ id: 'chat', label: 'Chat', Icon: ChatIcon },
{ id: 'sessions', label: 'Sessions', Icon: ListIcon },
{ id: 'usage', label: 'Usage', Icon: GaugeIcon },
]
// Bottom tab bar — the app's only navigation. Plain state, no router lib.
export function Nav({ active, onChange }: { active: Screen; onChange: (s: Screen) => void }) {
return (
<YStack
flexDirection="row"
borderTopWidth={1}
borderColor="$borderColor"
backgroundColor="$background"
paddingBottom="$2"
>
{TABS.map(({ id, label, Icon }) => {
const selected = id === active
return (
<YStack
key={id}
flex={1}
alignItems="center"
gap="$1"
paddingVertical="$3"
pressStyle={{ opacity: 0.6 }}
onPress={() => onChange(id)}
>
<Icon active={selected} />
<Text fontSize="$1" color={selected ? '$color' : '$color10'}>
{label}
</Text>
</YStack>
)
})}
</YStack>
)
}
-44
View File
@@ -1,44 +0,0 @@
import { useState, type ReactNode } from 'react'
import { Button, Input, Paragraph, Text, YStack } from '@hanzo/gui'
import { hasToken, setToken } from '../lib/auth'
// Simple paste-a-token gate. Renders children once a bearer token is present
// (from VITE_CHAT_TOKEN or a previous paste), otherwise a single input.
// TODO(iam): swap for a Hanzo IAM OIDC login (see lib/auth.ts).
export function TokenGate({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(hasToken())
const [value, setValue] = useState('')
if (authed) return <>{children}</>
const submit = () => {
const token = value.trim()
if (!token) return
setToken(token)
setAuthed(true)
}
return (
<YStack flex={1} justifyContent="center" gap="$4" padding="$6" backgroundColor="$background">
<Text fontSize="$8" fontWeight="700" color="$color">
Hanzo AI
</Text>
<Paragraph color="$color10">
Paste a bearer token to connect to the chat backend. You can set{' '}
<Text fontFamily="$mono">VITE_CHAT_TOKEN</Text> to skip this.
</Paragraph>
<Input
value={value}
onChangeText={setValue}
placeholder="Bearer token"
secureTextEntry
autoCapitalize="none"
size="$4"
onSubmitEditing={submit}
/>
<Button theme="active" size="$4" disabled={!value.trim()} onPress={submit}>
Connect
</Button>
</YStack>
)
}
-180
View File
@@ -1,180 +0,0 @@
// Thin typed client for the Hanzo chat backend (LibreChat fork).
//
// Two real endpoints, both Bearer-authenticated (see api/server/routes in the
// chat repo):
//
// POST {base}/api/agents/v1/chat/completions
// OpenAI-compatible chat completions (api/server/routes/agents/openai.js →
// OpenAIChatCompletionController). Body: { model, messages, stream }.
// `model` is an agent id. We call it non-streaming for a simple mobile UI.
//
// GET {base}/api/convos?limit=&cursor=
// Conversation list (api/server/routes/convos.js). Returns
// { conversations: Conversation[], nextCursor: string | null }.
//
// GET {base}/v1/chat/routing-defaults
// Server-driven org auto-routing defaults (api/server/controllers/
// RoutingDefaults.js). The SAME route the web client uses. Fail-soft:
// any error → { available: false } → local toggle only, exactly as today.
//
// Base URL comes from VITE_CHAT_API_URL (default https://hanzo.chat). The model
// / agent id comes from VITE_CHAT_MODEL (default "hanzo"). Auth header is
// `Authorization: Bearer <token>` — a JWT for /convos, an agent API key for the
// OpenAI-compatible route; in a gateway deployment the same IAM bearer covers
// both, so we send one token.
import { getToken } from './auth'
import { getSmartRoutingPref } from './settings'
import {
resolveSmartRouting,
orgDefaultRouting,
orgAutoRoutingActive,
cachedRoutingDefaults,
type RoutingDefaults,
} from './routing'
/** Gateway model that routes each prompt to the best/cheapest capable model. */
const SMART_ROUTING_MODEL = 'auto'
const BASE_URL = (
(import.meta.env.VITE_CHAT_API_URL as string | undefined) ?? 'https://hanzo.chat'
).replace(/\/$/, '')
const DEFAULT_MODEL = (import.meta.env.VITE_CHAT_MODEL as string | undefined) ?? 'hanzo'
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface Conversation {
conversationId: string
title?: string
endpoint?: string
model?: string
updatedAt?: string
}
export interface ConversationPage {
conversations: Conversation[]
nextCursor: string | null
}
interface ChatCompletion {
choices?: Array<{ message?: { role?: string; content?: string } }>
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
function authHeaders(): Record<string, string> {
const token = getToken()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return headers
}
async function readError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string; message?: string }
return body.error ?? body.message ?? res.statusText
} catch {
return res.statusText || `HTTP ${res.status}`
}
}
export function getBaseUrl(): string {
return BASE_URL
}
/**
* Send a full message history and return the assistant's reply text.
* Non-streaming for simplicity; the endpoint also supports SSE (stream:true).
*/
export async function chat(
messages: ChatMessage[],
opts: { model?: string; signal?: AbortSignal } = {},
): Promise<string> {
// Explicit opts.model wins; otherwise effective smart routing (local override
// resolved against the org's boot-fetched defaults) flips the default between
// "auto" (gateway routes) and the configured VITE_CHAT_MODEL.
const defaults = cachedRoutingDefaults()
const smartRouting = resolveSmartRouting(
getSmartRoutingPref(),
orgDefaultRouting(defaults),
orgAutoRoutingActive(defaults),
).enabled
const model = opts.model ?? (smartRouting ? SMART_ROUTING_MODEL : DEFAULT_MODEL)
const res = await fetch(`${BASE_URL}/api/agents/v1/chat/completions`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
model,
messages,
stream: false,
}),
signal: opts.signal,
})
if (!res.ok) throw new ApiError(res.status, await readError(res))
const data = (await res.json()) as ChatCompletion
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new ApiError(res.status, 'Malformed completion: no assistant content')
}
return content
}
/** List recent conversations for the authenticated user. */
export async function listConversations(
opts: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
): Promise<ConversationPage> {
const params = new URLSearchParams()
params.set('limit', String(opts.limit ?? 25))
if (opts.cursor) params.set('cursor', opts.cursor)
const res = await fetch(`${BASE_URL}/api/convos?${params.toString()}`, {
method: 'GET',
headers: authHeaders(),
signal: opts.signal,
})
if (!res.ok) throw new ApiError(res.status, await readError(res))
const data = (await res.json()) as Partial<ConversationPage>
return {
conversations: data.conversations ?? [],
nextCursor: data.nextCursor ?? null,
}
}
/**
* Fetch the org's server-driven auto-routing defaults (the SAME endpoint the web
* client uses). Fail-soft is the whole contract: any error no token, older
* cloud-api, network failure, malformed body resolves to `{ available: false }`
* so the app behaves exactly as today (local toggle only). Never throws.
*/
export async function fetchRoutingDefaults(
opts: { signal?: AbortSignal } = {},
): Promise<RoutingDefaults> {
try {
const res = await fetch(`${BASE_URL}/v1/chat/routing-defaults`, {
method: 'GET',
headers: authHeaders(),
signal: opts.signal,
})
if (!res.ok) return { available: false }
const data = (await res.json()) as Partial<RoutingDefaults>
if (data?.available !== true) return { available: false }
return {
available: true,
auto_routing_active: data.auto_routing_active === true,
default_session_routing: data.default_session_routing === true,
}
} catch {
return { available: false }
}
}
-45
View File
@@ -1,45 +0,0 @@
// Bearer-token auth for the chat backend.
//
// Precedence: a build-time VITE_CHAT_TOKEN (handy for local dev / CI) wins;
// otherwise we use whatever the user pasted into the token screen, persisted in
// localStorage. The pasted token survives reloads and (in the Tauri webview)
// app restarts.
//
// TODO(iam): replace the paste flow with a real Hanzo IAM (hanzo.id) OIDC login
// — open the IAM authorize URL in the system browser / an in-app auth webview,
// capture the callback, and store the returned access token here. The rest of
// the app already reads the token through getToken(), so only this module
// changes.
const STORAGE_KEY = 'hanzo.chat.token'
const envToken = (import.meta.env.VITE_CHAT_TOKEN as string | undefined)?.trim()
export function getToken(): string | undefined {
if (envToken) return envToken
try {
return localStorage.getItem(STORAGE_KEY) ?? undefined
} catch {
return undefined
}
}
export function setToken(token: string): void {
try {
localStorage.setItem(STORAGE_KEY, token.trim())
} catch {
// no-op: private-mode webview without storage; token lives for this session
}
}
export function clearToken(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// no-op
}
}
export function hasToken(): boolean {
return Boolean(getToken())
}
-82
View File
@@ -1,82 +0,0 @@
// Smart-routing resolution — the mobile mirror of the web client's
// client/src/utils/endpoints.ts (resolveSmartRouting). Mirrored, NOT imported
// across the app boundary: the mobile app is self-contained (not in the chat
// pnpm workspace), so the pure resolver is duplicated verbatim to keep the three
// Hanzo surfaces (web, mobile, …) behaviorally identical. The web copy carries
// the jest coverage; keep the semantics in lock-step.
/**
* Org-scoped auto-routing defaults from cloud (`GET /v1/get-routing-defaults`),
* proxied by the chat backend at `/v1/chat/routing-defaults`. `available` is
* false when the endpoint is absent (older cloud-api) or the fetch failed soft
* the client then behaves exactly as today (local preference only).
*/
export type RoutingDefaults = {
available: boolean
auto_routing_active?: boolean
default_session_routing?: boolean
}
/** Effective smart-routing state for a new session and its toggle. */
export interface SmartRoutingState {
/** Whether a fresh session should default to the `auto` (gateway-routed) model. */
enabled: boolean
/** Whether the user-facing toggle must be locked (org disabled auto-routing). */
toggleDisabled: boolean
}
/**
* Resolve smart routing from the user's local override and the org's
* server-driven defaults. The single source of truth for the effective state.
*
* - `localPref`: the user override. `null` === never touched (follow org default);
* `true`/`false` === an explicit user choice that wins.
* - `orgDefault`: `default_session_routing` from the org (null when the server
* gave no signal older cloud-api or a fail-soft fetch in which case we fall
* back to today's off-by-default behavior).
* - `autoRoutingActive`: `auto_routing_active` for the org. When false the org has
* disabled auto-routing entirely; the toggle is off and locked. When the fetch
* failed soft, callers pass `true` so nothing changes vs. today.
*/
export function resolveSmartRouting(
localPref: boolean | null,
orgDefault: boolean | null,
autoRoutingActive: boolean,
): SmartRoutingState {
if (!autoRoutingActive) {
return { enabled: false, toggleDisabled: true }
}
const enabled = localPref !== null ? localPref : orgDefault === true
return { enabled, toggleDisabled: false }
}
/**
* The org's default_session_routing, or null when there is no server signal
* (absent endpoint / fail-soft fetch). Mirrors the web derivation.
*/
export function orgDefaultRouting(defaults: RoutingDefaults | null): boolean | null {
return defaults?.available ? defaults.default_session_routing ?? null : null
}
/**
* Whether the org has auto-routing active. Fail-soft default is `true` so an
* absent/errored fetch keeps today's behavior (toggle usable, off by default).
*/
export function orgAutoRoutingActive(defaults: RoutingDefaults | null): boolean {
return defaults?.available ? defaults.auto_routing_active !== false : true
}
// Module-level cache of the org defaults fetched once at app boot, so the
// non-React api layer (lib/api.ts `chat`) can resolve effective routing for a
// new session without threading React state through. `null` until boot fills it.
let cached: RoutingDefaults | null = null
/** Record the boot-fetched org defaults for later reads by the api layer. */
export function cacheRoutingDefaults(defaults: RoutingDefaults): void {
cached = defaults
}
/** The last-cached org defaults, or null before boot / on a failed fetch. */
export function cachedRoutingDefaults(): RoutingDefaults | null {
return cached
}
-31
View File
@@ -1,31 +0,0 @@
// Local user settings, persisted to localStorage (survives reloads and, in the
// Tauri webview, app restarts). Mirrors the auth token store — a tiny typed
// helper so screens don't touch localStorage keys directly.
const SMART_ROUTING_KEY = 'hanzo.chat.smartRouting'
/**
* Smart-routing user OVERRIDE. `null` (never touched) === follow the org's
* server-driven default; `true`/`false` === an explicit user choice that wins.
* The effective state is resolved via resolveSmartRouting (lib/routing); when
* on, chat requests send model "auto" instead of the configured VITE_CHAT_MODEL,
* letting the gateway route each prompt to the best/cheapest capable model.
*/
export function getSmartRoutingPref(): boolean | null {
try {
const raw = localStorage.getItem(SMART_ROUTING_KEY)
if (raw === 'true') return true
if (raw === 'false') return false
return null
} catch {
return null
}
}
export function setSmartRoutingPref(enabled: boolean): void {
try {
localStorage.setItem(SMART_ROUTING_KEY, enabled ? 'true' : 'false')
} catch {
// no-op: private-mode webview without storage
}
}
-47
View File
@@ -1,47 +0,0 @@
// Usage store wiring for the mobile app.
//
// @hanzo/usage is headless: a UsageStore driven by a host that can read files
// (provider config in ~/.codex, ~/.claude, ~/.hanzo) and make HTTP calls. In
// Tauri we build that host from the fs + http plugins (createTauriHost). In a
// plain browser there is no filesystem, so there is no host — the Usage screen
// shows a connect empty-state instead.
import { UsageStore, allProviders } from '@hanzo/usage'
import { createTauriHost } from '@hanzo/usage/tauri'
/** True when running inside the Tauri webview (vs. a plain browser tab). */
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
/**
* Build a UsageStore backed by the Tauri fs/http plugins, or return null when
* not running under Tauri. Plugin modules are imported dynamically so a browser
* build never pulls the Tauri APIs into the initial bundle.
*/
export async function createUsageStore(): Promise<UsageStore | null> {
if (!isTauri()) return null
const [fs, http, path] = await Promise.all([
import('@tauri-apps/plugin-fs'),
import('@tauri-apps/plugin-http'),
import('@tauri-apps/api/path'),
])
const host = await createTauriHost({
fs: {
readTextFile: (p) => fs.readTextFile(p),
readDir: (p) => fs.readDir(p).then((es) => es.map((e) => ({ name: e.name }))),
writeTextFile: (p, contents) => fs.writeTextFile(p, contents),
mkdir: (p, opts) => fs.mkdir(p, opts),
},
fetch: http.fetch,
homeDir: path.homeDir,
})
return new UsageStore({
host,
providers: allProviders,
sourceMode: 'auto',
})
}
-19
View File
@@ -1,19 +0,0 @@
import '@hanzogui/core/reset.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { GuiProvider } from '@hanzo/gui'
import config from '../gui.config'
import { App } from './App'
const root = document.getElementById('root')
if (!root) throw new Error('root element missing')
// Dark by default — a chat surface reads best on a dark ground, and it matches
// the index.html background so there is no first-paint flash.
createRoot(root).render(
<StrictMode>
<GuiProvider config={config} defaultTheme="dark">
<App />
</GuiProvider>
</StrictMode>,
)
-103
View File
@@ -1,103 +0,0 @@
import { useRef, useState } from 'react'
import { Button, Input, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { chat, ApiError, type ChatMessage } from '../lib/api'
// Chat screen: message list + composer. Sends the full history to the backend's
// OpenAI-compatible completions endpoint and appends the reply.
export function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// RN-style ScrollView exposes scrollToEnd; the gui ref type is broad, so we
// narrow to the one method we call and attach it with a cast.
const scrollRef = useRef<{ scrollToEnd?: (o?: { animated?: boolean }) => void }>(null)
const send = async () => {
const content = draft.trim()
if (!content || busy) return
const next: ChatMessage[] = [...messages, { role: 'user', content }]
setMessages(next)
setDraft('')
setError(null)
setBusy(true)
try {
const reply = await chat(next)
setMessages([...next, { role: 'assistant', content: reply }])
} catch (e) {
const msg = e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)
setError(msg)
setMessages(next)
} finally {
setBusy(false)
requestAnimationFrame(() => scrollRef.current?.scrollToEnd?.({ animated: true }))
}
}
return (
<YStack flex={1} backgroundColor="$background">
<ScrollView ref={scrollRef as never} flex={1} contentContainerStyle={{ padding: 12, gap: 10 }}>
{messages.length === 0 && (
<YStack flex={1} alignItems="center" justifyContent="center" paddingVertical="$10">
<Paragraph color="$color10">Ask anything to get started.</Paragraph>
</YStack>
)}
{messages.map((m, i) => (
<Bubble key={i} message={m} />
))}
{busy && (
<XStack gap="$2" alignItems="center" paddingVertical="$2">
<Spinner size="small" color="$color10" />
<Text color="$color10">Thinking</Text>
</XStack>
)}
{error && (
<YStack backgroundColor="$red3" borderRadius="$4" padding="$3">
<Text color="$red11">{error}</Text>
</YStack>
)}
</ScrollView>
<XStack
gap="$2"
padding="$3"
borderTopWidth={1}
borderColor="$borderColor"
alignItems="flex-end"
>
<Input
flex={1}
value={draft}
onChangeText={setDraft}
placeholder="Message"
multiline
maxHeight={120}
size="$4"
onSubmitEditing={send}
/>
<Button theme="active" size="$4" disabled={busy || !draft.trim()} onPress={send}>
Send
</Button>
</XStack>
</YStack>
)
}
function Bubble({ message }: { message: ChatMessage }) {
const mine = message.role === 'user'
return (
<XStack justifyContent={mine ? 'flex-end' : 'flex-start'}>
<YStack
maxWidth="85%"
backgroundColor={mine ? '$color5' : '$color2'}
borderRadius="$5"
paddingHorizontal="$3"
paddingVertical="$2"
>
<Paragraph color="$color" whiteSpace="pre-wrap">
{message.content}
</Paragraph>
</YStack>
</XStack>
)
}
-80
View File
@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react'
import { Button, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { listConversations, ApiError, type Conversation } from '../lib/api'
// Sessions screen: the user's recent conversations from GET /api/convos.
export function Sessions() {
const [items, setItems] = useState<Conversation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = () => {
const controller = new AbortController()
setLoading(true)
setError(null)
listConversations({ signal: controller.signal })
.then((page) => setItems(page.conversations))
.catch((e) => {
if (controller.signal.aborted) return
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e))
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false)
})
return () => controller.abort()
}
useEffect(load, [])
if (loading) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" backgroundColor="$background">
<Spinner size="large" color="$color10" />
</YStack>
)
}
return (
<ScrollView flex={1} backgroundColor="$background" contentContainerStyle={{ padding: 12, gap: 8 }}>
{error && (
<YStack gap="$3" backgroundColor="$red3" borderRadius="$4" padding="$3">
<Text color="$red11">{error}</Text>
<Button size="$3" onPress={load}>
Retry
</Button>
</YStack>
)}
{!error && items.length === 0 && (
<YStack flex={1} alignItems="center" justifyContent="center" paddingVertical="$10">
<Paragraph color="$color10">No conversations yet.</Paragraph>
</YStack>
)}
{items.map((c) => (
<YStack
key={c.conversationId}
backgroundColor="$color2"
borderRadius="$4"
padding="$3"
gap="$1"
pressStyle={{ opacity: 0.7 }}
>
<Text color="$color" fontWeight="600" numberOfLines={1}>
{c.title?.trim() || 'Untitled'}
</Text>
<XStack gap="$2">
{c.model && (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{c.model}
</Text>
)}
{c.updatedAt && (
<Text fontSize="$1" color="$color10">
{new Date(c.updatedAt).toLocaleDateString()}
</Text>
)}
</XStack>
</YStack>
))}
</ScrollView>
)
}
-205
View File
@@ -1,205 +0,0 @@
import { useEffect, useState } from 'react'
import { Button, Paragraph, ScrollView, Spinner, Switch, Text, XStack, YStack } from '@hanzo/gui'
import { useUsage } from '@hanzo/usage/react'
import type { UsageStore, ProviderState, RateWindow } from '@hanzo/usage'
import { createUsageStore, isTauri } from '../lib/usage'
import { getSmartRoutingPref, setSmartRoutingPref } from '../lib/settings'
import {
resolveSmartRouting,
orgDefaultRouting,
orgAutoRoutingActive,
type RoutingDefaults,
} from '../lib/routing'
const PROVIDERS: Array<{ id: string; label: string }> = [
{ id: 'hanzo', label: 'Hanzo' },
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude' },
]
// Usage screen: provider cards from @hanzo/usage. Needs a filesystem host
// (Tauri) to read ~/.codex, ~/.claude, ~/.hanzo — outside Tauri we show a
// connect empty-state.
export function Usage({ routingDefaults }: { routingDefaults: RoutingDefaults | null }) {
const [store, setStore] = useState<UsageStore | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let live = true
let created: UsageStore | null = null
createUsageStore()
.then((s) => {
if (!live) {
s?.stop()
return
}
created = s
setStore(s)
s?.start()
})
.finally(() => live && setReady(true))
return () => {
live = false
created?.stop()
}
}, [])
// Smart routing is a chat setting, not usage data — keep it visible in every
// state (loading, connected, empty).
return (
<YStack flex={1} backgroundColor="$background">
<YStack padding={12} paddingBottom={0}>
<SmartRoutingToggle routingDefaults={routingDefaults} />
</YStack>
{!ready ? (
<YStack flex={1} alignItems="center" justifyContent="center">
<Spinner size="large" color="$color10" />
</YStack>
) : !store ? (
<ConnectEmptyState />
) : (
<UsageCards store={store} />
)}
</YStack>
)
}
// Smart-routing toggle: flips new chats between the configured model and the
// gateway's "auto" smart-routing model. The effective state is resolved from the
// local override (null === follow org default) against the org's server-driven
// defaults (fail-soft: absent === today's local-only behavior). When the org has
// disabled auto-routing the toggle is locked off. See lib/routing + lib/settings.
function SmartRoutingToggle({ routingDefaults }: { routingDefaults: RoutingDefaults | null }) {
const [pref, setPref] = useState<boolean | null>(getSmartRoutingPref)
const { enabled, toggleDisabled } = resolveSmartRouting(
pref,
orgDefaultRouting(routingDefaults),
orgAutoRoutingActive(routingDefaults),
)
const onChange = (value: boolean) => {
setPref(value)
setSmartRoutingPref(value)
}
return (
<YStack backgroundColor="$color2" borderRadius="$5" padding="$4" gap="$2">
<XStack justifyContent="space-between" alignItems="center" gap="$3">
<Text fontSize="$5" fontWeight="600" color="$color">
Smart routing
</Text>
<Switch size="$3" checked={enabled} disabled={toggleDisabled} onCheckedChange={onChange}>
<Switch.Thumb />
</Switch>
</XStack>
<Paragraph fontSize="$2" color="$color10">
{toggleDisabled
? 'Disabled for your organization'
: "Automatically route each message to the best, cheapest capable model. You're billed for whichever model serves it. Learn more at docs.hanzo.ai/docs/usage/routing"}
</Paragraph>
</YStack>
)
}
function ConnectEmptyState() {
return (
<YStack
flex={1}
alignItems="center"
justifyContent="center"
gap="$3"
padding="$6"
backgroundColor="$background"
>
<Text fontSize="$6" fontWeight="700" color="$color">
Usage is a desktop feature
</Text>
<Paragraph color="$color10" textAlign="center">
{isTauri()
? 'Usage data is unavailable in this build.'
: 'Open the Hanzo AI app to read your local Codex, Claude, and Hanzo provider usage.'}
</Paragraph>
</YStack>
)
}
function UsageCards({ store }: { store: UsageStore }) {
const state = useUsage(store)
return (
<ScrollView flex={1} backgroundColor="$background" contentContainerStyle={{ padding: 12, gap: 10 }}>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$6" fontWeight="700" color="$color">
Usage
</Text>
<Button size="$2" disabled={state.refreshing} onPress={() => void store.refresh()}>
{state.refreshing ? 'Refreshing…' : 'Refresh'}
</Button>
</XStack>
{PROVIDERS.map(({ id, label }) => (
<ProviderCard key={id} label={label} state={state.providers[id]} />
))}
</ScrollView>
)
}
function ProviderCard({ label, state }: { label: string; state?: ProviderState }) {
const snap = state?.snapshot
return (
<YStack backgroundColor="$color2" borderRadius="$5" padding="$4" gap="$3">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$5" fontWeight="600" color="$color">
{label}
</Text>
{state?.refreshing && <Spinner size="small" color="$color10" />}
</XStack>
{state?.error && <Text color="$red11">{state.error}</Text>}
{!state?.error && !snap && (
<Paragraph color="$color10">
{state?.refreshing ? 'Loading…' : 'Not connected. Sign in to this provider on this device.'}
</Paragraph>
)}
{snap && (
<YStack gap="$3">
<Meter title="Session" window={snap.primary} />
<Meter title="Weekly" window={snap.secondary} />
{snap.identity?.plan && (
<Text fontSize="$1" color="$color10">
Plan: {snap.identity.plan}
</Text>
)}
{state?.sourceLabel && (
<Text fontSize="$1" color="$color10">
Source: {state.sourceLabel}
</Text>
)}
</YStack>
)}
</YStack>
)
}
function Meter({ title, window: w }: { title: string; window?: RateWindow }) {
if (!w) return null
const pct = Math.round(Math.max(0, Math.min(100, w.usedPercent)))
return (
<YStack gap="$1.5">
<XStack justifyContent="space-between">
<Text fontSize="$2" color="$color11">
{title}
</Text>
<Text fontSize="$2" color="$color11">
{pct}% used
</Text>
</XStack>
<YStack height={8} borderRadius="$10" backgroundColor="$color4" overflow="hidden">
<YStack height={8} width={`${pct}%`} backgroundColor="$color10" />
</YStack>
{w.resetDescription && (
<Text fontSize="$1" color="$color10">
Resets {w.resetDescription}
</Text>
)}
</YStack>
)
}
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src", "gui.config.ts", "vite.config.ts"]
}
-35
View File
@@ -1,35 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// @hanzo/gui is a Tamagui fork: its web build reaches for `react-native`
// primitives, so we alias them to `react-native-web` (the same wiring the
// hanzoai/status Vite app uses). No Tamagui compiler plugin — runtime config
// via GuiProvider is enough for a mobile webview and keeps the build simple.
//
// Tauri: the dev server must stay on a fixed host/port so the Rust shell can
// point its WebView at it (see src-tauri/tauri.conf.json `devUrl`).
export default defineConfig({
plugins: [react()],
clearScreen: false,
resolve: {
alias: {
'react-native': 'react-native-web',
},
},
define: {
// Tamagui reads this to pick the web output at bundle time.
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
},
build: {
outDir: 'dist',
emptyOutDir: true,
// Match tsconfig (ES2022); esbuild refuses to downlevel some dependency
// syntax to vite's legacy baseline. Mobile webviews are modern.
target: 'es2022',
},
server: {
host: '0.0.0.0',
port: 1420,
strictPort: true,
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzochat/chat",
"version": "0.9.17",
"version": "0.9.16",
"description": "Chat - Unified interface to Agents, LLMs, MCPs and any AI model or OpenAPI documented API with full RAG stack",
"packageManager": "pnpm@10.27.0",
"workspaces": [
+446 -1722
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -21
View File
@@ -8,7 +8,6 @@ interface MCPConnectionParams {
userId?: string;
oauthTokens?: MCPOAuthTokens | null;
useSSRFProtection?: boolean;
allowedAddresses?: string[] | null;
}
export declare class MCPConnection extends EventEmitter {
client: Client;
@@ -22,34 +21,21 @@ export declare class MCPConnection extends EventEmitter {
private isReconnecting;
private isInitializing;
private reconnectAttempts;
private agents;
private readonly userId?;
private lastPingTime;
private lastConnectionCheckAt;
private oauthTokens?;
private requestHeaders?;
private oauthRequired;
private oauthRecovery;
private readonly useSSRFProtection;
private readonly allowedAddresses?;
private readonly proxyConfig?;
iconPath?: string;
timeout?: number;
sseReadTimeout?: number;
url?: string;
/**
* Timestamp when this connection was created.
* Used to detect if connection is stale compared to updated config.
*/
readonly createdAt: number;
private static circuitBreakers;
static clearCooldown(serverName: string): void;
private getCircuitBreaker;
private isCircuitOpen;
private recordCycle;
private recordFailedRound;
private resetFailedRounds;
static decrementCycleCount(serverName: string): void;
setRequestHeaders(headers: Record<string, string> | null): void;
getRequestHeaders(): Record<string, string> | null | undefined;
constructor(params: MCPConnectionParams);
@@ -59,9 +45,9 @@ export declare class MCPConnection extends EventEmitter {
* Factory function to create fetch functions without capturing the entire `this` context.
* This helps prevent memory leaks by only passing necessary dependencies.
*
* When `sseBodyTimeout` is provided, a second Agent is created with a much longer
* body timeout for GET requests (the Streamable HTTP SSE stream). POST requests
* continue using the normal timeout so they fail fast on real errors.
* @param getHeaders Function to retrieve request headers
* @param timeout Timeout value for the agent (in milliseconds)
* @returns A fetch function that merges headers appropriately
*/
private createFetchFunction;
private emitError;
@@ -70,12 +56,10 @@ export declare class MCPConnection extends EventEmitter {
private handleReconnection;
private subscribeToResources;
connectClient(): Promise<void>;
private patchTransportSend;
private setupTransportOnMessageHandler;
private setupTransportDebugHandlers;
connect(): Promise<void>;
private setupTransportErrorHandlers;
private closeAgents;
disconnect(resetCycleTracking?: boolean): Promise<void>;
disconnect(): Promise<void>;
fetchResources(): Promise<t.MCPResource[]>;
fetchTools(): Promise<{
name: string;
@@ -0,0 +1,350 @@
import { logger } from '@librechat/data-schemas';
/**
* Per-user Hanzo Cloud (hk-) key resolution the core of hanzo.chat per-user
* billing.
*
* Each authenticated chat request must be billed to the LOGGED-IN USER'S OWN
* Hanzo Cloud account, not a single shared key. Every IAM user has exactly one
* `hk-…` Cloud API key on their IAM record (`User.AccessKey`); the cloud gateway
* (api.hanzo.ai) debits that key's org commerce balance and returns 402 when the
* org runs out. So forwarding the right per-user key === correct per-user billing
* automatically.
*
* This module resolves (and, on first use, mints) that key from IAM by the
* authenticated user's `organization` (IAM owner) + email, then caches it. It is
* the SINGLE source of truth for "which hk- key bills this request"; the shared
* `${HANZO_API_KEY}` is only the (capped, non-exempt) fallback for the
* unauthenticated guest path.
*
* Identity: IAM resolves a user by `owner=<org>&email=<email>` (a Casdoor user's
* `name` is not necessarily their email, and an email-only / UUID lookup does not
* resolve), mirroring the console's `iamGetUserByOrgEmail`. Confidential-client
* Basic auth (the chat OIDC client id + secret) authorizes the lookup/mint.
*
* FAIL CLOSED: callers MUST treat a `null` return for an authenticated user as
* "cannot bill this user" and block never fall back to the shared key, or an
* IAM hiccup would silently route an authed user's spend onto the shared org.
*/
type IamUserRecord = {
owner?: string;
name?: string;
accessKey?: string;
};
/** Minimal shape of the authenticated request user we need to bill. */
export type HanzoBillingUser = {
id?: string;
email?: string | null;
organization?: string | null;
provider?: string;
/** Guest (anonymous preview) users carry this and must NOT get a per-user key. */
guest?: boolean;
/**
* Canonical Commerce billing subject (object.BillingSubject), stamped by
* resolveHanzoCloudKey from the authoritative IAM record. The balance gate
* reads this so it keys on the SAME account the gateway debits.
*/
billingSubject?: string | null;
};
const KEY_TTL_MS = 10 * 60 * 1000; // 10 minutes
const IAM_TIMEOUT_MS = 5000;
/** Per-user hk- key cache: cacheKey (user id|email) -> { key, expiresAt }. */
const keyCache = new Map<string, { key: string; expiresAt: number }>();
function truthy(value?: string): boolean {
return (value ?? '').toString().trim().toLowerCase() === 'true';
}
/** IAM base URL — prefer an in-cluster override, else the OIDC issuer. */
function iamBaseUrl(): string {
const base =
process.env.IAM_INTERNAL_URL ||
process.env.IAM_SERVER_URL ||
process.env.OPENID_ISSUER ||
'';
return base.replace(/\/+$/, '');
}
function iamClientId(): string {
return process.env.IAM_CLIENT_ID || process.env.OPENID_CLIENT_ID || '';
}
function iamClientSecret(): string {
return process.env.IAM_CLIENT_SECRET || process.env.OPENID_CLIENT_SECRET || '';
}
/**
* Whether per-user hk- billing is enabled AND fully configured. When false,
* `resolveHanzoCloudKey` returns null and the shared key is used (legacy).
*/
export function isHanzoPerUserKeyEnabled(): boolean {
return (
truthy(process.env.HANZO_PER_USER_KEY) &&
Boolean(iamBaseUrl()) &&
Boolean(iamClientId()) &&
Boolean(iamClientSecret())
);
}
function basicAuthHeader(): string {
const raw = `${iamClientId()}:${iamClientSecret()}`;
return `Basic ${Buffer.from(raw).toString('base64')}`;
}
// ── Per-user billing identity + starter credit ───────────────────────────────
/**
* Orgs whose MEMBERS are billed per-user (default: the shared "hanzo" catch-all,
* the home of every unaffiliated individual signup). MUST mirror the gateway's
* PERSONAL_BILLING_ORGS / object.BillingSubject (hanzoai/ai) so chat and the
* gateway derive the identical subject.
*/
function personalBillingOrgs(): Set<string> {
const raw = (
process.env.HANZO_PERSONAL_BILLING_ORGS ||
process.env.HANZO_DEFAULT_ORG ||
'hanzo'
)
.split(',')
.map((o) => o.trim().toLowerCase())
.filter(Boolean);
return new Set(raw.length ? raw : ['hanzo']);
}
/**
* Canonical Commerce billing subject for an IAM (owner, name) identity the
* account the cloud gateway debits and reads. Personal-billing org "owner/name"
* (per-user), pooled org "owner". Always lowercased so it nets against the
* gateway's usage writes. Byte-identical to object.BillingSubject in hanzoai/ai.
*/
export function billingSubject(owner?: string | null, name?: string | null): string {
const o = (owner ?? '').toString().trim().toLowerCase();
if (!o) {
return '';
}
if (personalBillingOrgs().has(o)) {
const n = (name ?? '').toString().trim().toLowerCase();
return n ? `${o}/${n}` : o;
}
return o;
}
function commerceBaseUrl(): string {
return (process.env.COMMERCE_API_URL || process.env.COMMERCE_ENDPOINT || '').replace(/\/+$/, '');
}
function commerceToken(): string {
return process.env.COMMERCE_TOKEN || process.env.COMMERCE_API_TOKEN || '';
}
/** Subjects whose starter credit this process has already ensured (commerce is also idempotent). */
const starterEnsured = new Set<string>();
/**
* Ensure the new-user $5 welcome credit exists on THIS user's billing subject,
* exactly once. Idempotent two ways: an in-process set (avoids re-hitting
* commerce every key refresh) and commerce's own tag-deduped, transaction-guarded
* grant (`POST /v1/billing/grant-starter`) so concurrent chats can never
* double-grant (no bleed).
*
* Best-effort but awaited: the credit must land on the subject BEFORE we forward
* the user's hk- key to the gateway, otherwise the gateway sees a $0 balance and
* 402s the very first chat. A commerce hiccup must NOT break key resolution,
* though the gateway still enforces balance, and the next message retries.
*/
async function ensureStarterCredit(subject: string, owner: string): Promise<void> {
const base = commerceBaseUrl();
if (!base || !subject || starterEnsured.has(subject)) {
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Hanzo-Org': owner,
};
const tok = commerceToken();
if (tok) {
headers['Authorization'] = `Bearer ${tok}`;
}
const resp = await fetch(`${base}/v1/billing/grant-starter`, {
method: 'POST',
headers,
body: JSON.stringify({ user: subject, trigger: 'chat_first_use' }),
signal: controller.signal,
});
if (resp.ok) {
starterEnsured.add(subject); // ensured this process; commerce dedupes across pods
} else {
logger.warn('[hanzoCloudKey] starter-credit grant non-OK (continuing)', {
subject,
status: resp.status,
});
}
} catch (err) {
logger.warn('[hanzoCloudKey] starter-credit grant failed (continuing; gateway enforces)', {
subject,
error: err instanceof Error ? err.message : String(err),
});
} finally {
clearTimeout(timeoutId);
}
}
/**
* Single IAM HTTP primitive (confidential-client Basic auth, `/v1/iam/*` JSON
* API). Throws on transport error / non-ok status so callers fail closed.
*/
async function iamRequest(
path: string,
params: Record<string, string>,
method: 'GET' | 'POST' = 'GET',
): Promise<{ status?: string; data?: IamUserRecord & { accessKey?: string } }> {
const url = new URL(`${iamBaseUrl()}${path}`);
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') {
url.searchParams.set(k, v);
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
try {
const resp = await fetch(url.toString(), {
method,
headers: {
Authorization: basicAuthHeader(),
'Content-Type': 'application/json',
},
...(method === 'POST' ? { body: '{}' } : {}),
signal: controller.signal,
});
if (!resp.ok) {
throw new Error(`IAM ${method} ${path} returned ${resp.status}`);
}
return (await resp.json()) as { status?: string; data?: IamUserRecord };
} finally {
clearTimeout(timeoutId);
}
}
/** Resolve the authoritative IAM record (owner/name/accessKey) by org + email. */
async function getIamUserByOrgEmail(
owner: string,
email: string,
): Promise<IamUserRecord | null> {
const res = await iamRequest('/v1/iam/get-user', {
owner,
email: email.toLowerCase(),
});
if (res.status !== 'ok' || !res.data?.owner || !res.data?.name) {
return null;
}
return res.data;
}
/** Mint (create) the per-user hk- key for an IAM sub ("owner/name"). */
async function mintUserKey(sub: string): Promise<string | null> {
const res = await iamRequest('/v1/iam/mint-user-keys', { id: sub }, 'POST');
if (res.status !== 'ok' || !res.data?.accessKey) {
return null;
}
return res.data.accessKey;
}
/**
* Resolve the authenticated user's own hk- Cloud API key, minting one on first
* use if the IAM record has none. Returns null when per-user billing is
* disabled/unconfigured, the user is a guest / has no email, or IAM cannot be
* reached (caller FAILS CLOSED on null for an authenticated user).
*/
export async function resolveHanzoCloudKey(
user?: HanzoBillingUser | null,
): Promise<string | null> {
if (!isHanzoPerUserKeyEnabled()) {
return null;
}
if (!user || user.guest) {
return null;
}
const email = (user.email ?? '').toString().toLowerCase();
if (!email) {
return null;
}
const cacheKey = user.id || email;
const cached = keyCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.key;
}
const defaultOrg = process.env.HANZO_DEFAULT_ORG || 'hanzo';
const owner = (user.organization ?? '').toString().trim() || defaultOrg;
try {
let record = await getIamUserByOrgEmail(owner, email);
// A user whose stored org is stale/missing may live in the default org.
if (!record && owner !== defaultOrg) {
record = await getIamUserByOrgEmail(defaultOrg, email);
}
if (!record?.owner || !record?.name) {
logger.warn('[hanzoCloudKey] No IAM user for billing identity', {
owner,
email,
});
return null;
}
// Single authoritative identity: stamp the REAL billing org (record.owner)
// back onto req.user. The OIDC-stored `organization` can be the Casdoor
// super-org "admin" for some users, which is NOT where their hk- key bills —
// so the downstream Commerce balance gate must use this resolved owner, not
// the login-time value. This keeps the key and the gate on ONE org.
const subject = billingSubject(record.owner, record.name);
try {
user.organization = record.owner;
// Stamp the canonical billing subject so the balance gate keys on the SAME
// account the gateway debits (per-user for the shared "hanzo" catch-all).
user.billingSubject = subject;
} catch {
/* req.user may be a frozen/lean doc — non-fatal; gate still has gateway as backstop */
}
// Ensure THIS user's one-time $5 welcome credit exists on THEIR subject
// before we hand back the key — so the gateway's first balance check sees it
// instead of 402-ing a brand-new account. Idempotent + best-effort.
await ensureStarterCredit(subject, record.owner);
let key = (record.accessKey ?? '').trim();
if (!key) {
// Mint on first chat — the key is theirs going forward.
key = (await mintUserKey(`${record.owner}/${record.name}`)) ?? '';
}
if (!key) {
logger.error('[hanzoCloudKey] Failed to resolve/mint hk- key', {
sub: `${record.owner}/${record.name}`,
});
return null;
}
keyCache.set(cacheKey, { key, expiresAt: Date.now() + KEY_TTL_MS });
return key;
} catch (err) {
logger.error('[hanzoCloudKey] IAM lookup failed (failing closed)', {
owner,
email,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
/** Test-only: clear the in-process key cache. */
export function _clearHanzoKeyCache(): void {
keyCache.clear();
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './config';
export * from './tenantBearer';
export * from './hanzoCloudKey';
export * from './hanzoGatewayFetch';
export * from './initialize';
+23 -14
View File
@@ -13,7 +13,11 @@ import { getCustomEndpointConfig } from '~/app/config';
import { fetchModels } from '~/endpoints/models';
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
import { standardCache } from '~/cache';
import { resolveTenantBearer, OPENID_BEARER_SENTINEL } from './tenantBearer';
import {
isHanzoPerUserKeyEnabled,
resolveHanzoCloudKey,
type HanzoBillingUser,
} from './hanzoCloudKey';
import { wrapHanzoGatewayFetch, type GatewayFetch } from './hanzoGatewayFetch';
const { PROXY } = process.env;
@@ -101,20 +105,25 @@ export async function initializeCustom({
let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
const baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
// Canonical Hanzo Cloud auth+billing: an endpoint that declares
// `apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"` bills the signed-in user's OWN org by
// forwarding THEIR IAM bearer to cloud (api.hanzo.ai) as the request
// credential. cloud validates the JWT, pins the tenant org from the verified
// `owner` claim, and meters the org's shared plan then PAYG — no shared key, no
// per-user minted key (see AUTH_BILLING_CONTRACT.md in hanzoai/cloud). If no
// forwardable bearer exists (signed out / expired) we FAIL CLOSED: the user
// must sign in with Hanzo. There is no fallback credential to spend on.
if (apiKey === OPENID_BEARER_SENTINEL) {
const bearer = resolveTenantBearer(req as unknown as Parameters<typeof resolveTenantBearer>[0]);
if (!bearer) {
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
// Hanzo per-user billing: an authenticated (non-guest) user's chat must be
// billed to THEIR OWN org via THEIR OWN hk- key — never the shared key. We
// resolve (mint on first chat) their key from IAM and use it here. If it
// cannot be resolved we FAIL CLOSED (throw) rather than silently fall back to
// the shared key, so an IAM hiccup can never route an authed user's spend onto
// the shared org. Guests (anonymous preview) keep the shared, capped key.
const billingUser = req.user as unknown as HanzoBillingUser | undefined;
const isAuthenticatedUser = Boolean(
billingUser && !billingUser.guest && billingUser.email,
);
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
const perUserKey = await resolveHanzoCloudKey(billingUser);
if (perUserKey) {
apiKey = perUserKey;
} else {
throw new Error(
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
);
}
apiKey = bearer;
}
if (userProvidesKey && !apiKey) {

Some files were not shown because too many files have changed in this diff Show More