mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-07 15:09:41 +00:00
* feat: wire Keenable web-search provider into config, schema, and UI Keenable landed as a search provider in @librechat/agents (#285, shipped in 3.2.58+), but LibreChat did not yet expose it. This adds the config/schema/UI glue so it can be selected, mirroring the existing Tavily provider. - data-provider: add `keenable` to SearchProvider type + SearchProviders enum, keenableApiKey/keenableApiUrl schema fields, and a keenableSearchOptions block (maxResults, site, attributionTitle, timeout). - data-schemas: register keenable in webSearchAuth.providers and default the key/URL placeholders in loadWebSearchConfig. - api/web: pass keenableSearchOptions through to the provider and handle Keenable's keyless model. Unlike other providers it authenticates with no key (the public endpoint), picking up an optional key/URL when set; the URL override is SSRF-preflighted like other user-provided URLs. - client: add Keenable to the provider dropdown with an optional API-key input. - docs: document KEENABLE_API_KEY/KEENABLE_API_URL in .env.example and a webSearch example in librechat.example.yaml. - tests: keyless + keyed auth resolution, config defaults, and schema parsing. * fix: ESLint no-unused-vars and clarify Keenable yaml example - Remove the now-unused RerankerTypes import in data-schemas web.ts (the lint job runs with --max-warnings 0 on changed files, so this latent warning failed CI once the file was touched). - Note in the librechat.example.yaml Keenable stanza that a scraper (and reranker) is still required for web search to load, and include a Firecrawl scraper in the example. * chore: fix import order drift (sort-imports) * feat: add Keenable as a keyless scraper and select it without a pinned provider The Keenable scraper landed in @librechat/agents#337, so wire the scraper category the same way the search provider already is: `scraperProvider: keenable` reads pages through Keenable's public fetch endpoint with no key (a key only lifts rate limits, and the endpoint is overridden with KEENABLE_FETCH_URL). Paired with `rerankerType: none` this makes a fully keyless web-search stack possible for the first time. Also closes the Codex finding on this PR: because none of Keenable's auth fields are required, the generic auth loop skips it whenever it isn't pinned, so a key submitted through the API-key dialog (which cannot pin a provider) left the providers category unauthenticated. Keenable is now selected in that case, gated on one of its values actually being present so installs that configured nothing keep their current behavior. The scraper gets the same fallback, additionally gated on Keenable being the resolved search provider, so it never silently scrapes for another provider. * fix: select the Keenable scraper from a supplied key, not only for Keenable search The API-key dialog submits credentials and cannot pin a provider, so choosing Keenable as the scraper while search stays on Serper/SearXNG/Tavily had no effect: the unpinned-scraper fallback required Keenable to also be the resolved search provider. A supplied Keenable value now triggers it as well, which is the only signal the dialog can send. The fallback still runs only when no keyed scraper authenticated, and with neither trigger the category stays unauthenticated, so a deployment that never configured Keenable is unaffected. Note the fully keyless choice still cannot be expressed through the dialog: Keenable's key is optional, so picking it with no key submits nothing at all. librechat.example.yaml now documents pinning scraperProvider: keenable for that case. * style: Sort Keenable imports * fix: Harden Keenable auth resolution * fix: Preserve Keenable selection intent * fix: Fail closed on invalid web search auth * fix: close keenable auth gaps * style: sort web auth imports * fix: preserve web search selection integrity * fix: isolate web search auth ordering * fix: silence expected credential misses * chore: bump agents sdk * fix: preserve web search preference ownership * fix: forward cleared Keenable endpoint * style: sort web search hook imports * fix: require intent for credential clears --------- Co-authored-by: Ilya Bogin <ilya.bogin@keenable.ai>
149 lines
4.6 KiB
JavaScript
149 lines
4.6 KiB
JavaScript
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
checkAccess: jest.fn(),
|
|
loadWebSearchAuth: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
getRoleByName: jest.fn(),
|
|
createToolCall: jest.fn(),
|
|
getToolCallsByConvo: jest.fn(),
|
|
getMessage: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
processFileURL: jest.fn(),
|
|
uploadImageBuffer: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
processCodeOutput: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/app/clients/tools/util', () => ({
|
|
loadTools: jest.fn(),
|
|
}));
|
|
|
|
const { Tools, AuthType } = require('librechat-data-provider');
|
|
const { loadWebSearchAuth } = require('@librechat/api');
|
|
const { verifyToolAuth } = require('../tools');
|
|
|
|
/**
|
|
* Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally
|
|
* returns system-authenticated. Sandbox auth moved server-side into the
|
|
* agents library, so the per-user `CODE_API_KEY` check that previously
|
|
* gated this endpoint is gone. The deployment contract is: if the
|
|
* admin enabled the `execute_code` capability, the sandbox is
|
|
* reachable. This endpoint does not probe reachability (would be too
|
|
* expensive per UI-gate query); failures surface at execution time.
|
|
*
|
|
* A regression where someone re-adds an auth check here would
|
|
* resurrect the per-user key-entry dialog on the client, which Phase 8
|
|
* explicitly removed. Pin the contract.
|
|
*/
|
|
describe('verifyToolAuth — execute_code system-auth contract', () => {
|
|
const makeReq = (toolId) => ({
|
|
params: { toolId },
|
|
user: { id: 'user-1' },
|
|
config: {},
|
|
});
|
|
|
|
const makeRes = () => {
|
|
const res = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
};
|
|
|
|
it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => {
|
|
const res = makeRes();
|
|
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
authenticated: true,
|
|
message: AuthType.SYSTEM_DEFINED,
|
|
});
|
|
});
|
|
|
|
it('returns 404 for unknown tool ids (not in directCallableTools)', async () => {
|
|
const res = makeRes();
|
|
await verifyToolAuth(makeReq('not_a_real_tool'), res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' });
|
|
});
|
|
|
|
it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => {
|
|
/* Regression guard: a future refactor that threads per-user auth back
|
|
in would resurface the key-entry dialog on the client. Pin that
|
|
the auth path is never consulted. */
|
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
|
loadAuthValues.mockClear();
|
|
|
|
await verifyToolAuth(makeReq(Tools.execute_code), makeRes());
|
|
|
|
expect(loadAuthValues).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => {
|
|
const res = makeRes();
|
|
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
|
|
|
const payload = res.json.mock.calls[0][0];
|
|
expect(payload.message).not.toBe(AuthType.USER_PROVIDED);
|
|
});
|
|
});
|
|
|
|
describe('verifyToolAuth — web search selection contract', () => {
|
|
const makeRes = () => {
|
|
const res = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
};
|
|
|
|
it('returns the resolved provider selections with the auth state', async () => {
|
|
loadWebSearchAuth.mockResolvedValue({
|
|
authenticated: true,
|
|
authTypes: {
|
|
providers: AuthType.USER_PROVIDED,
|
|
scrapers: AuthType.USER_PROVIDED,
|
|
rerankers: AuthType.SYSTEM_DEFINED,
|
|
},
|
|
authResult: {
|
|
searchProvider: 'keenable',
|
|
scraperProvider: 'keenable',
|
|
rerankerType: 'none',
|
|
},
|
|
});
|
|
const req = {
|
|
params: { toolId: Tools.web_search },
|
|
user: { id: 'user-1' },
|
|
config: { webSearch: {} },
|
|
};
|
|
const res = makeRes();
|
|
|
|
await verifyToolAuth(req, res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
expect(res.json).toHaveBeenCalledWith({
|
|
authenticated: true,
|
|
authTypes: {
|
|
providers: AuthType.USER_PROVIDED,
|
|
scrapers: AuthType.USER_PROVIDED,
|
|
rerankers: AuthType.SYSTEM_DEFINED,
|
|
},
|
|
searchProvider: 'keenable',
|
|
scraperProvider: 'keenable',
|
|
rerankerType: 'none',
|
|
});
|
|
});
|
|
});
|