LLM: provider-agnostic code (not finished); styles have to be in sylesheets (not finished)

This commit is contained in:
yu-i-i
2026-08-03 02:51:40 +02:00
parent e7ddd0ea61
commit 967abed4f9
11 changed files with 1589 additions and 1308 deletions
@@ -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;
}
}