Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,20 @@ OPENWEATHER_API_KEY=
# or
# COHERE_API_KEY=your_cohere_api_key

#======================#
# Classification #
#======================#

# Key for the provider named by `classification.provider` in librechat.yaml.
# Each provider declares which variable it reads through
# `classification.providers.<name>.apiKeyEnv`; this is the default.
# CLASSIFIER_API_KEY=your_classifier_api_key

# The presets read these instead.
# TYPESAFE_API_KEY=your_typesafe_api_key
# OPENROUTER_KEY=your_openrouter_key
# CLOUDFLARE_API_TOKEN=your_cloudflare_api_token

#======================#
# MCP Configuration #
#======================#
Expand Down
33 changes: 33 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,39 @@ actions:
# - 'host.docker.internal:8080'
# - '127.0.0.1:8080'

# Classification: small typed judgments (yes/no, pick one, rate) that code can
# branch on. Off unless enabled. The key is read from the environment variable
# named by apiKeyEnv, never written here.
# classification:
# enabled: true
# provider: http
# providers:
# http:
# baseURL: https://classifier.example.com/v1/classify
# apiKeyEnv: CLASSIFIER_API_KEY
# timeoutMs: 4000
#
# # Known hosts ship as presets, so naming one is usually enough. Cloudflare
# # is the exception: its URL carries your account id.
# provider: cloudflare
# providers:
# cloudflare:
# baseURL: https://api.cloudflare.com/client/v4/accounts/<account-id>/ai/run
# apiKeyEnv: CLOUDFLARE_API_TOKEN
#
# # A host with no preset needs no code either, only its shape. `dialect`
# # picks the wire vocabulary, `requestKey` nests the body, `responseKey`
# # unwraps the reply.
# provider: inhouse
# providers:
# inhouse:
# baseURL: https://classify.internal/v1/run
# model: your-model
# dialect: systemone
# requestKey: input
# responseKey: result
# apiKeyEnv: INHOUSE_CLASSIFIER_KEY

# Example MCP Servers Object Structure
# mcpServers:
# everything:
Expand Down
134 changes: 134 additions & 0 deletions packages/api/src/classification/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { classificationSchema } from 'librechat-data-provider';
import type { TClassificationConfig } from 'librechat-data-provider';
import { resolveClassifier } from './resolve';
import { boolean } from './questions';

/**
* Covers the path an operator's `librechat.yaml` actually travels: the zod
* schema, then the resolver, then the bytes on the wire. A field that parses
* but never reaches the request is the failure this file exists to catch.
*/

const ANSWER = { model: 'jev-1.13.0', answers: { d: { type: 'noul', noul: 0.8 } }, usage: {} };

function recorder(response: unknown = ANSWER) {
const calls: { url: string; body: Record<string, unknown> }[] = [];
const fetch = async (url: string, init: { body?: string }) => {
calls.push({ url, body: JSON.parse(init.body ?? '{}') });
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => JSON.stringify(response),
};
};
return { calls, fetch: fetch as never };
}

function parse(raw: unknown): TClassificationConfig {
return classificationSchema.parse(raw) as TClassificationConfig;
}

