Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
311a237e12 | ||
|
|
d7900178be | ||
|
|
31af61cb8b | ||
|
|
212ba7d822 | ||
|
|
16745e26fa | ||
|
|
e661f4c0b6 | ||
|
|
9e5e158145 | ||
|
|
c1df3816e3 | ||
|
|
77f768f7fa | ||
|
|
885f6ec33c | ||
|
|
83554c557d | ||
|
|
98d0dad34b | ||
|
|
44956e86b9 | ||
|
|
167108dca5 | ||
|
|
c57194b535 | ||
|
|
6cab482687 | ||
|
|
8b4a7a6b4b | ||
|
|
ca72857533 | ||
|
|
3595b2af22 | ||
|
|
0359f4c8fb | ||
|
|
04d041c2f4 | ||
|
|
3fe63b68d6 | ||
|
|
b6e5d1082b | ||
|
|
1990fa4072 |
@@ -12,8 +12,8 @@ const {
|
||||
loadWebSearchAuth,
|
||||
buildImageToolContext,
|
||||
buildWebSearchContext,
|
||||
resolveHanzoCloudKey,
|
||||
isHanzoPerUserKeyEnabled,
|
||||
resolveTenantBearer,
|
||||
OPENID_BEARER_SENTINEL,
|
||||
} = require('@hanzochat/api');
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
const {
|
||||
@@ -243,18 +243,17 @@ const loadTools = async ({
|
||||
customConstructors.dalle = async () => {
|
||||
const authFields = getAuthFields('dalle');
|
||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||
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',
|
||||
);
|
||||
// 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.');
|
||||
}
|
||||
authValues.DALLE3_API_KEY = perUserKey;
|
||||
authValues.DALLE3_API_KEY = bearer;
|
||||
}
|
||||
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
|
||||
};
|
||||
|
||||
@@ -301,6 +301,23 @@ async function indexSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store-aware gate: this routine is mongoose-native (it drives `syncWithMeili`
|
||||
// / `getSyncProgress` / `.collection` on the mongoose Conversation+Message
|
||||
// models). When those collections are served from the document store (Mongo=0,
|
||||
// or the SQLite-primary dual-write window), the store owns MeiliSearch —
|
||||
// `attachMeili` indexes live writes and `config/backfill-meili.js` backfills
|
||||
// history — so the mongoose sync must not run (and can't: no Mongo connection).
|
||||
const { Conversation, Message } = require('~/db/models');
|
||||
if (
|
||||
typeof Conversation?.syncWithMeili !== 'function' ||
|
||||
typeof Message?.syncWithMeili !== 'function'
|
||||
) {
|
||||
logger.info(
|
||||
'[indexSync] Conversation/Message served from the document store — store owns MeiliSearch (live writes + config/backfill-meili.js). Skipping mongoose index sync.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[indexSync] Starting index synchronization check...');
|
||||
|
||||
// Get or create FlowStateManager instance
|
||||
|
||||
+4
-1
@@ -37,7 +37,10 @@ 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]) {
|
||||
role = await new Role(roleDefaults[roleName]).save();
|
||||
// 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]);
|
||||
await cache.set(roleName, role);
|
||||
return role.toObject();
|
||||
}
|
||||
|
||||
+25
-18
@@ -150,6 +150,28 @@ 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.
|
||||
@@ -163,11 +185,7 @@ async function createAutoRefillTransaction(txData) {
|
||||
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
||||
return;
|
||||
}
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
transaction.inputTokenCount = txData.inputTokenCount;
|
||||
calculateTokenValue(transaction);
|
||||
await transaction.save();
|
||||
const transaction = await persistTransaction(txData, calculateTokenValue);
|
||||
|
||||
const balanceResponse = await updateBalance({
|
||||
user: transaction.user,
|
||||
@@ -198,12 +216,7 @@ async function createTransaction(_txData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
transaction.inputTokenCount = txData.inputTokenCount;
|
||||
calculateTokenValue(transaction);
|
||||
|
||||
await transaction.save();
|
||||
const transaction = await persistTransaction(txData, calculateTokenValue);
|
||||
if (!balance?.enabled) {
|
||||
return;
|
||||
}
|
||||
@@ -240,13 +253,7 @@ async function createStructuredTransaction(_txData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
transaction.inputTokenCount = txData.inputTokenCount;
|
||||
|
||||
calculateStructuredTokenValue(transaction);
|
||||
|
||||
await transaction.save();
|
||||
const transaction = await persistTransaction(txData, calculateStructuredTokenValue);
|
||||
|
||||
if (!balance?.enabled) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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');
|
||||
@@ -95,26 +94,12 @@ 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();
|
||||
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);
|
||||
// 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();
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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,9 +72,6 @@ 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,7 +1,6 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,6 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
@@ -40,16 +40,12 @@ 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 };
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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 };
|
||||
@@ -188,6 +188,8 @@ 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);
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
@@ -10,8 +10,6 @@ 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');
|
||||
@@ -38,8 +36,6 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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 };
|
||||
@@ -1,36 +0,0 @@
|
||||
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 };
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,6 @@ 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');
|
||||
@@ -20,8 +18,6 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
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');
|
||||
|
||||
@@ -20,85 +19,18 @@ 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getUserCloudBearer(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;
|
||||
return resolveTenantBearer(req);
|
||||
}
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
@@ -8,9 +8,6 @@ const {
|
||||
messageIpLimiter,
|
||||
configMiddleware,
|
||||
messageUserLimiter,
|
||||
enforceGuestScope,
|
||||
guestMessageLimiter,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
const { saveMessage } = require('~/models');
|
||||
const openai = require('./openai');
|
||||
@@ -38,15 +35,11 @@ router.use('/v1/responses', responses);
|
||||
router.use('/v1', openai);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
|
||||
|
||||
@@ -63,12 +56,10 @@ chatRouter.use((req, res, next) => {
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(requireJwtAuth);
|
||||
chatRouter.use(checkBan);
|
||||
chatRouter.use(uaParser);
|
||||
chatRouter.use(configMiddleware);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
@@ -82,19 +73,6 @@ 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);
|
||||
|
||||
@@ -17,7 +17,6 @@ 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');
|
||||
@@ -42,7 +41,6 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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');
|
||||
|
||||
@@ -60,8 +59,6 @@ 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
|
||||
@@ -124,8 +121,6 @@ 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,
|
||||
|
||||
@@ -15,7 +15,6 @@ 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');
|
||||
@@ -27,16 +26,8 @@ const assistantClients = {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
/** Conversation list — JWT-only (no guest chat). */
|
||||
router.get('/', requireJwtAuth, async (req, res) => {
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -9,6 +9,8 @@ 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');
|
||||
@@ -49,6 +51,8 @@ module.exports = {
|
||||
actions,
|
||||
presets,
|
||||
balance,
|
||||
usage,
|
||||
routingDefaults,
|
||||
messages,
|
||||
memories,
|
||||
endpoints,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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,12 +3,12 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
// Favorites — JWT-only (no guest chat).
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const controller = require('../controllers/Usage');
|
||||
const { requireJwtAuth } = require('../middleware/');
|
||||
|
||||
router.get('/', requireJwtAuth, controller);
|
||||
|
||||
module.exports = router;
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const settings = require('./settings');
|
||||
@@ -21,7 +20,7 @@ const settings = require('./settings');
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/settings', settings);
|
||||
router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
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'] });
|
||||
});
|
||||
});
|
||||
@@ -71,9 +71,12 @@ 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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
@@ -1,5 +1,4 @@
|
||||
const removePorts = require('./removePorts');
|
||||
const guestClientIp = require('./guestClientIp');
|
||||
const handleText = require('./handleText');
|
||||
const sendEmail = require('./sendEmail');
|
||||
const queue = require('./queue');
|
||||
@@ -8,7 +7,6 @@ const files = require('./files');
|
||||
module.exports = {
|
||||
...handleText,
|
||||
removePorts,
|
||||
guestClientIp,
|
||||
sendEmail,
|
||||
...files,
|
||||
...queue,
|
||||
|
||||
@@ -13,11 +13,11 @@ const jwtLogin = () =>
|
||||
async (payload, done) => {
|
||||
try {
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
if (payload?.guest === true) {
|
||||
return done(null, false);
|
||||
|
||||
@@ -699,16 +699,30 @@ 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'];
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
if (clientSecret) {
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Configuration} */
|
||||
@@ -735,7 +749,10 @@ 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,
|
||||
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
||||
// 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,
|
||||
},
|
||||
createOpenIDCallback(),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
jest.mock('winston', () => {
|
||||
const mockFormatFunction = jest.fn((fn) => fn);
|
||||
// `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 })));
|
||||
|
||||
mockFormatFunction.colorize = jest.fn();
|
||||
mockFormatFunction.combine = jest.fn();
|
||||
|
||||
@@ -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 } from '~/utils';
|
||||
import { cn, getMessageAriaLabel, SMART_ROUTING_MODEL } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
@@ -85,6 +85,22 @@ 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) {
|
||||
@@ -137,7 +153,17 @@ const MessageRender = memo(
|
||||
)}
|
||||
>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { MessageSquare, Command, DollarSign, BarChart3 } from 'lucide-react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import {
|
||||
GearIcon,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Personalization,
|
||||
Data,
|
||||
Balance,
|
||||
Usage,
|
||||
Account,
|
||||
} from './SettingsTabs';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
@@ -43,7 +44,9 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
SettingsTabValues.SPEECH,
|
||||
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
|
||||
SettingsTabValues.DATA,
|
||||
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
|
||||
...(startupConfig?.balance?.enabled
|
||||
? [SettingsTabValues.BALANCE, SettingsTabValues.USAGE]
|
||||
: []),
|
||||
SettingsTabValues.ACCOUNT,
|
||||
];
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
@@ -114,6 +117,11 @@ 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 }[])),
|
||||
{
|
||||
@@ -248,6 +256,11 @@ 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>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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,6 +2,7 @@ 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';
|
||||
|
||||
@@ -31,6 +31,38 @@ 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> => {
|
||||
|
||||
@@ -31,9 +31,17 @@ import {
|
||||
getDefaultEndpoint,
|
||||
getModelSpecPreset,
|
||||
buildDefaultConvo,
|
||||
isHanzoEndpoint,
|
||||
SMART_ROUTING_MODEL,
|
||||
resolveSmartRouting,
|
||||
logger,
|
||||
} from '~/utils';
|
||||
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import {
|
||||
useDeleteFilesMutation,
|
||||
useGetEndpointsQuery,
|
||||
useGetStartupConfig,
|
||||
useGetRoutingDefaults,
|
||||
} from '~/data-provider';
|
||||
import useAssistantListMap from './Assistants/useAssistantListMap';
|
||||
import { useResetChatBadges } from './useChatBadges';
|
||||
import { useApplyModelSpecEffects } from './Agents';
|
||||
@@ -51,6 +59,15 @@ 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();
|
||||
@@ -109,6 +126,11 @@ 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,
|
||||
@@ -200,6 +222,15 @@ 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) {
|
||||
@@ -252,7 +283,14 @@ const useNewConvo = (index = 0) => {
|
||||
state: disableFocus ? {} : { focusChat: true },
|
||||
});
|
||||
},
|
||||
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
|
||||
[
|
||||
endpointsConfig,
|
||||
defaultPreset,
|
||||
assistantsListMap,
|
||||
modelsQuery.data,
|
||||
hasAgentAccess,
|
||||
smartRouting,
|
||||
],
|
||||
);
|
||||
|
||||
const newConversation = useCallback(
|
||||
|
||||
@@ -584,12 +584,17 @@
|
||||
"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",
|
||||
@@ -604,6 +609,14 @@
|
||||
"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",
|
||||
@@ -1338,6 +1351,7 @@
|
||||
"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",
|
||||
|
||||
@@ -17,6 +17,12 @@ 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,
|
||||
|
||||
@@ -1538,7 +1538,7 @@ html {
|
||||
.dark body,
|
||||
.dark html {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(23, 23, 23, var(--tw-bg-opacity));
|
||||
background-color: rgba(0, 0, 0, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
#__next,
|
||||
@@ -1803,7 +1803,7 @@ html {
|
||||
.dark body,
|
||||
.dark html {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(33, 33, 33, var(--tw-bg-opacity));
|
||||
background-color: rgba(0, 0, 0, var(--tw-bg-opacity));
|
||||
}
|
||||
#__next,
|
||||
#root {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig, TConfig } from 'librechat-data-provider';
|
||||
import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints';
|
||||
import {
|
||||
getAvailableEndpoints,
|
||||
getEndpointsFilter,
|
||||
mapEndpoints,
|
||||
resolveSmartRouting,
|
||||
} from './endpoints';
|
||||
|
||||
const mockEndpointsConfig: TEndpointsConfig = {
|
||||
[EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 },
|
||||
@@ -83,3 +88,27 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,57 @@ 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.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* One-shot MeiliSearch backfill from the SQLite document store.
|
||||
*
|
||||
* After the chat-docdb drop, MeiliSearch is driven by the store: `attachMeili`
|
||||
* indexes live Conversation/Message writes, and `config/backfill-sqlite.js`
|
||||
* already copied Mongo → SQLite. This script closes the remaining gap — it makes
|
||||
* EXISTING history searchable by reading convos + messages from the SQLite store
|
||||
* at CHAT_SQLITE_PATH and indexing them into Meili (the same fields, via the same
|
||||
* `preprocessForIndex`, that the live path uses).
|
||||
*
|
||||
* Idempotent: `addDocuments` is add-or-replace keyed by conversationId/messageId,
|
||||
* so re-running just refreshes. Run it AFTER the flip (CHAT_STORE_SQLITE set) so
|
||||
* search returns pre-existing conversations and messages.
|
||||
*
|
||||
* Run INSIDE the chat pod (has CHAT_SQLITE_PATH + MEILI_HOST + MEILI_MASTER_KEY
|
||||
* + the built package):
|
||||
* node config/backfill-meili.js
|
||||
* Optional first arg: a comma list to limit scope (Conversation,Message).
|
||||
*/
|
||||
const path = require('path');
|
||||
// The built package. The pnpm workspace does not hoist a bare
|
||||
// `@librechat/data-schemas` symlink to the app root (where this script runs via
|
||||
// `node config/backfill-meili.js`), so fall back to the workspace path.
|
||||
function loadDataSchemas() {
|
||||
try {
|
||||
return require('@librechat/data-schemas');
|
||||
} catch {
|
||||
return require(path.resolve(__dirname, '../packages/data-schemas'));
|
||||
}
|
||||
}
|
||||
const { createSqliteHandle, backfillMeili, CHAT_COLLECTION_SPECS } = loadDataSchemas();
|
||||
|
||||
async function main() {
|
||||
const dbPath = process.env.CHAT_SQLITE_PATH;
|
||||
if (!dbPath) {
|
||||
throw new Error('CHAT_SQLITE_PATH is required');
|
||||
}
|
||||
if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY) {
|
||||
throw new Error('MEILI_HOST and MEILI_MASTER_KEY are required');
|
||||
}
|
||||
|
||||
const only = (process.argv[2] || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const searchable = ['Conversation', 'Message'];
|
||||
const names = (only.length ? only : searchable).filter(
|
||||
(n) => searchable.includes(n) && n in CHAT_COLLECTION_SPECS,
|
||||
);
|
||||
|
||||
if (names.length === 0) {
|
||||
throw new Error('No searchable collections selected (expected Conversation and/or Message)');
|
||||
}
|
||||
|
||||
console.log(`[backfill-meili] sqlite=${dbPath} collections=${names.join(',')}`);
|
||||
|
||||
const handle = createSqliteHandle(names, { dbPath });
|
||||
try {
|
||||
const report = await backfillMeili(handle.models);
|
||||
for (const { collection, indexed } of report) {
|
||||
console.log(`[backfill-meili] OK ${collection.padEnd(12)} indexed=${String(indexed).padStart(7)}`);
|
||||
}
|
||||
const total = report.reduce((n, r) => n + r.indexed, 0);
|
||||
console.log(`[backfill-meili] DONE — ${total} documents indexed into MeiliSearch.`);
|
||||
} finally {
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[backfill-meili] ERROR:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+7
-7
@@ -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: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
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: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -145,7 +145,7 @@ endpoints:
|
||||
modelDisplayLabel: "Qwen"
|
||||
- name: "Meta Llama"
|
||||
customOrder: 2
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -158,7 +158,7 @@ endpoints:
|
||||
modelDisplayLabel: "Meta Llama"
|
||||
- name: "DeepSeek"
|
||||
customOrder: 3
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -172,7 +172,7 @@ endpoints:
|
||||
modelDisplayLabel: "DeepSeek"
|
||||
- name: "Mistral"
|
||||
customOrder: 4
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
iconURL: "/assets/mistral.png"
|
||||
models:
|
||||
@@ -186,7 +186,7 @@ endpoints:
|
||||
modelDisplayLabel: "Mistral"
|
||||
- name: "Google Gemma"
|
||||
customOrder: 5
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -198,7 +198,7 @@ endpoints:
|
||||
modelDisplayLabel: "Google Gemma"
|
||||
- name: "OpenAI GPT-OSS"
|
||||
customOrder: 6
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Rust / Tauri
|
||||
src-tauri/target
|
||||
src-tauri/gen
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!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>
|
||||
Generated
+10497
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
+4892
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
[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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$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.
|
After Width: | Height: | Size: 722 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,68 @@
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an extra console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hanzo_chat_mobile_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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>,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzochat/chat",
|
||||
"version": "0.9.15",
|
||||
"version": "0.9.17",
|
||||
"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": [
|
||||
@@ -190,7 +190,8 @@
|
||||
"sharp",
|
||||
"protobufjs",
|
||||
"mongodb-memory-server",
|
||||
"unrs-resolver"
|
||||
"unrs-resolver",
|
||||
"better-sqlite3-multiple-ciphers"
|
||||
],
|
||||
"overrides": {
|
||||
"@ariakit/react": "0.4.29",
|
||||
|
||||
Vendored
+1721
-445
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+21
-5
@@ -8,6 +8,7 @@ interface MCPConnectionParams {
|
||||
userId?: string;
|
||||
oauthTokens?: MCPOAuthTokens | null;
|
||||
useSSRFProtection?: boolean;
|
||||
allowedAddresses?: string[] | null;
|
||||
}
|
||||
export declare class MCPConnection extends EventEmitter {
|
||||
client: Client;
|
||||
@@ -21,21 +22,34 @@ 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);
|
||||
@@ -45,9 +59,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.
|
||||
*
|
||||
* @param getHeaders Function to retrieve request headers
|
||||
* @param timeout Timeout value for the agent (in milliseconds)
|
||||
* @returns A fetch function that merges headers appropriately
|
||||
* 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.
|
||||
*/
|
||||
private createFetchFunction;
|
||||
private emitError;
|
||||
@@ -56,10 +70,12 @@ export declare class MCPConnection extends EventEmitter {
|
||||
private handleReconnection;
|
||||
private subscribeToResources;
|
||||
connectClient(): Promise<void>;
|
||||
private setupTransportDebugHandlers;
|
||||
private patchTransportSend;
|
||||
private setupTransportOnMessageHandler;
|
||||
connect(): Promise<void>;
|
||||
private setupTransportErrorHandlers;
|
||||
disconnect(): Promise<void>;
|
||||
private closeAgents;
|
||||
disconnect(resetCycleTracking?: boolean): Promise<void>;
|
||||
fetchResources(): Promise<t.MCPResource[]>;
|
||||
fetchTools(): Promise<{
|
||||
name: string;
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
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,4 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './hanzoCloudKey';
|
||||
export * from './tenantBearer';
|
||||
export * from './hanzoGatewayFetch';
|
||||
export * from './initialize';
|
||||
|
||||
@@ -13,11 +13,7 @@ import { getCustomEndpointConfig } from '~/app/config';
|
||||
import { fetchModels } from '~/endpoints/models';
|
||||
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
|
||||
import { standardCache } from '~/cache';
|
||||
import {
|
||||
isHanzoPerUserKeyEnabled,
|
||||
resolveHanzoCloudKey,
|
||||
type HanzoBillingUser,
|
||||
} from './hanzoCloudKey';
|
||||
import { resolveTenantBearer, OPENID_BEARER_SENTINEL } from './tenantBearer';
|
||||
import { wrapHanzoGatewayFetch, type GatewayFetch } from './hanzoGatewayFetch';
|
||||
|
||||
const { PROXY } = process.env;
|
||||
@@ -105,25 +101,20 @@ export async function initializeCustom({
|
||||
let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
|
||||
const baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
|
||||
|
||||
// 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',
|
||||
);
|
||||
// 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.');
|
||||
}
|
||||
apiKey = bearer;
|
||||
}
|
||||
|
||||
if (userProvidesKey && !apiKey) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* The ONE way chat forwards a signed-in user's Hanzo IAM bearer to cloud
|
||||
* (api.hanzo.ai). Both the chat-completion path (custom/initialize.ts) and the
|
||||
* cloud-agents path (server/routes/agents/cloud.js) resolve the bearer through
|
||||
* THIS function — there is no second bearer-resolution anywhere.
|
||||
*
|
||||
* The bearer IS the credential AND the billing identity: cloud validates the JWT
|
||||
* (JWKS sig + issuer + audience + exp), pins the tenant org from the verified
|
||||
* `owner` claim, and meters the org's shared plan then PAYG. chat holds NO shared
|
||||
* key and mints NO per-user key — see AUTH_BILLING_CONTRACT.md in hanzoai/cloud.
|
||||
*
|
||||
* The token lives server-side (the OIDC session store, httpOnly cookie fallback)
|
||||
* and is NEVER returned to the browser.
|
||||
*/
|
||||
|
||||
/** The request shape this resolver needs — a subset of the Express request. */
|
||||
export interface TenantBearerRequest {
|
||||
user?: {
|
||||
provider?: string;
|
||||
openidId?: string;
|
||||
} | null;
|
||||
session?: {
|
||||
openidTokens?: {
|
||||
idToken?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
};
|
||||
headers?: {
|
||||
cookie?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a Cookie header into a flat map (no dependency; RFC 6265 name=value pairs). */
|
||||
function parseCookies(header?: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) {
|
||||
return out;
|
||||
}
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, idx).trim();
|
||||
if (key && out[key] === undefined) {
|
||||
out[key] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
|
||||
* mandatory — the function only ever REMOVES a token from consideration:
|
||||
* - Decodable JWT: hanzo.id (the only cloud IdP) always issues JWTs; an
|
||||
* opaque/garbage token cannot be principal-bound, so it is never forwarded.
|
||||
* - Principal binding (MANDATORY): the token must NAME the authenticated user
|
||||
* (`sub === user.openidId`). If the binding cannot be ASSERTED — no
|
||||
* `openidId`, no `sub`, or a mismatch — the token is NOT forwarded. 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.
|
||||
*/
|
||||
export function isForwardableToken(token: string | undefined, openidId?: string): boolean {
|
||||
if (!token || !openidId) {
|
||||
return false;
|
||||
}
|
||||
const claims = jwt.decode(token);
|
||||
if (!claims || typeof claims !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const c = claims as { sub?: string; exp?: number };
|
||||
if (c.sub !== openidId) {
|
||||
return false;
|
||||
}
|
||||
if (typeof c.exp === 'number' && c.exp * 1000 <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's forwardable Hanzo IAM bearer, or null.
|
||||
*
|
||||
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
|
||||
* authoritative user loaded by requireJwtAuth), NOT any cookie. A local user
|
||||
* never carries a hanzo.id token, so a stale OpenID session in the browser can
|
||||
* never be forwarded under a local identity.
|
||||
*
|
||||
* Every candidate — session first, then the httpOnly cookie fallback; id_token
|
||||
* preferred, access_token second — must pass `isForwardableToken`. Returns null
|
||||
* for an honest 401, never a wrong-principal / expired / unbound token.
|
||||
*/
|
||||
export function resolveTenantBearer(req: TenantBearerRequest): string | null {
|
||||
const user = req.user;
|
||||
if (!user || user.provider !== 'openid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = req.session?.openidTokens;
|
||||
const cookies = parseCookies(req.headers?.cookie);
|
||||
|
||||
const idToken = session?.idToken || cookies.openid_id_token;
|
||||
if (isForwardableToken(idToken, user.openidId)) {
|
||||
return idToken as string;
|
||||
}
|
||||
|
||||
const accessToken = session?.accessToken || cookies.openid_access_token;
|
||||
if (isForwardableToken(accessToken, user.openidId)) {
|
||||
return accessToken as string;
|
||||
}
|
||||
|
||||
logger.debug('[tenantBearer] no forwardable IAM bearer for principal', {
|
||||
openidId: user.openidId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint-config sentinel that opts a custom endpoint into IAM-bearer
|
||||
* forwarding. An endpoint whose `apiKey` is this value bills the signed-in user's
|
||||
* own org via their forwarded IAM token (the canonical Hanzo Cloud path) instead
|
||||
* of any static key. This reuses LibreChat's existing OIDC-token placeholder name
|
||||
* so librechat.yaml stays self-documenting.
|
||||
*/
|
||||
export const OPENID_BEARER_SENTINEL = '{{LIBRECHAT_OPENID_TOKEN}}';
|
||||
@@ -183,9 +183,11 @@ export function createBearerAuthHeader(tokenInfo: OpenIDTokenInfo | null): strin
|
||||
}
|
||||
|
||||
export function isOpenIDAvailable(): boolean {
|
||||
// PUBLIC PKCE client: availability requires only a client id + issuer. A public
|
||||
// client has no secret, so requiring one would report OIDC as unavailable and
|
||||
// suppress login on a correctly-configured secretless deploy.
|
||||
const openidClientId = process.env.OPENID_CLIENT_ID;
|
||||
const openidClientSecret = process.env.OPENID_CLIENT_SECRET;
|
||||
const openidIssuer = process.env.OPENID_ISSUER;
|
||||
|
||||
return !!(openidClientId && openidClientSecret && openidIssuer);
|
||||
return !!(openidClientId && openidIssuer);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user