mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-20 20:20:42 +00:00
🩹 fix: Move Token-Config Resolution to TS; Key Live Usage by Created Convo
- extract the token-config resolution (override gathering + cache lookup + buildTokenConfigMap) into resolveTokenConfigMap in packages/api, leaving the /api controller a thin request-scoped wrapper (CLAUDE.md TS rule) - getConvoKey prefers the user message's real conversationId once the `created` event stamps it, so a new chat's first-response live gauge and totals land under the id TokenUsage subscribes to instead of NEW_CONVO
This commit is contained in:
parent
df37d1f0ec
commit
a5f9ae3351
5 changed files with 145 additions and 33 deletions
|
|
@ -1,47 +1,20 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, normalizeEndpointName } = require('librechat-data-provider');
|
||||
const { buildTokenConfigMap, getTokenConfigKey, tokenConfigCache } = require('@librechat/api');
|
||||
const { resolveTokenConfigMap } = require('@librechat/api');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { getValueKey, getMultiplier, getCacheMultiplier } = require('~/models');
|
||||
|
||||
/**
|
||||
* Returns server-resolved context windows (and pricing when
|
||||
* `interface.contextCost` is enabled) for every configured model.
|
||||
* `interface.contextCost` is enabled) for every configured model. Resolution
|
||||
* lives in `@librechat/api`; this controller only supplies request-scoped deps.
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
async function tokenConfigController(req, res) {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
const includePricing = appConfig?.interfaceConfig?.contextCost === true;
|
||||
const modelsConfig = await getModelsConfig(req);
|
||||
|
||||
/** @type {Record<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
|
||||
const endpointTokenConfigs = {};
|
||||
const customEndpoints = appConfig?.endpoints?.[EModelEndpoint.custom] ?? [];
|
||||
const cache = tokenConfigCache();
|
||||
for (const endpointConfig of customEndpoints) {
|
||||
/** Models config and the token-config cache key by the normalized name */
|
||||
const name = normalizeEndpointName(endpointConfig?.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (endpointConfig.tokenConfig != null) {
|
||||
endpointTokenConfigs[name] = endpointConfig.tokenConfig;
|
||||
continue;
|
||||
}
|
||||
/** Model fetches and chat initialization both store under this key —
|
||||
* user-scoped whenever the fetched config can be user-specific, so a
|
||||
* plain-name fallback would risk serving another user's entry */
|
||||
const tokenKey = getTokenConfigKey(endpointConfig, name, req.user.id);
|
||||
const cached = await cache.get(tokenKey);
|
||||
if (cached) {
|
||||
endpointTokenConfigs[name] = cached;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenConfigMap = buildTokenConfigMap(
|
||||
{ modelsConfig, endpointTokenConfigs, includePricing },
|
||||
const tokenConfigMap = await resolveTokenConfigMap(
|
||||
{ appConfig: req.config, modelsConfig, userId: req.user.id },
|
||||
{ getValueKey, getMultiplier, getCacheMultiplier },
|
||||
);
|
||||
res.json(tokenConfigMap);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
const FLUSH_INTERVAL_MS = 250;
|
||||
|
||||
interface UsageSubmissionLike {
|
||||
userMessage?: Pick<TMessage, 'messageId'> | null;
|
||||
userMessage?: Pick<TMessage, 'messageId' | 'conversationId'> | null;
|
||||
conversation?: Partial<Pick<TConversation, 'conversationId' | 'endpoint' | 'model'>> | null;
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +54,13 @@ export interface UsageHandlers {
|
|||
}
|
||||
|
||||
function getConvoKey(submission: UsageSubmissionLike): string {
|
||||
/** On a new chat's first turn the `created` event stamps the real id onto
|
||||
* the user message while the submission's conversation is still `new`;
|
||||
* prefer the real id so live writes land where TokenUsage is subscribed */
|
||||
const fromUserMessage = submission.userMessage?.conversationId;
|
||||
if (fromUserMessage != null && fromUserMessage !== Constants.NEW_CONVO) {
|
||||
return fromUserMessage;
|
||||
}
|
||||
return submission.conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from './google';
|
|||
export * from './models';
|
||||
export * from './openai';
|
||||
export * from './pricing';
|
||||
export * from './tokenConfig';
|
||||
|
|
|
|||
79
packages/api/src/endpoints/tokenConfig.spec.ts
Normal file
79
packages/api/src/endpoints/tokenConfig.spec.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { TokenomicsDeps } from './pricing';
|
||||
|
||||
const mockCacheGet = jest.fn();
|
||||
jest.mock('~/cache', () => ({
|
||||
tokenConfigCache: jest.fn(() => ({ get: (key: string) => mockCacheGet(key) })),
|
||||
}));
|
||||
|
||||
import { resolveTokenConfigMap } from './tokenConfig';
|
||||
|
||||
const deps: TokenomicsDeps = {
|
||||
getValueKey: () => 'value-key',
|
||||
getMultiplier: ({ tokenType }) => (tokenType === 'prompt' ? 5 : 15),
|
||||
getCacheMultiplier: () => null,
|
||||
};
|
||||
|
||||
function appConfigWith(custom: unknown[], contextCost = true): AppConfig {
|
||||
return {
|
||||
interfaceConfig: { contextCost },
|
||||
endpoints: { [EModelEndpoint.custom]: custom },
|
||||
} as unknown as AppConfig;
|
||||
}
|
||||
|
||||
describe('resolveTokenConfigMap', () => {
|
||||
beforeEach(() => mockCacheGet.mockReset().mockResolvedValue(null));
|
||||
|
||||
it('uses a static tokenConfig override without consulting the cache', async () => {
|
||||
const appConfig = appConfigWith([
|
||||
{
|
||||
name: 'MyProxy',
|
||||
tokenConfig: { 'custom-model': { prompt: 1.5, completion: 4.5, context: 32000 } },
|
||||
},
|
||||
]);
|
||||
|
||||
const map = await resolveTokenConfigMap(
|
||||
{ appConfig, modelsConfig: { MyProxy: ['custom-model'] }, userId: 'user-1' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(map.MyProxy['custom-model']).toEqual({ context: 32000, prompt: 1.5, completion: 4.5 });
|
||||
expect(mockCacheGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the cached fetched config when no static override exists', async () => {
|
||||
mockCacheGet.mockResolvedValue({ 'custom-model': { prompt: 2, completion: 6, context: 8000 } });
|
||||
const appConfig = appConfigWith([{ name: 'MyProxy', models: { fetch: true } }]);
|
||||
|
||||
const map = await resolveTokenConfigMap(
|
||||
{ appConfig, modelsConfig: { MyProxy: ['custom-model'] }, userId: 'user-1' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(mockCacheGet).toHaveBeenCalled();
|
||||
expect(map.MyProxy['custom-model'].context).toBe(8000);
|
||||
expect(map.MyProxy['custom-model'].prompt).toBe(2);
|
||||
});
|
||||
|
||||
it('omits pricing when contextCost is disabled', async () => {
|
||||
const appConfig = appConfigWith(
|
||||
[
|
||||
{
|
||||
name: 'MyProxy',
|
||||
tokenConfig: { 'custom-model': { prompt: 1.5, completion: 4.5, context: 32000 } },
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
const map = await resolveTokenConfigMap(
|
||||
{ appConfig, modelsConfig: { MyProxy: ['custom-model'] }, userId: 'user-1' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(map.MyProxy['custom-model'].context).toBe(32000);
|
||||
expect(map.MyProxy['custom-model'].prompt).toBeUndefined();
|
||||
expect(map.MyProxy['custom-model'].completion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
52
packages/api/src/endpoints/tokenConfig.ts
Normal file
52
packages/api/src/endpoints/tokenConfig.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { EModelEndpoint, normalizeEndpointName } from 'librechat-data-provider';
|
||||
import type { TModelsConfig, TTokenConfigMap, TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { EndpointTokenConfig } from '~/types';
|
||||
import { buildTokenConfigMap, type TokenomicsDeps } from '~/endpoints/pricing';
|
||||
import { getTokenConfigKey } from '~/endpoints/custom/initialize';
|
||||
import { tokenConfigCache } from '~/cache';
|
||||
|
||||
export interface ResolveTokenConfigParams {
|
||||
appConfig?: AppConfig;
|
||||
/** endpoint → model list, from the resolved models config */
|
||||
modelsConfig: TModelsConfig;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side token-config resolution for the `/endpoints/token-config`
|
||||
* route. Gathers each custom endpoint's override — a static yaml `tokenConfig`
|
||||
* or the cached fetched config — then resolves context windows (and pricing,
|
||||
* when `interface.contextCost` is enabled) via {@link buildTokenConfigMap}.
|
||||
* Lives in TypeScript so the `/api` controller stays a thin wrapper.
|
||||
*/
|
||||
export async function resolveTokenConfigMap(
|
||||
{ appConfig, modelsConfig, userId }: ResolveTokenConfigParams,
|
||||
deps: TokenomicsDeps,
|
||||
): Promise<TTokenConfigMap> {
|
||||
const includePricing = appConfig?.interfaceConfig?.contextCost === true;
|
||||
const customEndpoints = (appConfig?.endpoints?.[EModelEndpoint.custom] ?? []) as TEndpoint[];
|
||||
const cache = tokenConfigCache();
|
||||
const endpointTokenConfigs: Record<string, EndpointTokenConfig | undefined> = {};
|
||||
|
||||
for (const endpointConfig of customEndpoints) {
|
||||
/** Models config and the token-config cache key by the normalized name */
|
||||
const name = normalizeEndpointName(endpointConfig?.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (endpointConfig.tokenConfig != null) {
|
||||
endpointTokenConfigs[name] = endpointConfig.tokenConfig as EndpointTokenConfig;
|
||||
continue;
|
||||
}
|
||||
/** Model fetches and chat initialization both store under this key —
|
||||
* user-scoped whenever the fetched config can be user-specific */
|
||||
const tokenKey = getTokenConfigKey(endpointConfig, name, userId);
|
||||
const cached = (await cache.get(tokenKey)) as EndpointTokenConfig | undefined;
|
||||
if (cached) {
|
||||
endpointTokenConfigs[name] = cached;
|
||||
}
|
||||
}
|
||||
|
||||
return buildTokenConfigMap({ modelsConfig, endpointTokenConfigs, includePricing }, deps);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue