Files
chat/api/server/controllers/ModelController.js
zooqueen 694061e40b a free plan opens on the model it can run
A conversation opens on the first model its endpoint offers, and that was a
priced rung. An account on the free plan holds no balance by design — it is
free, and buys its own credit — so its first message reached the wallet gate
where an answer belonged.

The plan is read from the one service that decides it (cloud /v1/allowance,
which names the plan and the day's free calls) and the free route leads the
catalog for a free caller. Reordered, never trimmed: the priced rungs stay
listed, because seeing what a plan buys is the offer. A plan that cannot be
read is not a free plan, so no failure of that read gives the free lane away.

Consent follows the route rather than the caller. The free models cost nothing
and share data in exchange; that is as true of a free plan defaulting to them
as of a guest pinned to them.

A spent day of free calls now renders as the limit it is. The gateway names it
allowance_spent beside the same 402 an empty wallet gets, and unmapped it read
as "something went wrong on our side" — an outage, at the moment an account is
most ready to buy. It says when the count returns and offers the plan that
lifts it, and no longer offers a switch to the route that just refused.
2026-08-16 01:56:48 -07:00

87 lines
3.1 KiB
JavaScript

const { FREE_MODEL } = require('@hanzo/ai');
const { logger } = require('@hanzochat/data-schemas');
const { CacheKeys } = require('@hanzochat/data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
const { planOf, FREE } = require('~/server/services/plan');
const { getLogStores } = require('~/cache');
/**
* @param {ServerRequest} req
* @returns {Promise<TModelsConfig>} The models config.
*/
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (!modelsConfig) {
modelsConfig = await loadModels(req);
}
return modelsConfig;
};
/**
* Loads the models from the config.
* @param {ServerRequest} req - The Express request object.
* @returns {Promise<TModelsConfig>} The models config.
*/
async function loadModels(req) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (cachedModelsConfig) {
return cachedModelsConfig;
}
const defaultModelsConfig = await loadDefaultModels(req);
const customModelsConfig = await loadConfigModels(req);
const modelConfig = { ...defaultModelsConfig, ...customModelsConfig };
await cache.set(CacheKeys.MODELS_CONFIG, modelConfig);
return modelConfig;
}
/**
* The catalog with the free route in front.
*
* A conversation opens on the FIRST model its endpoint offers (buildDefaultConvo
* → parseConvo), and the rung that leads this list is priced — which a free
* account cannot pay for, so its first message met the wallet gate where an
* answer belonged. Leading with the route that costs nothing makes the default a
* model the account can actually run, bounded by the plan's daily allowance
* rather than by a balance it does not have.
*
* REORDERED, NEVER TRIMMED. The priced rungs stay listed and selectable: seeing
* what a plan buys is the offer, and picking one is how an account asks for it.
* A guest is narrowed instead, because a guest has no plan to be offered.
*
* @param {TModelsConfig} config
* @returns {TModelsConfig}
*/
const leadFree = (config) =>
Object.fromEntries(
Object.entries(config).map(([endpoint, models]) => [
endpoint,
Array.isArray(models) && models.includes(FREE_MODEL)
? [FREE_MODEL, ...models.filter((model) => model !== FREE_MODEL)]
: models,
]),
);
async function modelController(req, res) {
try {
if (req.user?.guest === true) {
return res.send(buildGuestModelsConfig());
}
const modelConfig = await loadModels(req);
// The catalog is one list for everyone and is cached as such; which model
// LEADS it is per-caller, so the plan is read here rather than baked in.
const plan = await planOf(req);
res.send(plan === FREE ? leadFree(modelConfig) : modelConfig);
} catch (error) {
logger.error('Error fetching models:', error);
res.status(500).send({ error: error.message });
}
}
module.exports = { modelController, loadModels, getModelsConfig, leadFree };