describe('classification config', () => {
it('parses an unset block into every capability off', () => {
const config = parse({});

expect(config.enabled).toBe(false);
expect(config.provider).toBe('http');
expect(config.providers).toEqual({});
});

it('keeps the wire-shape fields through parsing', () => {
const config = parse({
enabled: true,
provider: 'cloudflare',
providers: {
cloudflare: {
baseURL: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
model: 'typesafe/jev',
dialect: 'systemone',
requestKey: 'input',
responseKey: 'result',
apiKeyEnv: 'CLOUDFLARE_API_TOKEN',
timeoutMs: 9000,
maxRetries: 1,
},
},
});

expect(config.providers.cloudflare).toEqual({
baseURL: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
model: 'typesafe/jev',
dialect: 'systemone',
requestKey: 'input',
responseKey: 'result',
apiKeyEnv: 'CLOUDFLARE_API_TOKEN',
timeoutMs: 9000,
maxRetries: 1,
});
});

it('rejects a dialect it does not implement', () => {
const result = classificationSchema.safeParse({
enabled: true,
provider: 'x',
providers: { x: { baseURL: 'https://x.test/run', dialect: 'logprobs' } },
});

expect(result.success).toBe(false);
});

it('rejects a misspelled key instead of dropping it', () => {
const result = classificationSchema.safeParse({
enabled: true,
provider: 'x',
providers: { x: { baseURL: 'https://x.test/run', requestkey: 'input' } },
});

expect(result.success).toBe(false);
});

it('rejects a baseURL that is not a URL', () => {
const result = classificationSchema.safeParse({
enabled: true,
provider: 'x',
providers: { x: { baseURL: 'classify.internal' } },
});

expect(result.success).toBe(false);
});

it('carries a parsed config all the way onto the wire', async () => {
const config = parse({
enabled: true,
provider: 'cloudflare',
providers: {
cloudflare: { baseURL: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run' },
},
});
const { calls, fetch } = recorder({ result: ANSWER });

const classifier = resolveClassifier({ config, apiKey: 'k', fetch });
const result = await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].url).toBe('https://api.cloudflare.com/client/v4/accounts/abc/ai/run');
expect(calls[0].body).toHaveProperty('input.questions.d.type', 'noul');
expect(result.answers.d).toEqual({ type: 'boolean', probability: 0.8 });
});

it('lets a parsed override beat the preset it sits on', async () => {
const config = parse({
enabled: true,
provider: 'typesafe',
providers: { typesafe: { baseURL: 'https://proxy.internal/systemone', model: 'jev-1.13' } },
});
const { calls, fetch } = recorder();

const classifier = resolveClassifier({ config, apiKey: 'k', fetch });
await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].url).toBe('https://proxy.internal/systemone');
expect(calls[0].body.model).toBe('jev-1.13');
expect((calls[0].body.questions as Record<string, { type: string }>).d.type).toBe('noul');
});
});
5 changes: 5 additions & 0 deletions packages/api/src/classification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './types';
export * from './questions';
export * from './registry';
export * from './resolve';
export type { ProviderFetch, Transport, TransportOptions } from './providers/transport';
120 changes: 120 additions & 0 deletions packages/api/src/classification/presets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { TClassificationConfig } from 'librechat-data-provider';
import { PRESETS, presetFor, mergeSettings } from './registry';
import { resolveClassifier } from './resolve';
import { boolean } from './questions';

type Captured = { url: string; body: Record<string, unknown> };

function recorder(response: unknown) {
const calls: Captured[] = [];
const fetch = async (url: string, init: { body?: string }) => {
calls.push({ url, body: JSON.parse(init.body ?? '{}') });
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => JSON.stringify(response),
};
};
return { calls, fetch: fetch as never };
}

function configFor(provider: string, settings?: Record<string, unknown>): TClassificationConfig {
return {
enabled: true,
provider,
providers: settings == null ? {} : { [provider]: settings },
toolSelection: {},
memoryGate: {},
} as unknown as TClassificationConfig;
}

const NOUL = { model: 'jev-1.13.0', answers: { d: { type: 'noul', noul: 0.8 } }, usage: {} };

describe('presets', () => {
it('ships every known host as settings, not as code', () => {
expect(Object.keys(PRESETS).sort()).toEqual(['cloudflare', 'http', 'openrouter', 'typesafe']);
});

it('lets an operator override any field of a preset', () => {
const merged = mergeSettings(presetFor('typesafe'), { model: 'jev-1.13', timeoutMs: 9000 });

expect(merged.model).toBe('jev-1.13');
expect(merged.timeoutMs).toBe(9000);
expect(merged.baseURL).toBe('https://api.typesafe.ai/v1/systemone');
});

it('sends the System One vocabulary for the typesafe preset', async () => {
const { calls, fetch } = recorder(NOUL);
const classifier = resolveClassifier({ config: configFor('typesafe'), apiKey: 'k', fetch });

const result = await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].url).toBe('https://api.typesafe.ai/v1/systemone');
expect((calls[0].body.questions as Record<string, { type: string }>).d.type).toBe('noul');
expect(result.answers.d).toEqual({ type: 'boolean', probability: 0.8 });
});

it('nests the body and unwraps the envelope for the cloudflare preset', async () => {
const { calls, fetch } = recorder({ result: NOUL, success: true });
const config = configFor('cloudflare', {
baseURL: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
});

const classifier = resolveClassifier({ config, apiKey: 'k', fetch });
const result = await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].body).toHaveProperty('input.questions.d.type', 'noul');
expect(calls[0].body.model).toBe('typesafe/jev');
expect(result.answers.d).toEqual({ type: 'boolean', probability: 0.8 });
});

it('still reads a bare body when the host does not wrap it', async () => {
const { fetch } = recorder(NOUL);
const config = configFor('cloudflare', {
baseURL: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
});

const classifier = resolveClassifier({ config, apiKey: 'k', fetch });
const result = await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(result.answers.d).toEqual({ type: 'boolean', probability: 0.8 });
});

it('sends a flat body for the openrouter preset', async () => {
const { calls, fetch } = recorder(NOUL);
const classifier = resolveClassifier({ config: configFor('openrouter'), apiKey: 'k', fetch });

await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].url).toBe('https://openrouter.ai/api/alpha/decisions');
expect(calls[0].body).not.toHaveProperty('input');
expect(calls[0].body.model).toBe('~typesafe/jev-latest');
});

it('builds a host it has never heard of from config alone', async () => {
const { calls, fetch } = recorder({ data: NOUL });
const config = configFor('somethingnew', {
baseURL: 'https://classify.internal/v1/run',
model: 'house-classifier',
dialect: 'systemone',
requestKey: 'payload',
responseKey: 'data',
});

const classifier = resolveClassifier({ config, apiKey: 'k', fetch });
const result = await classifier!.classify({ state: {}, questions: { d: boolean('durable?') } });

expect(calls[0].url).toBe('https://classify.internal/v1/run');
expect(calls[0].body).toHaveProperty('payload.questions.d.type', 'noul');
expect(result.answers.d).toEqual({ type: 'boolean', probability: 0.8 });
});

it('stays off for an unknown name with no baseURL to go on', () => {
expect(resolveClassifier({ config: configFor('mystery'), apiKey: 'k' })).toBeNull();
});

it('stays off when a preset needs a baseURL the operator did not give', () => {
expect(resolveClassifier({ config: configFor('cloudflare'), apiKey: 'k' })).toBeNull();
});
});
65 changes: 65 additions & 0 deletions packages/api/src/classification/providers/dialect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { toWireQuestion, readAnswer } from './dialect';
import { boolean, choice, score } from '../questions';

describe('toWireQuestion', () => {
it('keeps the port vocabulary by default', () => {
expect(toWireQuestion(boolean('durable?'), 'port').type).toBe('boolean');
});

it('renames a yes/no question for System One', () => {
expect(toWireQuestion(boolean('durable?'), 'systemone').type).toBe('noul');
});

it('leaves choice and score alone in either dialect', () => {
const pick = choice('which', { a: null, b: null });
const rate = score('how much', ['low', 'high']);

expect(toWireQuestion(pick, 'systemone').type).toBe('choice');
expect(toWireQuestion(rate, 'systemone').type).toBe('score');
expect(toWireQuestion(pick, 'port').criteria).toEqual({ a: null, b: null });
});

it('omits criteria when the question carries none', () => {
expect(toWireQuestion(boolean('durable?'), 'port')).not.toHaveProperty('criteria');
});
});

describe('readAnswer', () => {
it('reads a port boolean', () => {
expect(readAnswer({ type: 'boolean', probability: 0.9 }, 'port')).toEqual({
type: 'boolean',
probability: 0.9,
});
});

it('reads a System One noul as a boolean', () => {
expect(readAnswer({ type: 'noul', noul: 0.9 }, 'systemone')).toEqual({
type: 'boolean',
probability: 0.9,
});
});

it('does not read a noul when the port dialect was asked for', () => {
expect(readAnswer({ type: 'noul', noul: 0.9 }, 'port')).toBeNull();
});

it('reports an unmeasured confidence as null rather than zero', () => {
const answer = readAnswer(
{ type: 'choice', choice: 'a', probabilities: { a: 1 } },
'systemone',
);

expect(answer).toEqual({
type: 'choice',
choice: 'a',
confidence: null,
probabilities: { a: 1 },
});
});

it('drops an answer it cannot read rather than inventing one', () => {
expect(readAnswer({ type: 'choice' }, 'port')).toBeNull();
expect(readAnswer(null, 'port')).toBeNull();
expect(readAnswer('yes', 'port')).toBeNull();
});
});
Loading