mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
LLM: provider-agnostic code (not finished); styles have to be in sylesheets (not finished)
This commit is contained in:
@@ -1,48 +1,50 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import { promises as fs } from 'fs'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import { encryptSecret, decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: at-rest encryption of admin API key
|
||||
import { createLLMProvider } from './LLMProviderFactory.mjs'
|
||||
import {
|
||||
DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
DEFAULT_ERROR_PROMPT,
|
||||
DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
mergeActionPrompts,
|
||||
DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
DEFAULT_ERROR_PROMPT,
|
||||
DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
mergeActionPrompts,
|
||||
} from './LLMPrompts.mjs' // overleaf-lab: editable prompt defaults + merge helper
|
||||
|
||||
// Persist admin LLM settings in the same volume used by Overleaf data
|
||||
const ADMIN_SETTINGS_PATH = process.env.LLM_ADMIN_SETTINGS_PATH ||
|
||||
'/var/lib/overleaf/data/llm-admin-settings.json'
|
||||
'/var/lib/overleaf/data/llm-admin-settings.json'
|
||||
|
||||
// overleaf-lab: fallback for the review answer budget when the admin has not set one.
|
||||
// Mirrors LLMComplianceController's REVIEW_MAX_TOKENS default (env override, else
|
||||
// 12000). Duplicated here on purpose: importing it would make the two controllers
|
||||
// import each other, since the compliance one already imports this module.
|
||||
const DEFAULT_REVIEW_MAX_TOKENS =
|
||||
Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) > 0
|
||||
? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10)
|
||||
: 12000
|
||||
Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) > 0
|
||||
? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10)
|
||||
: 12000
|
||||
|
||||
async function readAdminSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(ADMIN_SETTINGS_PATH, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return {}
|
||||
logger.warn({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not read admin settings file')
|
||||
return {}
|
||||
}
|
||||
function readAdminSettings() {
|
||||
try {
|
||||
const raw = fs.readFileSync(ADMIN_SETTINGS_PATH, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return {}
|
||||
logger.warn({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not read admin settings file')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAdminSettings(data) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(ADMIN_SETTINGS_PATH), { recursive: true })
|
||||
await fs.writeFile(ADMIN_SETTINGS_PATH, JSON.stringify(data, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file')
|
||||
throw err
|
||||
}
|
||||
function writeAdminSettings(data) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(ADMIN_SETTINGS_PATH), { recursive: true })
|
||||
fs.writeFileSync(ADMIN_SETTINGS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
} catch (err) {
|
||||
logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: the shared LLM backend can be configured either via this admin
|
||||
@@ -51,322 +53,310 @@ async function writeAdminSettings(data) {
|
||||
// here so the admin page and the model scan reflect an env-only configuration
|
||||
// instead of looking empty.
|
||||
function envModelList() {
|
||||
const raw = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || ''
|
||||
return raw
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(m => m.length > 0)
|
||||
const raw = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || ''
|
||||
return raw
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(m => m.length > 0)
|
||||
}
|
||||
|
||||
// Effective settings for display: the JSON value, else the env fallback, plus
|
||||
// flags telling the UI which values are inherited from the environment. The API
|
||||
// key value is never returned, only whether one is set.
|
||||
async function buildDisplaySettings() {
|
||||
const settings = await readAdminSettings()
|
||||
const envModels = envModelList()
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
return {
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '',
|
||||
hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY),
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModels,
|
||||
completionModel: settings.completionModel || '',
|
||||
llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL,
|
||||
hasApiKeyFromEnv: !settings.llmApiKey && !!process.env.LLM_API_KEY,
|
||||
allowedModelsFromEnv: !jsonHasModels && envModels.length > 0,
|
||||
// overleaf-lab: document compliance review settings
|
||||
complianceRubrics: Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [],
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags; absent field defaults to true so
|
||||
// existing installs keep every feature on.
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
// overleaf-lab: editable prompt overrides. Show the EFFECTIVE value (the
|
||||
// admin override when set, else the shipped default) plus the pristine
|
||||
// defaults so the admin page can offer a reset-to-default button.
|
||||
askAiSystemPrompt: settings.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: settings.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: settings.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(settings.askAiActionPrompts),
|
||||
promptDefaults: {
|
||||
askAiSystemPrompt: DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
},
|
||||
}
|
||||
const settings = readAdminSettings()
|
||||
const envModels = envModelList()
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
return {
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '',
|
||||
llmApiType: settings.llmApiType || process.env.LLM_API_TYPE || 'anthropic',
|
||||
hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY),
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModels,
|
||||
completionModel: settings.completionModel || '',
|
||||
llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL,
|
||||
llmApiTypeFromEnv: !settings.llmApiType && !!process.env.LLM_API_TYPE,
|
||||
hasApiKeyFromEnv: !settings.llmApiKey && !!process.env.LLM_API_KEY,
|
||||
allowedModelsFromEnv: !jsonHasModels && envModels.length > 0,
|
||||
// overleaf-lab: document compliance review settings
|
||||
complianceRubrics: Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [],
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags; absent field defaults to true so
|
||||
// existing installs keep every feature on.
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
// overleaf-lab: editable prompt overrides. Show the EFFECTIVE value (the
|
||||
// admin override when set, else the shipped default) plus the pristine
|
||||
// defaults so the admin page can offer a reset-to-default button.
|
||||
askAiSystemPrompt: settings.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: settings.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: settings.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(settings.askAiActionPrompts),
|
||||
promptDefaults: {
|
||||
askAiSystemPrompt: DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSettingsPage(req, res) {
|
||||
const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname
|
||||
res.render(pugPath, await buildDisplaySettings())
|
||||
const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname
|
||||
res.render(pugPath, await buildDisplaySettings())
|
||||
}
|
||||
|
||||
async function getAdminSettings(req, res) {
|
||||
res.json(await buildDisplaySettings())
|
||||
res.json(await buildDisplaySettings())
|
||||
}
|
||||
|
||||
const llmSettingsSchema = z.object({
|
||||
systemPrompt: z.string().max(4000),
|
||||
|
||||
llmApiUrl: z.string().optional(),
|
||||
llmApiType: z.string().optional(),
|
||||
llmApiKey: z.string().optional(),
|
||||
|
||||
allowedModels: z.array(z.string()).optional(),
|
||||
|
||||
completionModel: z.string().optional(),
|
||||
|
||||
complianceRubrics: z.array(z.unknown()).optional(),
|
||||
|
||||
reviewModel: z.string().optional(),
|
||||
|
||||
maxContextTokens: z.number().optional(),
|
||||
reviewMaxTokens: z.number().optional(),
|
||||
|
||||
chatEnabled: z.boolean().optional(),
|
||||
completionEnabled: z.boolean().optional(),
|
||||
reviewEnabled: z.boolean().optional(),
|
||||
|
||||
// overleaf-lab: editable prompt overrides. Each scalar prompt, when provided,
|
||||
// must be a string capped at 8000 chars. An empty string is allowed and means
|
||||
// "fall back to default" (buildDisplaySettings/getLLMPrompts use `|| DEFAULT`).
|
||||
askAiSystemPrompt: z.string().max(8000).optional(),
|
||||
errorPrompt: z.string().max(8000).optional(),
|
||||
reviewSystemPrompt: z.string().max(8000).optional(),
|
||||
|
||||
// overleaf-lab: action prompts, when provided, must be a plain (non-array) object.
|
||||
askAiActionPrompts: z
|
||||
.record(z.string(), z.string())
|
||||
.optional(),
|
||||
})
|
||||
|
||||
async function saveAdminSettings(req, res) {
|
||||
const {
|
||||
systemPrompt,
|
||||
llmApiUrl,
|
||||
llmApiKey,
|
||||
allowedModels,
|
||||
completionModel,
|
||||
complianceRubrics,
|
||||
reviewModel,
|
||||
maxContextTokens,
|
||||
reviewMaxTokens,
|
||||
chatEnabled,
|
||||
completionEnabled,
|
||||
reviewEnabled,
|
||||
// overleaf-lab: editable prompt overrides.
|
||||
askAiSystemPrompt,
|
||||
errorPrompt,
|
||||
reviewSystemPrompt,
|
||||
askAiActionPrompts,
|
||||
} = req.body
|
||||
const safeBody = llmSettingsSchema.safeParse(req.body)
|
||||
if (!safeBody.success) {
|
||||
const errors = Object.fromEntries(
|
||||
safeBody.error.issues.map(issue => [
|
||||
issue.path.join('.'),
|
||||
issue.message,
|
||||
])
|
||||
)
|
||||
logger.error( errors, 'Bad admin settings')
|
||||
// TODO: send all errors to the frontend and show them
|
||||
return res.status(400).json({
|
||||
error: safeBody.error.issues[0].message,
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof systemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'systemPrompt must be a string' })
|
||||
}
|
||||
if (systemPrompt.length > 4000) {
|
||||
return res.status(400).json({ error: 'systemPrompt must be 4000 characters or fewer' })
|
||||
}
|
||||
const {
|
||||
systemPrompt,
|
||||
llmApiUrl,
|
||||
llmApiType,
|
||||
llmApiKey,
|
||||
allowedModels,
|
||||
completionModel,
|
||||
complianceRubrics,
|
||||
reviewModel,
|
||||
maxContextTokens,
|
||||
reviewMaxTokens,
|
||||
chatEnabled,
|
||||
completionEnabled,
|
||||
reviewEnabled,
|
||||
// overleaf-lab: editable prompt overrides.
|
||||
askAiSystemPrompt,
|
||||
errorPrompt,
|
||||
reviewSystemPrompt,
|
||||
askAiActionPrompts,
|
||||
} = safeBody.data
|
||||
|
||||
if (llmApiUrl && typeof llmApiUrl !== 'string') {
|
||||
return res.status(400).json({ error: 'llmApiUrl must be a string' })
|
||||
}
|
||||
if (llmApiKey && typeof llmApiKey !== 'string') {
|
||||
return res.status(400).json({ error: 'llmApiKey must be a string' })
|
||||
}
|
||||
if (allowedModels && !Array.isArray(allowedModels)) {
|
||||
return res.status(400).json({ error: 'allowedModels must be an array' })
|
||||
}
|
||||
const existing = readAdminSettings()
|
||||
|
||||
// overleaf-lab: validate the document compliance review settings.
|
||||
if (complianceRubrics !== undefined && !Array.isArray(complianceRubrics)) {
|
||||
return res.status(400).json({ error: 'complianceRubrics must be an array' })
|
||||
}
|
||||
if (reviewModel !== undefined && typeof reviewModel !== 'string') {
|
||||
return res.status(400).json({ error: 'reviewModel must be a string' })
|
||||
}
|
||||
// overleaf-lab: sanitize each rubric and cap the count. Entries without an id or
|
||||
// name are dropped; text fields are length-capped. When not provided, keep the
|
||||
// existing rubrics untouched.
|
||||
let sanitizedRubrics
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
sanitizedRubrics = complianceRubrics
|
||||
.map(r => ({
|
||||
id: String((r && r.id) || ''),
|
||||
name: String((r && r.name) || '').slice(0, 200),
|
||||
guidelines: String((r && r.guidelines) || '').slice(0, 20000),
|
||||
// overleaf-lab: per-rubric mechanical scans ("Label :: regex" per
|
||||
// line); policy lives with the rubric it verifies, never in code.
|
||||
scanPatterns: String((r && r.scanPatterns) || '').slice(0, 4000),
|
||||
}))
|
||||
.filter(r => r.id && r.name)
|
||||
.slice(0, 50)
|
||||
} else {
|
||||
sanitizedRubrics = Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : []
|
||||
}
|
||||
|
||||
// overleaf-lab: per-feature enable flags are optional booleans. When provided
|
||||
// they must be booleans; when omitted the existing value is preserved below.
|
||||
if (chatEnabled !== undefined && typeof chatEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'chatEnabled must be a boolean' })
|
||||
}
|
||||
if (completionEnabled !== undefined && typeof completionEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'completionEnabled must be a boolean' })
|
||||
}
|
||||
if (reviewEnabled !== undefined && typeof reviewEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'reviewEnabled must be a boolean' })
|
||||
}
|
||||
// overleaf-lab: clamp the context window to a sane range; keep existing (or the
|
||||
// 32000 default) when not provided.
|
||||
let sanitizedMaxContextTokens
|
||||
if (maxContextTokens !== undefined) {
|
||||
const parsed = parseInt(maxContextTokens, 10)
|
||||
sanitizedMaxContextTokens = Number.isNaN(parsed)
|
||||
? existing.maxContextTokens || 32000
|
||||
: Math.min(1000000, Math.max(2000, parsed))
|
||||
} else {
|
||||
sanitizedMaxContextTokens = existing.maxContextTokens || 32000
|
||||
}
|
||||
|
||||
// overleaf-lab: editable prompt overrides. Each scalar prompt, when provided,
|
||||
// must be a string capped at 8000 chars. An empty string is allowed and means
|
||||
// "fall back to default" (buildDisplaySettings/getLLMPrompts use `|| DEFAULT`).
|
||||
if (askAiSystemPrompt !== undefined && typeof askAiSystemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'askAiSystemPrompt must be a string' })
|
||||
}
|
||||
if (typeof askAiSystemPrompt === 'string' && askAiSystemPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'askAiSystemPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
if (errorPrompt !== undefined && typeof errorPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'errorPrompt must be a string' })
|
||||
}
|
||||
if (typeof errorPrompt === 'string' && errorPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'errorPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
if (reviewSystemPrompt !== undefined && typeof reviewSystemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'reviewSystemPrompt must be a string' })
|
||||
}
|
||||
if (typeof reviewSystemPrompt === 'string' && reviewSystemPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'reviewSystemPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
// overleaf-lab: action prompts, when provided, must be a plain (non-array) object.
|
||||
if (
|
||||
askAiActionPrompts !== undefined &&
|
||||
(typeof askAiActionPrompts !== 'object' ||
|
||||
askAiActionPrompts === null ||
|
||||
Array.isArray(askAiActionPrompts))
|
||||
) {
|
||||
return res.status(400).json({ error: 'askAiActionPrompts must be an object' })
|
||||
}
|
||||
// overleaf-lab: clamp the review answer budget. This is the model's max_tokens for
|
||||
// the report AND the room reserved for it in the context check, so it is bounded
|
||||
// well below any real context window.
|
||||
let sanitizedReviewMaxTokens
|
||||
if (reviewMaxTokens !== undefined) {
|
||||
const parsed = parseInt(reviewMaxTokens, 10)
|
||||
sanitizedReviewMaxTokens = Number.isNaN(parsed)
|
||||
? existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
: Math.min(128000, Math.max(500, parsed))
|
||||
} else {
|
||||
sanitizedReviewMaxTokens = existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
}
|
||||
|
||||
const existing = await readAdminSettings()
|
||||
|
||||
// overleaf-lab: sanitize each rubric and cap the count. Entries without an id or
|
||||
// name are dropped; text fields are length-capped. When not provided, keep the
|
||||
// existing rubrics untouched.
|
||||
let sanitizedRubrics
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
sanitizedRubrics = complianceRubrics
|
||||
.map(r => ({
|
||||
id: String((r && r.id) || ''),
|
||||
name: String((r && r.name) || '').slice(0, 200),
|
||||
guidelines: String((r && r.guidelines) || '').slice(0, 20000),
|
||||
// overleaf-lab: per-rubric mechanical scans ("Label :: regex" per
|
||||
// line); policy lives with the rubric it verifies, never in code.
|
||||
scanPatterns: String((r && r.scanPatterns) || '').slice(0, 4000),
|
||||
}))
|
||||
.filter(r => r.id && r.name)
|
||||
.slice(0, 50)
|
||||
} else {
|
||||
sanitizedRubrics = Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : []
|
||||
}
|
||||
|
||||
// overleaf-lab: clamp the context window to a sane range; keep existing (or the
|
||||
// 32000 default) when not provided.
|
||||
let sanitizedMaxContextTokens
|
||||
if (maxContextTokens !== undefined) {
|
||||
const parsed = parseInt(maxContextTokens, 10)
|
||||
sanitizedMaxContextTokens = Number.isNaN(parsed)
|
||||
? existing.maxContextTokens || 32000
|
||||
: Math.min(1000000, Math.max(2000, parsed))
|
||||
} else {
|
||||
sanitizedMaxContextTokens = existing.maxContextTokens || 32000
|
||||
}
|
||||
|
||||
// overleaf-lab: clamp the review answer budget. This is the model's max_tokens for
|
||||
// the report AND the room reserved for it in the context check, so it is bounded
|
||||
// well below any real context window.
|
||||
let sanitizedReviewMaxTokens
|
||||
if (reviewMaxTokens !== undefined) {
|
||||
const parsed = parseInt(reviewMaxTokens, 10)
|
||||
sanitizedReviewMaxTokens = Number.isNaN(parsed)
|
||||
? existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
: Math.min(128000, Math.max(500, parsed))
|
||||
} else {
|
||||
sanitizedReviewMaxTokens = existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
}
|
||||
|
||||
// overleaf-lab: validate each rubric's scan patterns ("Label :: regex" per line)
|
||||
// so the admin learns about a broken regex at save time, not from a silently
|
||||
// hint-less review. The reviewer side skips invalid lines anyway (defense in
|
||||
// depth for settings written by other means).
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
for (const r of complianceRubrics) {
|
||||
const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : ''
|
||||
if (patternsText.length > 4000) {
|
||||
return res.status(400).json({
|
||||
error: `Scan patterns of rubric "${(r && r.name) || '?'}" must be 4000 characters or fewer`,
|
||||
})
|
||||
}
|
||||
for (const rawLine of patternsText.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const sep = line.indexOf('::')
|
||||
const body = (sep === -1 ? line : line.slice(sep + 2)).trim()
|
||||
if (!body) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(body, 'i')
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
// overleaf-lab: validate each rubric's scan patterns ("Label :: regex" per line)
|
||||
// so the admin learns about a broken regex at save time, not from a silently
|
||||
// hint-less review. The reviewer side skips invalid lines anyway (defense in
|
||||
// depth for settings written by other means).
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
for (const r of complianceRubrics) {
|
||||
const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : ''
|
||||
if (patternsText.length > 4000) {
|
||||
return res.status(400).json({
|
||||
error: `Scan patterns of rubric "${(r && r.name) || '?'}" must be 4000 characters or fewer`,
|
||||
})
|
||||
}
|
||||
for (const rawLine of patternsText.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: sanitize the action prompt overrides. When provided, keep only
|
||||
// known keys with string values, each capped at 4000 chars. When not provided,
|
||||
// keep the existing object untouched.
|
||||
let sanitizedActionPrompts
|
||||
if (askAiActionPrompts !== undefined) {
|
||||
sanitizedActionPrompts = {}
|
||||
for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) {
|
||||
const val = askAiActionPrompts[key]
|
||||
if (typeof val === 'string') {
|
||||
sanitizedActionPrompts[key] = val.slice(0, 4000)
|
||||
}
|
||||
const sep = line.indexOf('::')
|
||||
const body = (sep === -1 ? line : line.slice(sep + 2)).trim()
|
||||
if (!body) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
sanitizedActionPrompts =
|
||||
existing.askAiActionPrompts &&
|
||||
typeof existing.askAiActionPrompts === 'object' &&
|
||||
!Array.isArray(existing.askAiActionPrompts)
|
||||
? existing.askAiActionPrompts
|
||||
: {}
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(body, 'i')
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
...existing,
|
||||
systemPrompt,
|
||||
llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''),
|
||||
allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [],
|
||||
completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''),
|
||||
complianceRubrics: sanitizedRubrics,
|
||||
reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''),
|
||||
maxContextTokens: sanitizedMaxContextTokens,
|
||||
reviewMaxTokens: sanitizedReviewMaxTokens,
|
||||
// overleaf-lab: omitted flag keeps the existing value (default true).
|
||||
chatEnabled: typeof chatEnabled === 'boolean' ? chatEnabled : (existing.chatEnabled !== false),
|
||||
completionEnabled: typeof completionEnabled === 'boolean' ? completionEnabled : (existing.completionEnabled !== false),
|
||||
reviewEnabled: typeof reviewEnabled === 'boolean' ? reviewEnabled : (existing.reviewEnabled !== false),
|
||||
// overleaf-lab: editable prompt overrides. An empty string is stored as-is
|
||||
// and later falls back to the default via `|| DEFAULT`.
|
||||
askAiSystemPrompt: typeof askAiSystemPrompt === 'string' ? askAiSystemPrompt : (existing.askAiSystemPrompt || ''),
|
||||
errorPrompt: typeof errorPrompt === 'string' ? errorPrompt : (existing.errorPrompt || ''),
|
||||
reviewSystemPrompt: typeof reviewSystemPrompt === 'string' ? reviewSystemPrompt : (existing.reviewSystemPrompt || ''),
|
||||
askAiActionPrompts: sanitizedActionPrompts,
|
||||
// overleaf-lab: sanitize the action prompt overrides. When provided, keep only
|
||||
// known keys with string values, each capped at 4000 chars. When not provided,
|
||||
// keep the existing object untouched.
|
||||
let sanitizedActionPrompts
|
||||
if (askAiActionPrompts !== undefined) {
|
||||
sanitizedActionPrompts = {}
|
||||
for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) {
|
||||
const val = askAiActionPrompts[key]
|
||||
if (typeof val === 'string') {
|
||||
sanitizedActionPrompts[key] = val.slice(0, 4000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sanitizedActionPrompts =
|
||||
existing.askAiActionPrompts &&
|
||||
typeof existing.askAiActionPrompts === 'object' &&
|
||||
!Array.isArray(existing.askAiActionPrompts)
|
||||
? existing.askAiActionPrompts
|
||||
: {}
|
||||
}
|
||||
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) {
|
||||
updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest
|
||||
}
|
||||
const updatedSettings = {
|
||||
...existing,
|
||||
systemPrompt,
|
||||
llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''),
|
||||
llmApiType: typeof llmApiType === 'string' ? llmApiType : (existing.llmApiType || ''),
|
||||
allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [],
|
||||
completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''),
|
||||
complianceRubrics: sanitizedRubrics,
|
||||
reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''),
|
||||
maxContextTokens: sanitizedMaxContextTokens,
|
||||
reviewMaxTokens: sanitizedReviewMaxTokens,
|
||||
// overleaf-lab: omitted flag keeps the existing value (default true).
|
||||
chatEnabled: typeof chatEnabled === 'boolean' ? chatEnabled : (existing.chatEnabled !== false),
|
||||
completionEnabled: typeof completionEnabled === 'boolean' ? completionEnabled : (existing.completionEnabled !== false),
|
||||
reviewEnabled: typeof reviewEnabled === 'boolean' ? reviewEnabled : (existing.reviewEnabled !== false),
|
||||
// overleaf-lab: editable prompt overrides. An empty string is stored as-is
|
||||
// and later falls back to the default via `|| DEFAULT`.
|
||||
askAiSystemPrompt: typeof askAiSystemPrompt === 'string' ? askAiSystemPrompt : (existing.askAiSystemPrompt || ''),
|
||||
errorPrompt: typeof errorPrompt === 'string' ? errorPrompt : (existing.errorPrompt || ''),
|
||||
reviewSystemPrompt: typeof reviewSystemPrompt === 'string' ? reviewSystemPrompt : (existing.reviewSystemPrompt || ''),
|
||||
askAiActionPrompts: sanitizedActionPrompts,
|
||||
}
|
||||
|
||||
await writeAdminSettings(updatedSettings)
|
||||
logger.info({
|
||||
length: systemPrompt.length,
|
||||
llmApiUrl: !!updatedSettings.llmApiUrl,
|
||||
hasLlmApiKey: !!updatedSettings.llmApiKey,
|
||||
allowedModels: updatedSettings.allowedModels?.length || 0,
|
||||
}, '[LLM] Admin settings updated')
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) {
|
||||
updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest
|
||||
}
|
||||
|
||||
res.json({ success: true })
|
||||
await writeAdminSettings(updatedSettings)
|
||||
logger.info({
|
||||
length: systemPrompt.length,
|
||||
llmApiUrl: !!updatedSettings.llmApiUrl,
|
||||
llmApiType: !!updatedSettings.llmApiType,
|
||||
hasLlmApiKey: !!updatedSettings.llmApiKey,
|
||||
allowedModels: updatedSettings.allowedModels?.length || 0,
|
||||
}, '[LLM] Admin settings updated')
|
||||
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// Exported so LLMChatController can prepend the admin system prompt
|
||||
export async function getSystemPrompt() {
|
||||
const settings = await readAdminSettings()
|
||||
return settings.systemPrompt || null
|
||||
const settings = readAdminSettings()
|
||||
return settings.systemPrompt || null
|
||||
}
|
||||
|
||||
export async function getAdminLLMSettings() {
|
||||
const settings = await readAdminSettings()
|
||||
// overleaf-lab: fall back to env so the model scan / connection-check and the
|
||||
// chat share the same effective config (mirrors buildDisplaySettings above).
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
// overleaf-lab: the stored admin key is encrypted at rest; decrypt before use.
|
||||
// decryptSecret returns legacy plaintext (no enc:v1: prefix) unchanged.
|
||||
const jsonKey = settings.llmApiKey ? decryptSecret(settings.llmApiKey) : ''
|
||||
return {
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || null,
|
||||
llmApiKey: jsonKey || process.env.LLM_API_KEY || null,
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModelList(),
|
||||
completionModel: settings.completionModel || '',
|
||||
// overleaf-lab: document compliance review settings
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags (absent field defaults to true).
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
}
|
||||
const settings = readAdminSettings()
|
||||
// overleaf-lab: fall back to env so the model scan / connection-check and the
|
||||
// chat share the same effective config (mirrors buildDisplaySettings above).
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
// overleaf-lab: the stored admin key is encrypted at rest; decrypt before use.
|
||||
// decryptSecret returns legacy plaintext (no enc:v1: prefix) unchanged.
|
||||
const jsonKey = settings.llmApiKey ? decryptSecret(settings.llmApiKey) : ''
|
||||
return {
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || null,
|
||||
llmApiType: settings.llmApiType || process.env.LLM_API_TYPE || null,
|
||||
llmApiKey: jsonKey || process.env.LLM_API_KEY || null,
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModelList(),
|
||||
completionModel: settings.completionModel || '',
|
||||
// overleaf-lab: document compliance review settings
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags (absent field defaults to true).
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: per-feature enable flags for the chat, inline completion, and
|
||||
@@ -374,149 +364,113 @@ export async function getAdminLLMSettings() {
|
||||
// installs keep every feature on. Used for backend enforcement across the
|
||||
// project-scoped controllers and the user settings page.
|
||||
export async function getLLMFeatureFlags() {
|
||||
const s = await readAdminSettings()
|
||||
return {
|
||||
chatEnabled: s.chatEnabled !== false,
|
||||
completionEnabled: s.completionEnabled !== false,
|
||||
reviewEnabled: s.reviewEnabled !== false,
|
||||
}
|
||||
const s = readAdminSettings()
|
||||
return {
|
||||
chatEnabled: s.chatEnabled !== false,
|
||||
completionEnabled: s.completionEnabled !== false,
|
||||
reviewEnabled: s.reviewEnabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: exposed so the compliance controller can load the configured
|
||||
// rubrics (readAdminSettings already handles the missing-file case).
|
||||
export async function getComplianceRubrics() {
|
||||
const settings = await readAdminSettings()
|
||||
return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : []
|
||||
const settings = readAdminSettings()
|
||||
return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : []
|
||||
}
|
||||
|
||||
// overleaf-lab: resolve the EFFECTIVE editable prompts (admin override when set,
|
||||
// else the shipped default). Consumed by the compliance reviewer and the
|
||||
// project-scoped GET /llm/prompts endpoint so the frontend and backend agree.
|
||||
export async function getLLMPrompts() {
|
||||
const s = await readAdminSettings()
|
||||
return {
|
||||
askAiSystemPrompt: s.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: s.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: s.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(s.askAiActionPrompts),
|
||||
}
|
||||
const s = readAdminSettings()
|
||||
return {
|
||||
askAiSystemPrompt: s.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: s.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: s.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(s.askAiActionPrompts),
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminLLMConnection(req, res) {
|
||||
const { apiUrl, apiKey } = req.body
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const effectiveUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const effectiveKey = apiKey || adminSettings.llmApiKey
|
||||
// overleaf-lab: use first configured/allowed model instead of hardcoded 'qwen3-32b'
|
||||
const testModel =
|
||||
adminSettings.allowedModels[0] ||
|
||||
(process.env.LLM_MODEL_NAME || 'default').split(',')[0].trim()
|
||||
const { apiUrl, apiKey, apiType } = req.body
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const llmApiUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const llmApiType = apiType || adminSettings.llmApiType
|
||||
const llmApiKey = apiKey || adminSettings.llmApiKey
|
||||
|
||||
// overleaf-lab: only the URL is required. A local llama.cpp server has no
|
||||
// auth, so an empty key is valid; send Authorization only when a key exists.
|
||||
if (!effectiveUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM API URL is required',
|
||||
})
|
||||
}
|
||||
// use first configured/allowed model, if no model is configured, use a model hardcoded in the provider code
|
||||
const testModel =
|
||||
adminSettings.allowedModels[0] ||
|
||||
(process.env.LLM_MODEL_NAME || '').split(',')[0].trim()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
if (!llmApiUrl || !llmApiType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM API URL and type is required',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (typeof effectiveKey === 'string' && effectiveKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${effectiveKey}`
|
||||
}
|
||||
const response = await fetch(`${effectiveUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: testModel,
|
||||
messages: [{ role: 'user', content: 'Test connection' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType })
|
||||
await provider.checkConnection(testModel)
|
||||
|
||||
clearTimeout(timeout)
|
||||
res.json({ success: true, message: 'Connection successful' })
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM connection failed',
|
||||
status: response.status,
|
||||
details: body,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Connection successful' })
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
if (err.name === 'AbortError') {
|
||||
return res.status(504).json({
|
||||
success: false,
|
||||
error: 'Connection timeout',
|
||||
})
|
||||
}
|
||||
logger.error({ err }, '[LLM] Admin connection check failed')
|
||||
res.status(500).json({ success: false, error: 'Connection attempt failed' })
|
||||
}
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error({ err }, '[LLM] Admin connection check failed')
|
||||
res.status(errStatus).json({
|
||||
success: false,
|
||||
error: 'LLM connection failed',
|
||||
status: errStatus,
|
||||
details: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function scanAdminModels(req, res) {
|
||||
const { apiUrl, apiKey } = req.query
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const llmApiUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const llmApiKey = apiKey || adminSettings.llmApiKey
|
||||
const { apiUrl, apiKey, apiType } = req.query
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const llmApiUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const llmApiType = apiType || adminSettings.llmApiType
|
||||
const llmApiKey = apiKey || adminSettings.llmApiKey
|
||||
|
||||
// overleaf-lab: only the URL is required. A local llama.cpp server has no
|
||||
// auth, so an empty key is valid; send Authorization only when a key exists.
|
||||
if (!llmApiUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Admin LLM API URL must be configured first',
|
||||
})
|
||||
}
|
||||
if (!llmApiUrl || !llmApiType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Admin LLM API URL and type must be configured first',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {}
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
const response = await fetch(`${llmApiUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
try {
|
||||
const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType })
|
||||
const result = await provider.listModels()
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch models',
|
||||
status: response.status,
|
||||
details: body,
|
||||
})
|
||||
}
|
||||
const ids = Array.isArray(result?.data)
|
||||
? result.data.map(entry => String(entry.id))
|
||||
: []
|
||||
|
||||
const data = await response.json()
|
||||
const ids = Array.isArray(data?.data)
|
||||
? data.data.map(entry => String(entry.id))
|
||||
: []
|
||||
res.json({ success: true, models: ids })
|
||||
|
||||
res.json({ success: true, models: ids })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[LLM] Admin model scan failed')
|
||||
res.status(500).json({ success: false, error: 'Model scan failed' })
|
||||
}
|
||||
} catch (error) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error({ err }, '[LLM] Admin connection check failed')
|
||||
res.status(errStatus).json({
|
||||
success: false,
|
||||
error: 'Model scan failed',
|
||||
status: errStatus,
|
||||
details: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
adminSettingsPage: expressify(adminSettingsPage),
|
||||
getAdminSettings: expressify(getAdminSettings),
|
||||
saveAdminSettings: expressify(saveAdminSettings),
|
||||
checkAdminLLMConnection: expressify(checkAdminLLMConnection),
|
||||
scanAdminModels: expressify(scanAdminModels),
|
||||
adminSettingsPage: expressify(adminSettingsPage),
|
||||
getAdminSettings: expressify(getAdminSettings),
|
||||
saveAdminSettings: expressify(saveAdminSettings),
|
||||
checkAdminLLMConnection: expressify(checkAdminLLMConnection),
|
||||
scanAdminModels: expressify(scanAdminModels),
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import logger from '@overleaf/logger'
|
||||
import fetch from 'node-fetch'
|
||||
import { AbortController } from 'abort-controller'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import OError from '@overleaf/o-error'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import { User } from '../../../../app/src/models/User.mjs'
|
||||
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' // overleaf-lab: read project docs for error source context
|
||||
import Settings from '@overleaf/settings'
|
||||
import { getSystemPrompt, getAdminLLMSettings, getLLMFeatureFlags, getLLMPrompts } from './LLMAdminController.mjs'
|
||||
import { decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: decrypt user API keys stored at rest
|
||||
import { createLLMProvider } from './LLMProviderFactory.mjs'
|
||||
|
||||
// Helper function to remove <think> tags (for DeepSeek, Qwen and similar models)
|
||||
// Handles both closed <think>...</think> and unclosed <think>... at end of string
|
||||
@@ -229,23 +231,19 @@ async function chat(req, res) {
|
||||
})
|
||||
}
|
||||
|
||||
// if model is not configured, the model hardcoded in the provider code will be used
|
||||
const modelNameForApi = personalModelName
|
||||
? personalModelName
|
||||
: model || ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || 'default').split(',')[0].trim()) // overleaf-lab: env-configurable fallback instead of hardcoded 'qwen3-32b'
|
||||
: model || ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || '').split(',')[0].trim())
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 300000) // 5 minutes
|
||||
const provider = createLLMProvider({ llmApiUrl, llmApiKey })
|
||||
|
||||
try {
|
||||
const llmApiFullUrl = `${llmApiUrl}/chat/completions`
|
||||
|
||||
// Prepend the admin-configured system prompt (if any) plus an always-on
|
||||
// instruction to reply in the user's language, then merge any client
|
||||
// system message. This keeps replies in the input language regardless of
|
||||
// whether an admin prompt is set.
|
||||
const languageInstruction = `Reply in the same language as the user's latest message (for example, answer in Italian if the user writes in Italian).`
|
||||
const languageInstruction = "Reply in the same language as the user's latest message (for example, answer in Italian if the user writes in Italian)."
|
||||
const adminSystemPrompt = await getSystemPrompt()
|
||||
const systemPreamble = adminSystemPrompt
|
||||
? `${adminSystemPrompt}\n\n${languageInstruction}`
|
||||
@@ -271,99 +269,37 @@ async function chat(req, res) {
|
||||
{
|
||||
projectId,
|
||||
model: modelNameForApi,
|
||||
url: llmApiFullUrl,
|
||||
url: llmApiUrl,
|
||||
isPersonalModel,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
'[LLM] chat: sending request to LLM API'
|
||||
)
|
||||
|
||||
// overleaf-lab: send Authorization only when a non-empty key exists, so a
|
||||
// keyless local server is not sent a malformed empty Bearer header.
|
||||
const chatHeaders = { 'Content-Type': 'application/json' }
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
chatHeaders.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(llmApiFullUrl, {
|
||||
method: 'POST',
|
||||
headers: chatHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(
|
||||
{ projectId, status: response.status, duration: `${duration}ms` },
|
||||
'[LLM] chat: LLM API responded'
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
'[LLM] API error response'
|
||||
)
|
||||
return res.status(response.status).json({
|
||||
error: 'LLM API error',
|
||||
details: errorText,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Strip <think> tags from the response content
|
||||
if (
|
||||
data.choices &&
|
||||
data.choices[0] &&
|
||||
data.choices[0].message &&
|
||||
data.choices[0].message.content
|
||||
) {
|
||||
const originalLength = data.choices[0].message.content.length
|
||||
data.choices[0].message.content = stripThinkTags(
|
||||
data.choices[0].message.content
|
||||
)
|
||||
const strippedLength = data.choices[0].message.content.length
|
||||
if (originalLength !== strippedLength) {
|
||||
logger.debug(
|
||||
{ originalLength, strippedLength },
|
||||
'[LLM] chat: stripped <think> tags'
|
||||
)
|
||||
}
|
||||
}
|
||||
const data = await provider.chat({
|
||||
model: modelNameForApi,
|
||||
messages: finalMessages,
|
||||
max_tokens: 8192,
|
||||
// temperature: 0.7,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
{ projectId, duration: `${Date.now() - startTime}ms`, model: modelNameForApi },
|
||||
'[LLM] chat: response sent successfully'
|
||||
)
|
||||
res.json(data)
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return res.status(504).json({
|
||||
error: 'LLM service timeout',
|
||||
details: 'The LLM API did not respond within 5 minutes',
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ projectId, userId, err: error },
|
||||
'[LLM] Error communicating with LLM service'
|
||||
)
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to communicate with LLM service',
|
||||
details: error.message,
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error({ projectId, userId, err }, '[LLM] Error communicating with LLM service')
|
||||
return res.status(errStatus).json({
|
||||
error: err.message,
|
||||
details: info?.error?.message || error.cause?.message,
|
||||
status: errStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -485,71 +421,31 @@ async function completion(req, res) {
|
||||
return res.status(503).json({ success: false, error: 'LLM service is not configured' })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 30000) // 30 seconds for completions
|
||||
|
||||
const provider = createLLMProvider({ llmApiUrl, llmApiKey })
|
||||
try {
|
||||
const systemPrompt = `/no_think\nYou are a text completion engine. Output ONLY the missing text, in the same language as the surrounding text. No thinking, no explanation, no markdown, no code fences, no tags. Just the raw continuation characters.`
|
||||
const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]:\n\n${leftContext}[CURSOR]${rightContext}`
|
||||
|
||||
const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]:
|
||||
console.log("userPrompt = ", userPrompt)
|
||||
|
||||
${leftContext}[CURSOR]${rightContext}`
|
||||
|
||||
// overleaf-lab: send Authorization only when a non-empty key exists.
|
||||
const completionHeaders = { 'Content-Type': 'application/json' }
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
completionHeaders.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${llmApiUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: completionHeaders,
|
||||
body: JSON.stringify({
|
||||
model: completionModel,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxLength || 60,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
const data = await provider.completion({
|
||||
model: completionModel,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxLength || 60,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
return res
|
||||
.status(response.status)
|
||||
.json({ success: false, error: 'Completion request failed' })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const completionText =
|
||||
data.choices?.[0]?.message?.content?.trim() || ''
|
||||
|
||||
// Strip any think tags and clean up
|
||||
const cleaned = stripThinkTags(completionText)
|
||||
|
||||
res.json({ success: true, data: cleaned })
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return res
|
||||
.status(504)
|
||||
.json({ success: false, error: 'Completion timeout' })
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ projectId, userId, err: error },
|
||||
'[LLM] Completion error'
|
||||
)
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: 'Completion failed' })
|
||||
res.json({ success: true, data })
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error({ projectId, userId, err }, '[LLM] Completion error')
|
||||
return res.status(errStatus).json({
|
||||
success: false,
|
||||
error: 'Completion failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,7 @@ function extractJson(text) {
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
// Fricking vim makes the rest of the file red without this `
|
||||
const first = cleaned.indexOf('{')
|
||||
const last = cleaned.lastIndexOf('}')
|
||||
if (first === -1 || last === -1 || last < first) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
class ProviderRequestError extends OError {}
|
||||
|
||||
export {
|
||||
ProviderRequestError
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import OpenAIProvider from './providers/OpenAIProvider.mjs'
|
||||
import AnthropicProvider from './providers/AnthropicProvider.mjs'
|
||||
|
||||
export function createLLMProvider(settings) {
|
||||
switch (settings.llmProvider) {
|
||||
case 'openai':
|
||||
return new OpenAIProvider(settings)
|
||||
|
||||
case 'anthropic':
|
||||
default:
|
||||
return new AnthropicProvider(settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import BaseProvider from './BaseProvider.mjs'
|
||||
|
||||
// Helper function to remove <think> tags (for DeepSeek, Qwen and similar models)
|
||||
// Handles both closed <think>...</think> and unclosed <think>... at end of string
|
||||
function stripThinkTags(content) {
|
||||
let cleaned = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*/gi, '')
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
export default class AnthropicProvider extends BaseProvider {
|
||||
headers() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
|
||||
if (this.apiKey) {
|
||||
headers['x-api-key'] = this.apiKey
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return this.fetch('/v1/models')
|
||||
}
|
||||
|
||||
async checkConnection(model) {
|
||||
return this.chat({
|
||||
model: model || 'claude-sonnet-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Test connection',
|
||||
},
|
||||
],
|
||||
max_tokens: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async chat(body) {
|
||||
const data = await this.fetch('/v1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.buildRequest(body)),
|
||||
})
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
model: data.model,
|
||||
usage: data.usage,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: data.stop_reason,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: this.extractText(data),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async complete(body) {
|
||||
const data = await this.fetch('/v1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.buildRequest(body)),
|
||||
})
|
||||
|
||||
return this.extractText(data)
|
||||
}
|
||||
|
||||
buildRequest(body) {
|
||||
const { messages = [], ...rest } = body
|
||||
|
||||
const system = messages
|
||||
.filter(m => m.role === 'system')
|
||||
.map(m => m.content)
|
||||
.join('\n\n')
|
||||
|
||||
const request = {
|
||||
...rest,
|
||||
messages: messages.filter(m => m.role !== 'system'),
|
||||
}
|
||||
|
||||
if (system) {
|
||||
request.system = system
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
extractText(data) {
|
||||
return stripThinkTags(
|
||||
(data.content || [])
|
||||
.filter(x => x.type === 'text')
|
||||
.map(x => x.text)
|
||||
.join('')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import AbortError from 'node-fetch'
|
||||
import { fetchJson, RequestFailedError } from '@overleaf/fetch-utils'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
function handleError(err) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new OError('LLM request timed out', { timeout: REQUEST_TIMEOUT_MS / 1000, status: 504 }).withCause(err)
|
||||
}
|
||||
const status = err.response?.status || 500
|
||||
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
throw new OError('Something wrong with LLM request', { status }).withCause(err)
|
||||
}
|
||||
|
||||
let errInfo
|
||||
try {
|
||||
errInfo = JSON.parse(err.body)
|
||||
} catch {
|
||||
errInfo = { message: err.body }
|
||||
}
|
||||
|
||||
throw new OError('LLM request failed', errInfo).withCause(err)
|
||||
}
|
||||
|
||||
export default class BaseProvider {
|
||||
constructor({ llmApiUrl, llmApiKey }) {
|
||||
this.apiUrl = llmApiUrl.replace(/\/+$/, '')
|
||||
this.apiKey = llmApiKey || ''
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async checkConnection(model) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async chat(body) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async complete(body) {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async fetch(path, options = {}) {
|
||||
return await fetchJson(`${this.apiUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.headers(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}).catch(err => { handleError(err) })
|
||||
}
|
||||
|
||||
normalizeChatResponse(data) {
|
||||
return data
|
||||
}
|
||||
|
||||
headers() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BaseProvider from './BaseProvider.mjs'
|
||||
|
||||
// Helper function to remove <think> tags (for DeepSeek, Qwen and similar models)
|
||||
// Handles both closed <think>...</think> and unclosed <think>... at end of string
|
||||
function stripThinkTags(content) {
|
||||
let cleaned = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*/gi, '')
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
export default class OpenAIProvider extends BaseProvider {
|
||||
headers() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (this.apiKey) {
|
||||
headers.Authorization = `Bearer ${this.apiKey}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return this.fetch('/models')
|
||||
}
|
||||
|
||||
async checkConnection(model) {
|
||||
return this.chat({
|
||||
model: model || 'default',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Test connection',
|
||||
},
|
||||
],
|
||||
max_tokens: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async chat(body) {
|
||||
const data = await this.fetch('/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!data?.choices?.[0]?.message) {
|
||||
throw new Error('Invalid OpenAI compatible provider chat response')
|
||||
}
|
||||
|
||||
data.choices[0].message.content = this.extractText(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async complete(body) {
|
||||
const data = this.fetch('/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return this.extractText(data)
|
||||
}
|
||||
|
||||
extractText(data) {
|
||||
return stripThinkTags(
|
||||
data?.choices?.[0]?.message?.content?.trim() || ''
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ block vars
|
||||
block append meta
|
||||
meta(name='ol-systemPrompt' data-type='string' content=systemPrompt)
|
||||
meta(name='ol-llmApiUrl' data-type='string' content=llmApiUrl)
|
||||
meta(name='ol-llmApiType' data-type='string' content=llmApiType)
|
||||
meta(name='ol-hasLlmApiKey' data-type='string' content=hasLlmApiKey ? 'true' : 'false')
|
||||
meta(name='ol-allowedModels' data-type='string' content=(allowedModels || []).join(','))
|
||||
meta(name='ol-completionModel' data-type='string' content=completionModel)
|
||||
meta(name='ol-llmApiUrlFromEnv' data-type='string' content=llmApiUrlFromEnv ? 'true' : 'false')
|
||||
meta(name='ol-llmApiTypeFromEnv' data-type='string' content=llmApiTypeFromEnv ? 'true' : 'false')
|
||||
meta(name='ol-complianceRubrics' data-type='json' content=JSON.stringify(complianceRubrics || []))
|
||||
meta(name='ol-reviewModel' data-type='string' content=reviewModel)
|
||||
meta(name='ol-maxContextTokens' data-type='string' content=String(maxContextTokens))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
.llm-settings {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.llm-settings-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
color: var(--content-secondary, #6c757d);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-settings-section {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color-01, #dee2e6);
|
||||
background-color: var(--bg-light-primary, #fff);
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
|
||||
.llm-settings-section-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-accent-01);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-desc {
|
||||
color: var(--content-secondary, #6c757d);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
&-body {
|
||||
border: 1px solid var(--border-color-01, #dee2e6);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown-toggle {
|
||||
width: 100%;
|
||||
font-size: var(--font-size-03);
|
||||
font-weight: 100;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--bs-border-radius);
|
||||
padding: 0.4rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
&::after {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
.dropdown-item {
|
||||
font-size: var(--font-size-03);
|
||||
}
|
||||
}
|
||||
&-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
|
||||
&-success {
|
||||
color: var(--green-60, #198754);
|
||||
}
|
||||
|
||||
&-error {
|
||||
color: var(--red-60, #dc3545);
|
||||
}
|
||||
}
|
||||
|
||||
&-status-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user