Compare commits

...
Author SHA1 Message Date
hanzo-dev 7d3ed82472 fix(chat/sse): surface upstream errors instead of a silent empty bubble
A rejected gateway key or an out-of-credits balance can end a `stream:true`
completion with NO content and WITHOUT throwing — api.hanzo.ai answers as a
200 event-stream that yields zero deltas — so the agent run "succeeds" with an
empty response and the client renders an EMPTY assistant bubble. This is the
exact silent failure behind the dead-guest-key GA blocker (a dead key -> 401 ->
{"msg":"invalid API key"} that the SSE client rendered as an empty reply).

Add a backstop in ResumableAgentController: right after `client.sendMessage`,
if the response carries no user-visible content and the run was not aborted,
throw. The throw lands in the existing catch -> `GenerationJobManager.emitError`
-> the `event: error` SSE frame the client already renders as an error bubble.
No new SSE plumbing — it reuses the one error path, so a dead key or exhausted
balance can never again be invisible.

The emptiness decision is a pure, unit-tested helper (`isEmptyAgentResponse`):
non-empty iff there is non-whitespace `text`, a non-blank text/think part, or
any non-text content part (error, tool_call, image, ...). An ERROR part still
renders, so the gateway-threw path is untouched.

Tests:
- emptyResponse.spec.js: 8 cases pinning the predicate (empty content array,
  blank-text-only, error/tool_call/text parts non-empty).
- emptyResponseController.spec.js: drives the real controller — empty response
  emits `error` (not a silent `done`); a response with content emits `done`.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ
2026-07-09 22:07:52 -07:00
cb0649e10b feat(chat): shared NetworkSwitcher + WalletMenu — the ONE hanzo.network standard (#67)
* feat(chat): shared NetworkSwitcher + WalletMenu in the HanzoHeader

Puts hanzo.chat on the ONE hanzo.network standard (same model as the
hanzo CLI, console, desktop, app): @hanzo/ui/network + @hanzo/ui/wallet
via the npm alias @hanzo/ui -> @hanzo/ui-shadcn@^5.7.3 (the 5.x line
continued under its post-v8-rename name; existing imports untouched).

- Nav/NetworkWallet: the surface adapter — injected EIP-1193 wallet
  (non-custodial, zero key material) pinned to the selected network env
  (mainnet 36963 / testnet 36964 / devnet 36965 / local / custom).
- Mounted via the HanzoHeader headerRight slot; dark-scoped to match the
  monochrome bar.
- tailwind: scan the shared components in node_modules; add the missing
  popover/destructive tokens (fallback to background/foreground vars).

vite build green; tsc baseline unchanged (1014 pre-existing errors on
clean main, zero added — upstream CI gates vite build only).

* chore(chat): bump @hanzo/ui-shadcn 5.7.3 -> 5.7.4 (genesis-canonical chain IDs)

Picks up the corrected network set (testnet 36962, devnet 36964, local
1337). vite build green.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:12:23 -07:00
zeekay 31af61cb8b Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/server/middleware/__tests__/enforceGuestScope.spec.js
#	api/server/middleware/enforceGuestScope.js
#	api/server/services/guestConfig.js
2026-07-08 06:05:49 -07:00
212ba7d822 feat(mobile): honor server-driven auto-routing defaults from admin (#66)
Mirror the web client's server-driven Smart Routing onto the mobile
surface. On boot (authed), fetch the SAME backend route the web client
uses — GET /v1/chat/routing-defaults — fail-soft to { available: false }
so any error keeps today's local-toggle-only behavior.

- lib/routing.ts: mirror of web resolveSmartRouting (pure) plus org
  derivation helpers and a module cache for the non-React api layer.
- lib/settings.ts: smart-routing pref is now nullable (null === follow
  org default; true/false === explicit user choice that wins).
- lib/api.ts: fetchRoutingDefaults() boot fetch; chat() resolves the
  effective model from pref + cached org defaults.
- App.tsx: authed AppShell fetches defaults once, caches + holds state.
- Usage.tsx: toggle uses resolveSmartRouting; locks off with copy
  'Disabled for your organization' when auto_routing_active is false.

Resolver is mirrored (not imported) across app boundaries, matching the
three web apps; web copy carries the jest coverage. Typecheck + build
green.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 23:31:40 -07:00
zeekay 16745e26fa fix: true-black #000 chat body canvas (was #212121 gray) — DESIGN.md §4 2026-07-07 22:24:03 -07:00
e661f4c0b6 feat(routing): honor server-driven auto-routing defaults from admin (#65)
New conversations resolve smart routing from the org's server-driven
defaults (GET /v1/chat/routing-defaults proxying cloud's
/v1/get-routing-defaults on-behalf-of) with the local toggle as a
nullable user override (null = follow org default). Fail-soft: an older
cloud-api (404), network error, or non-ok wrapper behaves exactly as
today (local preference only). When auto_routing_active is false the
toggle is locked with honest copy.

Effective state is one pure function, resolveSmartRouting
(client/src/utils/endpoints.ts), unit-tested in endpoints.spec.ts.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-07 18:40:42 -07:00
Hanzo AI 9e5e158145 feat(routing): user-facing Smart Routing toggle (web + mobile)
Web: 'Smart routing' toggle in the Usage settings tab, persisted via the
smartRouting localStorage recoil atom. When on, new conversations on the
Hanzo endpoint default to model "auto" (gateway routes each prompt to the
best/cheapest capable model; billed as whatever serves it). Scoped to the
Hanzo house endpoint and only applied to defaults — an explicit model pick
or a non-Hanzo provider family is never rewritten. Message header surfaces
the served model (msg.model) when a routed convo echoes it. Docs link +
en localization strings.

Mobile: same toggle on the Usage screen, persisted to localStorage via a
small settings helper; flips chat between VITE_CHAT_MODEL and "auto" in
lib/api.ts.
2026-07-07 17:49:06 -07:00
zeekayandClaude Opus 4.8 c1df3816e3 chat: tier-aware guest model deep-links (GUEST_MODELS allowlist)
Guest deep-links (hanzo.chat/?endpoint=Hanzo&model=zen4-max) honored flexibly:
allowlisted models run free (within GUEST_MESSAGE_MAX); off-list/paid models return
402 MODEL_REQUIRES_LOGIN so the client opens login. Back-compat; fail-closed preserved.
guestConfig GUEST_MODELS allowlist + resolveGuestModel; enforceGuestScope honors it;
buildGuestModelsConfig exposes it. 19 tests pass. Client wiring + GUEST_MODELS env + deploy remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:47:41 -07:00
Hanzo AI 77f768f7fa feat(usage): unified per-user AI Usage settings tab
Backend: GET /api/usage (requireJwtAuth) aggregates this user's prompt+
completion Transaction spend over today/7d/30d plus a per-model breakdown,
enriched with the org tier from CommerceClient. Response mirrors the
@hanzo/usage UsageSnapshot shape (providerId 'hanzo', totals, providerCost)
for one wire format across Hanzo products.

Frontend: new Usage settings tab beside Balance, reusing the UsageBar idiom;
useGetUserUsage hook follows useGetUserBalance; en localization strings added.
2026-07-07 15:31:03 -07:00
Hanzo AI 885f6ec33c feat(mobile): scaffold Hanzo AI mobile app (Tauri v2 + React 19 + @hanzo/gui)
Self-contained mobile/ app (not in any workspace):
- Tauri v2 shell (productName "Hanzo AI", id ai.hanzo.chat) with fs + http
  plugins, scoped capabilities (fs: ~/.codex ~/.claude ~/.hanzo; http:
  chatgpt.com, api.anthropic.com, api.hanzo.ai, claude.ai, hanzo.chat).
- React 19 + Vite + @hanzo/gui (createGui + defaultConfig from
  @hanzogui/config/v5). Three state-nav screens: Chat, Sessions, Usage.
- Chat -> POST /api/agents/v1/chat/completions (OpenAI-compatible);
  Sessions -> GET /api/convos; both Bearer-auth. Usage via @hanzo/usage
  (createTauriHost + useUsage), connect empty-state outside Tauri.
- Bearer token via VITE_CHAT_TOKEN or paste screen (localStorage); IAM TODO.
- Rust engine/node feature flags OFF by default; cfg-gated engine_status +
  device_register stubs pointing at hanzoai/engine + hanzoai/node.

vite build green; cargo check green (default + engine,node).
2026-07-07 15:29:38 -07:00
hanzo-dev 83554c557d Merge branch 'feat/unified-iam-tenant-auth' 2026-07-07 10:43:17 -07:00
hanzo-dev 98d0dad34b feat(auth): unified IAM user-token tenancy; delete guest/shared-key cruft 2026-07-07 10:42:29 -07:00
zeekayandClaude Opus 4.8 44956e86b9 fix(test): faithful winston.format mock so data-schemas logger loads
The api jest winston mock returned the raw transform fn from
`winston.format(fn)`, so any `redactFormat()` call ran `fn(undefined)` and
crashed at `info.level`. `@librechat/data-schemas`'s own `config/winston`
calls `redactFormat()` at import time, so every backend suite that pulls in
`createModels`/`logger` failed to run ("Cannot read properties of undefined
(reading 'level')" at data-schemas parsers.ts:63).

Make the mock faithful to winston's contract: `format(fn)` returns a factory
that yields a Format instance (`{ transform: fn }`) — it never invokes the
transform at construction. On the models/strategies/Config slice this turns
20 fail-to-run suites into 20 passing (tests running 174 -> 681); the
level-throw is fully gone. Remaining failures are unrelated pre-existing
issues (DB-backed model tests, azureOpenAI config, role permissions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 09:59:00 -07:00
hanzo-dev 167108dca5 release(0.9.17): P1 session/token/transaction store-aware fix + frontend build-hang fix 2026-07-04 21:10:29 -07:00
hanzo-dev c57194b535 Merge commit '8b4a7a6b4b' into integrate-0917
# Conflicts:
#	packages/data-schemas/dist/index.cjs.map
#	packages/data-schemas/dist/index.es.js.map
2026-07-04 20:11:07 -07:00
hanzo-dev 6cab482687 fix(store/sqlite): decouple store types from better-sqlite3 driver types — fixes frontend build hang
The node:sqlite → better-sqlite3-multiple-ciphers driver swap made the store's
public type surface (SqliteHandle.db, DocModel.db, openDatabase) reference the
driver's shipped `Database.Database` type. That type is large and conditional-
generic-heavy (`prepare<...>` → `Statement<...>`). Because @hanzochat/api imports
@librechat/data-schemas' public types (253 files), the driver types entered api's
TS program and the checker's structural-relation pass exploded (recursiveTypeRelatedTo
/ instantiateType depth-limit storm): api's rollup type-check ran >27 min and CI
canceled it. Pre-swap, node:sqlite's DatabaseSync was absent from @types/node@20 so
it resolved to `any` — trivial surface, build completed in ~3 min.

Fix: declare the minimal better-sqlite3 surface the store actually uses
(exec/prepare/pragma/close, run/get/all) as a local `SqliteDatabase`/`SqliteStatement`
interface and use it in place of `Database.Database`; cast the lazy require to the
minimal ctor. Runtime is unchanged — the native handle satisfies the contract
structurally. Rebuilt data-schemas dist.

Local proof (Node 20):
- api tsc --noEmit: >27min/canceled -> 151s
- api rollup build (--max-old-space-size=6144, as Dockerfile): 98s, emits dist
- pnpm run frontend: completes end-to-end, exit 0
2026-07-04 20:02:26 -07:00
hanzo-dev 8b4a7a6b4b fix(api/models): route Transaction + Role creation through the store-aware .create()
The prod hand-written model layer (api/models/*, separate from the data-schemas
methods) still constructed mongoose documents, which throw under the CHAT_STORE_SQLITE
flip where these models resolve to the SQLite DocModel/DualWriteModel:

- api/models/Transaction.js (createTransaction / createStructuredTransaction /
  createAutoRefillTransaction): 'new Transaction(txData); ...; await transaction.save()'
  -> a persistTransaction(txData, calculate) helper that runs the calculator FIRST
  (it mutates rate/tokenValue/rateDetail in place, so ordering matters), strips the
  non-schema calculator inputs (endpointTokenConfig/inputTokenCount/rateDetail) to
  match mongoose's on-save strip, then Transaction.create(). Balance math, the
  returned {rate,user,balance,[tokenType]} and result.transaction are unchanged.
- api/models/Role.js getRoleByName: 'new Role(defaults).save()' -> Role.create();
  '.toObject()' is present on the created doc across all backends. The name-required
  guard is untouched. Prevents 'Role is not a constructor' breaking RBAC self-heal.

Same bug class as the session P1; these are the actual prod paths (a DRY smell vs
the data-schemas methods — consolidation deferred). api-only; no data-schemas dist
change. The existing api Transaction/Role specs cannot run in this env due to a
PRE-EXISTING logger-mock harness break (reproduced on clean origin/main); the
identical persistTransaction pattern is proven green in data-schemas on both the
mongoose and SQLite/DualWrite stores.
2026-07-04 19:59:21 -07:00
hanzo-dev ca72857533 build(data-schemas): rebuild dist; annotate return types to unblock declaration emit
Typing the store-resolved model as Model<IUser>/Model<ITransaction> (matching the
28 migrated domains) ballooned the inferred return of the methods that had NO
explicit return type — searchUsers, and getTransactions/deleteTransactions/
deleteBalances/createAutoRefillTransaction — past TS's serialization limit
(TS7056), so their factory .d.ts stopped emitting. Add explicit return types to
exactly those methods (pure type annotations + two no-op .lean() casts; zero
runtime change). Declaration emit is restored.

Rebuild the shipped dist (npm run build, exit 0): bundles + session/token/user
type declarations now carry the store-aware DataHandle signatures. Transaction
methods are internal (not in AllMethods), so they are tree-shaken from the bundle
and their .d.ts is not part of the shipped surface.
2026-07-04 19:48:40 -07:00
hanzo-dev 3595b2af22 test(data-schemas/auth): prove Session/Token/User/Transaction against the SQLite + DualWrite store
Adds authStore.sqlite.spec.ts (isolated native-sqlite spec): the REAL production
factories run against both served shapes the flip produces —
  1) DualWriteModel (SQLite primary + SQLite mirror), and
  2) the real createModels() wiring under CHAT_STORE_SQLITE (DocModel served).

Covers the exact regression (createSession no longer throws 'Session is not a
constructor'): createSession → findSession(refreshToken|sessionId) →
updateExpiration → generateRefreshToken(rotate) → deleteSession →
deleteAllUserSessions → countActiveSessions; createToken → findToken →
updateToken → deleteTokens (verify/reset + email normalization); createUser →
findUser → getUserById → updateUser → deleteUserById + Balance seeding;
createTransaction (debit + non-schema-field stripping) / createStructuredTransaction
/ createAutoRefillTransaction → getTransactions / findBalanceByUser /
deleteTransactions. Asserts refresh tokens stay hashed and the served model is
never a mongoose Model. Registered in test/ci.mjs ISOLATED (native driver).

14/14 green; the 101 pre-existing mongoose specs for the touched files still pass.
2026-07-04 19:32:51 -07:00
hanzo-dev 0359f4c8fb fix(data-schemas/auth): route User+Transaction onto DataHandle; drop storeHandle cast
Same store-bypass class as Session: transaction.ts constructed documents with
new Transaction() + .save() (latent P1 — breaks identically once Transaction is
SQLite-served); user.ts + transaction.ts resolved their model via mongoose.models.
Refactor both factories to take a DataHandle and resolve via handle.models.<Name>.

Transaction create/auto-refill/structured now persist via the bounded .create()
(persistTransaction), stripping the non-schema calculator inputs
(endpointTokenConfig/inputTokenCount/rateDetail) so the persisted document is
byte-identical to the mongoose path (which strips non-schema paths on save);
balance math + returned TransactionResult unchanged. User already used .create()/
.findByIdAndUpdate(); only its model resolution moves.

createMethods now hands the real DataHandle to all four (User/Session/Token) —
the storeHandle mongoose-cast hack is removed; every domain resolves the store
uniformly.
2026-07-04 19:25:21 -07:00
hanzo-dev 04d041c2f4 fix(data-schemas/auth): route Session+Token methods through the store-aware DataHandle
Cold logins have been failing since Session was flipped to the SQLite store
(CHAT_STORE_SQLITE): createSession threw 'Session is not a constructor' because
session.ts used the mongoose-document constructor (new Session()) + doc.save(),
which the DocModel/DualWriteModel served under the flip do not implement.

Refactor createSessionMethods + createTokenMethods to take a DataHandle (the same
seam the 28 migrated domains use) and resolve their model via handle.models.<Name>.
Session create/rotate now use the bounded Model API: createSession generates the
_id up front (handle.Types.ObjectId), signs the refresh token bound to it, and
persists via .create(); generateRefreshToken (rotation) persists via .updateOne();
updateExpiration via .findByIdAndUpdate(). Refresh tokens stay hashed (hashToken),
JWT claims + expiry math unchanged, no session fixation. Token methods already used
the bounded API; only their model resolution moves off mongoose.models.

storeHandle (mongoose-cast) in createMethods still feeds these factories unchanged
(it is structurally a DataHandle); the caller cleanup follows in the next commit.
2026-07-04 19:24:49 -07:00
hanzo-devandz 3fe63b68d6 ci(data-schemas): isolate native-sqlite specs one-file-per-process; v0.9.16
The 15 specs that instantiate the native better-sqlite3 driver corrupt each
other under a shared jest worker: jest gives each spec file its own JS module
realm while the native addon is process-cached per worker, so cross-file state
breaks sibling in-memory databases (driver-agnostic — reproduces with mainstream
better-sqlite3; a compound json_extract UNIQUE stops firing). Prod is unaffected
(one realm, one shared handle for the process lifetime).

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

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

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

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

Scrub all node:sqlite/DatabaseSync references from src (comments + spec names).
2026-07-04 15:55:19 -07:00
143 changed files with 25913 additions and 2728 deletions
+12 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+5 -20
View File
@@ -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) {
+73
View File
@@ -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;
+93
View File
@@ -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;
-4
View File
@@ -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 };
+14
View File
@@ -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 };
+2
View File
@@ -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;
-4
View File
@@ -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);
}
});
});
-4
View File
@@ -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);
});
});
+7 -75
View File
@@ -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);
+6 -28
View File
@@ -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);
-2
View File
@@ -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
View File
@@ -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,
+2 -11
View File
@@ -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);
+2 -2
View File
@@ -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;
+4
View File
@@ -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,
+2 -2
View File
@@ -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;
+8
View File
@@ -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 -3
View File
@@ -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;
+8
View File
@@ -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;
+1 -2
View File
@@ -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);
-118
View File
@@ -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,
};
-120
View File
@@ -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'] });
});
});
+4 -1
View File
@@ -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
-30
View File
@@ -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;
-2
View File
@@ -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,
+5 -5
View File
@@ -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);
+20 -3
View File
@@ -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(),
);
+8 -1
View File
@@ -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
View File
@@ -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>
);
}
+15 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign } 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';
+32
View File
@@ -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> => {
+40 -2
View File
@@ -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(
+14
View File
@@ -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",
+6
View File
@@ -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,
+2 -2
View File
@@ -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 {
+30 -1
View File
@@ -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 });
});
});
+51
View File
@@ -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.
+10
View File
@@ -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
View File
@@ -88,7 +88,7 @@ endpoints:
# completion path that carries the per-user hk- key) instead of bare
# `agents`, which requires an explicit agent_id and 400s on first send.
customOrder: 0
apiKey: "${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:
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
*.tsbuildinfo
.env
.env.local
# Rust / Tauri
src-tauri/target
src-tauri/gen
+88
View File
@@ -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`.
+20
View File
@@ -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
}
}
+25
View File
@@ -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>
+10497
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+4892
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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
+3
View File
@@ -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

+68
View File
@@ -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(),
}
}
}
+18
View File
@@ -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");
}
+6
View File
@@ -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()
}
+38
View File
@@ -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"
]
}
}
+66
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+180
View File
@@ -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 }
}
}
+45
View File
@@ -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())
}
+82
View File
@@ -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
}
+31
View File
@@ -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
}
}
+47
View File
@@ -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',
})
}
+19
View File
@@ -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>,
)
+103
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+205
View File
@@ -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>
)
}
+22
View File
@@ -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"]
}
+35
View File
@@ -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
View File
@@ -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",
+1721 -445
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long

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