Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3ed82472 | ||
|
|
cb0649e10b | ||
|
|
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 });
|
||||
};
|
||||
|
||||
+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 };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const { isEmptyAgentResponse } = require('../emptyResponse');
|
||||
|
||||
describe('isEmptyAgentResponse', () => {
|
||||
it('treats nullish responses as empty', () => {
|
||||
expect(isEmptyAgentResponse(undefined)).toBe(true);
|
||||
expect(isEmptyAgentResponse(null)).toBe(true);
|
||||
expect(isEmptyAgentResponse({})).toBe(true);
|
||||
});
|
||||
|
||||
it('is empty when content array is empty and text is blank', () => {
|
||||
// The exact silent-failure shape: agents endpoint sets text:'' and the
|
||||
// stream yielded zero deltas, so content is [].
|
||||
expect(isEmptyAgentResponse({ text: '', content: [] })).toBe(true);
|
||||
expect(isEmptyAgentResponse({ text: ' ', content: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it('is empty when the only content part is a blank text part', () => {
|
||||
expect(
|
||||
isEmptyAgentResponse({ text: '', content: [{ type: ContentTypes.TEXT, text: '' }] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isEmptyAgentResponse({ text: '', content: [{ type: ContentTypes.TEXT, text: ' \n ' }] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT empty when text is present', () => {
|
||||
expect(isEmptyAgentResponse({ text: 'hello' })).toBe(false);
|
||||
expect(isEmptyAgentResponse({ text: 'hi', content: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('is NOT empty when a text content part carries content', () => {
|
||||
expect(
|
||||
isEmptyAgentResponse({ text: '', content: [{ type: ContentTypes.TEXT, text: 'answer' }] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is NOT empty when an error content part is present', () => {
|
||||
// The gateway-threw path pushes an ERROR part; that must still render (never
|
||||
// be re-flagged as empty).
|
||||
expect(
|
||||
isEmptyAgentResponse({
|
||||
text: '',
|
||||
content: [{ type: ContentTypes.ERROR, error: 'insufficient credits' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is NOT empty when a tool_call part is present (no text yet)', () => {
|
||||
expect(
|
||||
isEmptyAgentResponse({
|
||||
text: '',
|
||||
content: [{ type: ContentTypes.TOOL_CALL, tool_call: { name: 'search' } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is NOT empty when a non-blank string part is present', () => {
|
||||
expect(isEmptyAgentResponse({ text: '', content: ['done'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Proves the empty-response backstop in ResumableAgentController is WIRED to the
|
||||
* SSE error path: a completion that returns no content (a dead key / out-of-
|
||||
* credits stream that yields zero deltas without throwing) must surface as an
|
||||
* `emitError` — never a silent `emitDone` with an empty message.
|
||||
*/
|
||||
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
|
||||
const mockGenerationJobManager = {
|
||||
createJob: jest.fn(),
|
||||
getJob: jest.fn(),
|
||||
emitDone: jest.fn(),
|
||||
emitError: jest.fn(),
|
||||
emitChunk: jest.fn(),
|
||||
completeJob: jest.fn(),
|
||||
updateMetadata: jest.fn(),
|
||||
setContentParts: jest.fn(),
|
||||
getResumeState: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
getViolationInfo: jest.fn(),
|
||||
GenerationJobManager: mockGenerationJobManager,
|
||||
decrementPendingRequest: jest.fn().mockResolvedValue(undefined),
|
||||
sanitizeFileForTransmit: jest.fn((file) => file),
|
||||
sanitizeMessageForTransmit: jest.fn((msg) => msg),
|
||||
checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/cleanup', () => ({
|
||||
disposeClient: jest.fn(),
|
||||
clientRegistry: null,
|
||||
requestDataMap: new Map(),
|
||||
}));
|
||||
jest.mock('~/server/middleware', () => ({ handleAbortError: jest.fn() }));
|
||||
jest.mock('~/cache', () => ({ logViolation: jest.fn() }));
|
||||
jest.mock('~/models', () => ({ saveMessage: jest.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const AgentController = require('../request');
|
||||
|
||||
/** Resolves once the controller settles onto a terminal emit (done or error). */
|
||||
const runController = async (response) => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerationJobManager.getResumeState.mockResolvedValue(null);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const createdAt = Date.now();
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt,
|
||||
abortController,
|
||||
readyPromise: Promise.resolve(),
|
||||
emitter: { on: jest.fn() },
|
||||
});
|
||||
// Job is never "replaced" — same createdAt on re-read.
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt });
|
||||
|
||||
const settled = new Promise((resolve) => {
|
||||
mockGenerationJobManager.emitError.mockImplementation(() => resolve('error'));
|
||||
mockGenerationJobManager.emitDone.mockImplementation(() => resolve('done'));
|
||||
});
|
||||
|
||||
const client = {
|
||||
sender: 'AI',
|
||||
contentParts: [],
|
||||
sendMessage: jest.fn().mockResolvedValue({
|
||||
...response,
|
||||
messageId: 'resp-1',
|
||||
databasePromise: Promise.resolve({ conversation: { conversationId: 'c1', title: 'T' } }),
|
||||
}),
|
||||
};
|
||||
const initializeClient = jest.fn().mockResolvedValue({ client });
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
text: 'hi',
|
||||
conversationId: 'new',
|
||||
parentMessageId: 'p1',
|
||||
endpointOption: { endpoint: 'agents', modelOptions: { model: 'zen5-mini' } },
|
||||
},
|
||||
user: { id: 'u1' },
|
||||
};
|
||||
const res = { json: jest.fn(), status: jest.fn().mockReturnThis() };
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
let timer;
|
||||
const guard = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('controller never settled')), 2000);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([settled, guard]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
describe('ResumableAgentController empty-response backstop', () => {
|
||||
it('emits an ERROR (not a silent done) when the response has no content', async () => {
|
||||
const outcome = await runController({ text: '', content: [] });
|
||||
|
||||
expect(outcome).toBe('error');
|
||||
expect(mockGenerationJobManager.emitError).toHaveBeenCalledTimes(1);
|
||||
const [, message] = mockGenerationJobManager.emitError.mock.calls[0];
|
||||
expect(String(message)).toMatch(/empty response/i);
|
||||
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits DONE normally when the response carries content', async () => {
|
||||
const outcome = await runController({
|
||||
text: '',
|
||||
content: [{ type: ContentTypes.TEXT, text: 'a real answer' }],
|
||||
});
|
||||
|
||||
expect(outcome).toBe('done');
|
||||
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerationJobManager.emitError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Content-part types whose payload is a plain string and which render nothing
|
||||
* when that string is empty. Everything else (error, tool_call, image, audio,
|
||||
* agent_update, ...) renders some visible affordance even without text.
|
||||
*/
|
||||
const TEXT_LIKE_TYPES = new Set([ContentTypes.TEXT, ContentTypes.THINK, ContentTypes.TEXT_DELTA]);
|
||||
|
||||
/**
|
||||
* True when an assembled agent response carries nothing the user would see.
|
||||
*
|
||||
* The Hanzo Cloud gateway can end a `stream:true` completion with NO content and
|
||||
* WITHOUT throwing — a rejected key or an out-of-credits balance answered as a
|
||||
* 200 `text/event-stream` that yields zero deltas. The agent run then completes
|
||||
* "successfully" with an empty `content` array, so the client renders an empty
|
||||
* assistant bubble instead of a visible error (the GA-blocker silent failure).
|
||||
* Callers use this to detect that case and surface a real error over the SSE
|
||||
* error path instead.
|
||||
*
|
||||
* A response is non-empty if it has any non-whitespace `text`, or any content
|
||||
* part that renders something: a text/think part with non-whitespace text, or
|
||||
* any non-text part (error, tool_call, image, audio, ...).
|
||||
*
|
||||
* @param {{ text?: unknown, content?: unknown } | null | undefined} response
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmptyAgentResponse(response) {
|
||||
if (!response) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof response.text === 'string' && response.text.trim() !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { content } = response;
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !content.some((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return part.trim() !== '';
|
||||
}
|
||||
if (part == null || typeof part !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (TEXT_LIKE_TYPES.has(part.type)) {
|
||||
const value = part[part.type];
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
// Any non-text content part renders a visible affordance on its own.
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { isEmptyAgentResponse };
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
checkAndIncrementPendingRequest,
|
||||
} = require('@hanzochat/api');
|
||||
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
|
||||
const { isEmptyAgentResponse } = require('./emptyResponse');
|
||||
const { handleAbortError } = require('~/server/middleware');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { saveMessage } = require('~/models');
|
||||
@@ -240,6 +241,19 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
||||
|
||||
const response = await client.sendMessage(text, messageOptions);
|
||||
|
||||
// Backstop against a silent empty assistant bubble. The Hanzo Cloud
|
||||
// gateway can end a `stream:true` completion with no content and WITHOUT
|
||||
// throwing (a rejected key or an out-of-credits balance answered as a
|
||||
// 200 event-stream that yields zero deltas), so the run "succeeds" with
|
||||
// an empty response. Surface it as a real error over the existing SSE
|
||||
// error path (caught below → emitError) instead of emitting an empty
|
||||
// message — a dead key or exhausted balance must never be invisible.
|
||||
if (!job.abortController.signal.aborted && isEmptyAgentResponse(response)) {
|
||||
throw new Error(
|
||||
'The model returned an empty response. This usually means the request was rejected upstream (invalid API key or insufficient credits). Please try again or contact support.',
|
||||
);
|
||||
}
|
||||
|
||||
const messageId = response.messageId;
|
||||
const endpoint = endpointOption.endpoint;
|
||||
response.endpoint = endpoint;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://hanzo.ai/chat",
|
||||
"dependencies": {
|
||||
"@hanzo/ui": "^5.3.41",
|
||||
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
|
||||
"@ariakit/react": "^0.4.15",
|
||||
"@ariakit/react-core": "^0.4.17",
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
|
||||
@@ -10,7 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, getMessageAriaLabel } 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">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import React, { useCallback } from 'react';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import NetworkWallet from './NetworkWallet';
|
||||
|
||||
// Use require() to bypass Vite CJS static analysis for @hanzo/ui
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
@@ -30,6 +31,7 @@ export default function ChatHanzoHeader() {
|
||||
<HanzoHeaderComponent
|
||||
currentApp="Chat"
|
||||
currentAppId="chat"
|
||||
headerRight={<NetworkWallet />}
|
||||
user={
|
||||
user
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Chat's network + wallet cluster — the shared @hanzo/ui pair over the
|
||||
* web custody model (injected EIP-1193, non-custodial). ONE adapter
|
||||
* instance for the surface; selection persists in the shared network
|
||||
* store so every consumer of `getNetwork()` follows the switcher.
|
||||
*
|
||||
* Wrapped in `dark` because the HanzoHeader bar is always monochrome
|
||||
* dark, independent of the app theme.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { NetworkSwitcher } from '@hanzo/ui/network';
|
||||
import { injectedEvmAdapter, WalletMenu } from '@hanzo/ui/wallet';
|
||||
|
||||
const walletAdapter = injectedEvmAdapter();
|
||||
|
||||
export default function NetworkWallet() {
|
||||
return (
|
||||
<div className="dark mr-1 hidden items-center gap-1.5 md:flex">
|
||||
<NetworkSwitcher />
|
||||
<WalletMenu adapter={walletAdapter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -24,6 +24,9 @@ module.exports = {
|
||||
'./src/**/*.{js,jsx,ts,tsx}',
|
||||
// Include component library files
|
||||
'../packages/client/src/**/*.{js,jsx,ts,tsx}',
|
||||
// Shared @hanzo/ui components (network switcher / wallet menu)
|
||||
'./node_modules/@hanzo/ui/dist/network/**/*.{js,mjs}',
|
||||
'./node_modules/@hanzo/ui/dist/wallet/**/*.{js,mjs}',
|
||||
],
|
||||
// darkMode: 'class',
|
||||
darkMode: ['class'],
|
||||
@@ -165,6 +168,13 @@ module.exports = {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover, var(--background)))',
|
||||
foreground: 'hsl(var(--popover-foreground, var(--foreground)))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'var(--text-destructive)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
|
||||
+7
-7
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user