From 6d1703d79f24853d2f5b63d212f5523bcc7be5a3 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Sat, 22 Aug 2026 23:24:42 +0000 Subject: [PATCH] Remodeled + BringYourOwn Api Key --- .gitignore | 5 + server-ce/.dockerignore | 10 + .../Features/ServerAdmin/AdminController.mjs | 5 + services/web/app/src/models/User.mjs | 18 + services/web/app/views/admin/index.pug | 9 + services/web/config/settings.defaults.js | 47 +- .../components/toolbar/command-dropdown.tsx | 9 +- .../ide-react/components/toolbar/menu-bar.tsx | 14 + .../settings/components/llm-user-section.tsx | 35 + .../js/features/settings/components/root.tsx | 9 + .../components/menu-bar/menu-bar-dropdown.tsx | 4 +- .../shared/components/navbar/admin-menu.tsx | 12 +- services/web/locales/de.json | 145 ++- services/web/locales/en.json | 144 ++- services/web/modules/llm/README.md | 191 +++ .../llm/app/src/LLMAdminController.mjs | 258 ++-- .../web/modules/llm/app/src/LLMBudget.mjs | 126 ++ .../modules/llm/app/src/LLMChatController.mjs | 1146 +++++++++++------ .../web/modules/llm/app/src/LLMClient.mjs | 316 +++++ .../web/modules/llm/app/src/LLMCompileFix.mjs | 119 ++ .../llm/app/src/LLMComplianceController.mjs | 579 ++++++--- .../web/modules/llm/app/src/LLMCrypto.mjs | 24 + .../web/modules/llm/app/src/LLMModelRef.mjs | 23 + .../web/modules/llm/app/src/LLMPrompts.mjs | 44 +- .../web/modules/llm/app/src/LLMRouter.mjs | 150 ++- .../llm/app/src/LLMSettingsController.mjs | 786 +++++++---- .../modules/llm/app/test/LLMClient.live.mjs | 39 + .../modules/llm/app/test/llm-client.test.mjs | 113 ++ .../llm/app/test/llm-compile-fix.test.mjs | 165 +++ .../app/test/llm-compliance-rubrics.test.mjs | 64 + .../modules/llm/app/test/llm-crypto.test.mjs | 46 + .../llm/app/views/llm-admin-settings.pug | 1 + .../js/components/llm-admin-settings-page.tsx | 596 +++------ .../frontend/js/components/llm-chat-pane.tsx | 72 +- .../frontend/js/components/llm-completion.ts | 49 +- .../js/components/llm-compliance-pane.tsx | 15 +- .../js/components/llm-compliance-settings.tsx | 239 ++++ .../js/components/llm-model-select-modal.tsx | 109 ++ .../frontend/js/components/llm-rail-pane.tsx | 17 +- .../js/components/llm-settings-page.tsx | 14 + .../js/components/llm-settings-section.tsx | 1088 +++++++++------- .../llm-source-editor-component.tsx | 97 +- .../frontend/js/components/llm-toolbar.tsx | 694 ++++++---- .../components/pdf-llm-compile-fix-card.tsx | 314 +++++ .../pdf-log-entry-ask-ai-button.tsx | 187 +-- .../extensions/llm-editor-toolbar-ask-ai.tsx | 65 + .../js/extensions/llm-file-menu-commands.tsx | 222 ++++ .../frontend/js/extensions/llm-fm-ask-ai.tsx | 52 + .../js/extensions/llm-insert-menu-section.tsx | 37 + .../llm/frontend/js/hooks/use-llm-chat.ts | 121 +- .../frontend/js/hooks/use-llm-compliance.ts | 108 +- .../llm/frontend/js/hooks/use-llm-features.ts | 16 +- .../js/hooks/use-llm-model-selection.ts | 154 +++ .../llm/frontend/js/hooks/use-llm-prompts.ts | 15 +- .../js/utils/llm-compile-fix-store.ts | 147 +++ .../llm/frontend/js/utils/llm-editor-theme.ts | 117 ++ .../frontend/js/utils/llm-selected-model.ts | 31 +- .../llm/frontend/stylesheets/llm-chat.scss | 149 ++- .../frontend/stylesheets/llm-settings.scss | 681 +++++++++- .../llm/frontend/stylesheets/llm-ui.scss | 418 ++++++ services/web/package.json | 3 +- .../unit/src/llm-admin-page-render.test.mjs | 59 + .../unit/src/llm-byo-draft-render.test.mjs | 167 +++ .../web/test/unit/src/llm-vitest-setup.mjs | 3 + services/web/vitest.llm-frontend.config.js | 26 + 65 files changed, 8204 insertions(+), 2534 deletions(-) create mode 100644 services/web/frontend/js/features/settings/components/llm-user-section.tsx create mode 100644 services/web/modules/llm/README.md create mode 100644 services/web/modules/llm/app/src/LLMBudget.mjs create mode 100644 services/web/modules/llm/app/src/LLMClient.mjs create mode 100644 services/web/modules/llm/app/src/LLMCompileFix.mjs create mode 100644 services/web/modules/llm/app/src/LLMModelRef.mjs create mode 100644 services/web/modules/llm/app/test/LLMClient.live.mjs create mode 100644 services/web/modules/llm/app/test/llm-client.test.mjs create mode 100644 services/web/modules/llm/app/test/llm-compile-fix.test.mjs create mode 100644 services/web/modules/llm/app/test/llm-compliance-rubrics.test.mjs create mode 100644 services/web/modules/llm/app/test/llm-crypto.test.mjs create mode 100644 services/web/modules/llm/frontend/js/components/llm-compliance-settings.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-model-select-modal.tsx create mode 100644 services/web/modules/llm/frontend/js/components/pdf-llm-compile-fix-card.tsx create mode 100644 services/web/modules/llm/frontend/js/extensions/llm-editor-toolbar-ask-ai.tsx create mode 100644 services/web/modules/llm/frontend/js/extensions/llm-file-menu-commands.tsx create mode 100644 services/web/modules/llm/frontend/js/extensions/llm-fm-ask-ai.tsx create mode 100644 services/web/modules/llm/frontend/js/extensions/llm-insert-menu-section.tsx create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-model-selection.ts create mode 100644 services/web/modules/llm/frontend/js/utils/llm-compile-fix-store.ts create mode 100644 services/web/modules/llm/frontend/js/utils/llm-editor-theme.ts create mode 100644 services/web/modules/llm/frontend/stylesheets/llm-ui.scss create mode 100644 services/web/test/unit/src/llm-admin-page-render.test.mjs create mode 100644 services/web/test/unit/src/llm-byo-draft-render.test.mjs create mode 100644 services/web/test/unit/src/llm-vitest-setup.mjs create mode 100644 services/web/vitest.llm-frontend.config.js diff --git a/.gitignore b/.gitignore index 8c5a87b978..dea926f0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ # user defined files .env docker-compose.override.yml + +# local-only agent docs + plan notes (never track: they hold credentials/creds refs) +references/ +AGENTS.md +todo.md diff --git a/server-ce/.dockerignore b/server-ce/.dockerignore index 81a97331cc..661fb2ceeb 100644 --- a/server-ce/.dockerignore +++ b/server-ce/.dockerignore @@ -8,3 +8,13 @@ server-ce/test services/web/scripts/plan-prices services/web/scripts/translations services/web/scripts/ukamf + +# dev-environment node_modules symlinks must never enter the image: +# BuildKit cannot replace a directory already present in the base/cache +# layer (overleaf/services/web/node_modules) with a file/symlink +node_modules +services/web/node_modules +libraries/*/node_modules +services/*/node_modules +services/web/modules/*/node_modules +tools/migrations/node_modules diff --git a/services/web/app/src/Features/ServerAdmin/AdminController.mjs b/services/web/app/src/Features/ServerAdmin/AdminController.mjs index cc8a71f75b..d6606df65e 100644 --- a/services/web/app/src/Features/ServerAdmin/AdminController.mjs +++ b/services/web/app/src/Features/ServerAdmin/AdminController.mjs @@ -48,6 +48,11 @@ const AdminController = { openSockets, systemMessages, privilegesMatrix, + // overleaf-lab: LLM module flag for the "LLM Configuration" tab (reviewer ask: + // settings live on Manage Site, not as a separate navbar entry). + llmEnabled: + process.env.LLM_ENABLED === 'true' || + !!(Settings.llm && Settings.llm.enabled), } if (Features.hasFeature('saas')) { diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 13aa4c817f..e1c677d01c 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -255,6 +255,24 @@ export const UserSchema = new Schema( llmModelName: { type: String, default: '' }, llmApiUrl: { type: String, default: '' }, llmCompletionModel: { type: String, default: '' }, + // overleaf-lab: LLM module (services/web/modules/llm). Extra legacy fields for the + // single-connection migration, plus the bring-your-own provider rows. + llmApiType: { type: String, default: '' }, + llmModels: { type: [String], default: [] }, + llmModelNames: { type: [String], default: [] }, + llmCompletionModels: { type: [String], default: [] }, + // overleaf-lab: user-scoped shared LLM model selection (File → "Select + // LLM Model"): model id or `u::`; '' = deployment default. + // Written by LLMSettingsController.saveSelectedModel (module:llm). + llmSelectedModel: { type: String, default: '' }, + // overleaf-lab (2026-08-27): per-user compliance review rubrics (owner + // request: the rubrics moved from global admin settings to the user's own + // LLM settings page). Free-form documents of the shared rubric shape + // (id/name/guidelines/scanPatterns), sanitized by the module's shared helper. + llmComplianceRubrics: { type: [Object], default: [] }, + // Each row is a free-form document shaped by the module's row schema + // (id/name/providerType/baseUrl/apiKey[encrypted]/models/completionModel/enabled). + llmProviders: { type: [Object], default: [] }, }, { minimize: false } ) diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index 4ac4f505ed..b2e7066f60 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -17,6 +17,8 @@ block content +bookmarkable-tabset-header('active-projects', 'Active Projects') +bookmarkable-tabset-header('open-sockets', 'Open Sockets') +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') + if llmEnabled + +bookmarkable-tabset-header('llm-configuration', 'LLM Configuration') if hasFeature('saas') +bookmarkable-tabset-header('privileges-matrix', 'Privileges Matrix') +bookmarkable-tabset-header('tpds', 'TPDS/Dropbox Management') @@ -78,6 +80,13 @@ block content button.btn.btn-danger(type='submit') Reopen Editor p.small Will reopen the editor after closing. + .tab-pane(role='tabpanel' id='llm-configuration') + if llmEnabled + p.text-muted Site-wide LLM backend, model allowlist, and compliance review settings. + iframe(src='/admin/llm/settings' title='LLM Configuration' style='width: 100%; height: calc(100vh - 280px); min-height: 480px; border: 0;') + else + p.text-muted LLM is disabled on this deployment (set LLM_ENABLED=true to enable). + if hasFeature('saas') .tab-pane(role='tabpanel' id='privileges-matrix') p This matrix shows the access levels for different admin roles across various user management privileges. The matrix is automatically generated from the code-defined roles and capabilities. diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 8e1f900884..af2e1c04cc 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1079,7 +1079,12 @@ module.exports = { '../modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button' ), ], - pdfLogEntryComponents: [], + pdfLogEntryComponents: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/components/pdf-llm-compile-fix-card' + ), + ], pdfLogEntriesComponents: [], pdfPreviewPromotions: [], diagnosticActions: [], @@ -1090,7 +1095,14 @@ module.exports = { '../modules/symbol-palette/frontend/components/symbol-palette' ), ], - sourceEditorToolbarStartButtons: [], + sourceEditorToolbarStartButtons: [ + // overleaf-lab (2026-08, reference-synced): the editor toolbar "Ask AI" + // (smart_toy) button — opens the LLM context menu. + Path.resolve( + __dirname, + '../modules/llm/frontend/js/extensions/llm-editor-toolbar-ask-ai' + ), + ], sourceEditorToolbarButtonGroups: [], sourceEditorToolbarComponents: [], sourceEditorToolbarEndButtons: [], @@ -1136,8 +1148,30 @@ module.exports = { __dirname, '../modules/template-gallery/frontend/js/features/template/components/menubar-manage-template' ), + // overleaf-lab: LLM whole-document generators (title/abstract/keywords) registered + // for the Generate group (see insertMenuSections below). + Path.resolve( + __dirname, + '../modules/llm/frontend/js/extensions/llm-file-menu-commands' + ), + ], + insertMenuSections: [ + // overleaf-lab (owner request 2026-08): the "AI Generate" group + // (smart_toy icon + Title/Abstract/Keywords) moved from the File menu + // to the INSERT menu — upstream-style placement for AI tools. + Path.resolve( + __dirname, + '../modules/llm/frontend/js/extensions/llm-insert-menu-section' + ), + ], + // overleaf-lab: BYO LLM provider management embedded in Account Settings (reviewer #2); + // the core section renders it when present, else falls back to a link card. + llmUserSettingsSection: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/components/llm-settings-section' + ), ], - insertMenuSections: [], oauth2Server: [ Path.resolve( __dirname, @@ -1202,7 +1236,12 @@ module.exports = { referenceSearchSetting: [], settingsModalEditorTabSections: [], settingsModalSpellcheckSections: [], - editorFloatingMenuActions: [], + editorFloatingMenuActions: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/extensions/llm-fm-ask-ai.tsx' + ), + ], errorLogsComponents: [], referenceIndices: [ Path.resolve( diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/command-dropdown.tsx index e0457b8217..84739b7b16 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/command-dropdown.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/command-dropdown.tsx @@ -13,7 +13,7 @@ import { NestedMenuBarDropdown, } from '@/shared/components/menu-bar/menu-bar-dropdown' import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option' -import { Fragment, useCallback, useMemo } from 'react' +import { Fragment, ReactNode, useCallback, useMemo } from 'react' type CommandId = string type TaggedCommand = Command & { @@ -23,11 +23,14 @@ type TaggedCommand = Command & { type Entry = T | GroupStructure type GroupStructure = { id: string - title: string + // overleaf-lab: title may be a ReactNode so module sections can render + // icon + label groups (e.g. the LLM "AI Generate" entry); plain strings + // (the common case) render exactly as before. + title: string | ReactNode children: Array> } export type MenuSectionStructure = { - title?: string + title?: string | ReactNode id: string children: Array> } diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx index 626eb56ead..3097ff03d8 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx @@ -91,6 +91,11 @@ export const ToolbarMenuBar = () => { id: 'file-file-tree', children: ['new_file', 'new_folder', 'upload_file', 'copy_project'], }, + // overleaf-lab: the "AI Generate" group (whole-document LLM generators) + // lives in the INSERT menu now (owner request 2026-08, upstream-style + // placement + smart_toy mark). It is contributed by modules/llm through + // the `insertMenuSections` overleafModuleImports hook; CommandDropdown + // filters out unregistered commands, so non-LLM deployments never see it. { id: 'file-tools', children: ['show_version_history', 'word_count'] }, { id: 'submit', children: ['submit-project', 'manage-template'] }, { @@ -109,6 +114,15 @@ export const ToolbarMenuBar = () => { }, ], }, + // overleaf-lab (owner request 2026-08-26): File → "Select LLM Model" + // (smart_toy leading icon, contributed by modules/llm via the + // llm_select_model command in llm-file-menu-commands) — the ONE and + // ONLY model selection entry point. The section is filtered out + // automatically when the command is not registered (non-LLM deploys). + { + id: 'file-llm-model', + children: ['llm_select_model'], + }, { id: 'settings', children: ['open-settings'], diff --git a/services/web/frontend/js/features/settings/components/llm-user-section.tsx b/services/web/frontend/js/features/settings/components/llm-user-section.tsx new file mode 100644 index 0000000000..fc21b2aca2 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/llm-user-section.tsx @@ -0,0 +1,35 @@ +// overleaf-lab: LLM section on the Account Settings page. +// +// overleaf-lab (2026-08-27, owner request): the inline BYO provider table used +// to render HERE. It is now a single LINK to the dedicated /user/llm-settings +// page (BYO providers + the user's own compliance review rubrics), keeping the +// Account Settings page lean. The module import was dropped with the table — +// the page itself loads the module's React code. +import React from 'react' +import { useTranslation } from 'react-i18next' + +function LLMUserSection() { + const { t } = useTranslation() + + return ( +
+

{t('llm_section_title', 'AI assistant')}

+

+ {t( + 'llm_section_help', + 'Manage your LLM connections, your selected model and your compliance review rubrics. API keys are encrypted on this server.', + )} +

+ + {t('llm_section_open', 'Open AI settings')} + +
+ ) +} + +export default LLMUserSection diff --git a/services/web/frontend/js/features/settings/components/root.tsx b/services/web/frontend/js/features/settings/components/root.tsx index c2a71b229f..d34d7ae633 100644 --- a/services/web/frontend/js/features/settings/components/root.tsx +++ b/services/web/frontend/js/features/settings/components/root.tsx @@ -12,6 +12,7 @@ import LabsProgramSection from './labs-program-section' import SessionsSection from './sessions-section' import NewsletterSection from './newsletter-section' import LeaveSection from './leave-section' +import LLMUserSection from './llm-user-section' import * as eventTracking from '../../../infrastructure/event-tracking' import { UserProvider } from '../../../shared/context/user-context' import { SSOProvider } from '../context/sso-context' @@ -46,6 +47,8 @@ function SettingsPageRoot() { function SettingsPageContent() { const { t } = useTranslation() const { isOverleaf, labsEnabled } = getMeta('ol-ExposedSettings') + // overleaf-lab: gate for the LLM personal-settings section (instance flag). + const llmAllowUserSettings = !!(getMeta('ol-ExposedSettings') as any)?.llmAllowUserSettings const inNotificationsSplitTest = isSplitTestEnabled('email-notifications') return ( @@ -81,6 +84,12 @@ function SettingsPageContent() { ) : null} + {llmAllowUserSettings ? ( + <> +
+ + + ) : null} {isOverleaf ? ( <> diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx index 798b028ad7..3ad5e81a93 100644 --- a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx +++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx @@ -3,7 +3,7 @@ import { DropdownMenu, DropdownToggle, } from '@/shared/components/dropdown/dropdown-menu' -import { FC, forwardRef, useCallback } from 'react' +import { FC, ReactNode, forwardRef, useCallback } from 'react' import classNames from 'classnames' import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown' import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context' @@ -101,7 +101,7 @@ const NestedDropdownToggle: FC = forwardRef< }) export const NestedMenuBarDropdown: FC< - React.PropsWithChildren<{ id: string; title: string }> + React.PropsWithChildren<{ id: string; title: string | ReactNode }> > = ({ children, id, title }) => { const { menuId, selected, setSelected } = useNestableDropdown() const select = useCallback(() => { diff --git a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx index 322defcc2f..b92b1600bb 100644 --- a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx +++ b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx @@ -2,7 +2,6 @@ import type { DefaultNavbarMetadata } from '@/shared/components/types/default-na import NavDropdownMenu from '@/shared/components/navbar/nav-dropdown-menu' import NavDropdownLinkItem from '@/shared/components/navbar/nav-dropdown-link-item' import { useSendProjectListMB } from '@/features/project-list/components/project-list-events' -import getMeta from '@/utils/meta' export default function AdminMenu({ canDisplayAdminMenu, @@ -39,6 +38,12 @@ export default function AdminMenu({ {canDisplayAdminMenu ? ( <> Manage Site + {/* overleaf-lab: explicit Admin dropdown entry for the site LLM + settings (reviewer follow-up: users expected the menu item; the + /admin "LLM Configuration" tab remains as well). */} + + Manage Site LLM + Manage Users @@ -69,11 +74,6 @@ export default function AdminMenu({ View Script Logs ) : null} - {(getMeta('ol-ExposedSettings') as any)?.llmEnabled ? ( - - LLM Settings - - ) : null} ) } diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 5dc6f42b83..ec2ad53cd2 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -113,6 +113,7 @@ "add_ons": "Erweiterungen", "add_or_remove_project_from_tag": "Projekt zum Stichwort __tagName__ hinzufügen oder davon entfernen", "add_role_and_department": "Rolle und Abteilung hinzufügen", + "add_rubric": null, "add_to_dictionary": "Zum Wörterbuch hinzufügen", "add_to_tag": "Zu Stichwort hinzufügen", "add_your_comment_here": "Füge hier einen Kommentar hinzu", @@ -138,6 +139,8 @@ "ai_assist_in_overleaf_is_included_via_writefull_groups": "AI Assist in Overleaf ist Teil des Writefull-Abonnements deiner Gruppe oder Organisation. Um Änderungen vorzunehmen, musst du dich an den Administrator deines Abonnements wenden.", "ai_assist_in_overleaf_is_included_via_writefull_individual": "AI Assist in Overleaf ist Teil deines Writefull-Abonnements. Du kannst deinen Zugriff auf AI Assist in deinen Writefull-Abonnementeinstellungen kündigen oder verwalten.", "ai_assist_unavailable": "KI-Funktionen sind jetzt in jedem kostenpflichtigen Plan enthalten. Auf der <0>Preisseite erfahren Sie, was die einzelnen Pläne beinhalten.", + "ai_assistant": null, + "ai_assistant_input": null, "ai_can_make_mistakes": "KI kann Fehler machen. Überprüfe Korrekturen, bevor du sie anwendest.", "ai_features": "KI-Funktionen", "ai_feedback_please_provide_more_detail": "Bitte gib mehr Details dazu an, was nicht gestimmt hat (optional)", @@ -147,6 +150,7 @@ "ai_feedback_the_suggestion_didnt_fix_the_error": "Der Vorschlag hat den Fehler nicht behoben", "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "Der Vorschlag war nicht die bestmögliche Korrektur", "ai_feedback_there_was_no_code_fix_suggested": "Es wurde keine Code-Korrektur vorgeschlagen", + "ai_prompts": null, "alignment": "Ausrichtung", "all": "Alle", "all_borders": "Alle Rahmen", @@ -174,6 +178,7 @@ "anonymous": "Anonym", "anyone_with_link_can_edit": "Jeder mit diesem Link kann dieses Projekt bearbeiten", "anyone_with_link_can_view": "Jeder mit diesem Link kann dieses Projekt anzeigen", + "api_connection": null, "app_on_x": "__appName__ bei __social__", "appearance": "Erscheinungsbild", "apply": "Anwenden", @@ -196,6 +201,10 @@ "as_a_member_of_sso_required": "Als Mitglied von __institutionName__ musst du dich über dein institutionelles Portal bei __appName__ anmelden.", "as_email": "als __email__", "ascending": "Aufsteigend", + "ask_ai_about_error": null, + "ask_ai_action_templates": null, + "ask_ai_behavior_prompt": null, + "ask_ai_behavior_prompt_help": null, "ask_proj_owner_to_add_you_as_github_collaborator": "Du hast keine Berechtigung, in das verknüpfte Repository (<0>__repoFullName__) zu pushen. Bitte den Projektinhaber (<1>__projectOwnerEmail__), dich als Kollaborator zu diesem Repository hinzuzufügen.", "ask_proj_owner_to_export_to_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__), das Projekt in ein Git-Repository zu exportieren und dich als Kollaborator hinzuzufügen.", "ask_proj_owner_to_unlink_from_current_github": "Bitte den Projektbesitzer (<0>__projectOwnerEmail__), das Projekt vom aktuellen GitHub-Repository zu entkoppeln und eine Verbindung zu einem anderen herzustellen.", @@ -209,6 +218,7 @@ "auto_close_brackets": "Klammern automatisch schließen", "auto_compile": "Automatisch kompilieren", "auto_complete": "Autovervollständigung", + "auto_first_allowed_model": null, "autocompile": "Automatisches Kompilieren", "autocompile_disabled": "Automatisches Kompilieren deaktiviert", "autocompile_disabled_reason": "Aufgrund hoher Serverlast ist das automatische Kompilieren vorübergehend deaktiviert. Bitte kompiliere stattdessen manuell über die Schaltfläche oben.", @@ -290,6 +300,7 @@ "change_to_group_plan": "Wechsle zu einem Gruppen-Abonnement", "change_to_this_plan": "Auf dieses Abonnement wechseln", "changing_the_position_of_your_figure": "Position der Abbildung verändern", + "characters": null, "chat": "Chat", "chat_error": "Chatnachrichten konnten nicht geladen werden, versuche es erneut.", "check_your_email": "Bitte prüfe deinen E-Mail-Posteingang.", @@ -302,6 +313,8 @@ "cite_directly_or_import_references": "Referenzen importieren", "city": "Stadt", "clear_cached_files": "Zwischengespeicherte Dateien löschen", + "clear_conversation": null, + "clear_conversation_confirm": null, "clear_search": "Suche löschen", "clear_sessions": "Sessions löschen", "clear_sessions_description": "Das ist eine Liste anderer Sessions (Logins), die auf deinem Konto aktiv sind, exklusive deiner aktuellen Session. Klicke auf „Sessions löschen“, um sie auszuloggen.", @@ -342,6 +355,8 @@ "compiler": "Compiler", "compiling": "Kompilieren", "complete": "Fertig", + "compliance_review": null, + "configure_api_first": null, "confirm": "Bestätigen", "confirm_affiliation": "Zugehörigkeit bestätigen", "confirm_affiliation_to_relink_dropbox": "Bitte bestätige, dass du noch immer bei der Institution bist und über deren Lizenz verfügst, oder aktualisiere dein Konto, um dein Dropbox-Konto erneut zu verknüpfen.", @@ -355,8 +370,11 @@ "confirming": "Bestätigung", "conflicting_paths_found": "Dateipfadkonflikte gefunden", "congratulations_youve_successfully_join_group": "Glückwunsch! Du bist dem Gruppenabonnement erfolgreich beigetreten.", + "connected": null, "connected_users": "Verbundene Nutzer", "connecting": "Verbinden", + "connection_failed": null, + "connection_successful": null, "contact": "Kontakt", "contact_group_admin": "Bitte wende dich an den Gruppenadministrator.", "contact_message_label": "Nachricht", @@ -380,7 +398,6 @@ "coupon_code_is_not_valid_for_selected_plan": "Der Gutscheincode ist nicht gültig für das gewählte Abonnement", "coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nächsten Zahlung angewandt werden", "create": "Erstellen", - "create_account": "Konto erstellen", "create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto", "create_a_new_project": "Erstelle ein neues Projekt", "create_account": "Konto erstellen", @@ -426,10 +443,10 @@ "delete_certificate": "Zertifikat löschen", "delete_comment": "Kommentar löschen", "delete_figure": "Abbildung löschen", + "delete_projects": "Projekte löschen", "delete_tag": "Stichwort löschen", "delete_template": "Vorlage löschen", "delete_token": "Token löschen", - "delete_projects": "Projekte löschen", "delete_user": "Nutzer löschen", "delete_your_account": "Lösche dein Konto", "deleted_at": "Gelöscht am", @@ -461,6 +478,7 @@ "dont_have_account": "Du hast kein Konto?", "download": "Herunterladen", "download_pdf": "PDF herunterladen", + "download_report": null, "download_zip_file": ".zip-Datei herunterladen", "drag_here": "hierher ziehen", "drag_here_paste_an_image_or": "Datei hierher verschieben, Bild einfügen, oder", @@ -545,10 +563,13 @@ "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Gib deine E-Mail-Adresse unten ein, und wir senden Dir einen Link zum Zurücksetzen deines Passworts", "equation_preview": "Gleichungsvorschau", "error": "Fehler", + "error_help_prompt": null, + "error_help_prompt_help": null, "error_performing_request": "Bei der Ausführung deiner Anfrage ist ein Fehler aufgetreten.", "es": "Spanisch", "essential_cookies_only": "Nur notwendige Cookies", "every": "pro", + "evidence": null, "example": "Beispiel", "example_project": "Beispielprojekt", "examples": "Beispiele", @@ -566,8 +587,14 @@ "faq_how_does_free_trial_works_answer": "Während deines __len__-tägigen Probe-Abonnements erhältst du vollen Zugriff auf die Funktionen des von dir gewählten __appName__-Abonnements. Es besteht keine Verpflichtung, über die Testperiode hinaus fortzufahren. Deine Karte wird am Ende des __len__-tägigen Testzeitraums belastet, sofern du nicht vorher gekündigt hast. Um zu kündigen, gehe zu deinen Abonnementeinstellungen in deinem Konto.", "fast": "Schnell", "fast_draft": "Schnell [Entwurf]", + "feature_chat": null, + "feature_chat_help": null, + "feature_completion": null, + "feature_completion_help": null, "feature_included": "Funktion enthalten", "feature_not_included": "Funktion nicht enthalten", + "feature_review": null, + "feature_review_help": null, "featured": "Vorgestellt", "featured_latex_templates": "Ausgewählte LaTeX-Vorlagen", "features": "Features", @@ -776,6 +803,7 @@ "including_all_subdomains": "einschließlich aller Subdomains", "increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen", "info": "Info", + "inline_completion_model": null, "inr_discount_modal_info": "Erhalte Dokumentenverlauf, Änderungsverfolgung, weitere Mitarbeiter und mehr zu Preisen nach Kaufkraftparität.", "inr_discount_modal_title": "70 % Rabatt auf alle Overleaf-Premium-Abonnements für Nutzer in Indien", "insert_figure": "Abbildung einfügen", @@ -864,6 +892,7 @@ "last_used": "Zuletzt verwendet", "latam_discount_modal_info": "Nutze das volle Potenzial von Overleaf mit __discount__ % Rabatt auf Premium-Abonnements, die in __currencyName__ bezahlt werden. Erhalte eine längere Kompilierungszeit, vollständigen Dokumentenverlauf, Änderungsverfolgung, weitere Mitarbeiter und mehr.", "latam_discount_modal_title": "Rabatt auf Premium-Abonnements", + "latex_ai_assistant": null, "latex_articles_page_title": "Artikel – Papers, Präsentationen, Berichte und mehr", "latex_examples": "LaTeX-Beispiele", "latex_examples_page_title": "Beispiele - Gleichungen, Formatierung, TikZ, Pakete und mehr", @@ -909,6 +938,70 @@ "linked_collabratec_description": "Verwende Collabratec, um deine __appName__-Projekte zu verwalten.", "linked_file": "Importierte Datei", "links": "Links", + "llm_admin_from_env": null, + "llm_api_key": null, + "llm_api_key_optional_local": null, + "llm_api_key_placeholder": null, + "llm_api_key_placeholder_stored": null, + "llm_api_key_remove": null, + "llm_api_key_stored": null, + "llm_api_type": null, + "llm_api_url": null, + "llm_byo_add": "Anbieter hinzufügen", + "llm_byo_anthropic_hint": "Verwendet api.anthropic.com, sofern Sie keine Basis-URL setzen.", + "llm_byo_baseurl": "Basis-URL", + "llm_byo_cancel": "Abbrechen", + "llm_byo_col_actions": "Aktionen", + "llm_byo_col_key": "Schlüssel", + "llm_byo_col_models": "Modelle", + "llm_byo_col_provider": "Anbieter", + "llm_byo_col_status": "Status", + "llm_byo_compatible_hint": "Jeder Server mit OpenAI-ähnlicher /v1-API: Ollama, vLLM, llama.cpp, OpenRouter, ...", + "llm_byo_completion_default": "Erstes aufgeführtes Modell verwenden", + "llm_byo_completion_model": "Inline-Ergänzungsmodell (optional)", + "llm_byo_delete": "Löschen", + "llm_byo_delete_confirm": "Diesen Anbieter und seinen gespeicherten API-Schlüssel löschen?", + "llm_byo_delete_failed": "Löschen fehlgeschlagen", + "llm_byo_deleted": "Anbieter gelöscht", + "llm_byo_disabled": "Deaktiviert", + "llm_byo_edit": "Bearbeiten", + "llm_byo_enabled": "Aktiviert", + "llm_byo_enabled_label": "Aktiviert (im Editor sichtbar)", + "llm_byo_err_completion": "Das Ergänzungsmodell muss eines der aufgeführten Modelle sein", + "llm_byo_err_models": "Bitte mindestens ein Modell hinzufügen", + "llm_byo_err_name": "Bitte geben Sie einen Anbieter-Namen ein", + "llm_byo_err_url": "Für OpenAI-kompatible Anbieter ist eine Basis-URL erforderlich", + "llm_byo_key": "API-Schlüssel", + "llm_byo_key_keep": "Gespeicherter Schlüssel vorhanden — leer lassen, um ihn zu behalten", + "llm_byo_key_keep_check": "Gespeicherten Schlüssel behalten", + "llm_byo_key_new": "Ihr API-Schlüssel (für lokale Server ohne Auth leer lassen)", + "llm_byo_model_add": "Hinzufügen", + "llm_byo_model_placeholder": "qwen3.8:latest, gpt-4o, ...", + "llm_byo_model_remove": "Modell entfernen", + "llm_byo_models": "Modelle", + "llm_byo_models_note": "Das erste Modell ist die Voreinstellung dieses Anbieters. Hervorgehobenes Modell = Inline-Ergänzung.", + "llm_byo_name": "Name", + "llm_byo_name_placeholder": "z. B. Ollama (Labor)", + "llm_byo_openai_hint": "Verwendet api.openai.com, sofern Sie keine Basis-URL setzen.", + "llm_byo_provider": "Anbieter-Typ", + "llm_byo_save": "Anbieter speichern", + "llm_byo_save_failed": "Speichern fehlgeschlagen", + "llm_byo_saved": "Anbieter gespeichert. Er ist jetzt im Editor verfügbar.", + "llm_byo_saving": "Wird gespeichert ...", + "llm_byo_scan": "Modelle am Backend abrufen", + "llm_byo_scan_failed": "Modell-Abfrage fehlgeschlagen", + "llm_byo_scan_none": "Das Backend meldete keine Modelle", + "llm_byo_scan_ok": "Modelle zur Zeile hinzugefügt", + "llm_byo_scanning": "Wird abgerufen ...", + "llm_byo_test": "Verbindung testen", + "llm_byo_test_fail": "Verbindung fehlgeschlagen", + "llm_byo_test_ok": "Verbindung erfolgreich", + "llm_byo_testing": "Wird getestet ...", + "llm_byo_title": "Meine LLM-Anbieter", + "llm_configuration": null, + "llm_features": null, + "llm_settings": null, + "llm_settings_saved": null, "loading": "Laden", "loading_content": "Erstelle Projekt", "loading_github_repositories": "Deine GitHub-Repositories werden geladen", @@ -974,6 +1067,7 @@ "math": "Mathe", "math_display": "Formeln im abgesetzten Modus", "math_inline": "Formeln im Zeilenmodus", + "max_context_tokens": null, "maximum_files_uploaded_together": "Maximal __max__ Dateien zusammen hochgeladen", "may": "Mai", "maybe_later": "Vielleicht später", @@ -990,6 +1084,8 @@ "menu": "Menü", "merge": "Mergen", "merging": "Mergen", + "model_label": null, + "model_selection": null, "month": "Monat", "month_plural": "Monate", "monthly": "Monatlich", @@ -1045,6 +1141,7 @@ "no_preview_available": "Entschuldigung, es ist keine Vorschau verfügbar.", "no_project_notifications_description": "Du wirst keine Benachrichtigungen über dieses Projekt erhalten.", "no_projects": "Keine Projekte", + "no_rubrics_yet": null, "no_search_results": "Keine Suchergebnisse", "no_selection_select_file": "Derzeit ist keine Datei ausgewählt. Bitte wähle eine Datei aus dem Dateibaum aus.", "no_symbols_found": "Keine Symbole gefunden", @@ -1218,6 +1315,7 @@ "push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden", "raw_logs": "Raw Logs", "raw_logs_description": "Raw Logs vom LaTeX-Compiler", + "re_run_last_question": null, "ready_to_set_up": "Bereit zur Einrichtung", "realtime_track_changes": "Änderungen in Echtzeit nachverfolgen", "reauthorize_github_account": "Autorisiere dein GitHub-Konto erneut", @@ -1250,8 +1348,8 @@ "regards": "Viele Grüße", "register": "Registrieren", "register_error": "Registrierungsfehler", - "register_success": "Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu aktivieren.", "register_intercept_sso": "Du kannst dein __authProviderName__-Konto nach der Anmeldung auf der Seite Kontoeinstellungen verknüpfen.", + "register_success": "Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu aktivieren.", "register_to_edit_template": "Bitte registriere dich um die __templateName__ Vorlage zu bearbeiten", "register_with_another_email": "Registriere Dich bei __appName__ mit einer anderen E-Mail-Adresse.", "registered": "Registriert", @@ -1288,6 +1386,7 @@ "resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden", "resending_confirmation_code": "Bestätigungscode wird erneut gesendet", "reset_password": "Passwort zurücksetzen", + "reset_to_default": null, "reset_your_password": "Dein Passwort zurücksetzen", "resolve": "Lösen", "resolved_comments": "Gelöste Kommentare", @@ -1303,11 +1402,32 @@ "reverse_x_sort_order": "Sortierreihenfolge für __x__ umkehren", "revert_pending_plan_change": "Abonnement-Änderung rückgängig machen", "review": "Überprüfen", + "review_checking": null, + "review_disabled": null, + "review_empty": null, + "review_failed": null, + "review_max_tokens": null, + "review_model": null, + "review_model_shared_default": null, + "review_queue_position": null, + "review_queued": null, + "review_rubric": null, + "review_summarizing": null, + "review_system_prompt": null, + "review_system_prompt_help": null, + "review_tokens_answer": null, + "review_tokens_document": null, + "review_tokens_limit": null, "revoke": "Zurückziehen", "revoke_invite": "Einladung zurückziehen", "ro": "Rumänisch", "role": "Funktion", "ru": "Russisch", + "rubric_guidelines": null, + "rubric_name": null, + "rubric_name_placeholder": null, + "rubric_scan_patterns": null, + "run_review": null, "saml": "SAML", "saml_commons_reconfirmation_unable_to_find_user": "Es tut uns leid, wir konnten deine Zugehörigkeit nicht bestätigen. Dies könnte durch eine Änderung der SSO-Daten verursacht worden sein, die deine Institution an uns sendet. Bitte <0>kontaktiere uns, wenn du Hilfe beim erneuten Verknüpfen deines Overleaf-Kontos mit dem SSO deiner Institution benötigst.", "saml_create_admin_instructions": "Wähle eine E-Mail-Adresse für den ersten __appName__-Admin-Konto. Dieses sollte bereits im SAML-System vorhanden sein. Du wirst dann aufgefordert, dich mit diesem Konto einzuloggen.", @@ -1315,8 +1435,11 @@ "save_or_cancel-cancel": "Abbrechen", "save_or_cancel-or": "oder", "save_or_cancel-save": "Speichern", + "save_settings": null, "saving": "Speichern", "saving_notification_with_seconds": "__docname__ speichern... (__seconds__ Sekunden ungespeicherter Änderungen)", + "scan_failed": null, + "scan_for_models": null, "search": "Suchen", "search_bib_files": "Nach Autor, Titel, Jahr suchen", "search_command_find": "Finden", @@ -1340,6 +1463,7 @@ "select_a_file": "Datei auswählen", "select_a_new_owner_for_projects": "Neuen Besitzer für die Projekte dieses Benutzers auswählen", "select_a_project": "Projekt auswählen", + "select_all": null, "select_all_projects": "Alle Projekte auswählen", "select_all_users": "Alle Benutzer auswählen", "select_an_output_file": "Ausgabedatei auswählen", @@ -1355,8 +1479,8 @@ "selected": "Ausgewählt", "selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern", "send": "Absenden", - "send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden", "send_confirmation_code": "Bestätigungscode senden", + "send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden", "send_test_email": "Test-Mail senden", "sending": "Wird gesendet", "september": "September", @@ -1391,9 +1515,9 @@ "showing_x_results_of_total": "Es wird __count__ Ergebnis von __total__ angezeigt", "showing_x_results_of_total_plural": "Es werden __count__ Ergebnisse von __total__ angezeigt", "showing_x_results_plural": "Es werden __count__ Ergebnisse angezeigt", + "signed_up": "Angemeldet", "single_sign_on_sso": "Single Sign-On (SSO)", "site_description": "Ein einfach bedienbarer Online-LaTeX-Editor. Keine Installation notwendig, Zusammenarbeit in Echtzeit, Versionskontrolle, Hunderte von LaTeX-Vorlagen und mehr", - "signed_up": "Angemeldet", "skip_to_content": "Zum Inhalt springen", "solutions": "Lösungen", "something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.", @@ -1421,8 +1545,13 @@ "start_free_trial": "Kostenlosen Test starten", "state": "Status", "status_checks": "Statusüberprüfungen", + "status_missing": null, + "status_na": null, + "status_ok": null, + "status_partial": null, "still_have_questions": "Hast du noch Fragen?", "stop_compile": "Kompiliervorgang stoppen", + "stop_generation": null, "stop_on_first_error": "Beim ersten Fehler anhalten", "stop_on_first_error_enabled_description": "<0>„Anhalten beim ersten Fehler“ ist aktiviert. Durch Deaktivieren kann der Compiler möglicherweise eine PDF-Datei erstellen (Das Projekt wird aber weiterhin Fehler enthalten).", "stop_on_first_error_enabled_title": "Kein PDF: Anhalten beim ersten Fehler aktiviert", @@ -1461,6 +1590,7 @@ "sync_with_github": "Mit GitHub synchronisieren", "synctex_failed": "Die entsprechende Quelldatei konnte nicht gefunden werden", "syntax_validation": "Syntaxüberprüfung", + "system_prompt": null, "tab_connecting": "Verbindung mit dem Editor wird hergestellt", "tabs_open_in_preview_mode_until_you_interact_with_them": "Tabs werden im Vorschaumodus geöffnet, bis du mit ihnen interagierst", "tag_color": "Stichwortfarbe", @@ -1483,6 +1613,7 @@ "templates_page_title": "Vorlagen - Zeitschriften, Lebensläufe, Präsentationen, Berichte und mehr", "terminated": "Kompiliervorgang abgebrochen", "terms": "Nutzungsbedingungen", + "test_connection": null, "tex_live_version": "TeX Live Version", "thank_you": "Vielen Dank", "thank_you_email_confirmed": "Vielen Dank, deine E-Mail-Adresse ist jetzt bestätigt", @@ -1507,6 +1638,7 @@ "there_was_an_error_opening_your_content": "Beim Erstellen deines Projekts ist ein Fehler aufgetreten", "these_settings_might_change_in_the_future": "Diese Einstellungen können sich in Zukunft ändern.", "thesis": "Doktorarbeit", + "thinking": null, "this_action_can_be_undone_within_limited_period": "Diese Aktion kann nur innerhalb eines begrenzten Zeitraums rückgängig gemacht werden.", "this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.", "this_field_is_required": "Dieses Feld wird benötigt", @@ -1527,6 +1659,7 @@ "token": "Token", "token_access_failure": "Zugriff kann nicht gewährt werden", "token_limit_reached": "Du hast das Limit von 10 Tokens erreicht. Um einen neuen Authentifizierungstoken zu generieren, lösche bitte einen bestehenden.", + "tokens": null, "too_many_attempts": "Zu viele Versuche. Bitte warte eine Weile und versuche es erneut.", "too_many_confirm_code_resend_attempts": "Zu viele Versuche. Bitte warte 1 Minute und versuche es dann erneut.", "too_many_confirm_code_verification_attempts": "Zu viele Verifizierungsversuche. Bitte warte 1 Minute und versuche es dann erneut.", @@ -1560,6 +1693,7 @@ "tried_to_log_in_with_email": "Du hast versucht, dich mit __email__ anzumelden.", "tried_to_register_with_email": "Du hast versucht, dich mit __email__ zu registrieren, das bereits bei __appName__ als institutionelles Konto registriert ist.", "try_again": "Bitte versuche es erneut", + "try_asking": null, "try_for_free": "Kostenlos testen", "try_now": "Jetzt versuchen", "try_recompile_project": "Versuche bitte, das Projekt von Grund auf neu zu kompilieren.", @@ -1602,6 +1736,7 @@ "unlinking": "Verknüpfung wird aufgehoben", "unpublish": "Veröffentlichung aufheben", "unpublishing": "Veröffentlichung aufheben", + "unselect_all": null, "unset_admin": "Admin entfernen", "unset_admin_account": "Administratorrechte entziehen", "unsubscribe": "Abbestellen", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 54c8846b4c..6ebf355927 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -124,6 +124,7 @@ "add_reference": "Add reference", "add_references": "Add references", "add_role_and_department": "Add role and department", + "add_rubric": "Add rubric", "add_to_dictionary": "Add to dictionary", "add_to_tag": "Add to tag", "add_txt_record_to_dns_with_token": "To complete the process of domain verification you need to add a TXT record to your DNS with the following token:", @@ -166,6 +167,7 @@ "ai_assistant_description": "Get help with LaTeX syntax, formatting, and troubleshooting", "ai_assistant_disabled": "AI Assistant disabled", "ai_assistant_explanation": "A LaTeX-fluent AI Assistant built into your editor.", + "ai_assistant_input": "AI assistant input", "ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.", "ai_features": "AI features", "ai_features_unavailable_on_this_project": "AI features have been turned off for this project", @@ -177,6 +179,7 @@ "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "The suggestion wasn’t the best fix available", "ai_feedback_there_was_no_code_fix_suggested": "There was no code fix suggested", "ai_language_suggestions": "AI language suggestions", + "ai_prompts": "AI Prompts", "ai_shortcut_on_empty_lines": "AI shortcut on empty lines", "ai_shortcut_on_text_selection": "AI shortcut on text selection", "ai_usage": "AI usage", @@ -202,8 +205,8 @@ "already_have_sl_account": "Already have an __appName__ account?", "already_in_your_library": "Already in your library", "also": "Also", - "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.", + "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", "and": "and", @@ -217,6 +220,7 @@ "anyone_with_link_can_edit": "Anyone with this link can edit this project", "anyone_with_link_can_view": "Anyone with this link can view this project", "anyone_with_the_link": "Anyone with the link", + "api_connection": "API Connection", "app_on_x": "__appName__ on __social__", "appearance": "Appearance", "apply": "Apply ", @@ -243,6 +247,9 @@ "as_email": "as __email__", "ascending": "Ascending", "ask_ai_about_error": "Ask AI about this error", + "ask_ai_action_templates": "Ask AI action templates", + "ask_ai_behavior_prompt": "Ask AI behavior prompt", + "ask_ai_behavior_prompt_help": "System prompt for the selection toolbar (Ask AI / paraphrase / rewrite).", "ask_proj_owner_to_add_you_as_github_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__. Ask the owner of the project (<1>__projectOwnerEmail__) to add you as a collaborator to this repository.", "ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to export the project to a Git repository and add you as a collaborator.", "ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to unlink the project from the current git repository and create a connection to a different repository.", @@ -259,6 +266,7 @@ "auto_close_brackets": "Auto-close brackets", "auto_compile": "Auto compile", "auto_complete": "Auto-complete", + "auto_first_allowed_model": "Auto (first allowed model)", "autocompile": "Autocompile", "autocompile_disabled": "Autocompile disabled", "autocompile_disabled_reason": "Due to high server load, background recompilation has been temporarily disabled. Please recompile by clicking the button above.", @@ -404,6 +412,8 @@ "cite_faster": "Cite faster", "city": "City", "clear_cached_files": "Clear cached files", + "clear_conversation": "Clear conversation", + "clear_conversation_confirm": "Clear conversation?", "clear_filters": "Clear filters", "clear_search": "clear search", "clear_sessions": "Clear sessions", @@ -476,7 +486,9 @@ "compiling": "Compiling", "complete": "Complete", "compliance": "Compliance", + "compliance_review": "Compliance Review", "compromised_password": "Compromised password", + "configure_api_first": "Configure the API connection above first", "configure_sso": "Configure SSO", "configured": "Configured", "confirm": "Confirm", @@ -500,9 +512,12 @@ "confirming": "Confirming", "conflicting_paths_found": "Conflicting Paths Found", "congratulations_youve_successfully_join_group": "Congratulations! You‘ve successfully joined the group subscription.", + "connected": "Connected", "connected_users": "Connected users", "connecting": "Connecting", + "connection_failed": "Connection failed — check URL and key", "connection_lost_with_unsaved_changes": "Connection lost with unsaved changes.", + "connection_successful": "Connection successful", "contact": "Contact", "contact_group_admin": "Please contact your group administrator.", "contact_message_label": "Message", @@ -711,6 +726,7 @@ "download_csv": "Download CSV", "download_metadata": "Download Overleaf metadata", "download_pdf": "Download PDF", + "download_report": "Download report", "download_zip_file": "Download .zip file", "draft_sso_configuration": "Draft SSO configuration", "drag_here": "drag here", @@ -840,6 +856,8 @@ "entry_plural": "entries", "equation_preview": "Equation preview", "error": "Error", + "error_help_prompt": "Error help prompt", + "error_help_prompt_help": "Instructions appended when a user clicks Ask AI about a compile error.", "error_loading_references": "References couldn’t be loaded. Refresh the page to try again.", "error_opening_document": "Error opening document", "error_opening_document_detail": "Sorry, something went wrong opening this document. Please try again.", @@ -855,6 +873,7 @@ "everything_in_group_professional_plus": "Everything in Pro group, plus…", "everything_in_group_standard_plus": "Everything in Standard group, plus…", "everything_in_standard_plus": "Everything in Standard, plus…", + "evidence": "Evidence", "example": "Example", "example_project": "Example project", "examples": "Examples", @@ -891,9 +910,15 @@ "fast": "Fast", "fast_draft": "Fast [draft]", "fastest": "Fastest", + "feature_chat": "Chat", + "feature_chat_help": "The AI chat panel and Ask AI on selection.", + "feature_completion": "Inline completion", + "feature_completion_help": "Autocomplete suggestions while typing.", "feature_enabled_or_disabled": "Feature enabled/disabled", "feature_included": "Feature included", "feature_not_included": "Feature not included", + "feature_review": "Compliance review", + "feature_review_help": "The whole-document review.", "featured": "Featured", "featured_latex_templates": "Featured LaTeX Templates", "features": "Features", @@ -1256,6 +1281,7 @@ "info": "Info", "initiator": "Initiator", "inline": "Inline", + "inline_completion_model": "Inline completion model", "inline_math": "Inline math", "inr_discount_modal_info": "Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.", "inr_discount_modal_title": "70% off all Overleaf premium plans for users in India", @@ -1422,6 +1448,7 @@ "latam_discount_modal_info": "Unlock the full potential of Overleaf with a __discount__% discount on premium subscriptions paid in __currencyName__. Get a longer compile timeout, full document history, track changes, additional collaborators, and more.", "latam_discount_modal_title": "Premium subscription discount", "latam_discount_offer_plans_page_banner": "__flag__ We’ve applied a __discount__ discount to premium plans on this page for our users in __country__. Check out the new lower prices (in __currency__).", + "latex_ai_assistant": "LaTeX AI Assistant", "latex_articles_page_title": "Articles - Papers, Presentations, Reports and more", "latex_examples": "LaTeX examples", "latex_examples_page_title": "Examples - Equations, Formatting, TikZ, Packages and More", @@ -1492,6 +1519,70 @@ "linked_collabratec_description": "Use Collabratec to manage your __appName__ projects.", "linked_file": "Imported file", "links": "Links", + "llm_admin_from_env": "Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.", + "llm_api_key": "API Key", + "llm_api_key_optional_local": "Leave blank for a local server with no auth (e.g. a llama.cpp server).", + "llm_api_key_placeholder": "Paste your API key here", + "llm_api_key_placeholder_stored": "•••••••• (stored — leave blank to keep)", + "llm_api_key_remove": "Remove stored key", + "llm_api_key_stored": "An API key is already stored. Leave blank to keep it.", + "llm_api_type": "API Type", + "llm_api_url": "API Endpoint URL", + "llm_byo_add": "Add provider", + "llm_byo_anthropic_hint": "Uses api.anthropic.com unless you set a base URL.", + "llm_byo_baseurl": "Base URL", + "llm_byo_cancel": "Cancel", + "llm_byo_col_actions": "Actions", + "llm_byo_col_key": "Key", + "llm_byo_col_models": "Models", + "llm_byo_col_provider": "Provider", + "llm_byo_col_status": "Status", + "llm_byo_compatible_hint": "Any server with an OpenAI-style /v1 API: Ollama, vLLM, llama.cpp, OpenRouter, ...", + "llm_byo_completion_default": "Use the first listed model", + "llm_byo_completion_model": "Inline completion model (optional)", + "llm_byo_delete": "Delete", + "llm_byo_delete_confirm": "Delete this provider and its stored API key?", + "llm_byo_delete_failed": "Delete failed", + "llm_byo_deleted": "Provider deleted", + "llm_byo_disabled": "Disabled", + "llm_byo_edit": "Edit", + "llm_byo_enabled": "Enabled", + "llm_byo_enabled_label": "Enabled (visible in the editor)", + "llm_byo_err_completion": "The completion model must be one of the listed models", + "llm_byo_err_models": "Please add at least one model", + "llm_byo_err_name": "Please enter a provider name", + "llm_byo_err_url": "A base URL is required for OpenAI-compatible providers", + "llm_byo_key": "API key", + "llm_byo_key_keep": "Stored key present — leave blank to keep it", + "llm_byo_key_keep_check": "Keep the stored key", + "llm_byo_key_new": "Your API key (left blank for keyless local servers)", + "llm_byo_model_add": "Add", + "llm_byo_model_placeholder": "qwen3.8:latest, gpt-4o, ...", + "llm_byo_model_remove": "Remove model", + "llm_byo_models": "Models", + "llm_byo_models_note": "The first model is the default for this provider. Highlighted model = inline completion.", + "llm_byo_name": "Name", + "llm_byo_name_placeholder": "e.g. Ollama (lab)", + "llm_byo_openai_hint": "Uses api.openai.com unless you set a base URL.", + "llm_byo_provider": "Provider type", + "llm_byo_save": "Save provider", + "llm_byo_save_failed": "Saving failed", + "llm_byo_saved": "Provider saved. It is now available in the editor.", + "llm_byo_saving": "Saving...", + "llm_byo_scan": "Scan backend for models", + "llm_byo_scan_failed": "Model scan failed", + "llm_byo_scan_none": "The backend reported no models", + "llm_byo_scan_ok": "Models added to the row", + "llm_byo_scanning": "Scanning...", + "llm_byo_test": "Test connection", + "llm_byo_test_fail": "Connection failed", + "llm_byo_test_ok": "Connection successful", + "llm_byo_testing": "Testing...", + "llm_byo_title": "My LLM providers", + "llm_configuration": "LLM Configuration", + "llm_features": "Features", + "llm_settings": "LLM Settings", + "llm_settings_saved": "LLM settings saved successfully.", "load_domains_failed": "There was an error loading the domains. Please reload the page to try again. If the problem continues please <0>contact us.", "loading": "Loading", "loading_content": "Creating Project", @@ -1600,6 +1691,7 @@ "max_ai": "Max", "max_ai_allowance": "Max AI allowance", "max_ai_allowance_tooltip": "Full access to every AI tool. Fair usage applies.", + "max_context_tokens": "Max context tokens", "maximum_files_uploaded_together": "Maximum __max__ files uploaded together", "may": "May", "maybe_later": "Maybe later", @@ -1629,6 +1721,8 @@ "missing_payment_details": "Missing payment details", "missing_required_sso_fields": "Missing required SSO fields in order to update configuration. Please check the SSO configuration and try again. Missing: __missingFields__.", "model": "Model", + "model_label": "Model", + "model_selection": "Model Selection", "money_back_guarantee": "14-day money back guarantee, hassle free", "month": "month", "month_plural": "months", @@ -1736,6 +1830,7 @@ "no_projects": "No projects", "no_resolved_comments": "No resolved comments", "no_results_found": "No results found", + "no_rubrics_yet": "No rubrics yet. Add one to enable the compliance review for users.", "no_search_results": "No Search Results", "no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.", "no_symbols_found": "No symbols found", @@ -2088,6 +2183,7 @@ "quoted_text": "Quoted text", "raw_logs": "Raw logs", "raw_logs_description": "Raw logs from the LaTeX compiler", + "re_run_last_question": "Re-run last question", "reactivate": "Reactivate", "reactivate_add_on_failed": "Something went wrong while reactivating your add-on. Please try again later.", "reactivate_subscription": "Reactivate your subscription", @@ -2137,11 +2233,11 @@ "reference_managers": "Reference managers", "reference_search": "Advanced reference search", "reference_search_info_new": "Find your references easily—search by author, title, year, or journal.", + "reference_search_results": "Reference search results", "reference_search_setting": "Reference search", "reference_search_settings": "Reference search settings", "reference_search_style": "Reference search style", "reference_sync": "Reference manager sync", - "reference_search_results": "Reference search results", "references_from_these_libraries_will_be_included_in_your_reference_search_results": "References from these libraries will be included in your reference search results.", "references_picker_empty_hint": "Start typing to search references", "references_picker_title": "Select references", @@ -2153,8 +2249,8 @@ "regards": "Regards", "register": "Register", "register_error": "Registration error", - "register_success": "Registration successful. Please check your email to activate your account.", "register_intercept_sso": "You can link your __authProviderName__ account from the Account settings page after logging in.", + "register_success": "Registration successful. Please check your email to activate your account.", "register_to_accept_invitation": "Register to accept invitation", "register_to_edit_template": "Please register to edit the __templateName__ template", "register_with_another_email": "Register with __appName__ using another email.", @@ -2228,6 +2324,7 @@ "reset_password": "Reset Password", "reset_password_link": "Click this link to reset your password", "reset_password_sentence_case": "Reset password", + "reset_to_default": "Reset to default", "reset_your_password": "Reset your password", "resize": "Resize", "resolve": "Resolve", @@ -2238,7 +2335,6 @@ "resources": "Resources", "restore": "Restore", "restore_accounts": "Restore accounts", - "restore_projects": "Restore projects", "restore_file": "Restore file", "restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.", "restore_file_confirmation_title": "Restore this version?", @@ -2246,6 +2342,7 @@ "restore_file_error_title": "Restore File Error", "restore_file_version": "Restore this version", "restore_project_to_this_version": "Restore project to this version", + "restore_projects": "Restore projects", "restore_this_version": "Restore this version", "restoring": "Restoring", "restricted": "Restricted", @@ -2262,7 +2359,23 @@ "reverse_x_sort_order": "Reverse __x__ sort order", "revert_pending_plan_change": "Revert scheduled plan change", "review": "Review", + "review_checking": "Checking requirement", + "review_disabled": "The AI service is disabled.", + "review_empty": "This project has no text to review.", + "review_failed": "The review failed. Please try again.", + "review_max_tokens": "Review answer budget (tokens)", + "review_model": "Review model", + "review_model_shared_default": "Shared chat model (default)", "review_panel": "Review panel", + "review_queue_position": "Requests ahead of you:", + "review_queued": "In queue", + "review_rubric": "Review rubric", + "review_summarizing": "Writing the summary...", + "review_system_prompt": "Review system prompt", + "review_system_prompt_help": "System prompt for the whole-document compliance review.", + "review_tokens_answer": "reserved for the answer", + "review_tokens_document": "Document", + "review_tokens_limit": "limit", "reviewer": "Reviewer", "reviewer_dropbox_sync_message": "As a reviewer you can sync the current project version to Dropbox, but changes made in Dropbox will <0>not sync back to Overleaf.", "reviewing": "Reviewing", @@ -2273,9 +2386,14 @@ "ro": "Romanian", "role": "Role", "ru": "Russian", + "rubric_guidelines": "Guidelines", + "rubric_name": "Rubric name", + "rubric_name_placeholder": "e.g. Thesis writing guidelines", + "rubric_scan_patterns": "Scan patterns (optional, one per line)", "run": "Run", "run_current_script_to_see_output": "Run the current script to see output.", "run_python_code": "Run Python code", + "run_review": "Run review", "saml": "SAML", "saml_auth_error": "Sorry, your identity provider responded with an error. Please contact your administrator for more information.", "saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.", @@ -2304,10 +2422,13 @@ "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", + "save_settings": "Save Settings", "save_x_or_more": "Save __percentage__ or more", "saved": "Saved", "saving": "Saving", "saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)", + "scan_failed": "Scan failed — check connection first", + "scan_for_models": "Scan for Models", "scope": "Scope", "search": "Search", "search_all_project_files": "Search all project files", @@ -2390,9 +2511,9 @@ "selected_plural": "selected", "selection_deleted": "Selection deleted", "send": "Send", - "send_notification_emails_to_users": "Send notification emails to the current and the new owners", "send_confirmation_code": "Send confirmation code", "send_message": "Send message", + "send_notification_emails_to_users": "Send notification emails to the current and the new owners", "send_request": "Send request", "send_reset_link": "Send reset link", "send_test_email": "Send a test email", @@ -2409,9 +2530,9 @@ "session_error": "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.", "session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds", "sessions": "Sessions", - "set_as_main_document": "Set as main document", "set_admin": "Set admin", "set_admin_account": "Grant admin privileges", + "set_as_main_document": "Set as main document", "set_color": "set color", "set_column_width": "Set column width", "set_new_password": "Set new password", @@ -2572,9 +2693,14 @@ "state": "State", "status": "Status", "status_checks": "Status Checks", + "status_missing": "Missing", + "status_na": "N/A", + "status_ok": "OK", + "status_partial": "Partial", "still_have_questions": "Still have questions?", "stop": "Stop", "stop_compile": "Stop compilation", + "stop_generation": "Stop", "stop_on_first_error": "Stop on first error", "stop_on_first_error_enabled_description": "<0>“Stop on first error” is enabled. Disabling it may allow the compiler to produce a PDF (but your project will still have errors).", "stop_on_first_error_enabled_title": "No PDF: Stop on first error enabled", @@ -2648,6 +2774,7 @@ "synctex_failed": "Couldn’t find the corresponding source file", "syntax_checks": "Syntax checks", "syntax_validation": "Code check", + "system_prompt": "System Prompt", "tab_connecting": "Connecting with the editor", "tab_settings": "Tab settings…", "table": "Table", @@ -2684,6 +2811,7 @@ "test": "Test", "test_configuration": "Test configuration", "test_configuration_successful": "Test configuration successful", + "test_connection": "Test Connection", "tex_live_version": "TeX Live version", "texgpt": "TeXGPT", "text": "Text", @@ -2746,6 +2874,7 @@ "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "They will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only.", "they_will_retain_their_existing_account_on_the_free_plan": "They will retain their existing account on the __appName__ free plan.", "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "They won’t be able to log in with SSO (if you have this enabled). They will need to set an __appName__ password.", + "thinking": "Thinking...", "this_action_can_be_undone_within_limited_period": "This action can be undone only within a limited period.", "this_action_cannot_be_reversed": "This action cannot be reversed.", "this_action_cannot_be_undone": "This action cannot be undone.", @@ -2807,6 +2936,7 @@ "token_limit_reached": "You’ve reached the 10 token limit. To generate a new authentication token, please delete an existing one.", "token_read_only": "token read-only", "token_read_write": "token read-write", + "tokens": "tokens", "too_many_attempts": "Too many attempts. Please wait for a while and try again.", "too_many_comments_or_tracked_changes": "Too many comments or tracked changes", "too_many_comments_or_tracked_changes_detail": "Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.", @@ -2895,6 +3025,7 @@ "tried_to_log_in_with_email": "You’ve tried to log in with __email__.", "tried_to_register_with_email": "You’ve tried to register with __email__, which is already registered with __appName__ as an institutional account.", "try_again": "Please try again", + "try_asking": "Try asking", "try_for_free": "Try for free", "try_for_free_instead": "Try for free instead", "try_now": "Try Now", @@ -2961,6 +3092,7 @@ "unpausing": "Unpausing", "unpublish": "Unpublish", "unpublishing": "Unpublishing", + "unselect_all": "Unselect all", "unset_admin": "Unset admin", "unset_admin_account": "Revoke admin privileges", "unsubscribe": "Unsubscribe", diff --git a/services/web/modules/llm/README.md b/services/web/modules/llm/README.md new file mode 100644 index 0000000000..92eb4f546f --- /dev/null +++ b/services/web/modules/llm/README.md @@ -0,0 +1,191 @@ +# LLM module for Overleaf Community Edition + +Bring your own LLM endpoints (BYO) plus a site-wide shared backend into the +project editor: selection-based **Ask AI** toolbar, a **chat rail**, **inline +completion**, whole-document **generators** (title / abstract / keywords), and +**rubric-based compliance reviews**. + +## Provider stack (Vercel AI SDK) + +All model calls go through one seam — `app/src/LLMClient.mjs` — built on the +Vercel AI SDK (`ai`, `@ai-sdk/openai`, `@ai-sdk/anthropic`, +`@ai-sdk/openai-compatible`). Three provider types: + +| Type | Adapter | Use for | +|----------------------|-------------------------|------------------------------------------------------| +| `openai` | `@ai-sdk/openai` | api.openai.com (or any override base URL) | +| `anthropic` | `@ai-sdk/anthropic` | api.anthropic.com (or any override base URL) | +| `openaiCompatible` | `@ai-sdk/openai-compatible` | Ollama, vLLM, llama.cpp, academiccloud, OpenRouter, any OpenAI-style `/v1` server | + +Structured outputs (compliance reviews) use `generateObject`; backends that +cannot do strict structured output fall back to plain JSON + a tolerant parser +with retries. Reasoning ("thinking") models are supported; empty answers where +the budget was spent on thinking are surfaced as a clear `empty-response` +error. + +## Lanes + +Every LLM call runs on one of two lanes, resolved from the **model id**: + +- **Site lane** — bare model id (e.g. `qwen3.8:latest`), the admin-configured + backend. Explicit model ids must be in the admin `allowedModels` list. +- **User lane** — namespaced id `u::`, one of the user's BYO + provider rows. The user's own credentials are used. + +A request **without** a model resolves to the user's first enabled BYO row (if +BYO is allowed), else the site default. + +## Endpoints + +All chat/completion/generation endpoints are project-scoped (read permission); +settings are user- or admin-scoped. + +| Method | Path | Purpose | +|--------|---------------------------------------------|--------------------------------------| +| POST | `/project/:id/llm/chat` | Chat (Ask AI rail + toolbar) | +| GET | `/project/:id/llm/models` | Site models + user provider rows | +| GET | `/project/:id/llm/features` | Per-feature enable flags | +| GET | `/project/:id/llm/source-context` | Source window around a compile error | +| GET | `/project/:id/llm/prompts` | Effective editable prompts | +| POST | `/project/:id/llm/completion` | Inline completion | +| POST | `/project/:id/llm/generate` | Title / abstract / keywords | +| GET | `/project/:id/llm/compliance/rubrics` | Configured rubrics | +| POST | `/project/:id/llm/compliance/start` | Start a review job | +| GET | `/project/:id/llm/compliance/status/:jobId`| Poll a review job | +| POST | `/project/:id/llm/compliance/cancel/:jobId`| Cancel a review job | +| GET | `/user/llm-providers` | List BYO rows (keys masked) | +| POST | `/user/llm-providers` | Add a BYO row | +| POST | `/user/llm-providers/:id` | Update a BYO row | +| POST | `/user/llm-providers/:id/delete` | Delete a BYO row | +| POST | `/user/llm-providers/check` | Test an existing row or draft | +| POST | `/user/llm-providers/scan` | List models served by a backend | +| GET | `/admin/llm/settings` (+ `/json`) | Site backend settings page/API | +| POST | `/admin/llm/settings` | Save site backend settings | +| POST | `/admin/llm/settings/check` | Connection test (model list fetch) | +| POST | `/admin/llm/models` | Scan the site backend for models | + +* `GET /user/llm-settings` — dedicated BYO settings page (provider table, + Test/Model scan, Add/edit). Linked from the Account menu. +* Admin settings UI: **Manage Site → “LLM Configuration” tab** + (`/admin`), served by the standalone page `GET /admin/llm/settings`. + There is intentionally **no separate navbar entry** (reviewer requirement). + +## Environment variables + +| Variable | Default | Meaning | +|----------------------------|------------------|-----------------------------------------------------| +| `LLM_ENABLED` | `true` if `llm.enabled !== false` | Loads the module at all | +| `LLM_API_URL` | — | Fallback site backend base URL (admin file wins) | +| `LLM_API_KEY` | — | Fallback site backend key | +| `LLM_MODEL_NAME` | — | Fallback default model (comma list for several) | +| `LLM_AVAILABLE_MODELS` | — | Fallback allowed model list | +| `LLM_API_TYPE` | auto-detect | Fallback provider type (`openai`/`anthropic`/`openaiCompatible`) | +| `LLM_ALLOW_USER_SETTINGS` | **off** | `'true'` enables BYO rows (all user endpoints 403 otherwise) | +| `LLM_KEY_SECRET` | — | Secret for encrypting stored API keys (AESCrypt, `enc:v1:`). **Set it** — without it keys are stored in plaintext. | +| `LLM_ADMIN_SETTINGS_PATH` | `/var/lib/overleaf/data/llm-admin-settings.json` | Site settings file | +| `LLM_USER_RATE_PER_MINUTE` | `60` | Per-user LLM calls per rolling minute | +| `LLM_USER_DAILY_TOKENS` | `1000000` | Per-user output-token budget per UTC day | +| `LLM_REVIEW_MAX_TOKENS` | `12000` | Review answer budget | +| `LLM_REVIEW_MAX_PASSES` | `200` | Max review passes per job (per-file caps include) | + +## Security notes + +- BYO endpoints are gated on `LLM_ALLOW_USER_SETTINGS` in **every** handler + (router registration is unconditional; disabled deployments answer `403`). +- Site-lane model ids are validated against the admin allowlist server-side. +- API keys are encrypted at rest when `LLM_KEY_SECRET` is set (never returned + to the browser, only `hasKey`). +- LLM output is rendered through `marked` + `DOMPurify` (sanitized) in the + chat rail and selection toolbar. +- Per-user rate + daily token guards protect the shared backend from + runaway usage (inline completion is the high-frequency path). +- Review jobs are capped: per-user in-flight limit, global queue limit, and a + hard pass budget per job. + +## Legacy import (upgrade path) + +Users of the previous single-backend design stored `llmApiUrl`/`llmApiKey`/ +`llmModelName` (+ `llmApiType`) on the user document. On the first +`loadProviders()` call, that configuration is **materialized once** into +`llmProviders[0]` — an `id: 'legacy'` row named **“Imported settings”** +(`enabled: true`) with the key **encrypted at rest** — and served under its +`legacy` id from then on. The legacy fields are left untouched, so a rollback +deep-links the same data. Users who already have provider rows are not +affected. + +## Error surfacing for failed connections + +`check`/`scan` and admin saves surface *specific* failures: + +- Provider 401/403 → `HTTP 401` with a human message ("provider rejected + the key / no key provided", provider detail included when the backend + returns one). The UI shows that message in the connection notice. +- Admin settings save → `400 {ok:false, error, errors:[{field,message}]}` + (all zod issues), shown as a banner next to the Save button. +- Rubric scan regexes are validated by `safeRubricRegex` (rejects recursive + groups and repeated-quantifier patterns) to keep reviews from hanging. + +## Tests + +```bash +node --test services/web/modules/llm/app/test/llm-client.test.mjs # offline unit tests +node --test services/web/modules/llm/app/test/llm-crypto.test.mjs # key-encrypt roundtrip + legacy plaintext +node services/web/modules/llm/app/test/LLMClient.live.mjs # live smoke (needs a reachable backend) + +# Frontend render regressions (jsdom; needs the standalone harness config): +cd services/web +NODE_ENV=development /.bin/vitest run --config vitest.llm-frontend.config.js +``` + +Deployed-container verification (bash/curl driver) covers login, site/user +lanes, BYO CRUD + check/scan, rate-guard burst, compliance job lifecycle, +and the settings pages: 12/12 passing on the latest build. + +## Reviewer compliance (issue #222, remaining items) + +- **#5 auto-detect API type** — `POST /user/llm-providers/check` and `/scan` try the + requested type first, then every supported type. A working type different from + the saved one is returned as `detectedProviderType`, the UI persists it on the + row (drafts are switched too), and the Test button says "API type auto-set to …". +- **#9 model-list freshness (decided: manual only)** — no automatic background sync + (removed by owner decision 2026-08-21: timer-based auto-refreshes can replace a + user-curated model list and surprise users; the benefit was small). Model lists + are refreshed explicitly: **Test connection** (verifies and returns the current + list) and **Scan models** (merges the freshly served list into the row). + Providers whose model roster changes over time are handled with one click of + Scan — no timers, no background traffic, no rate-limit exposure. +- **#11 split/join** — removed from the editor toolbar (type, labels, modes 5/6; + the admin prompt templates remain available if ever needed). +- **#13 generators in the File menu** — title / abstract / keywords moved from the + selection toolbar into the core **File menu** (module extensions + `frontend/js/extensions/llm-file-menu-*`, core `insertMenuSections` + + `menubarExtraComponents` in `config/settings.defaults.js`). They read the whole + project (`POST /project/:id/llm/generate`) — matching the reviewer's point + that whole-document generators do not belong to a selection context — and open + a result modal with copy-to-clipboard. Non-LLM deployments see no menu items + (unregistered commands are filtered by the core menu renderer). +- **#2 user settings inside Account Settings** — the BYO table/editor is rendered + directly in the core Account Settings page ("AI assistant" section, + `frontend/js/features/settings/components/llm-user-section.tsx`) via the + `overleafModuleImports` mechanism (`llmUserSettingsSection`, same pattern as + the github/zotero widgets — no dual-React risk); the section renders in + `compact` mode (no duplicate header) and falls back to a link card when the + module is absent. `/user/llm-settings` remains available as a deep link / the + Account ▸ 'AI Settings' entry. + +## Layout + +- `app/src/LLMClient.mjs` — AI-SDK seam (models, chat text/objects, model listing) +- `app/src/LLMModelRef.mjs` — model-ref grammar (`u::`) +- `app/src/LLMBudget.mjs` — per-user rate + token guards +- `app/src/LLMChatController.mjs` — chat / completion / models / generators +- `app/src/LLMSettingsController.mjs` — BYO provider rows (CRUD, check, scan) +- `app/src/LLMAdminController.mjs` — site backend settings, check, scan, prompts +- `app/src/LLMComplianceController.mjs` — rubric review engine (job queue) +- `app/src/LLMCrypto.mjs` — at-rest key encryption +- `app/src/LLMPrompts.mjs` — prompt defaults + merges +- `app/views/*.pug` — page shells (settings, admin) +- `frontend/js/components` — toolbar, chat rail, compliance pane, settings UI +- `frontend/js/hooks` — chat / compliance / features state +- `frontend/js/pages` — webpack entries (`user/llm-settings`, `admin/...`) +- `frontend/stylesheets/*.scss` — module styles diff --git a/services/web/modules/llm/app/src/LLMAdminController.mjs b/services/web/modules/llm/app/src/LLMAdminController.mjs index 19d3c6832b..3706b288f3 100644 --- a/services/web/modules/llm/app/src/LLMAdminController.mjs +++ b/services/web/modules/llm/app/src/LLMAdminController.mjs @@ -1,10 +1,10 @@ import logger from '@overleaf/logger' -import fs from 'fs' -import path from 'path' +import fs from 'node:fs' +import path from 'node: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 { listModels, detectProviderType } from './LLMClient.mjs' // overleaf-lab: AI-SDK provider seam import { DEFAULT_ASK_AI_SYSTEM_PROMPT, DEFAULT_ERROR_PROMPT, @@ -40,7 +40,13 @@ function readAdminSettings() { 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 }) + // F9: atomic write — temp file in the same directory + rename (POSIX + // rename is atomic) fixes torn reads; explicit chmod also corrects the + // mode of an EXISTING file created with looser permissions. + const tmp = `${ADMIN_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)) + fs.chmodSync(tmp, 0o600) + fs.renameSync(tmp, ADMIN_SETTINGS_PATH) } catch (err) { logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file') throw err @@ -71,9 +77,21 @@ async function buildDisplaySettings() { return { systemPrompt: settings.systemPrompt || '', llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '', - llmApiType: settings.llmApiType || process.env.LLM_API_TYPE || 'anthropic', + llmApiType: + settings.llmApiType || + process.env.LLM_API_TYPE || + detectProviderType(settings.llmApiUrl || process.env.LLM_API_URL), hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY), allowedModels: jsonHasModels ? settings.allowedModels : envModels, + // overleaf-lab: item 8 (PR decision) — the FULL fetched model list; allowedModels + // is the checked/enabled subset. The UI keeps unchecked models visible so an + // admin can re-enable them without rescan. + knownModels: + Array.isArray(settings.knownModels) && settings.knownModels.length > 0 + ? settings.knownModels + : jsonHasModels + ? settings.allowedModels + : envModels, completionModel: settings.completionModel || '', llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL, llmApiTypeFromEnv: !settings.llmApiType && !!process.env.LLM_API_TYPE, @@ -115,13 +133,18 @@ async function getAdminSettings(req, res) { } const llmSettingsSchema = z.object({ - systemPrompt: z.string().max(4000), + // overleaf-lab: optional so a save that omits it (or sends '') still works; the + // chat falls back to its own language instruction when no prompt is set. + systemPrompt: z.string().max(4000).optional(), llmApiUrl: z.string().optional(), llmApiType: z.string().optional(), llmApiKey: z.string().optional(), + clearLlmApiKey: z.boolean().optional(), allowedModels: z.array(z.string()).optional(), + // overleaf-lab: item 8 — the full fetched model list (checked subset = allowedModels) + knownModels: z.array(z.string()).optional(), completionModel: z.string().optional(), @@ -152,16 +175,16 @@ const llmSettingsSchema = z.object({ async function saveAdminSettings(req, res) { 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 + const errors = safeBody.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + logger.error( { errors }, 'Bad admin settings') + // F8: return ALL validation errors so the UI can surface them. return res.status(400).json({ + ok: false, error: safeBody.error.issues[0].message, + errors, }) } @@ -170,7 +193,9 @@ async function saveAdminSettings(req, res) { llmApiUrl, llmApiType, llmApiKey, + clearLlmApiKey, allowedModels, + knownModels, completionModel, complianceRubrics, reviewModel, @@ -188,25 +213,19 @@ async function saveAdminSettings(req, res) { const existing = 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 + // overleaf-lab: sanitize each rubric and cap the count via the shared helper + // (same rules as the per-user rubric save). 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 : [] + const sanitizedRubricsInput = sanitizeComplianceRubrics(complianceRubrics) + // overleaf-lab: validate the RAW input so an over-long scan-patterns block + // fails loudly (the sanitizer would otherwise silently truncate it). + const rubricError = validateComplianceRubrics(Array.isArray(complianceRubrics) ? complianceRubrics : []) + if (rubricError) { + return res.status(400).json({ error: rubricError }) } + const sanitizedRubrics = sanitizedRubricsInput === null + ? (Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : []) + : sanitizedRubricsInput // overleaf-lab: clamp the context window to a sane range; keep existing (or the // 32000 default) when not provided. @@ -233,39 +252,8 @@ async function saveAdminSettings(req, res) { 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: scan-pattern validation moved to the shared helper above + // (validateComplianceRubrics — same 4000-char cap + per-line RegExp check). // overleaf-lab: sanitize the action prompt overrides. When provided, keep only // known keys with string values, each capped at 4000 chars. When not provided, @@ -290,10 +278,17 @@ async function saveAdminSettings(req, res) { const updatedSettings = { ...existing, - systemPrompt, + systemPrompt: typeof systemPrompt === 'string' ? systemPrompt : (existing.systemPrompt || ''), llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''), llmApiType: typeof llmApiType === 'string' ? llmApiType : (existing.llmApiType || ''), allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [], + knownModels: Array.isArray(knownModels) + ? knownModels + : Array.isArray(existing.knownModels) + ? existing.knownModels + : Array.isArray(existing.allowedModels) + ? existing.allowedModels + : [], completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''), complianceRubrics: sanitizedRubrics, reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''), @@ -311,7 +306,9 @@ async function saveAdminSettings(req, res) { askAiActionPrompts: sanitizedActionPrompts, } - if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) { + if (clearLlmApiKey) { + updatedSettings.llmApiKey = '' // overleaf-lab: explicit "remove stored key" from the admin UI + } else if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) { updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest } @@ -322,6 +319,7 @@ async function saveAdminSettings(req, res) { llmApiType: !!updatedSettings.llmApiType, hasLlmApiKey: !!updatedSettings.llmApiKey, allowedModels: updatedSettings.allowedModels?.length || 0, + knownModels: updatedSettings.knownModels?.length || 0, }, '[LLM] Admin settings updated') res.json({ success: true }) @@ -344,7 +342,10 @@ export async function getAdminLLMSettings() { 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, + llmApiType: + settings.llmApiType || + process.env.LLM_API_TYPE || + detectProviderType(settings.llmApiUrl || process.env.LLM_API_URL), llmApiKey: jsonKey || process.env.LLM_API_KEY || null, allowedModels: jsonHasModels ? settings.allowedModels : envModelList(), completionModel: settings.completionModel || '', @@ -379,6 +380,55 @@ export async function getComplianceRubrics() { return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [] } +// overleaf-lab (2026-08-27): shared rubric hygiene, used by BOTH the admin save +// and the per-user compliance rubric save (owner request: rubrics became +// user-scoped in /user/llm-settings — same caps, same validation). +// Sanitizes a rubric list (drops entries without id/name, caps text fields, +// caps the list). Returns null when `list` is not an array (caller keeps the +// existing value). +export function sanitizeComplianceRubrics(list) { + if (!Array.isArray(list)) return null + return list + .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) +} + +// overleaf-lab: validates each rubric's scan patterns ("Label :: regex" per +// line) so a broken regex fails at SAVE time, not as a silently hint-less +// review. Returns the first error message, or null when everything parses +// (reviewer-side parsing skips invalid lines anyway — defense in depth). +export function validateComplianceRubrics(list) { + if (!Array.isArray(list)) return null + for (const r of list) { + const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : '' + if (patternsText.length > 4000) { + return `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 `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}` + } + } + } + return null +} + // 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. @@ -396,73 +446,87 @@ async function checkAdminLLMConnection(req, res) { 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 + const llmApiType = apiType || adminSettings.llmApiType || detectProviderType(llmApiUrl) - // 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() - if (!llmApiUrl || !llmApiType) { + if (!llmApiUrl) { return res.status(400).json({ success: false, - error: 'LLM API URL and type is required', + error: 'LLM API URL is required', }) } + // overleaf-lab: PR decision (item 7) — "testing the connection" IS a + // successful model-list fetch: it proves reachability, auth, and that the + // backend serves at least one model, in ONE round trip. The first configured + // model (if any) must be in the list, which catches typos/stopped models. + const spec = normalizeSpecFor(llmApiUrl, llmApiKey, llmApiType, testModel || 'probe') + try { - const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType }) - await provider.checkConnection(testModel) + const { ids } = await listModels(spec, { timeoutMs: 60000 }) - res.json({ success: true, message: 'Connection successful' }) + if (testModel && !ids.includes(testModel)) { + logger.warn({ testModel, ids: ids.length }, '[LLM] Admin check: configured model not in backend list') + return res.status(404).json({ + success: false, + error: `Model "${testModel}" is not available on this backend`, + status: 404, + models: ids, + }) + } + res.json({ success: true, message: 'Connection successful', models: ids }) } catch (err) { - const info = OError.getFullInfo(err) - const errStatus = info?.status || 500 - logger.error({ err }, '[LLM] Admin connection check failed') - res.status(errStatus).json({ + const status = err.code === 'auth' ? 401 : (err.status || 500) + logger.error({ err: err.message, code: err.code }, '[LLM] Admin connection check failed') + res.status(status).json({ success: false, error: 'LLM connection failed', - status: errStatus, + status, details: err.message, + models: [], }) } } +function normalizeSpecFor(baseUrl, apiKey, providerType, model) { + const type = providerType || detectProviderType(baseUrl) + const base = String(baseUrl || '').replace(/\/+$/, '') + const baseForType = type === 'anthropic' ? base.replace(/\/v\d+$/, '') : base + return { providerType: type, baseUrl: baseForType, apiKey: apiKey || '', model } +} + async function scanAdminModels(req, res) { - const { apiUrl, apiKey, apiType } = req.query + // overleaf-lab: credentials come from the POST body, never the URL query + // string (keys in query strings leak into access logs). + 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 + const llmApiType = apiType || adminSettings.llmApiType || detectProviderType(llmApiUrl) - if (!llmApiUrl || !llmApiType) { + if (!llmApiUrl) { return res.status(400).json({ success: false, - error: 'Admin LLM API URL and type must be configured first', + error: 'Admin LLM API URL must be configured first', }) } try { - const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType }) - const result = await provider.listModels() - - const ids = Array.isArray(result?.data) - ? result.data.map(entry => String(entry.id)) - : [] - + const { ids } = await listModels(normalizeSpecFor(llmApiUrl, llmApiKey, llmApiType, 'scan'), { timeoutMs: 60000 }) res.json({ success: true, models: ids }) - } catch (error) { - const info = OError.getFullInfo(err) - const errStatus = info?.status || 500 - logger.error({ err }, '[LLM] Admin connection check failed') - res.status(errStatus).json({ + const status = error.code === 'auth' ? 401 : (error.status || 500) + logger.error({ error: error.message, code: error.code }, '[LLM] Admin model scan failed') + res.status(status).json({ success: false, error: 'Model scan failed', - status: errStatus, - details: err.message, + status, + details: error.message, }) } } diff --git a/services/web/modules/llm/app/src/LLMBudget.mjs b/services/web/modules/llm/app/src/LLMBudget.mjs new file mode 100644 index 0000000000..82f489f1c2 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMBudget.mjs @@ -0,0 +1,126 @@ +/** + * overleaf-lab: per-user LLM budget guards (F4). + * + * Two guards, both enforced BEFORE a model call: + * + * 1. Rate gate: at most RATE_PER_MINUTE LLM calls (chat + completion + + * generate) per user per rolling wall-clock minute. + * 2. Daily token guard: at most DAILY_TOKENS OUTPUT tokens per user per + * UTC day (counted from the model-reported usage of successful calls). + * + * Counters live in mongo (collection `llmuserbudget`) so they are shared + * across ALL web worker processes — an in-memory map would only guard each + * worker (the sharelatex image runs two web nodes behind one nginx). + * The write per call is a single atomic $inc, negligible next to LLM + * latency. A TTL index cleans up stale documents automatically. + * + * Failure mode is OPEN on counter-store errors (logged): the guard must not + * take the shared backend down for everyone if the DB hiccups; the daily + * token guard remains best-effort by design. + * + * Env: LLM_USER_RATE_PER_MINUTE (default 60), LLM_USER_DAILY_TOKENS + * (default 1_000_000). + */ +import logger from '@overleaf/logger' +import mongoose from '../../../../app/src/infrastructure/Mongoose.mjs' + +const RATE_PER_MINUTE = Math.max(1, Number.parseInt(process.env.LLM_USER_RATE_PER_MINUTE, 10) || 60) +// Minimum sane daily floor: a few thousand tokens of chat must always fit. +const DAILY_TOKENS = Math.max(1000, Number.parseInt(process.env.LLM_USER_DAILY_TOKENS, 10) || 1_000_000) + +// overleaf-lab: ensure the shared collection + TTL index exist (idempotent). +let ready = null +function collection() { + if (!ready) { + ready = mongoose.connection.collection('llmUserBudget') + } + return ready +} +let indexReady = Promise.resolve() +function ensureIndex() { + if (!indexReady) { + indexReady = mongoose.connection + .collection('llmUserBudget') + .createIndex({ userId: 1 }, { expireAfterSeconds: 86400 * 2, name: 'ttl' }) + indexReady = indexReady.then( + () => undefined, + err => logger.warn({ err }, '[LLMBudget] TTL index (non-fatal)') + ) + } + return indexReady +} + +function minuteKey() { + // wall-clock minute bucket (UTC) — rolling minute, no carry-over + return new Date().toISOString().slice(0, 16) +} +function dayKey() { + return new Date().toISOString().slice(0, 10) +} + +/** + * Guard BEFORE an LLM call. Throws a normalized error (code 'llm-rate-limited' + * or 'llm-budget') when the user's rate or daily budget is exhausted. + * Returns { record(outputTokens) } which MUST be called after a successful + * call with the model's reported output tokens. + */ +export async function guardLLMCall(userId) { + if (!userId) { + return { record: () => {} } + } + void ensureIndex() + const minute = minuteKey() + const day = dayKey() + const coll = collection() + try { + const bumped = await coll.findOneAndUpdate( + { userId, minute }, + { $inc: { calls: 1 }, $set: { minute, day } }, + { upsert: true, returnDocument: 'after' } + ) + if (bumped.calls > RATE_PER_MINUTE) { + throw Object.assign( + new Error(`Rate limit reached (${RATE_PER_MINUTE} calls/minute). Wait a minute and try again.`), + { code: 'llm-rate-limited' } + ) + } + } catch (err) { + if (err && err.code) throw err + // counter store failure: fail OPEN (log; do not take the service down) + logger.warn({ err, userId }, '[LLMBudget] rate check unavailable, continuing') + } + + // Daily token budget (check against the CURRENT day's counter). + try { + const doc = await coll.findOne({ userId }) + const usedToday = doc && doc.day === day ? doc.tokens || 0 : 0 + if (DAILY_TOKENS - usedToday <= 0) { + throw Object.assign( + new Error(`Daily token budget reached (${DAILY_TOKENS} tokens/day). Resets at midnight UTC.`), + { code: 'llm-budget' } + ) + } + } catch (err) { + if (err && err.code) throw err + logger.warn({ err, userId }, '[LLMBudget] daily check unavailable, continuing') + } + + return { + record: (outputTokens) => { + const tokens = Number(outputTokens) || 0 + if (tokens <= 0) return + const d = dayKey() + collection() + .findOneAndUpdate( + { userId, day: d }, + { $inc: { tokens: tokens }, $set: { day: d } }, + { upsert: true } + ) + .catch(err => logger.warn({ err }, '[LLMBudget] token record failed (non-fatal)')) + }, + } +} + +export const LLM_BUDGET_INFO = { RATE_PER_MINUTE, DAILY_TOKENS } + +export default { guardLLMCall, LLM_BUDGET_INFO } diff --git a/services/web/modules/llm/app/src/LLMChatController.mjs b/services/web/modules/llm/app/src/LLMChatController.mjs index 03c416cbff..2665a0e6a5 100644 --- a/services/web/modules/llm/app/src/LLMChatController.mjs +++ b/services/web/modules/llm/app/src/LLMChatController.mjs @@ -1,466 +1,434 @@ +/* + * LLMChatController - chat, inline completion, model discovery, features, + * whole-document generators, source context, and prompt resolution. + * + * Lane routing (the model id IS the router): + * - site models: bare model id (e.g. "qwen3.8:latest") + * - user BYO rows: namespaced id u:: + * + * A request without a model resolves, in order: the user's first enabled BYO + * row (when allowed), then the site default. This preserves the old + * "personal settings preferred, shared fallback" behavior. + * + * Gating (F1): every user lane requires LLM_ALLOW_USER_SETTINGS=true. + * Allowlist (F2): site-lane model ids must be in the admin allowedModels. + * Provider calls go through LLMClient (Vercel AI SDK). + */ + 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 ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' 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 tags (for DeepSeek, Qwen and similar models) -// Handles both closed ... and unclosed ... at end of string -function stripThinkTags(content) { - let cleaned = content.replace(/[\s\S]*?<\/think>\s*/gi, '') - cleaned = cleaned.replace(/[\s\S]*/gi, '') - return cleaned.trim() -} - -// Parse available models from admin settings or environment variable -async function getAvailableModels() { - if (Settings.llm && !Settings.llm.enabled) { - logger.debug({ llmEnabled: Settings.llm.enabled }, '[LLM] getAvailableModels: LLM disabled') - return [] - } +import { decryptSecret } from './LLMCrypto.mjs' +import { chatText, chatObject, normalizeProviderSpec, detectProviderType, assertNonEmpty } from './LLMClient.mjs' +import { isUserSettingsAllowed, loadProviders } from './LLMSettingsController.mjs' +import { User } from '../../../../app/src/models/User.mjs' +import { parseModelRef } from './LLMModelRef.mjs' +import { guardLLMCall } from './LLMBudget.mjs' // overleaf-lab: per-user rate + daily token budget (F4) +import { compileFixSchema, validateCompileFixObject, buildCompileFixMessages } from './LLMCompileFix.mjs' // overleaf-lab: AI Error Assist +/* + * Build the model list for the editor picker: site models (bare ids) plus + * the user's enabled BYO rows (namespaced ids, grouped). + */ +async function getSiteModels() { const adminSettings = await getAdminLLMSettings() - let modelsList = Array.isArray(adminSettings.allowedModels) + let models = Array.isArray(adminSettings.allowedModels) ? adminSettings.allowedModels.filter(m => typeof m === 'string' && m.trim().length > 0) : [] - - if (modelsList.length === 0) { + if (!models.length) { const modelsEnv = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME - logger.debug( - { - LLM_AVAILABLE_MODELS: process.env.LLM_AVAILABLE_MODELS, - LLM_MODEL_NAME: process.env.LLM_MODEL_NAME, - resolved: modelsEnv, - }, - '[LLM] getAvailableModels: reading env' - ) - - if (modelsEnv) { - modelsList = modelsEnv - .split(',') - .map(m => m.trim()) - .filter(m => m.length > 0) - } + if (modelsEnv) models = modelsEnv.split(',').map(m => m.trim()).filter(Boolean) } - - if (modelsList.length === 0) { - return [] - } - - const result = modelsList.map((id, index) => ({ - id, - name: id.replace(/-/g, ' ').toUpperCase(), - isDefault: index === 0, - })) - - logger.debug({ count: result.length, models: result.map(m => m.id) }, '[LLM] getAvailableModels: parsed models') - return result + return models.map((id, index) => ({ id, name: id.replace(/-/g, ' ').toUpperCase(), isDefault: index === 0 })) } async function getModels(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) const projectId = req.params.Project_id - logger.debug( - { projectId, userId, llmSettings: Settings.llm }, - '[LLM] getModels: request received' - ) + if (Settings.llm && !Settings.llm.enabled) { + return res.json({ models: [], userRows: [] }) + } + const flags = await getLLMFeatureFlags() + if (!flags.chatEnabled) { + return res.json({ models: [], userRows: [] }) + } - try { - if (Settings.llm && !Settings.llm.enabled) { - logger.debug({}, '[LLM] getModels: LLM disabled, returning empty') - return res.json({ models: [] }) - } - - // overleaf-lab: chat feature disabled by admin -> no models to select. - const flags = await getLLMFeatureFlags() - if (!flags.chatEnabled) { - return res.json({ models: [] }) - } - - const models = [] - - // 1. Add server-wide models from admin settings/env - const serverModels = await getAvailableModels() - models.push(...serverModels) - - // 2. Add user's personal LLM model if configured and activated - if (userId && Settings.llm && Settings.llm.allowUserSettings) { - try { - const user = await User.findById( - userId, - 'useOwnLLMSettings llmModelName llmApiUrl llmApiKey' - ) - - if ( - user && - user.useOwnLLMSettings && - user.llmModelName && - user.llmApiUrl - ) { - // overleaf-lab: llmModelName may be a comma-separated list of - // personal chat models; expose one selectable entry per id. - const personalModelIds = user.llmModelName - .split(',') - .map(id => id.trim()) - .filter(id => id.length > 0) - for (const modelId of personalModelIds) { - models.push({ - id: `personal-${modelId}`, - name: `${modelId} (🔒 Personal)`, - isDefault: false, - isPersonal: true, - label: 'Private', - }) - } - } - } catch (error) { - logger.warn( - { userId, projectId, err: error }, - '[LLM] Error fetching user LLM settings' - ) + const siteModels = await getSiteModels() + const userRows = [] + if (userId && isUserSettingsAllowed()) { + try { + const providers = await loadProviders(userId) + for (const row of providers.filter(r => r.enabled !== false && r.models?.length)) { + userRows.push({ + id: row.id, + name: row.name, + providerType: row.providerType, + completionModel: row.completionModel || '', + models: row.models.map((modelId, index) => ({ + id: `u:${row.id}:${modelId}`, + name: modelId, + isDefault: index === 0 + })) + }) } } - - logger.debug( - { count: models.length, modelIds: models.map(m => m.id) }, - '[LLM] getModels: returning models' - ) - res.json({ models }) - } catch (error) { - logger.error( - { userId, projectId, err: error }, - '[LLM] Error fetching available models' - ) - res.status(500).json({ - success: false, - error: 'Failed to fetch available models', - }) + catch (error) { + logger.warn({ userId, projectId, err: error }, '[LLM] getModels: user rows failed') + } } + + logger.debug({ count: siteModels.length, userRows: userRows.length }, '[LLM] getModels: returning models') + res.json({ models: siteModels, userRows }) +} + +/* + * Resolve the shared (site) lane. Returns { spec } or null when unconfigured. + * Enforces the admin model allowlist (F2) for explicit model ids. + */ +async function resolveSiteLane(modelName) { + const adminSettings = await getAdminLLMSettings() + const baseUrl = adminSettings.llmApiUrl || process.env.LLM_API_URL + const apiKey = adminSettings.llmApiKey ? decryptSecret(adminSettings.llmApiKey) : (process.env.LLM_API_KEY || '') + const providerType = adminSettings.llmProviderType || adminSettings.llmApiType || detectProviderType(baseUrl) + + const allowed = Array.isArray(adminSettings.allowedModels) + ? adminSettings.allowedModels.filter(m => typeof m === 'string' && m.trim().length > 0) + : [] + const allowedEnv = (process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || '') + .split(',').map(m => m.trim()).filter(Boolean) + const pool = allowed.length ? allowed : allowedEnv + + const model = modelName || pool[0] + if (modelName && pool.length && !pool.includes(modelName)) { + throw Object.assign( + new Error(`Model "${modelName}" is not available on the site backend`), + { code: 'llm-bad-model' } + ) + } + if (!baseUrl || !model) { + throw Object.assign(new Error('LLM service is not configured'), { code: 'llm-disabled' }) + } + const spec = normalizeProviderSpec({ providerType, baseUrl, apiKey }, { model }) + return { spec, model: spec.model } +} + +/* + * Resolve a user BYO lane from a namespaced model ref (or the first enabled + * row when no model id was sent). Enforces the BYO gate (F1). + */ +async function resolveUserLane(userId, ref) { + if (!isUserSettingsAllowed()) { + throw Object.assign(new Error('Bring-your-own LLM settings are disabled on this deployment'), { code: 'disabled' }) + } + const providers = await loadProviders(userId) + let row = null + if (ref?.rowId) { + row = providers.find(r => r.id === ref.rowId) + if (!row) throw Object.assign(new Error('Unknown provider row'), { code: 'llm-bad-row' }) + if (ref.model && !row.models?.includes(ref.model)) { + throw Object.assign(new Error(`Model "${ref.model}" is not in the provider "${row.name}"`), { code: 'llm-bad-model' }) + } + } + else { + row = providers.find(r => r.enabled !== false && r.models?.length) + if (!row) throw Object.assign(new Error('No BYO provider configured'), { code: 'llm-bad-row' }) + } + if (row.enabled === false) { + throw Object.assign(new Error(`Provider "${row.name}" is disabled`), { code: 'llm-bad-row' }) + } + const modelName = ref?.model || row.completionModel || row.models?.[0] + if (!modelName) throw Object.assign(new Error('Provider row has no models'), { code: 'llm-bad-row' }) + const spec = normalizeProviderSpec( + { + providerType: row.providerType, + baseUrl: row.baseUrl || '', + apiKey: row.apiKey ? decryptSecret(row.apiKey) : '' + }, + { model: modelName } + ) + return { spec, model: spec.model, row } +} + +/* + * Map a client model ref (or none) to { spec, model, lane }. A bare/missing + * model on the site lane wins over user rows ONLY when user rows do not + * exist - users keep their preference for their own backends (old behavior). + * + * overleaf-lab (owner request 2026-08-26): an EMPTY model ref now first falls + * back to the user's SHARED selection stored on the profile + * (User.llmSelectedModel — File → "Select LLM Model"), so every surface + * (chat, review, generators, compile-fix) honors the one user-scoped choice + * without the client repeating it in each request. An explicit request ref + * always still wins. + */ +async function resolveLane(userId, modelRef) { + if (!modelRef.model) { + const profile = await User.findOne({ _id: userId }).lean().catch(() => null) + const profileRef = typeof profile?.llmSelectedModel === 'string' ? profile.llmSelectedModel.trim() : '' + if (profileRef) { + modelRef = parseModelRef(profileRef) + } + } + if (modelRef.kind === 'user') { + return { ...(await resolveUserLane(userId, modelRef)), lane: 'user' } + } + const providers = isUserSettingsAllowed() ? await loadProviders(userId).catch(() => []) : [] + const hasEnabledRow = providers.some(r => r.enabled !== false && r.models?.length) + if (!modelRef.model && hasEnabledRow) { + return { ...(await resolveUserLane(userId, { rowId: null })), lane: 'user' } + } + if (modelRef.model && modelRef.model.startsWith('personal-')) { + // Legacy id from pre-BYO clients: treat as the first user row. + if (hasEnabledRow) { + return { ...(await resolveUserLane(userId, { rowId: null, model: modelRef.model.slice('personal-'.length) })), lane: 'user' } + } + } + return { ...(await resolveSiteLane(modelRef.model)), lane: 'site' } +} + +// overleaf-lab: expose lane resolution so other features (e.g. the Review tab's +// model selector) can honor an explicit model ref — either a site model id or +// a namespaced 'u::' BYO ref — with that row's own baseUrl + key. +export async function resolveModelLane(userId, modelRefString) { + return resolveLane(userId, parseModelRef(String(modelRefString || '').trim())) +} + +function sendError(res, err, fallbackStatus) { + const statusMap = { + auth: 401, + 'llm-bad-model': 404, + 'llm-rate-limited': 429, + disabled: 403, + 'llm-bad-row': 400, + 'llm-bad-config': 400, + 'llm-invalid': 400, + 'empty-response': 502, + 'llm-timeout': 504, + 'llm-abort': 409, + 'llm-disabled': 503, + 'llm-budget': 429 + } + const status = statusMap[err?.code] || fallbackStatus || 502 + logger.error?.({ code: err?.code, message: err?.message }, '[LLM] request failed') + res.status(status).json({ + ok: false, + error: err?.code || 'llm-error', + message: err?.message || 'LLM request failed' + }) } async function chat(req, res) { - const { messages, model } = req.body + const { messages, model: modelRefString } = req.body const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) - logger.debug( - { - projectId, - userId, - model, - messageCount: Array.isArray(messages) ? messages.length : 'invalid', - isPersonalModel: model?.startsWith('personal-'), - }, - '[LLM] chat: request received' - ) - - if (!messages || !Array.isArray(messages)) { - return res.status(400).json({ error: 'Invalid messages format' }) + if (!Array.isArray(messages) || !messages.length) { + return res.status(400).json({ ok: false, error: 'llm-invalid', message: 'messages must be a non-empty array' }) } - if (Settings.llm && !Settings.llm.enabled) { - return res.status(503).json({ error: 'LLM service is disabled' }) + return res.status(503).json({ ok: false, error: 'llm-disabled', message: 'LLM service is disabled' }) } - - // overleaf-lab: chat feature disabled by admin. Enforced for everyone, including - // users with personal API keys, before any personal-settings resolution. const flags = await getLLMFeatureFlags() if (!flags.chatEnabled) { - return res.status(403).json({ error: 'feature_disabled', message: 'The chat feature is disabled' }) + return res.status(403).json({ ok: false, error: 'feature_disabled', message: 'The chat feature is disabled' }) } - const isPersonalModel = model && model.startsWith('personal-') - // overleaf-lab: when the request carries NO model (the selection toolbar / - // "Ask AI" sends only messages), honor the user's personal LLM settings if they - // have them, so those features use the user's own key/model just like the chat - // does. Falls back to the shared backend when no personal settings exist. - const tryPersonalNoModel = - !model && userId && Settings.llm && Settings.llm.allowUserSettings - - const adminLlmSettings = await getAdminLLMSettings() - let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL - let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY - let personalModelName = null - - if ((isPersonalModel || tryPersonalNoModel) && userId) { - try { - const user = await User.findById( - userId, - 'useOwnLLMSettings llmApiUrl llmApiKey llmModelName' - ) - if ( - user && - user.useOwnLLMSettings && - user.llmApiUrl && - (isPersonalModel || user.llmModelName) - ) { - llmApiUrl = user.llmApiUrl - llmApiKey = user.llmApiKey ? decryptSecret(user.llmApiKey) : '' // overleaf-lab: decrypt stored key at rest (empty when keyless) - personalModelName = isPersonalModel - ? model.substring('personal-'.length) - : user.llmModelName.split(',')[0].trim() // overleaf-lab: no model sent -> use the user's first (default) model - } else if (isPersonalModel) { - return res.status(400).json({ - error: - 'Your LLM settings are incomplete. Please configure API URL, API Key, and Model Name in your account settings.', - }) - } - // A no-model request with incomplete personal settings falls through to - // the shared backend checked below. - } catch (error) { - if (isPersonalModel) { - return res.status(500).json({ - error: 'Failed to retrieve user LLM settings', - }) - } - } - } - - if (!llmApiUrl) { - return res.status(503).json({ - error: - 'LLM service is not configured. Please contact your administrator or configure your own LLM settings.', - }) - } - - // 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 || '').split(',')[0].trim()) - - const provider = createLLMProvider({ llmApiUrl, llmApiKey }) - + // overleaf-lab: F4 — per-user rate + daily token budget (both lanes). + let budget try { - // 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 adminSystemPrompt = await getSystemPrompt() - const systemPreamble = adminSystemPrompt - ? `${adminSystemPrompt}\n\n${languageInstruction}` - : languageInstruction - const hasSystemMessage = messages.length > 0 && messages[0].role === 'system' - const finalMessages = hasSystemMessage - ? [ - { role: 'system', content: `${systemPreamble}\n\n${messages[0].content}` }, - ...messages.slice(1), - ] - : [{ role: 'system', content: systemPreamble }, ...messages] + budget = await guardLLMCall(userId) + } + catch (err) { + return sendError(res, err, 429) + } - const requestBody = { - model: modelNameForApi, - messages: finalMessages, - max_tokens: 8192, - temperature: 0.7, - } + let lane + let spec + let model + try { + const resolved = await resolveLane(userId, parseModelRef(modelRefString)) + lane = resolved.lane + spec = resolved.spec + model = resolved.model + } + catch (err) { + return sendError(res, err, 400) + } - const startTime = Date.now() + // System prompt: admin-configured prompt (if any) + language-follow + // instruction, merged with a client system message when present. + 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 = lane === 'site' ? (await getSystemPrompt()) : '' + const systemPreamble = adminSystemPrompt + ? `${adminSystemPrompt}\n\n${languageInstruction}` + : languageInstruction + const hasSystemMessage = messages[0]?.role === 'system' + const finalMessages = hasSystemMessage + ? [{ role: 'system', content: `${systemPreamble}\n\n${messages[0].content}` }, ...messages.slice(1)] + : [{ role: 'system', content: systemPreamble }, ...messages] - logger.info( - { - projectId, - model: modelNameForApi, - url: llmApiUrl, - isPersonalModel, - maxTokens: 8192, - }, - '[LLM] chat: sending request to LLM API' + const started = Date.now() + logger.info( + { projectId, lane, model, messages: messages.length, timeout: 300000 }, + '[LLM] chat: sending request' + ) + try { + const { text, usage, finishReason } = await chatText( + spec, + finalMessages, + { maxOutputTokens: 8192, temperature: 0.7, timeoutMs: 300000 } ) - - - const duration = Date.now() - startTime - - 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 (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, - }) + assertNonEmpty(text) + budget.record(usage?.outputTokens) + logger.info({ projectId, lane, model, duration: `${Date.now() - started}ms` }, '[LLM] chat: ok') + res.json({ ok: true, content: text.trim(), usage, model, lane, finishReason }) + } + catch (err) { + sendError(res, err, 502) } } async function completion(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) - const { cursorOffset, leftContext, rightContext, language, maxLength } = - req.body - - logger.debug( - { - projectId, - userId, - language, - maxLength, - leftContextLen: leftContext?.length, - rightContextLen: rightContext?.length, - }, - '[LLM] completion: request received' - ) + const { leftContext, rightContext, maxLength, model: modelRefString } = req.body if (!leftContext && !rightContext) { return res.status(400).json({ success: false, error: 'No context provided' }) } - if (Settings.llm && !Settings.llm.enabled) { - return res.status(503).json({ success: false, error: 'LLM service is disabled' }) + return res.json({ success: true, data: '' }) // silent: no LLM at all } - - // overleaf-lab: inline completion disabled by admin. Enforced before any - // personal-settings resolution so a personal key cannot re-enable completion. - // Return an empty suggestion so the editor simply shows nothing, with no error. const flags = await getLLMFeatureFlags() if (!flags.completionEnabled) { return res.json({ success: true, data: '' }) } - const adminLlmSettings = await getAdminLLMSettings() - let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL - let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY - - // overleaf-lab: the admin can disable shared inline completion (a sentinel value - // for the completion model). When disabled, only users with their own completion - // route get suggestions; everyone else gets none. This keeps a self-hosted CPU - // backend free from the high-frequency autocomplete load. - const sharedCompletionDisabled = adminLlmSettings.completionModel === '__disabled__' - - // overleaf-lab: default completion model for the shared backend. Prefer the - // admin-chosen completion model, then the env override, then the first env - // model. May be overridden below by the user's personal completion settings. - let completionModel = - adminLlmSettings.completionModel || - process.env.LLM_COMPLETION_MODEL || - (process.env.LLM_MODEL_NAME || 'default').split(',')[0].trim() // 'default' instead of hardcoded 'qwen3-32b' - - // overleaf-lab: per-user inline-completion override (highest precedence). When - // the user opted into their own provider AND explicitly picked a completion - // model, route completion to their personal endpoint+key+model, overriding the - // shared/local server. Incomplete personal creds -> silently fall back to shared. - let usingPersonalCompletion = false - if (userId) { - try { - const user = await User.findById( - userId, - 'useOwnLLMSettings llmApiUrl llmApiKey llmCompletionModel' - ) - if ( - user && - user.useOwnLLMSettings && - user.llmCompletionModel && - user.llmApiUrl && - user.llmApiKey - ) { - llmApiUrl = user.llmApiUrl - llmApiKey = decryptSecret(user.llmApiKey) // decrypt stored key at rest - completionModel = user.llmCompletionModel - usingPersonalCompletion = true - } - } catch (error) { - logger.warn({ userId, err: error }, '[LLM] Error loading user completion settings') - } + // overleaf-lab: F4 — the rate gate here is what makes inline completion safe + // to run while typing (per-user, per-minute). + let budget + try { + budget = await guardLLMCall(userId) } - - // Try the user's own settings when NO shared server URL is configured, OR when - // the admin disabled shared completion (so a user with their own API still gets - // suggestions from their own endpoint). - // overleaf-lab: gate on the URL alone (not the key). A keyless shared endpoint - // is valid, so an empty key must NOT hijack completion onto the user's personal - // endpoint while the shared one is fine. When we do fall back, also switch - // completionModel to the user's own model, otherwise the env-derived 'default' - // is sent to their endpoint (e.g. OpenAI 404s on model 'default'). - if (!usingPersonalCompletion && (!llmApiUrl || sharedCompletionDisabled) && userId) { - try { - const user = await User.findById( - userId, - 'useOwnLLMSettings llmApiUrl llmApiKey llmModelName' - ) - if (user && user.useOwnLLMSettings && user.llmApiUrl) { - llmApiUrl = user.llmApiUrl - llmApiKey = user.llmApiKey ? decryptSecret(user.llmApiKey) : '' // decrypt stored key at rest (empty when keyless) - if (user.llmModelName) { - completionModel = user.llmModelName.split(',')[0].trim() - } - } - } catch (error) { - logger.warn({ userId, err: error }, '[LLM] Error loading user settings for completion') - } - } - - // overleaf-lab: shared completion disabled and no personal route resolved (the - // completion model is still the sentinel) -> return an empty suggestion so the - // editor simply shows nothing, with no error. - if (completionModel === '__disabled__') { + catch (err) { + // Silent for completion: the editor simply shows no suggestion, with no + // error toast (same semantics as disabled features). + logger.debug({ userId, code: err?.code }, '[LLM] completion: budget gate') return res.json({ success: true, data: '' }) } - if (!llmApiUrl) { - return res.status(503).json({ success: false, error: 'LLM service is not configured' }) + // Resolve the lane. overleaf-lab (2026-08-27, owner request): the client no + // longer sends a model — order is the user's SHARED selection (profile), + // then the first enabled BYO row, then the site backend. The former admin + // "Inline completion model" picker is gone; deployments WITHOUT a global + // LLM work through the same chain (BYO row or profile selection). + const adminSettings = await getAdminLLMSettings() + let ref = parseModelRef(modelRefString) + let refFromProfile = false + if (!ref.model) { + const profile = await User.findOne({ _id: userId }).lean().catch(() => null) + const profileValue = typeof profile?.llmSelectedModel === 'string' ? profile.llmSelectedModel.trim() : '' + if (profileValue) { + try { + ref = parseModelRef(profileValue) + refFromProfile = true + } + catch { /* stale/invalid saved selection - fall through to the chain below */ } + } + } + const adminSharedCompletionDisabled = adminSettings.completionModel === '__disabled__' + const siteCompletionModel = adminSettings.completionModel !== '__disabled__' + ? (adminSettings.completionModel || + process.env.LLM_COMPLETION_MODEL || + (process.env.LLM_MODEL_NAME || '').split(',')[0].trim()) + : null + + const candidates = [] + if (ref.kind === 'user') candidates.push({ lane: 'user', ref }) + else if (!ref.model && isUserSettingsAllowed()) candidates.push({ lane: 'user', ref: { rowId: null } }) + if (!adminSharedCompletionDisabled && (adminSettings.llmApiUrl || process.env.LLM_API_URL) && (adminSettings.allowedModels?.length || (process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME))) { + if (adminSettings.allowedModels?.length) { + if (!refFromProfile && ref.kind === 'site' && ref.model && !adminSettings.allowedModels.includes(ref.model)) { + // explicit model not allowed -> reject (F2); a profile-derived + // ref just fails this candidate and continues the chain + return res.status(400).json({ success: false, error: `Model "${ref.model}" is not available` }) + } + } + candidates.push({ lane: 'site', ref }) } - 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}` - -console.log("userPrompt = ", userPrompt) - - const data = await provider.completion({ - model: completionModel, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - max_tokens: maxLength || 60, - }) - - 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', - }) + const started = Date.now() + let lastError = null + for (const candidate of candidates) { + try { + const resolved = candidate.lane === 'user' + ? { ...(await resolveUserLane(userId, candidate.ref)), lane: 'user' } + : { + ...resolveSiteSpec(adminSettings, ref.model || siteCompletionModel), + lane: 'site' + } + 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 maxTokens = Math.max(16, Math.min(1024, parseInt(maxLength, 10) || 320)) + const { text, usage } = await chatText( + resolved.spec, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + { maxOutputTokens: maxTokens, temperature: 0.2, timeoutMs: 15000 } + ) + const data = text || '' + budget.record(usage?.outputTokens) + logger.debug( + { projectId, lane: resolved.lane, model: resolved.model, len: data.length, duration: `${Date.now() - started}ms` }, + '[LLM] completion: ok' + ) + return res.json({ success: true, data, model: resolved.model, lane: resolved.lane }) + } + catch (err) { + lastError = err + logger.debug({ lane: candidate.lane, code: err?.code, message: err?.message }, '[LLM] completion: lane failed, trying next') + } } + + const error = lastError || Object.assign(new Error('No usable LLM backend configured'), { code: 'llm-disabled' }) + if (error.code === 'disabled' || error.code === 'llm-bad-row') { + // No valid BYO lane is allowed/configured: silent empty suggestion. + return res.json({ success: true, data: '' }) + } + logger.warn({ projectId, err: error.message, code: error.code }, '[LLM] completion: all lanes failed') + const status = error.code === 'auth' ? 401 : (error.code === 'llm-disabled' ? 503 : 502) + return res.status(status).json({ success: false, error: error.code || 'Completion failed', message: error.message }) +} + +/* + * Site lane spec builder (inline to avoid the circular-resolve dance used in + * resolveSiteLane, which also enforces the allowlist on explicit ids). + */ +function resolveSiteSpec(adminSettings, modelName) { + const baseUrl = adminSettings.llmApiUrl || process.env.LLM_API_URL + const apiKey = adminSettings.llmApiKey ? decryptSecret(adminSettings.llmApiKey) : (process.env.LLM_API_KEY || '') + const providerType = adminSettings.llmProviderType || adminSettings.llmApiType || detectProviderType(baseUrl) + const pool = Array.isArray(adminSettings.allowedModels) && adminSettings.allowedModels.length + ? adminSettings.allowedModels + : (process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || '').split(',').map(m => m.trim()).filter(Boolean) + const model = modelName || pool[0] + const spec = normalizeProviderSpec({ providerType, baseUrl, apiKey }, { model }) + return { spec, model: spec.model } } -// overleaf-lab: expose the per-feature enable flags to the project UI so it can hide -// disabled features. allowUserSettings tells the client whether personal settings -// are available at all. async function getFeatures(req, res) { const flags = await getLLMFeatureFlags() - res.json({ ...flags, allowUserSettings: !!(Settings.llm && Settings.llm.allowUserSettings) }) + res.json({ ...flags, allowUserSettings: isUserSettingsAllowed() }) } -// overleaf-lab: return a window of source lines around a compile-error line, so the -// "Ask AI about this error" prompt can include the actual code (not just the log -// message). Works for any project file via getAllDocs, so an error inside an -// \input-ed file that is not open in the editor is still covered. +// overleaf-lab: source lines around a compile-error line, for "Ask AI about +// this error". (Unchanged from the pre-BYO controller.) async function getSourceContext(req, res) { const flags = await getLLMFeatureFlags() if (!flags.chatEnabled) { @@ -474,19 +442,13 @@ async function getSourceContext(req, res) { if (!Number.isFinite(radius) || radius < 0) { radius = 15 } - radius = Math.min(radius, 40) // overleaf-lab: cap the snippet size + radius = Math.min(radius, 40) if (!rawFile || !Number.isFinite(line) || line < 1) { return res.json({ ok: false, error: 'bad_request' }) } - // overleaf-lab: normalize a log file path (may be /compile/x, ./x, or /x) to the - // project doc path key used by getAllDocs. - const norm = p => - String(p || '') - .replace(/^\/?compile\//, '') - .replace(/^\.\//, '') - .replace(/^\//, '') + const norm = p => String(p || '').replace(/^\/?compile\//, '').replace(/^\.\//, '').replace(/^\//, '') try { const docsByPath = await ProjectEntityHandler.promises.getAllDocs(projectId) @@ -495,9 +457,7 @@ async function getSourceContext(req, res) { let match = null let baseMatch = null for (const [docPath, value] of Object.entries(docsByPath || {})) { - if (!value) { - continue - } + if (!value) continue const np = norm(docPath) if (np === target || np.endsWith('/' + target) || target.endsWith('/' + np)) { match = { path: docPath, lines: value.lines || [] } @@ -518,37 +478,381 @@ async function getSourceContext(req, res) { const end = Math.min(lines.length, idx + radius + 1) const numbered = [] for (let i = start; i < end; i++) { - // overleaf-lab: a leading '>' marks the line the compiler flagged. const marker = i === idx ? '>' : ' ' numbered.push(`${marker} ${i + 1}: ${lines[i]}`) } - - return res.json({ - ok: true, - file: match.path, - line, - startLine: start + 1, - snippet: numbered.join('\n'), - }) - } catch (err) { + return res.json({ ok: true, file: match.path, line, startLine: start + 1, snippet: numbered.join('\n') }) + } + catch (err) { logger.warn({ projectId, err }, '[LLM] source-context failed') return res.json({ ok: false, error: 'failed' }) } } -// overleaf-lab: expose the EFFECTIVE editable prompts (admin override or default) to -// the project UI so the "Ask AI" toolbar and the "Ask AI about this error" button use -// the admin-tuned system prompt, action templates, and error instruction block. The -// review system prompt stays server-side and is not returned here. async function getPrompts(req, res) { const prompts = await getLLMPrompts() res.json({ askAiSystemPrompt: prompts.askAiSystemPrompt, errorPrompt: prompts.errorPrompt, - askAiActionPrompts: prompts.askAiActionPrompts, + askAiActionPrompts: prompts.askAiActionPrompts }) } +// overleaf-lab: AI Error Assist — suggested fix per compile log entry +// (upstream-style: explanation + exact suggested code, driven by the shared +// "Select LLM Model" choice — site lane or any BYO row). +async function compileFix(req, res) { + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + + if (Settings.llm && !Settings.llm.enabled) { + return res.status(503).json({ ok: false, error: 'llm-disabled', message: 'LLM service is disabled' }) + } + const flags = await getLLMFeatureFlags() + if (!flags.chatEnabled) { + return res.status(403).json({ ok: false, error: 'feature_disabled', message: 'The LLM assist feature is disabled' }) + } + + const body = req.body || {} + const file = String(body.file || '') + const line = parseInt(body.line, 10) + const level = String(body.level || 'error') + const message = String(body.message || '').slice(0, 2000) + // "Suggest a different fix": the previous suggestion, to be avoided. + const hint = + body.hint && typeof body.hint === 'object' + ? { old: String(body.hint.old || '').slice(0, 3000), new: String(body.hint.new || '').slice(0, 3000) } + : null + if (!file || !Number.isFinite(line) || line < 1) { + return res.status(400).json({ ok: false, error: 'bad_request', message: 'file and line are required' }) + } + + // overleaf-lab: the same per-user rate + daily token budget gate as chat. + let budget + try { + budget = await guardLLMCall(userId) + } + catch (err) { + return sendError(res, err, 429) + } + + let lane + let spec + let model + try { + const resolved = await resolveLane(userId, parseModelRef(body.model)) + lane = resolved.lane + spec = resolved.spec + model = resolved.model + } + catch (err) { + return sendError(res, err, 400) + } + + // Numbered source window around the failing line (same matching logic + // as /llm/source-context, inlined to keep one round trip). + const norm = p => String(p || '').replace(/^\/?compile\//, '').replace(/^\.\//, '').replace(/^\//, '') + let ctx + try { + const radius = 12 + const docsByPath = await ProjectEntityHandler.promises.getAllDocs(projectId) + const target = norm(file) + const targetBase = target.split('/').pop() + let match = null + let baseMatch = null + for (const [docPath, value] of Object.entries(docsByPath || {})) { + if (!value) continue + const np = norm(docPath) + if (np === target || np.endsWith('/' + target) || target.endsWith('/' + np)) { + match = { path: docPath, lines: value.lines || [] } + break + } + if (!baseMatch && np.split('/').pop() === targetBase) { + baseMatch = { path: docPath, lines: value.lines || [] } + } + } + match = match || baseMatch + if (!match) { + return res.status(404).json({ ok: false, error: 'not_found', message: 'Source file not found in the project' }) + } + const lines = match.lines + const idx = line - 1 + const start = Math.max(0, idx - radius) + const end = Math.min(lines.length, idx + radius + 1) + const numbered = [] + for (let i = start; i < end; i++) { + numbered.push(`${i === idx ? '>' : ' '} ${i + 1}: ${lines[i]}`) + } + ctx = { path: match.path, line, startLine: start + 1, snippet: numbered.join('\n') } + } + catch (err) { + logger.warn({ projectId, err }, '[LLM] compile-fix: source context failed') + return res.status(500).json({ ok: false, error: 'failed', message: 'Could not read the source file' }) + } + + // The admin-editable error prompt (site lane only) sets the spirit of the + // answer; the JSON contract from LLMCompileFix.mjs is always enforced. + const prompts = await getLLMPrompts() + const adminPrompt = lane === 'site' ? prompts.errorPrompt || '' : '' + const messages = buildCompileFixMessages({ + level, + file: ctx.path, + line: ctx.line, + message, + snippet: ctx.snippet, + hint: hint ? { old: hint.old, new: hint.new } : '', + adminPrompt + }) + + const started = Date.now() + logger.info( + { projectId, lane, model, file: ctx.path, line: ctx.line }, + '[LLM] compile-fix: sending request' + ) + // overleaf-lab: small provider retry — structured-output misses on + // prompt-based backends ("No object generated" / empty response) are + // flaky, so give the model one more chance before failing the request. + let lastErr = null + const nudged = [ + messages[0], + { + role: 'user', + content: + messages[1].content + + '\n\nREMINDER: answer with ONLY the JSON object described in the system prompt — no fences, no prose, all four fields.' + } + ] + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const { object, usage } = await chatObject( + spec, + attempt === 1 ? messages : nudged, + compileFixSchema, + { + temperature: 0.4, + maxOutputTokens: 8000, + timeoutMs: 180000 + } + ) + const clean = validateCompileFixObject(object) + budget.record(usage?.outputTokens) + logger.info( + { projectId, lane, model, attempt, duration: `${Date.now() - started}ms`, chars: clean.suggestedNew.length }, + '[LLM] compile-fix: ok' + ) + return res.json({ + ok: true, + file: ctx.path, + line: ctx.line, + startLine: ctx.startLine, + snippet: ctx.snippet, + explanation: clean.explanation, + suggestedOld: clean.suggestedOld, + suggestedNew: clean.suggestedNew, + span: clean.span, + lane, + model + }) + } + catch (err) { + lastErr = err + const flaky = err && (err.code === 'empty-response' || /did not match schema|No object generated/i.test(err.message || '')) + if (flaky && attempt < 2) { + logger.warn({ projectId, err: err?.message }, '[LLM] compile-fix: retrying (structured-output miss)') + continue + } + break + } + } + if (lastErr && lastErr.code === 'llm-bad-fix') { + return res.status(422).json({ + ok: false, + error: 'llm-bad-fix', + message: 'The model did not return a usable suggestion — please try again.' + }) + } + if (lastErr && (lastErr.code === 'empty-response' || /did not match schema|No object generated/i.test(lastErr.message || ''))) { + return res.status(422).json({ + ok: false, + error: 'llm-bad-fix', + message: 'The model did not return a usable suggestion — please try again.' + }) + } + sendError(res, lastErr, 502) +} + +// overleaf-lab: whole-document generators (title/abstract/keywords). +// Budgets raised for reasoning models (thinking consumes output budget). +const GENERATOR_TYPES = { + title: { + maxOutputTokens: 4000, + temperature: 0.4, + instruction: + 'Write ONE concise, grammatically correct title for the document below, in the same language as the document\'s main body text. ' + + 'Return ONLY the title text — no quotes, no numbering, no explanation.' + }, + abstract: { + maxOutputTokens: 8000, + temperature: 0.3, + instruction: + 'Write a structured abstract (150–250 words) for the document below, in the same language as the document\'s main body text: ' + + 'purpose, methods, key results/findings, and conclusion in that order. ' + + 'Return ONLY the abstract text — no heading, no quotes, no explanation.' + }, + keywords: { + maxOutputTokens: 4000, + temperature: 0.2, + instruction: + 'Generate 5–8 keyword phrases for the document below that capture its ' + + 'core topics, methods, and domain, in the same language as the document\'s main body text. Return ONLY the keywords, ' + + 'separated by commas, in order of importance.' + } +} + +async function generateDocument(req, res) { + const { type, model: modelRefString } = req.body + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + + const generator = GENERATOR_TYPES[type] + if (!generator) { + return res.status(400).json({ + ok: false, + error: 'llm-invalid', + message: `Unknown generator type. Expected one of: ${Object.keys(GENERATOR_TYPES).join(', ')}` + }) + } + if (Settings.llm && !Settings.llm.enabled) { + return res.status(503).json({ ok: false, error: 'llm-disabled', message: 'LLM service is disabled' }) + } + const flags = await getLLMFeatureFlags() + if (!flags.chatEnabled) { + return res.status(403).json({ ok: false, error: 'feature_disabled', message: 'The chat feature is disabled' }) + } + + let budget + try { + budget = await guardLLMCall(userId) + } + catch (err) { + return sendError(res, err, 429) + } + + let lane + let spec + let model + try { + const resolved = await resolveLane(userId, parseModelRef(modelRefString)) + lane = resolved.lane + spec = resolved.spec + model = resolved.model + } + catch (err) { + // overleaf-lab: a broken/stale BYO row must not hard-fail the whole-document + // generators when this deployment also has a working site backend (parity + // with the completion endpoint's lane chain). resolveSiteLane() throws + // when no site backend is configured, so this is a safe no-op otherwise. + const site = await resolveSiteLane() + .then(s => ({ ...s, lane: 'site' })) + .catch(() => null) + if (site) { + lane = site.lane + spec = site.spec + model = site.model + logger.info({ userId, err: err?.message }, '[LLM] generate: user lane failed, using site lane') + } + if (!lane) { + return res.status(503).json({ ok: false, error: err?.code || 'llm-disabled', message: err?.message || 'No LLM backend is configured' }) + } + } + + try { + // Whole-project document text (LaTeX sources first), with per-file and + // total caps so worst-case prompts fit typical context windows. + const docsByPath = (await ProjectEntityHandler.promises.getAllDocs(projectId)) || {} + const entries = Object.entries(docsByPath).filter(([, v]) => v && Array.isArray(v.lines)) + const isTex = p => /\.(tex|sty|cls|bib)$/i.test(String(p)) + entries.sort((a, b) => Number(isTex(b[0])) - Number(isTex(a[0]))) + + const perFileCap = 60000 + const totalCap = 240000 + let docText = '' + const included = [] + for (const [docPath, value] of entries) { + if (docText.length >= totalCap) break + const text = (value.lines || []).join('\n') + if (!text.trim()) continue + const clipped = text.length > perFileCap ? text.slice(0, perFileCap) + '\n[...truncated...]' : text + docText += `===== FILE: ${docPath} =====\n${clipped}\n\n` + included.push(docPath) + } + if (!docText.trim()) { + return res.status(422).json({ ok: false, error: 'no_document', message: 'The project contains no readable document file' }) + } + + const systemPrompt = lane === 'site' ? (await getSystemPrompt()) || '' : '' + const templates = (await getLLMPrompts()).askAiActionPrompts || {} + const styleGuidance = templates[type] ? `STYLE GUIDANCE from the author's template: ${templates[type]}\n\n` : '' + + const messages = [] + if (systemPrompt) messages.push({ role: 'system', content: systemPrompt }) + messages.push({ role: 'user', content: `${styleGuidance}${generator.instruction}\n\nDOCUMENT:\n${docText}` }) + + // overleaf-lab (owner bug report #2): several instruct models answer + // document-generation requests with a FABRICATED TOOL CALL such as + // `tool call: get_keywords_from_document("/main.tex")` instead of the + // requested text. Mitigations: a hard no-tool-call instruction on + // every attempt, one nudge-retry when the output still looks like a + // tool call, then a clear, actionable error (never show the gibberish + // as the "result"). + const NO_TOOLS = + ' Do not call, name, or simulate any tool, function, or API (never output lines such as "tool call: ..." or "function ..."). Answer directly with the requested text only.' + messages[messages.length - 1].content += NO_TOOLS + + const started = Date.now() + let text = '' + let usage = null + const baseUserContent = messages[messages.length - 1].content + const prefix = messages.slice(0, -1) + let toolish = false + for (let attempt = 1; attempt <= 2 && !text; attempt++) { + const userContent = + attempt === 1 + ? baseUserContent + : `${baseUserContent}\n\nReminder (second attempt): produce the plain ${type} text now. No tool calls, no function names, no placeholders, no code fences.` + const r = await chatText(spec, [...prefix, { role: 'user', content: userContent }], { + maxOutputTokens: generator.maxOutputTokens, + temperature: generator.temperature, + timeoutMs: 300000 + }) + usage = r.usage || usage + toolish = /^(tool|function)\s*call\b|^\s*get_[a-z0-9_]+\(/im.test((r.text || '').trim()) + if (!toolish) { + text = (r.text || '').trim() + } + } + if (toolish) { + throw Object.assign( + new Error( + 'The model answered with a tool-call-like response instead of the requested text. Choose a different model (File → Select LLM Model) and try again.' + ), + { code: 'llm-tool-call-output' } + ) + } + assertNonEmpty(text) + budget.record(usage?.outputTokens) + + logger.info( + { projectId, lane, model, type, files: included.length, chars: docText.length, duration: `${Date.now() - started}ms` }, + '[LLM] generateDocument: ok' + ) + res.json({ ok: true, type, output: text.trim(), model, lane, files: included.length }) + } + catch (err) { + sendError(res, err, 502) + } +} + export default { chat: expressify(chat), getModels: expressify(getModels), @@ -556,4 +860,6 @@ export default { getFeatures: expressify(getFeatures), getSourceContext: expressify(getSourceContext), getPrompts: expressify(getPrompts), + compileFix: expressify(compileFix), + generateDocument: expressify(generateDocument) } diff --git a/services/web/modules/llm/app/src/LLMClient.mjs b/services/web/modules/llm/app/src/LLMClient.mjs new file mode 100644 index 0000000000..c9a63da678 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMClient.mjs @@ -0,0 +1,316 @@ +/* + * LLMClient - single seam to all LLM providers, built on the Vercel AI SDK. + * + * Provider types + * - 'openai' -> @ai-sdk/openai (api.openai.com by default; baseURL optional) + * - 'anthropic' -> @ai-sdk/anthropic (api.anthropic.com by default) + * - 'openaiCompatible' -> @ai-sdk/openai-compatible (Ollama, vLLM, llama.cpp, + * academiccloud, OpenRouter, ... any OpenAI-style /v1 server) + * + * Everything the controllers need (chat text, structured objects, model + * listing, token estimation, response cleanup, normalized errors) goes + * through here so provider wiring exists in exactly one place. + * + * Notes + * - All call sites pass a normalized `spec` (see normalizeProviderSpec): + * { providerType, baseUrl, apiKey, model } + * - Reasoning ("thinking") models may spend part of the output budget on + * reasoning before producing visible text. Callers should use generous + * maxOutputTokens for short tasks; empty text on success is mapped to an + * 'empty-response' error (assertNonEmpty). + * - Token counting: exact tokenizers are not guaranteed for arbitrary + * compatible backends, so context sizing uses estimateTokens + * (~3.5 chars per token) - conservative enough for budgeting. + */ + +import { generateText, generateObject, APICallError, NoContentGeneratedError, NoObjectGeneratedError } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; + +export const PROVIDER_TYPES = ['openai', 'anthropic', 'openaiCompatible']; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +/* + * Normalize the different settings shapes (admin file, user provider rows) + * into one spec object. `settings` may contain providerType or the legacy + * llmApiType, baseUrl or the legacy llmApiUrl, apiKey or the legacy + * llmApiKey. `model` is required. + */ +export function normalizeProviderSpec(settings = {}, { model } = {}) { + const providerType = PROVIDER_TYPES.includes(settings.providerType) + ? settings.providerType + : (PROVIDER_TYPES.includes(settings.llmApiType) ? settings.llmApiType : null); + if (!providerType) { + throw Object.assign(new Error('Unknown provider type'), { code: 'llm-bad-config' }); + } + let baseUrl = String(settings.baseUrl || settings.llmApiUrl || '').trim().replace(/\/+$/, ''); + const apiKey = String(settings.apiKey || settings.llmApiKey || '').trim(); + if (!model) { + throw Object.assign(new Error('No model name provided'), { code: 'llm-bad-config' }); + } + if (providerType === 'anthropic') { + // The Anthropic SDK appends /v1 to the base URL itself. + baseUrl = baseUrl.replace(/\/v\d+$/, ''); + } + return { providerType, baseUrl, apiKey, model: String(model) }; +} + +/* + * Detect a provider type from a base URL (presets, legacy migration). + */ +export function detectProviderType(baseUrl) { + const url = String(baseUrl || ''); + if (url.includes('anthropic.com')) return 'anthropic'; + if (url.includes('openai.com')) return 'openai'; + return 'openaiCompatible'; +} + +/* + * Create an AI SDK language model instance. + */ +export function createModel(spec) { + if (spec.providerType === 'anthropic') { + const provider = createAnthropic({ + apiKey: spec.apiKey || 'overleaf-local', + ...(spec.baseUrl ? { baseURL: spec.baseUrl } : {}) + }); + return provider(spec.model); + } + if (spec.providerType === 'openai') { + const provider = createOpenAI({ + apiKey: spec.apiKey || 'overleaf-local', + ...(spec.baseUrl ? { baseURL: spec.baseUrl } : {}) + }); + return provider(spec.model); + } + const provider = createOpenAICompatible({ + name: 'overleaf-llm', + baseURL: spec.baseUrl, + ...(spec.apiKey ? { headers: { authorization: `Bearer ${spec.apiKey}` } } : {}) + }); + return provider(spec.model); +} + +/* + * Split messages into (system, coreMessages) for the AI SDK call shape. + */ +function splitMessages(messages, extraSystem) { + const system = []; + const rest = []; + for (const message of messages || []) { + const content = String(message?.content ?? ''); + if (message?.role === 'system') system.push(content); + else rest.push({ role: message?.role === 'assistant' ? 'assistant' : 'user', content }); + } + return { + system: [extraSystem, ...system].filter(Boolean).join('\n\n') || undefined, + messages: rest + }; +} + +function wrapError(err, modelName) { + if (err instanceof APICallError) { + if (err.statusCode === 401 || err.statusCode === 403) { + return Object.assign( + new Error('The provider rejected the API key (HTTP 401/403). Check the key and that it has not expired.'), + { code: 'auth' } + ); + } + if (err.statusCode === 404) { + // overleaf-lab (owner bug report #3): say WHICH model failed and how to + // fix it — a bare "404" is useless when the provider's catalog changed. + return Object.assign( + new Error( + `Model not found on the backend (404)${modelName ? ` (model: ${modelName})` : ''}. ` + + 'Re-scan the provider model list (Account → LLM settings → Scan) and select an available model.' + ), + { code: 'llm-bad-model' } + ); + } + if (err.statusCode === 429) { + return Object.assign(new Error('Rate limited by LLM backend (429)'), { code: 'llm-rate-limited' }); + } + // WS5 review: do not echo arbitrary backend response bodies to the client + // (possible internal hostname/fragment leakage); status + code is enough. + // The 401/403 branch above intentionally keeps a generic key-rejection hint. + return Object.assign( + // overleaf-lab (owner bug report #3): 5xx from the backend (e.g. Ollama + // overloads) — include the model so the failure is diagnosable. + new Error( + `LLM backend error (HTTP ${err.statusCode})${modelName ? ` for model: ${modelName}` : ''}. ` + + 'This is usually transient provider overload — retry, or select a different model.' + ), + { code: 'llm-error' } + ); + } + if (err instanceof NoContentGeneratedError || err instanceof NoObjectGeneratedError) { + return Object.assign(new Error(err.message || 'Empty response'), { code: 'empty-response' }); + } + if (err && /abort/i.test(String(err.name || err.message || ''))) { + return Object.assign(new Error('Request aborted'), { code: 'llm-abort' }); + } + if (err && err.code) return err; + const base = err?.message ? `LLM request failed: ${err.message}` : 'LLM request failed'; + const suffix = modelName ? ` (model: ${modelName})` : ''; + return Object.assign(new Error(base + suffix), { code: 'llm-error' }); +} + +/* + * Remove stray chain-of-thought markers from visible text (some servers, + * e.g. Ollama with Qwen3, leak thinking into `content`). + */ +export function stripThinkTags(text) { + return String(text || '').replace(/<\/?think[^>]*>/gi, ''); +} + +function usageOf(result) { + const u = result?.usage; + if (!u) return null; + return { + inputTokens: u.inputTokens ?? null, + outputTokens: u.outputTokens ?? null, + totalTokens: u.totalTokens ?? null + }; +} + +function commonCallOptions(options, { defaultMaxRetries = 1 } = {}) { + return { + temperature: options.temperature, + maxOutputTokens: options.maxOutputTokens, + maxRetries: options.maxRetries ?? defaultMaxRetries, + abortSignal: options.signal, + timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + providerOptions: options.providerOptions + }; +} + +/* + * Plain text chat/completion. + * Returns { text, usage, finishReason } + */ +export async function chatText(spec, messages, options = {}) { + const { system, messages: coreMessages } = splitMessages(messages, options.system); + try { + const result = await generateText({ + model: createModel(spec), + system, + messages: coreMessages, + headers: { 'user-agent': 'overleaf-llm-module' }, + ...commonCallOptions(options) + }); + return { text: stripThinkTags(result.text), usage: usageOf(result), finishReason: result.finishReason }; + } + catch (err) { + throw wrapError(err, spec.model); + } +} + +/* + * Structured object generation (compliance reviews). + * Returns { object, usage } + */ +export async function chatObject(spec, messages, schema, options = {}) { + const { system, messages: coreMessages } = splitMessages(messages, options.system); + try { + const result = await generateObject({ + model: createModel(spec), + schema, + system, + messages: coreMessages, + ...commonCallOptions(options, { defaultMaxRetries: 0 }) + }); + return { object: result.object, usage: usageOf(result) }; + } + catch (err) { + throw wrapError(err, spec.model); + } +} + +/* + * List models via the standard endpoint. Both OpenAI-style servers + * (Ollama, vLLM, llama.cpp, most gateways) and Anthropic expose them. + */ +export async function listModels(spec, { timeoutMs = 60000 } = {}) { + const defaultBase = spec.providerType === 'anthropic' + ? 'https://api.anthropic.com' + : 'https://api.openai.com/v1'; + const base = (spec.baseUrl || defaultBase).replace(/\/+$/, ''); + const isAnthropic = spec.providerType === 'anthropic'; + const url = `${isAnthropic ? base + '/v1' : base}/models`; + const headers = { accept: 'application/json', 'user-agent': 'overleaf-llm-module' }; + if (isAnthropic) { + headers['x-api-key'] = spec.apiKey || 'overleaf-local'; + headers['anthropic-version'] = '2023-06-01'; + } + else if (spec.apiKey) headers.authorization = `Bearer ${spec.apiKey}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = await fetch(url, { headers, signal: controller.signal }); + } + catch (err) { + const message = /abort/i.test(String(err?.name || '')) + ? `Timed out listing models at ${url}` + : `Could not reach ${url} (${err?.message || 'network error'})`; + throw Object.assign(new Error(message), { code: /abort/i.test(String(err?.name || '')) ? 'llm-timeout' : 'llm-error' }); + } + finally { + clearTimeout(timer); + } + if (!response.ok) { + const body = (await response.text().catch(() => '')).slice(0, 120); + if (response.status === 401 || response.status === 403) { + throw Object.assign( + new Error(`The provider rejected the API key (HTTP ${response.status})${body ? ` — ${body}` : ''}`), + { code: 'auth', status: response.status } + ); + } + const code = response.status === 404 ? 'llm-bad-model' : 'llm-error'; + throw Object.assign(new Error(`Backend returned ${response.status}${body ? `: ${body}` : ''}`), { code, status: response.status }); + } + const data = await response.json(); + const ids = (data?.data || data?.models || []) + .map((m) => (typeof m === 'string' ? m : m?.id || m?.name)) + .filter(Boolean) + .sort(); + return { ids, raw: data }; +} + +/* + * Conservative token estimate (~3.5 chars/token) for context budgeting. + */ +export function estimateTokens(text) { + return Math.max(1, Math.ceil(String(text || '').length / 3.5)); +} + +/* + * Reasoning models can return 200 with all budget spent on thinking. + * Surface that as a clear error instead of an empty result. + */ +export function assertNonEmpty(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + throw Object.assign( + new Error('The model returned no visible text. Reasoning models may spend the whole output budget on thinking - raise the output budget or disable reasoning for this task.'), + { code: 'empty-response' } + ); + } + return trimmed; +} + +export default { + PROVIDER_TYPES, + normalizeProviderSpec, + detectProviderType, + createModel, + chatText, + chatObject, + listModels, + estimateTokens, + stripThinkTags, + assertNonEmpty +}; diff --git a/services/web/modules/llm/app/src/LLMCompileFix.mjs b/services/web/modules/llm/app/src/LLMCompileFix.mjs new file mode 100644 index 0000000000..51e65d1472 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMCompileFix.mjs @@ -0,0 +1,119 @@ +// overleaf-lab: "AI Error Assist" — per-compile-error suggested fixes +// (upstream-style: explanation + suggested code, BYO-model-aware). +// +// Pure helpers live here so they are unit-testable without the web stack: +// the response schema, the prompt/message builder for the model call, and +// the post-call validation (chatObject already enforces the schema at +// generation time; this validates cross-field invariants such as span order). + +import { z } from 'zod' + +// Response contract for POST /project/:id/llm/compile-fix. +// +// suggestedOld: the EXACT current text to change (a contiguous snippet of +// one line, or a multi-line block; empty string = pure insert). +// suggestedNew: the replacement text; empty string = pure DELETION of suggestedOld. +// span: optional [firstLine, lastLine] (1-based) for multi-line fixes. +// explanation: optional (the card falls back to a generic sentence). +// At least one of suggestedOld/suggestedNew must be non-empty — enforced in +// validateCompileFixObject (cross-field check). +export const compileFixSchema = z.object({ + explanation: z + .string() + .max(6000) + .optional() + .describe('Optional 1-3 short sentences explaining the cause and the fix, in the language of the log message.'), + suggestedOld: z + .string() + .max(8000) + .describe('The exact text currently in the document that the fix applies to (an exact copy, may be empty for a pure insertion).'), + suggestedNew: z + .string() + .max(8000) + .describe('The replacement text for suggestedOld. Use an EMPTY string for a pure deletion.'), + span: z + .tuple([z.number().int().positive(), z.number().int().positive()]) + .optional() + .describe('Optional [firstLine, lastLine] 1-based line numbers when the fix spans multiple lines.') +}) + +const MAX_EXPLANATION = 6000 +const MAX_SUGGESTED = 8000 + +// Cross-field validation for chatObject results (span ordering and the +// "at least a change" rule cannot be expressed in the schema itself). +export function validateCompileFixObject(obj) { + if (!obj || typeof obj !== 'object') { + throw Object.assign(new Error('compile-fix result must be an object'), { code: 'llm-bad-fix' }) + } + const explanation = String(obj.explanation || '').trim().slice(0, MAX_EXPLANATION) + const suggestedOld = String(obj.suggestedOld ?? '') + const suggestedNew = String(obj.suggestedNew ?? '').trim() + if (suggestedOld.length > MAX_SUGGESTED || suggestedNew.length > MAX_SUGGESTED) { + throw Object.assign(new Error('compile-fix: oversized suggestion'), { code: 'llm-bad-fix' }) + } + if (!suggestedOld && !suggestedNew) { + throw Object.assign(new Error('compile-fix: no change provided (both suggestedOld and suggestedNew are empty)'), { code: 'llm-bad-fix' }) + } + let span = null + if (Array.isArray(obj.span)) { + const a = Number.parseInt(obj.span[0], 10) + const b = Number.parseInt(obj.span[1], 10) + if (Number.isFinite(a) && Number.isFinite(b) && a >= 1 && b >= a) { + span = [a, b] + } + } + return { explanation, suggestedOld, suggestedNew, span } +} + +// Messages for the model call. `snippet` is the numbered source window +// (">" marks the failing line) produced by the controller. +export function buildCompileFixMessages({ + level = 'error', + file = '', + line = 1, + message = '', + snippet = '', + hint = '', + adminPrompt = '' +}) { + const contract = [ + 'You are an expert LaTeX assistant fixing a compile error/warning in an Overleaf project.', + 'You receive: the log entry, its file and line, and a numbered window of the actual source lines (" > " marks the failing line).', + 'Return the object described by the schema. Rules:', + '- Keep the fix MINIMAL: change only what is needed to resolve the entry; never rewrite untouched code, do not add comments or explanations to suggestedNew.', + '- suggestedOld MUST be an exact copy of the current text (same whitespace, same case). For single-line fixes use a contiguous part of that line (often the whole line); for multi-line fixes join the lines with newlines exactly as they appear and also set span = [firstLine, lastLine].', + '- suggestedNew is what replaces suggestedOld. If the fix is to REMOVE text, set suggestedNew to an empty string (a pure deletion). Do not use markdown code fences anywhere.', + '- If an alternative fix genuinely does not exist, a minimal valid change (e.g. pure deletion of the offending characters) is better than no change.', + '- The answer is a JSON object: inside string values every backslash MUST be escaped (write one backslash as \\\\). Newlines inside strings must be \\u000a or \\n.', + '- Reply in the same language as the log message.' + ].join('\n') + + const userParts = [] + if (adminPrompt) { + userParts.push(`Deployment instruction from the site administrator (apply its spirit):\n${adminPrompt}`) + } + if (hint) { + userParts.push( + 'The user asked for a DIFFERENT suggestion. A previous (unsatisfactory) suggestion was:\n' + + ` old: ${hint.old}\n new: ${hint.new}\n` + + 'Do NOT repeat it. Suggest an alternative fix.' + ) + } + userParts.push( + `Log entry (${level}) at ${file}, line ${line}:\n${message || '(no message text)'}` + ) + userParts.push(`Numbered source lines (the line marked " > " is line ${line}):\n${snippet}`) + userParts.push('Return only the JSON object — no prose outside the object.') + + return [ + { role: 'system', content: contract }, + { role: 'user', content: userParts.join('\n\n') } + ] +} + +export default { + compileFixSchema, + validateCompileFixObject, + buildCompileFixMessages +} diff --git a/services/web/modules/llm/app/src/LLMComplianceController.mjs b/services/web/modules/llm/app/src/LLMComplianceController.mjs index 370eede464..419d1665d0 100644 --- a/services/web/modules/llm/app/src/LLMComplianceController.mjs +++ b/services/web/modules/llm/app/src/LLMComplianceController.mjs @@ -3,7 +3,13 @@ import Settings from '@overleaf/settings' import { expressify } from '@overleaf/promise-utils' import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' -import { getAdminLLMSettings, getComplianceRubrics, getLLMFeatureFlags, getLLMPrompts } from './LLMAdminController.mjs' +import { getAdminLLMSettings, getLLMFeatureFlags, getLLMPrompts } from './LLMAdminController.mjs' +import { getComplianceRubricsForUser } from './LLMSettingsController.mjs' // overleaf-lab (2026-08-27): user-scoped rubrics +import OError from '@overleaf/o-error' +import { chatText, chatObject, listModels as listModelsSdk, normalizeProviderSpec } from './LLMClient.mjs' // overleaf-lab: AI-SDK provider seam (PR item 4: provider-agnostic review passes) +// overleaf-lab: Review-tab model selector — resolve an explicit model ref (site +// model id or 'u::') to the right lane + spec. +import { resolveModelLane } from './LLMChatController.mjs' // overleaf-lab: in-memory job queue for compliance reviews. A review sends the // whole project to the LLM and can run for minutes, so we run one at a time per @@ -16,6 +22,10 @@ let running = false // one review at a time // user last looked at the panel, so the retention must be generous. const JOB_TTL_MS = 30 * 60 * 1000 +// overleaf-lab: F6 — concurrency caps (enforced in startReview). +const MAX_USER_REVIEWS_IN_FLIGHT = 3 // 1 running + 2 queued per user +const MAX_GLOBAL_QUEUE = 5 // queued jobs across all users + // overleaf-lab: FALLBACK token budget for the review's JSON answer, used when the // admin has not set one in the LLM settings page (which is the normal way to change // it). The effective value is BOTH the hard max_tokens sent to the model AND the room @@ -26,6 +36,12 @@ const REVIEW_MAX_TOKENS = ? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) : 12000 +// overleaf-lab: F6 — maximum model passes per review job (see the cap where +// mainPassCount is computed). +const MAX_PASSES_PER_JOB = Number.parseInt(process.env.LLM_REVIEW_MAX_PASSES, 10) > 0 + ? Number.parseInt(process.env.LLM_REVIEW_MAX_PASSES, 10) + : 200 + // overleaf-lab: rough backend throughput, now used only to SIZE THE PER-PASS TIMEOUT // (progress is pass-based and needs no time estimate, so a wrong rate can only make // the safety timeout more generous, never mislead the user). @@ -51,6 +67,55 @@ const FALLBACK_GEN_TPS = 4 let measuredPrefillTps = null let measuredGenTps = null +// overleaf-lab: AI-SDK seam for review passes. currentBaseSpec is set per job +// from the effective admin settings; llmChat() derives the full spec from the +// body's model (same body shape as the old provider calls). +let currentBaseSpec = null + +function specFor(model) { + return normalizeProviderSpec(currentBaseSpec || {}, { model }) +} + +const STRUCTURED_FALLBACK_CODES = new Set(['empty-response', 'llm-error']) + +async function chatDetailedCompat(spec, body, opts = {}) { + const schema = body.response_format?.json_schema?.schema + const call = { + maxOutputTokens: body.max_tokens, + temperature: body.temperature, + signal: opts.signal, + timeoutMs: opts.timeoutMs, + } + try { + if (schema) { + const { object } = await chatObject(spec, body.messages, schema, call) + return { content: JSON.stringify(object), raw: {} } + } + const { text } = await chatText(spec, body.messages, call) + return { content: text, raw: {} } + } catch (err) { + if (err && (err.code === 'llm-abort' || /abort/i.test(String(err.name || '')))) { + throw err + } + if (schema && STRUCTURED_FALLBACK_CODES.has(err?.code)) { + // The backend does not honor structured output (or the strict parse + // failed): ask for plain JSON text and let the caller's extractJson + // + retry loop do the tolerant work. + try { + const { text } = await chatText(spec, body.messages, call) + return { content: text, raw: {} } + } catch (fallbackErr) { + throw err + } + } + throw err + } +} + +function llmChat(body, opts) { + return chatDetailedCompat(specFor(body.model), body, opts) +} + // overleaf-lab: sample-size gates for trusting a timings measurement. llama.cpp // reports prompt_per_second over the tokens it ACTUALLY evaluated (prompt_n): on a // prompt-cache hit that can be a single token, and the resulting "rate" is pure @@ -100,39 +165,52 @@ function recordTimings(timings) { } } -// overleaf-lab: ask the backend for the EXACT token count of the prompt. llama.cpp -// exposes /tokenize, and the router maps /v1/tokenize onto the server root where -// it actually lives, so the module only needs the one OpenAI-style base URL. +// overleaf-lab: provider errors arrive as OError (BaseProvider.handleError) whose +// CAUSE carries the transport-level failure, so abort detection must look one +// level deeper than a bare fetch() would. Both the user cancel and the pass-timeout +// safety net abort through job.controller. +function isAbortErr(err) { + return ( + (err && err.name === 'AbortError') || + (err && err.cause && (err.cause.name === 'AbortError' || err.cause.name === 'TimeoutError')) + ) +} + +// overleaf-lab: rebuild a best-effort error string from an OError (API message + +// raw body) so parseBackendError's context-overflow detection keeps working against +// provider errors, exactly as it did against raw fetch bodies. +function providerErrorText(err) { + let apiMessage = '' + try { + const info = OError.getFullInfo(err) + apiMessage = + (info && info.error && info.error.message) || (info && info.message) || '' + } catch { + apiMessage = '' + } + const raw = (err && err.cause && (err.cause.body || err.cause.message)) || '' + return `${apiMessage} ${raw}`.trim() +} + +// overleaf-lab: ask the backend for the EXACT token count of the prompt. The +// OpenAI-compatible provider maps this to the llama.cpp /tokenize extension; +// other providers (e.g. Anthropic) have none and report null, which the caller +// treats as "estimate only". Provider-agnostic since PR item 4: the provider +// owns the endpoint shape and the auth header. // // Why this matters more than it looks: a character-per-token heuristic can only ever be // roughly right for LaTeX, whose density varies a lot between prose and math. When it // errs low the backend rejects the request and tells us the truth, which is recoverable. // When it errs HIGH we refuse a document that would actually have fit, and nothing // downstream can correct that: the user is simply blocked. The exact count removes both. -// Returns null for any backend without /tokenize, so the caller falls back. -async function countPromptTokens(llmApiUrl, llmApiKey, text) { - try { - const headers = { 'Content-Type': 'application/json' } - if (typeof llmApiKey === 'string' && llmApiKey.length > 0) { - headers.Authorization = `Bearer ${llmApiKey}` - } - const response = await fetch(`${llmApiUrl}/tokenize`, { - method: 'POST', - headers, - body: JSON.stringify({ content: text }), - }) - if (!response.ok) { - return null - } - const data = await response.json() - if (Array.isArray(data && data.tokens)) { - return data.tokens.length - } - return null - } catch (err) { - logger.debug({ err }, '[LLM] compliance: /tokenize unavailable, using the estimate') - return null - } +// Returns null for any backend without the exact count, so the caller falls back. +// overleaf-lab: exact token counting. The custom provider factory used to expose +// llama.cpp's /tokenize endpoint; the AI-SDK seam (v6) has no cross-provider exact +// counter, so the conservative estimate (estimateTokens + safety margin) is the +// authoritative budgeting path. Kept as an async function so a backend that DOES +// serve exact counts can plug back in without touching the call sites. +async function countPromptTokens() { + return null } // overleaf-lab: floor for the review timeout (the value it used to be fixed at). const REVIEW_MIN_TIMEOUT_MS = 60 * 60 * 1000 @@ -274,6 +352,54 @@ function buildScanHints(strippedDocs, customPatterns = []) { // The save endpoint already refuses invalid regexes, but settings written by other // means must not break a review, so invalid lines are skipped here too. Capped to // keep the hint block small. +/* + * F5: ReDoS guard for admin-authored rubric regexes. These patterns run over + * document text, so a catastrophic pattern (nested quantifiers such as (a+)+, + * recursive groups) could hang a review. Heuristic: reject recursive-group + * syntax and any group containing a quantifier that is itself quantified. + * Patterns that fail are skipped (with a debug log), never fatal. + */ +function containsNestedQuantifier(pattern) { + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] !== '(' || pattern[i - 1] === '\\') continue + let depth = 1 + let inner = false + let close = -1 + for (let j = i + 1; j < pattern.length; j++) { + if (pattern[j - 1] === '\\') continue + const c = pattern[j] + if (c === '(') depth++ + else if (c === ')') { + depth-- + if (depth === 0) { + close = j + break + } + } + else if (depth === 1 && (c === '+' || c === '*' || c === '{')) { + inner = true + } + } + if (close !== -1 && inner && /^\s*(?:\+|\*|\{)/.test(pattern.slice(close + 1))) { + return true + } + if (close !== -1) i = close // continue after this group + } + return false +} + +function safeRubricRegex(pattern) { + if (typeof pattern !== 'string' || !pattern || pattern.length > 300) return null + if (/\(?\?R|\(?\?&/.test(pattern)) return null // recursive groups + if (containsNestedQuantifier(pattern)) return null + try { + return new RegExp(pattern, 'i') + } + catch { + return null + } +} + function parseScanPatterns(text) { const patterns = [] for (const rawLine of String(text || '').split('\n')) { @@ -291,9 +417,16 @@ function parseScanPatterns(text) { continue } try { - patterns.push({ label: label || body, regex: new RegExp(body, 'i') }) - } catch (err) { - logger.debug({ line }, '[LLM] compliance: skipping invalid scan pattern') + const regex = safeRubricRegex(body) + if (regex) { + patterns.push({ label: label || body, regex }) + } + else { + logger.debug({ line }, '[LLM] compliance: skipping unsafe/invalid scan pattern') + } + } + catch (err) { + logger.debug({ line, err: err?.message }, '[LLM] compliance: skipping invalid scan pattern') } } return patterns @@ -481,19 +614,6 @@ function splitRubric(text) { } return { preamble: preambleLines.join('\n').trim(), requirements } } - -// overleaf-lab: the compliance reviewer system prompt now lives in LLMPrompts.mjs as -// DEFAULT_REVIEW_SYSTEM_PROMPT and is resolved per review via getLLMPrompts() so a -// super-admin override takes effect. See performReview below. - -// overleaf-lab: remove ... blocks (case-insensitive, dot-all), same -// approach as LLMChatController, for models like DeepSeek/Qwen that emit reasoning. -function stripThinkTags(text) { - let cleaned = text.replace(/[\s\S]*?<\/think>\s*/gi, '') - cleaned = cleaned.replace(/[\s\S]*/gi, '') - return cleaned.trim() -} - // overleaf-lab: strip LaTeX line comments to save tokens. For each line, cut from // the first unescaped `%` (a `%` not preceded by a backslash) to end of line, while // keeping escaped `\%`. This is simple and conservative: it can over-strip inside @@ -656,28 +776,32 @@ async function performReview(job) { // overleaf-lab: resolve the rubric fresh at run time (the job only stores id and // name) so the guidelines text is current when the job finally runs. - const rubrics = await getComplianceRubrics() + // overleaf-lab (2026-08-27): from the USER's rubric set (own or inherited). + const rubrics = await getComplianceRubricsForUser(userId) const rubric = rubrics.find(r => r.id === job.rubricId) if (!rubric) { throw new Error('Rubric is no longer available') } - // Effective backend configuration. + // overleaf-lab (2026-08-27): the ONE shared model resolution — explicit + // job override > user's profile selection > first BYO row > site backend. + // The former admin "Review model" and the not_configured URL check are gone: + // a deployment without a global LLM works through the same lane chain. const admin = await getAdminLLMSettings() - const llmApiUrl = admin.llmApiUrl - const llmApiKey = admin.llmApiKey - if (!llmApiUrl) { - throw new Error('LLM backend is not configured') - } + const resolved = await resolveModelLane(userId, job.modelOverride || undefined).catch(err => { + throw Object.assign( + new Error(err?.message || 'No usable LLM backend is configured'), + { code: 'model-unavailable' }, + ) + }) + currentBaseSpec = resolved.spec + const reviewModel = resolved.model + // overleaf-lab: deployment context budgets (admin-configured, with safe + // defaults) — unchanged by the per-user migration. const maxContextTokens = admin.maxContextTokens || 32000 - // overleaf-lab: the admin-set answer budget wins; fall back to the env default. + // overleaf-lab: the admin-set answer budget; the effective room adapts to + // what the document leaves free inside maxContextTokens. const reviewMaxTokens = admin.reviewMaxTokens || REVIEW_MAX_TOKENS - // overleaf-lab: prefer the admin-chosen review model, then the first allowed - // model, then the env-derived default (mirrors the chat model fallback). - const reviewModel = - (admin.reviewModel && admin.reviewModel.trim()) || - (admin.allowedModels && admin.allowedModels[0]) || - ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || 'default').split(',')[0].trim()) // overleaf-lab: resolve the effective editable prompts (admin override or the // shipped default) so the review uses the admin-tuned system prompt. @@ -750,11 +874,7 @@ async function performReview(job) { estimateTokens(scanHints) + estimateTokens(rubric.guidelines) + estimateTokens(prompts.reviewSystemPrompt) - const exactPromptTokens = await countPromptTokens( - llmApiUrl, - llmApiKey, - `${prompts.reviewSystemPrompt}\n${rubric.guidelines}\n${assembled}\n${scanHints}` - ) + const exactPromptTokens = await countPromptTokens() const promptTokens = exactPromptTokens || heuristicPromptTokens logger.debug( { projectId, promptTokens, exact: exactPromptTokens != null, heuristicPromptTokens }, @@ -793,31 +913,15 @@ async function performReview(job) { // surface any real error. if (typeof admin.reviewModel === 'string' && admin.reviewModel.trim().length > 0) { try { - const modelsHeaders = {} - if (typeof llmApiKey === 'string' && llmApiKey.length > 0) { - modelsHeaders.Authorization = `Bearer ${llmApiKey}` - } - const modelsResponse = await fetch(`${llmApiUrl}/models`, { - method: 'GET', - headers: modelsHeaders, - }) - if (modelsResponse.ok) { - const modelsData = await modelsResponse.json() - const ids = Array.isArray(modelsData?.data) - ? modelsData.data.map(entry => String(entry.id)) - : [] - if (!ids.includes(reviewModel)) { - return { - type: 'error', - errorCode: 'model_unavailable', - message: 'The configured review model is not available on the backend', - } + // overleaf-lab: provider-agnostic model preflight (PR item 4) — same list + // API as the admin model scan; the provider owns path + auth. + const { ids } = await listModelsSdk(specFor(reviewModel), { timeoutMs: 60000 }) + if (!ids.includes(reviewModel)) { + return { + type: 'error', + errorCode: 'model_unavailable', + message: 'The configured review model is not available on the backend', } - } else { - logger.warn( - { projectId, status: modelsResponse.status }, - '[LLM] compliance: /models check returned non-ok, continuing' - ) } } catch (err) { logger.warn({ projectId, err }, '[LLM] compliance: /models check failed, continuing') @@ -833,12 +937,8 @@ async function performReview(job) { const guidelinesFor = requirement => `GUIDELINES (check ONLY these):\n${preamble ? `${preamble}\n` : ''}${requirement}` - // 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}` - } + // overleaf-lab: auth is the provider's job now (OpenAI-compatible Bearer or + // Anthropic x-api-key, sent only when a key exists). // overleaf-lab: per-pass safety timeout, SIZED FROM THE WORK with the old fixed // hour as the floor. The worst case per pass is a full document prefill (the @@ -856,11 +956,20 @@ async function performReview(job) { // overleaf-lab: pass-based progress, read by the status endpoint. A [per-file] // requirement counts one pass per source file. - const mainPassCount = requirements.reduce( + let mainPassCount = requirements.reduce( (n, r) => n + (isPerFileRequirement(r) && strippedDocs.length > 1 ? strippedDocs.length : 1), 0 ) + // overleaf-lab: F6 — hard cap on passes per job. [per-file] requirements on a + // 100-file project would otherwise multiply passes (and cost) by 100x. + if (mainPassCount > MAX_PASSES_PER_JOB) { + logger.warn( + { projectId, requested: mainPassCount, cap: MAX_PASSES_PER_JOB }, + '[LLM] compliance: pass cap applied' + ) + mainPassCount = MAX_PASSES_PER_JOB + } job.passesTotal = mainPassCount job.passesDone = 0 let completedPasses = 0 @@ -877,6 +986,21 @@ async function performReview(job) { } const perFile = isPerFileRequirement(requirements[i]) && strippedDocs.length > 1 const requirement = stripPerFileMarker(requirements[i]) + // overleaf-lab: F6 — hard stop at the configured pass budget. This and + // every remaining requirement are reported as 'skipped' below so the + // report stays complete and honest about coverage. + const passCost = perFile ? strippedDocs.length : 1 + if (completedPasses + passCost > MAX_PASSES_PER_JOB) { + for (let k = i; k < requirements.length; k++) { + allItems.push({ + requirement: stripPerFileMarker(requirements[k]), + status: 'skipped', + evidence: `Pass budget reached (${MAX_PASSES_PER_JOB} passes; see LLM_REVIEW_MAX_PASSES)`, + suggestion: '', + }) + } + break + } job.passesDone = completedPasses job.currentRequirement = requirement.replace(/\s+/g, ' ').slice(0, 160) @@ -926,30 +1050,31 @@ async function performReview(job) { } }, passTimeoutMs()) try { - const response = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(subBody), - signal: job.controller ? job.controller.signal : undefined, - }) - if (!response.ok) { - const errorText = await response.text() - logger.warn( - { projectId, pass: i, file: doc.path, status: response.status }, - '[LLM] compliance: per-file sub-pass refused' - ) + // overleaf-lab: PR item 4 — provider call; a refusal now throws + // (OError) instead of returning a non-ok Response. + let detailed = null + try { + detailed = await llmChat(subBody, { + signal: job.controller ? job.controller.signal : undefined, + }) + } catch (err) { + if (job.status === 'cancelled' || isAbortErr(err)) { + throw err + } + const backendError = parseBackendError(providerErrorText(err)) fileResults.push({ path: doc.path, status: 'na', - evidence: `check refused (HTTP ${response.status})`, + evidence: backendError.message + ? `check refused: ${backendError.message.slice(0, 200)}` + : `check refused (${err.message})`, suggestion: '', }) - } else { - const data = await response.json() + } + if (detailed) { + const data = detailed.raw recordTimings(data && data.timings) - const content = stripThinkTags( - data?.choices?.[0]?.message?.content || '' - ) + const content = detailed.content try { const parsed = extractJson(content) const first = Array.isArray(parsed.items) ? parsed.items[0] : null @@ -977,7 +1102,7 @@ async function performReview(job) { } } } catch (err) { - if (job.status === 'cancelled' || (err && err.name === 'AbortError')) { + if (job.status === 'cancelled' || isAbortErr(err)) { throw err } logger.warn( @@ -1033,21 +1158,24 @@ async function performReview(job) { } }, passTimeoutMs()) try { - const response = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(requestBody), - // overleaf-lab: job signal, so cancel (and the pass timeout) abort it. - signal: job.controller ? job.controller.signal : undefined, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - { projectId, userId, status: response.status, pass: i, error: errorText }, - '[LLM] compliance: LLM API error' - ) - const backendError = parseBackendError(errorText) + // overleaf-lab: PR item 4 — provider call; a non-ok response now + // SURFACES AS A THROW, so the refusal handling moved into the inner + // catch (same semantics: context overflow fails the job, anything + // else downgrades this requirement). + let data + let content + try { + const detailed = await llmChat(requestBody, { + signal: job.controller ? job.controller.signal : undefined, + }) + data = detailed.raw + content = detailed.content + } catch (err) { + if (job.status === 'cancelled' || isAbortErr(err)) { + throw err + } + logger.error({ projectId, userId, pass: i, err }, '[LLM] compliance: LLM API error') + const backendError = parseBackendError(providerErrorText(err)) // overleaf-lab: a context overflow means the DOCUMENT does not fit, so // every other pass would fail identically: fail the whole job, with the @@ -1071,21 +1199,17 @@ async function performReview(job) { allItems.push({ requirement: job.currentRequirement, status: 'na', - evidence: `The check could not run (HTTP ${response.status}${ - backendError.message - ? `: ${backendError.message.slice(0, 200)}` - : '' - })`, + evidence: backendError.message + ? `The check could not run: ${backendError.message.slice(0, 200)}` + : `The check could not run: ${err.message}`, suggestion: '', }) continue } - let data = await response.json() // overleaf-lab: a full-size prefill is the best throughput measurement // there is; cache-hit passes are rejected by the sample-size gate. recordTimings(data && data.timings) - let content = stripThinkTags(data?.choices?.[0]?.message?.content || '') // overleaf-lab: parse, with ONE retry on an unusable answer. The typical // cause is a broad requirement (e.g. "check every citation") whose analysis @@ -1114,18 +1238,27 @@ async function performReview(job) { requestBody.messages[1], ], } - const retryResponse = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(retryBody), - signal: job.controller ? job.controller.signal : undefined, - }) - if (!retryResponse.ok) { - break + let retryD + let retryOk = false + try { + retryD = await llmChat(retryBody, { + signal: job.controller ? job.controller.signal : undefined, + }) + retryOk = true + } catch (rerr) { + if (job.status === 'cancelled' || isAbortErr(rerr)) { + throw rerr + } + logger.warn( + { projectId, pass: i, rerr }, + '[LLM] compliance: retry refused, treating the answer as unusable' + ) + } + if (retryOk) { + data = retryD.raw + recordTimings(retryD.raw && retryD.raw.timings) + content = retryD.content } - data = await retryResponse.json() - recordTimings(data && data.timings) - content = stripThinkTags(data?.choices?.[0]?.message?.content || '') } try { parsed = extractJson(content) @@ -1174,7 +1307,7 @@ async function performReview(job) { } catch (err) { // An abort (user cancel or the pass timeout) must stop the whole review; // anything else downgrades to an unverifiable requirement. - if (job.status === 'cancelled' || (err && err.name === 'AbortError')) { + if (job.status === 'cancelled' || isAbortErr(err)) { throw err } logger.warn({ projectId, pass: i, err }, '[LLM] compliance: pass failed') @@ -1282,48 +1415,40 @@ async function performReview(job) { } }, passTimeoutMs()) try { - const response = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(verifyBody), + // overleaf-lab: PR item 4 — provider call; a refusal now throws and + // falls into the outer catch ("keeping the finding"), same as before. + const detailed = await llmChat(verifyBody, { signal: job.controller ? job.controller.signal : undefined, }) - if (response.ok) { - const data = await response.json() - recordTimings(data && data.timings) - const content = stripThinkTags( - data?.choices?.[0]?.message?.content || '' - ) - try { - const parsed = extractJson(content) - const verified = Array.isArray(parsed.items) - ? parsed.items[0] - : null - if ( - verified && - ['ok', 'partial', 'missing', 'na'].includes(verified.status) - ) { - allItems[idx] = { - // The requirement is not the verifier's to rewrite. - requirement: finding.requirement, - status: verified.status, - evidence: repairJsonEscapeArtifacts( - verified.evidence || finding.evidence - ).slice(0, 600), - suggestion: repairJsonEscapeArtifacts( - verified.suggestion || '' - ).slice(0, 600), - } + try { + const parsed = extractJson(detailed.content) + const verified = Array.isArray(parsed.items) + ? parsed.items[0] + : null + if ( + verified && + ['ok', 'partial', 'missing', 'na'].includes(verified.status) + ) { + allItems[idx] = { + // The requirement is not the verifier's to rewrite. + requirement: finding.requirement, + status: verified.status, + evidence: repairJsonEscapeArtifacts( + verified.evidence || finding.evidence + ).slice(0, 600), + suggestion: repairJsonEscapeArtifacts( + verified.suggestion || '' + ).slice(0, 600), } - } catch (err) { - logger.warn( - { projectId, err }, - '[LLM] compliance: unparseable verification, keeping the finding' - ) } + } catch (err) { + logger.warn( + { projectId, err }, + '[LLM] compliance: unparseable verification, keeping the finding' + ) } } catch (err) { - if (job.status === 'cancelled' || (err && err.name === 'AbortError')) { + if (job.status === 'cancelled' || isAbortErr(err)) { throw err } logger.warn( @@ -1355,6 +1480,7 @@ async function performReview(job) { // empty instead of failing a review whose per-requirement work already succeeded. let summary = '' try { + // overleaf-lab: PR item 4 — provider call; refusal fails only the summary. const summaryBody = { model: reviewModel, messages: [ @@ -1386,19 +1512,22 @@ async function performReview(job) { }, }, } - const response = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(summaryBody), - signal: job.controller ? job.controller.signal : undefined, - }) - if (response.ok) { - const data = await response.json() - const content = stripThinkTags(data?.choices?.[0]?.message?.content || '') - summary = repairJsonEscapeArtifacts(extractJson(content).summary || '') + let detailed = null + try { + detailed = await llmChat(summaryBody, { + signal: job.controller ? job.controller.signal : undefined, + }) + } catch (err) { + if (job.status === 'cancelled' || isAbortErr(err)) { + throw err + } + logger.warn({ projectId, err }, '[LLM] compliance: summary synthesis refused') + } + if (detailed) { + summary = repairJsonEscapeArtifacts(extractJson(detailed.content).summary || '') } } catch (err) { - if (job.status === 'cancelled' || (err && err.name === 'AbortError')) { + if (job.status === 'cancelled' || isAbortErr(err)) { throw err } logger.warn({ projectId, err }, '[LLM] compliance: summary synthesis failed') @@ -1473,11 +1602,9 @@ async function processQueue() { running = false job.controller = null // overleaf-lab: never let one job's failure stall the queue. - try { - processQueue() - } catch (err) { + processQueue().catch(err => { logger.error({ err }, '[LLM] compliance: failed to continue the queue') - } + }) } } @@ -1487,7 +1614,11 @@ async function getRubrics(req, res) { if (!flags.reviewEnabled) { return res.json({ rubrics: [] }) } - const rubrics = await getComplianceRubrics() + // overleaf-lab (2026-08-27): USER-SCOPED rubrics (the user configured them + // under /user/llm-settings); a user without their own inherits the + // deployment-wide set. + const userId = SessionManager.getLoggedInUserId(req.session) + const rubrics = await getComplianceRubricsForUser(userId) // overleaf-lab: expose names only, never the guidelines text, to the project UI. res.json({ rubrics: rubrics.map(r => ({ id: r.id, name: r.name })) }) } @@ -1509,21 +1640,52 @@ async function startReview(req, res) { // 2. Request context. const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) - const { rubricId } = req.body || {} + const { rubricId, model: requestedModel } = req.body || {} logger.debug({ projectId, userId, rubricId }, '[LLM] compliance: start requested') // 3. Resolve the requested rubric (capture its name for the job). - const rubrics = await getComplianceRubrics() + // overleaf-lab (2026-08-27): from the USER's own rubrics (fall back to the + // deployment-wide set) — rubrics are per-user now, not global. + const rubrics = await getComplianceRubricsForUser(userId) const rubric = rubrics.find(r => r.id === rubricId) if (!rubric) { return res.json({ ok: false, error: 'no_rubric', message: 'Unknown or missing rubric' }) } - // 4. Effective backend configuration must at least have a URL. - const admin = await getAdminLLMSettings() - if (!admin.llmApiUrl) { - return res.json({ ok: false, error: 'not_configured', message: 'LLM backend is not configured' }) + // 4. A usable lane must exist: the user's selection / BYO row / site backend. + // overleaf-lab (2026-08-27): deployments WITHOUT a global LLM are first-class + // — resolving the lane also honors the user's profile selection and BYO rows. + try { + await resolveModelLane(userId, (typeof requestedModel === 'string' && requestedModel.trim()) || undefined) + } catch (err) { + logger.debug({ userId, err: err?.message }, '[LLM] compliance: no usable lane') + return res.json({ + ok: false, + error: 'not_configured', + message: 'No usable LLM backend: set a model (File → Select LLM Model) or add your own LLM connection', + }) + } + + // overleaf-lab: F6 — concurrency caps. One user keeps at most one review + // running plus two queued; the global queue stays bounded so a slow shared + // backend cannot be stacked with work by many projects. + const userActive = [...jobs.values()].filter( + j => j.userId === userId && (j.status === 'running' || j.status === 'queued') + ).length + if (userActive >= MAX_USER_REVIEWS_IN_FLIGHT) { + return res.json({ + ok: false, + error: 'too_many', + message: `You already have ${MAX_USER_REVIEWS_IN_FLIGHT} reviews running or queued; wait for one to finish`, + }) + } + if (queue.length >= MAX_GLOBAL_QUEUE) { + return res.json({ + ok: false, + error: 'server_busy', + message: 'Several reviews are already queued; try again shortly', + }) } // 5. Create and enqueue the job. @@ -1534,6 +1696,9 @@ async function startReview(req, res) { userId, rubricId, rubricName: rubric.name, + // overleaf-lab: Review-tab model selector — explicit model ref + // (site id or 'u::'); null = deployment default. + modelOverride: typeof requestedModel === 'string' && requestedModel.trim() ? requestedModel.trim().slice(0, 300) : null, status: 'queued', result: null, errorCode: null, @@ -1555,7 +1720,9 @@ async function startReview(req, res) { queue.push(job.id) // overleaf-lab: kick the queue; it runs to its first await, so if nothing else // is running this job may already be 'running' by the time we respond. - processQueue() + processQueue().catch(err => { + logger.error({ err }, '[LLM] compliance: failed to launch the queue') + }) return res.json({ ok: true, diff --git a/services/web/modules/llm/app/src/LLMCrypto.mjs b/services/web/modules/llm/app/src/LLMCrypto.mjs index 9ed8920bee..474eb08f19 100644 --- a/services/web/modules/llm/app/src/LLMCrypto.mjs +++ b/services/web/modules/llm/app/src/LLMCrypto.mjs @@ -50,6 +50,30 @@ export function decryptSecret(stored) { return stored } + return storedToPlaintext(stored) +} + +/* + * F20 helpers (legacy-row migration safety): + * - normalizeStoredSecret: value already encrypted -> as-is; + * legacy plaintext -> encrypt now (so rows never persist plaintext keys). + * - storedToPlaintext: encrypted -> decrypt; legacy plaintext -> as-is. + */ +export function hasEncPrefix(v) { + return typeof v === 'string' && v.startsWith(ENC_PREFIX) +} + +export function normalizeStoredSecret(v) { + if (!v) return '' + if (hasEncPrefix(v)) return v + return encryptSecret(String(v)) +} + +export function storedToPlaintext(stored) { + if (!stored) { + return stored + } + // Legacy plaintext back-compat: values written before encryption was added // (or while LLM_KEY_SECRET was unset) have no prefix and are returned as-is. if (!stored.startsWith(ENC_PREFIX)) { diff --git a/services/web/modules/llm/app/src/LLMModelRef.mjs b/services/web/modules/llm/app/src/LLMModelRef.mjs new file mode 100644 index 0000000000..793c9bd182 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMModelRef.mjs @@ -0,0 +1,23 @@ +/* + * LLMModelRef - the model-ref grammar shared by the client and the controllers. + * + * site lane: "qwen3.8:latest" (bare model id) + * user lane: "u::" (BYO provider row + model) + * + * Kept as a pure module (no app imports) so the unit tests can load it without + * pulling the whole Overleaf app tree. + */ + +const USER_REF = /^u:([0-9a-f]{8}):(.+)$/ + +export function parseModelRef(modelString) { + const match = USER_REF.exec(String(modelString || '')) + if (match) return { kind: 'user', rowId: match[1], model: match[2] } + return { kind: 'site', model: String(modelString || '') } +} + +export function makeUserModelRef(rowId, model) { + return `u:${rowId}:${model}` +} + +export default { parseModelRef, makeUserModelRef } diff --git a/services/web/modules/llm/app/src/LLMPrompts.mjs b/services/web/modules/llm/app/src/LLMPrompts.mjs index 6e05b9ec39..ca84970805 100644 --- a/services/web/modules/llm/app/src/LLMPrompts.mjs +++ b/services/web/modules/llm/app/src/LLMPrompts.mjs @@ -8,15 +8,21 @@ // was replaced with the literal `{{selection}}` placeholder the UI substitutes. // overleaf-lab: the floating "Ask AI" selection toolbar system prompt. -export const DEFAULT_ASK_AI_SYSTEM_PROMPT = `You are a LaTeX writing assistant embedded in an editor. Preserve existing LaTeX commands, math, and citation keys exactly, and reply in the same language as the input. When asked to rewrite or transform text, return only the resulting text, with no preamble and no Markdown code fences.` +// overleaf-lab (2026-08 prompt review): clarified the language rule — the +// default is "same language as the input", EXCEPT when the action names an +// explicit target language (Translate), which the action template enforces. +export const DEFAULT_ASK_AI_SYSTEM_PROMPT = `You are a LaTeX writing assistant embedded in an editor. Preserve existing LaTeX commands, math, and citation keys exactly. Reply in the same language as the input text, unless the request explicitly names a target language (e.g. a translation) — in that case use the target language. When asked to rewrite or transform text, return only the resulting text, with no preamble and no Markdown code fences.` // overleaf-lab: trailing instruction block appended to a compile error before it is // sent to the chat by the "Ask AI about this error" button. +// overleaf-lab (2026-08 prompt review): tightened — the old four-point list +// invited long, generic answers; the common case is "fix this one line". export const DEFAULT_ERROR_PROMPT = `**Please help me:** -1. Explain what this error means in simple terms -2. Show me exactly what's wrong in my code -3. Provide the corrected code -4. Explain how to avoid this error in the future` +1. In one or two sentences, what this error actually means +2. The exact line(s) with the problem and why +3. The minimal corrected code (only the changed lines, in a \`\`\`latex block) +4. One short tip to avoid it in the future +Keep the answer compact — a precise two-line fix beats a long explanation.` // overleaf-lab: system prompt for the document compliance reviewer. export const DEFAULT_REVIEW_SYSTEM_PROMPT = `You are a meticulous reviewer that checks whether a LaTeX document complies with writing guidelines for academic theses and internship reports. @@ -38,6 +44,8 @@ Evidence rules: Reply in the same language as the GUIDELINES (for example, in Italian if the guidelines are in Italian). This includes the "suggestion" field. +Length discipline: keep each "analysis" under about 2000 characters — it is a worksheet, not a transcript; if the enumeration is long, summarize it ("checked items 1-31: 4 problems, listed below"). + Return ONLY a JSON object, with no preamble, no explanation, and no code fences, in exactly this shape: { "items": [ @@ -48,20 +56,20 @@ Use "ok" when clearly satisfied, "partial" when partially satisfied or only part // overleaf-lab: per-action templates for the "Ask AI" selection toolbar. Each // template embeds the selected text where the `{{selection}}` placeholder appears; -// the frontend substitutes it before sending. Keys map to the toolbar modes: -// 1=paraphrase, 2=academic, 3=concise, 4=punchy, 5=split, 6=join, 7=summarize, -// 8=explain, 9=title, 10=abstract. +// the frontend substitutes it before sending. Keys map to the toolbar modes — +// after the 2026-08-27 reference-synced menu rebuild ONLY these remain +// (audit #7 removed the orphaned punchy/split/join/summarize/explain/mathFix/ +// checkCitations templates; title/abstract/keywords run through the +// whole-document generators endpoint instead). export const DEFAULT_ASK_AI_ACTION_PROMPTS = { - paraphrase: `Paraphrase the following LaTeX text. Keep every LaTeX command, math, and citation key intact. Output only the paraphrased text, with no preamble, no explanation, and no code fences.\n\n{{selection}}`, - academic: `Rewrite the following LaTeX text in fluent, formal academic English. Preserve every LaTeX command, math, and citation key. Output only the rewritten text, with no preamble and no code fences.\n\n{{selection}}`, - concise: `Rewrite the following LaTeX text more concisely, preserving its meaning and every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n{{selection}}`, - punchy: `Rewrite the following LaTeX text in a punchier, more engaging style while keeping it accurate. Preserve every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n{{selection}}`, - split: `Split the following LaTeX paragraph into several shorter, well-structured paragraphs. Keep the wording and all LaTeX; only add paragraph breaks. Output only the resulting LaTeX, nothing else.\n\n{{selection}}`, - join: `Join the following LaTeX paragraphs into a single cohesive paragraph, preserving every LaTeX command, math, and citation. Output only the resulting paragraph, nothing else.\n\n{{selection}}`, - summarize: `Summarize the following LaTeX text concisely. Output only the summary as plain LaTeX, with no preamble and no code fences.\n\n{{selection}}`, - explain: `Explain the following LaTeX text clearly and concisely for the author:\n\n{{selection}}`, - title: `Propose one concise, specific academic title for the following content. Output only the title text: no quotes, no label, no trailing period.\n\n{{selection}}`, - abstract: `Write a single self-contained academic abstract (about 150 to 250 words) for the following content. Output only the abstract text: no heading, no label, and no code fences.\n\n{{selection}}`, + paraphrase: `Paraphrase the following LaTeX text. Keep every LaTeX command, math, and citation key intact. Output only the paraphrased text, with no preamble, no explanation, and no code fences. Do not call, name, or simulate any tool, function, or API — answer directly with the text.\n\n{{selection}}`, + academic: `Rewrite the following LaTeX text in fluent, formal academic English. Preserve every LaTeX command, math, and citation key. Output only the rewritten text, with no preamble and no code fences. Do not call, name, or simulate any tool, function, or API — answer directly with the text.\n\n{{selection}}`, + concise: `Rewrite the following LaTeX text more concisely, preserving its meaning and every LaTeX command, math, and citation. Output only the rewritten text, nothing else. Do not call, name, or simulate any tool, function, or API — answer directly with the text.\n\n{{selection}}`, + // overleaf-lab (2026-08): reference-synced menu actions — translate (the + // extra `{{language}}` placeholder is substituted by the frontend) and a + // focused synonym helper. + translate: `Translate the following LaTeX text into {{language}}. Keep every LaTeX command, math, and citation key exactly as-is (translate only the natural-language text). Output only the translated text, with no preamble and no code fences. Do not call, name, or simulate any tool, function, or API — answer directly with the translated text.\n\n{{selection}}`, + synonyms: `Suggest two or three well-chosen academic synonyms or phrasings for each key content word in the following LaTeX text, one per line, in the format "word — alternative(s)". Keep every LaTeX command intact and do not rewrite the whole text. Output only the list, with no preamble and no code fences, and do not call or simulate any tool or function.\n\n{{selection}}`, } // overleaf-lab: return the default action prompts with any valid string overrides diff --git a/services/web/modules/llm/app/src/LLMRouter.mjs b/services/web/modules/llm/app/src/LLMRouter.mjs index 0708ef7d98..3796258a25 100644 --- a/services/web/modules/llm/app/src/LLMRouter.mjs +++ b/services/web/modules/llm/app/src/LLMRouter.mjs @@ -14,7 +14,7 @@ export default { allowUserSettings: Settings.llm?.allowUserSettings, apiUrl: process.env.LLM_API_URL ? '(set)' : '(not set)', apiKey: process.env.LLM_API_KEY ? '(set)' : '(not set)', - modelName: process.env.LLM_MODEL_NAME, + modelName: process.env.LLM_MODEL_NAME }, '[LLM] Registering routes' ) @@ -27,6 +27,14 @@ export default { ) logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/chat') + // overleaf-lab: AI Error Assist — suggested fix per compile log entry. + webRouter.post( + '/project/:Project_id/llm/compile-fix', + AuthorizationMiddleware.ensureUserCanReadProject, + LLMChatController.compileFix + ) + logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/compile-fix') + webRouter.get( '/project/:Project_id/llm/models', AuthorizationMiddleware.ensureUserCanReadProject, @@ -50,8 +58,7 @@ export default { ) logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/source-context') - // overleaf-lab: effective editable prompts (Ask AI system prompt, error - // instruction block, and per-action templates) for the project UI. + // overleaf-lab: effective editable prompts for the project UI. webRouter.get( '/project/:Project_id/llm/prompts', AuthorizationMiddleware.ensureUserCanReadProject, @@ -67,6 +74,14 @@ export default { ) logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/completion') + // overleaf-lab: whole-document generators (title/abstract/keywords) + webRouter.post( + '/project/:Project_id/llm/generate', + AuthorizationMiddleware.ensureUserCanReadProject, + LLMChatController.generateDocument + ) + logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/generate') + // overleaf-lab: document compliance review endpoints (project-scoped) webRouter.get( '/project/:Project_id/llm/compliance/rubrics', @@ -96,42 +111,107 @@ export default { ) logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/compliance/cancel/:jobId') - // User LLM settings (only if allowed) + // overleaf-lab (owner request 2026-08-26): user-scoped shared LLM model + // selection — the File → "Select LLM Model" modal persists here, on the + // user profile, and follows the user across projects and browsers. + webRouter.get( + '/user/llm/selected-model', + AuthenticationController.requireLogin(), + LLMSettingsController.getSelectedModel + ) + logger.debug({}, '[LLM] Route registered: GET /user/llm/selected-model') + + webRouter.post( + '/user/llm/selected-model', + AuthenticationController.requireLogin(), + LLMSettingsController.saveSelectedModel + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm/selected-model') + + // overleaf-lab (2026-08-27, owner request): user-scoped compliance + // review rubrics — every user configures their own under + // /user/llm-settings; the reviewer reads them per user. + webRouter.get( + '/user/llm/compliance', + AuthenticationController.requireLogin(), + LLMSettingsController.getUserCompliance + ) + logger.debug({}, '[LLM] Route registered: GET /user/llm/compliance') + + webRouter.post( + '/user/llm/compliance', + AuthenticationController.requireLogin(), + LLMSettingsController.saveUserCompliance + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm/compliance') + + // overleaf-lab: BYO provider rows (user-scoped). Every handler enforces + // the LLM_ALLOW_USER_SETTINGS gate itself (F1): the router registers them + // unconditionally, and disabled deployments answer 403, so there is no + // registration path that skips the gate. + webRouter.get( + '/user/llm-providers', + AuthenticationController.requireLogin(), + LLMSettingsController.getProvidersJson + ) + logger.debug({}, '[LLM] Route registered: GET /user/llm-providers') + + webRouter.post( + '/user/llm-providers', + AuthenticationController.requireLogin(), + LLMSettingsController.addProvider + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-providers') + + // overleaf-lab: /check and /scan MUST be registered before /:id — + // Express matches in registration order, so ':id' would swallow them + // (observed: POST /check 404'd as updateProvider with id='check'). + webRouter.post( + '/user/llm-providers/check', + AuthenticationController.requireLogin(), + LLMSettingsController.checkProviderConnection + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-providers/check') + + webRouter.post( + '/user/llm-providers/scan', + AuthenticationController.requireLogin(), + LLMSettingsController.scanProviderModels + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-providers/scan') + + webRouter.post( + '/user/llm-providers/:id', + AuthenticationController.requireLogin(), + LLMSettingsController.updateProvider + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-providers/:id') + + webRouter.post( + '/user/llm-providers/:id/delete', + AuthenticationController.requireLogin(), + LLMSettingsController.deleteProvider + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-providers/:id/delete') + + // overleaf-lab: /user/llm-settings is the dedicated BYO settings page + // (Account menu 'AI Settings' and the Account Settings card link here). + webRouter.get( + '/user/llm-settings', + AuthenticationController.requireLogin(), + LLMSettingsController.llmSettingsPage + ) + logger.debug({}, '[LLM] Route registered: GET /user/llm-settings (settings page)') + if (Settings.llm && Settings.llm.allowUserSettings) { - webRouter.get( - '/user/llm-settings', - AuthenticationController.requireLogin(), - LLMSettingsController.llmSettingsPage - ) - logger.debug({}, '[LLM] Route registered: GET /user/llm-settings') + logger.debug({}, '[LLM] BYO enabled by deployment config') } else { logger.debug( { allowUserSettings: Settings.llm?.allowUserSettings }, - '[LLM] Skipping /user/llm-settings route (user settings disabled)' + '[LLM] BYO endpoints registered but gated (LLM_ALLOW_USER_SETTINGS not set -> 403)' ) } - webRouter.post( - '/user/llm-settings/check', - AuthenticationController.requireLogin(), - LLMSettingsController.checkLLMConnection - ) - logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/check') - - webRouter.post( - '/user/llm-settings/models', - AuthenticationController.requireLogin(), - LLMSettingsController.scanUserModels - ) - logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/models') - - webRouter.post( - '/user/llm-settings', - AuthenticationController.requireLogin(), - LLMSettingsController.saveLLMSettings - ) - logger.debug({}, '[LLM] Route registered: POST /user/llm-settings') - logger.info({}, '[LLM] All routes registered successfully') // Admin routes @@ -163,11 +243,11 @@ export default { ) logger.debug({}, '[LLM] Route registered: POST /admin/llm/settings/check') - webRouter.get( + webRouter.post( '/admin/llm/models', AuthorizationMiddleware.ensureUserIsSiteAdmin, LLMAdminController.scanAdminModels ) - logger.debug({}, '[LLM] Route registered: GET /admin/llm/models') - }, + logger.debug({}, '[LLM] Route registered: POST /admin/llm/models') + } } diff --git a/services/web/modules/llm/app/src/LLMSettingsController.mjs b/services/web/modules/llm/app/src/LLMSettingsController.mjs index 4a9f6e3121..cd9b1cab82 100644 --- a/services/web/modules/llm/app/src/LLMSettingsController.mjs +++ b/services/web/modules/llm/app/src/LLMSettingsController.mjs @@ -1,313 +1,539 @@ +/* + * LLMSettingsController - bring-your-own (BYO) LLM provider rows. + * + * Data model (per user): + * User.llmProviders: [ { + * id string (8 hex chars) + * name string (1..80) + * providerType 'openai' | 'anthropic' | 'openaiCompatible' + * baseUrl string (required for openaiCompatible; default endpoints + * used when empty for openai/anthropic) + * apiKey string (encrypted at rest via LLMCrypto; '' = keyless) + * models string[] (1..100) + * completionModel string (optional; must be one of models) + * enabled boolean + * createdAt string (ISO timestamp) + * } ] + * + * Legacy single-connection settings (llmApiUrl/llmApiKey/llmModelName/...) + * are migrated to row #1 on first read/write when present. + * + * Every endpoint here is gated by LLM_ALLOW_USER_SETTINGS (F1 fix: the gate + * previously only enforced chat(), not settings/check/scan/save). + */ + +import { z } from 'zod' +import Settings from '@overleaf/settings' import logger from '@overleaf/logger' -import fetch from 'node-fetch' -import { AbortController } from 'abort-controller' -import { fileURLToPath } from 'url' import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' import { User } from '../../../../app/src/models/User.mjs' import { expressify } from '@overleaf/promise-utils' -import { encryptSecret, decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: at-rest encryption of user API keys -import { getLLMFeatureFlags } from './LLMAdminController.mjs' // overleaf-lab: per-feature enable flags +import { encryptSecret, normalizeStoredSecret, storedToPlaintext } from './LLMCrypto.mjs' +import { normalizeProviderSpec, chatText, listModels, detectProviderType, PROVIDER_TYPES } from './LLMClient.mjs' +import { sanitizeComplianceRubrics, validateComplianceRubrics, getComplianceRubrics, getLLMFeatureFlags } from './LLMAdminController.mjs' +import OError from '@overleaf/o-error' -const llmSettingsPugPath = fileURLToPath( - new URL('../../app/views/llm-settings.pug', import.meta.url) -) +// overleaf-lab: hard cap on rows per user - keeps the editor model list and +// the user document bounded. +const MAX_PROVIDERS_PER_USER = 10 +const MAX_ROWS_IN_LIST = 500 // listModels result cap -async function llmSettingsPage(req, res) { - const userId = SessionManager.getLoggedInUserId(req.session) - - logger.debug({ userId, pugPath: llmSettingsPugPath }, '[LLM] llmSettingsPage: rendering') - - // overleaf-lab: when both chat and inline completion are disabled by the admin, - // the personal-settings page has nothing to configure, so send the user home. - const flags = await getLLMFeatureFlags() - if (!flags.chatEnabled && !flags.completionEnabled) { - return res.redirect('/') - } - - let user = {} - try { - user = await User.findById( - userId, - 'useOwnLLMSettings llmModelName llmApiUrl llmApiKey llmCompletionModel' - ) - } catch (err) { - logger.warn({ userId, err }, '[LLM] Error loading user for settings page') - } - - const llmSettings = { - useOwnSettings: user?.useOwnLLMSettings || false, - modelName: user?.llmModelName || '', - apiUrl: user?.llmApiUrl || '', - hasApiKey: !!(user?.llmApiKey), - completionModel: user?.llmCompletionModel || '', // overleaf-lab: per-user inline-completion model - } - - logger.debug( - { userId, useOwnSettings: llmSettings.useOwnSettings, hasApiKey: llmSettings.hasApiKey }, - '[LLM] llmSettingsPage: user settings loaded' - ) - - res.render(llmSettingsPugPath, { - user: { llmSettings }, - // overleaf-lab: expose the enable flags so the page can hide the disabled section. - featureFlags: { chatEnabled: flags.chatEnabled, completionEnabled: flags.completionEnabled }, +export const rowSchema = z + .object({ + name: z.string().trim().min(1).max(80), + providerType: z.enum(['openai', 'anthropic', 'openaiCompatible']), + baseUrl: z.string().trim().max(500).optional().default(''), + apiKey: z.string().trim().max(2000).optional().default(''), + models: z.array(z.string().trim().min(1).max(200)).min(1).max(100), + completionModel: z.string().trim().max(200).optional().default(''), + enabled: z.boolean().optional().default(true), }) + .superRefine((value, ctx) => { + if (value.providerType === 'openaiCompatible' && !value.baseUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['baseUrl'], + message: 'A base URL is required for OpenAI-compatible providers' + }) + } + if (value.completionModel && !value.models.includes(value.completionModel)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completionModel'], + message: 'completionModel must be one of the listed models' + }) + } + }) + +function makeRowId() { + return Math.random().toString(16).slice(2, 10).padEnd(8, '0') } -async function checkLLMConnection(req, res) { - const { apiUrl, apiKey: providedApiKey, modelName } = req.body - const userId = SessionManager.getLoggedInUserId(req.session) +/* + * F1: the single source of truth for "is BYO allowed on this deployment?". + * Used by every user-facing LLM endpoint (settings CRUD, check/scan, and the + * user-lane branches of chat/completion in LLMChatController). + */ +export function isUserSettingsAllowed() { + return process.env.LLM_ALLOW_USER_SETTINGS === 'true' +} - logger.debug( - { userId, apiUrl, modelName, hasProvidedKey: !!providedApiKey }, - '[LLM] checkLLMConnection: request received' - ) +async function requireUserSettingsAllowed(req, res) { + if (isUserSettingsAllowed()) return true + logger.info({ userId: req.session?.userId }, '[LLM] Blocked BYO settings access (LLM_ALLOW_USER_SETTINGS not set)') + res.status(403).json({ ok: false, error: 'disabled', message: 'Bring-your-own LLM settings are disabled on this deployment' }) + return false +} - // If no API key provided, fall back to stored key - let apiKey = providedApiKey - if (!apiKey) { - try { - const user = await User.findById(userId, 'llmApiKey') - if (user && user.llmApiKey) { - apiKey = decryptSecret(user.llmApiKey) // overleaf-lab: decrypt stored key at rest - logger.debug({ userId }, '[LLM] checkLLMConnection: using stored API key') - } - } catch (err) { - logger.warn({ err }, '[LLM] Could not fetch stored API key') - } - } - - // overleaf-lab: the key is optional (a local llama.cpp server has no auth); - // only the URL and model name are required. - if (!apiUrl || !modelName) { - return res.status(400).json({ error: 'Missing required parameters' }) - } - - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, 30000) - - try { - const llmApiUrl = `${apiUrl}/chat/completions` - - const requestBody = { - model: modelName, - messages: [{ role: 'user', content: 'Test connection' }], - max_tokens: 10, - temperature: 0.7, - } - - const startTime = Date.now() - - const headers = { 'Content-Type': 'application/json' } - if (typeof apiKey === 'string' && apiKey.length > 0) { - headers.Authorization = `Bearer ${apiKey}` - } - - const response = await fetch(llmApiUrl, { - method: 'POST', - headers, - body: JSON.stringify(requestBody), - signal: controller.signal, - }) - - clearTimeout(timeout) - const duration = Date.now() - startTime - - logger.debug( - { userId, apiUrl, modelName, status: response.status, duration: `${duration}ms` }, - '[LLM] checkLLMConnection: LLM API responded' - ) - - if (!response.ok) { - const errorText = await response.text() - return res.status(400).json({ - success: false, - error: 'LLM connection failed', - details: errorText, - status: response.status, - }) - } - - res.json({ - success: true, - message: 'LLM connection successful', - duration: `${duration}ms`, - }) - } catch (error) { - clearTimeout(timeout) - - if (error.name === 'AbortError') { - return res.status(504).json({ - success: false, - error: 'Connection timeout', - details: 'The LLM API did not respond within 30 seconds', - }) - } - - res.status(500).json({ - success: false, - error: 'Failed to test LLM connection', - details: error.message, - }) +function publicRow(row) { + return { + id: row.id, + name: row.name, + providerType: row.providerType, + baseUrl: row.baseUrl || '', + hasKey: !!row.apiKey, + models: row.models, + completionModel: row.completionModel || '', + enabled: row.enabled !== false, + createdAt: row.createdAt || new Date(0).toISOString() } } -// overleaf-lab: scan the user's own LLM provider for available model ids. -// Mirrors LLMAdminController.scanAdminModels but resolves credentials from the -// request body, falling back to the user's stored key/URL (like checkLLMConnection). -async function scanUserModels(req, res) { - const userId = SessionManager.getLoggedInUserId(req.session) - const { apiUrl: providedApiUrl, apiKey: providedApiKey } = req.body +/* + * Load the user's provider rows, migrating legacy single-connection settings + * to row #1 (virtually, without writing) when present. + */ +export async function loadProviders(userId) { + const user = await User.findById(userId, 'llmProviders llmApiUrl llmApiType llmApiKey llmModelName llmModels llmModelNames llmCompletionModel llmCompletionModels useOwnLLMSettings') + if (!user) return [] - logger.debug( - { userId, apiUrl: providedApiUrl, hasProvidedKey: !!providedApiKey }, - '[LLM] scanUserModels: request received' - ) + const providers = Array.isArray(user.llmProviders) + ? user.llmProviders.filter(r => r?.id && Array.isArray(r?.models)) + : [] - let apiKey = providedApiKey - let apiUrl = providedApiUrl - // Fall back to stored key/URL when either is omitted - if (!apiKey || !apiUrl) { + // Legacy settings (one single saved connection) are always represented + // as the "Imported settings" row, next to any BYO rows. F20: the legacy + // plaintext key is encrypted on migration; the row is persisted (if still + // not present) so save/edit/delete operate on a real row with an + // encrypted key. Making the row visible when other rows exist is + // required for delete (the delete of that row is what unsets the legacy + // fields — owner bug report: "row reappears after delete / can't delete"). + if (user.llmApiUrl + && !providers.some(r => r.id === 'legacy' + || (r.name === 'Imported settings' && r.baseUrl === user.llmApiUrl))) { + const models = [ + ...(user.llmModelNames || user.llmModels || []), + ...(user.llmModelName && !user.llmModels?.includes(user.llmModelName) ? [user.llmModelName] : []) + ] + const completionModels = [ + ...(user.llmCompletionModels || []), + ...(user.llmCompletionModel && !user.llmCompletionModels?.includes(user.llmCompletionModel) ? [user.llmCompletionModel] : []) + ] + const legacyType = ['openai', 'anthropic', 'openaiCompatible'].includes(user.llmApiType) ? user.llmApiType : null + const row = { + id: 'legacy', + name: 'Imported settings', + providerType: legacyType || detectProviderType(user.llmApiUrl), + baseUrl: user.llmApiUrl, + apiKey: normalizeStoredSecret(user.llmApiKey || ''), + models: models.slice(0, 100), + completionModel: (completionModels[0] && models.includes(completionModels[0])) ? completionModels[0] : (models[0] || ''), + enabled: true, + createdAt: new Date(0).toISOString() + } try { - const user = await User.findById(userId, 'llmApiKey llmApiUrl') - if (user) { - if (!apiKey && user.llmApiKey) { - apiKey = decryptSecret(user.llmApiKey) // overleaf-lab: decrypt stored key at rest - } - if (!apiUrl && user.llmApiUrl) { - apiUrl = user.llmApiUrl - } + const result = await User.updateOne( + { _id: userId }, + { $set: { llmProviders: [row, ...providers] } } + ) + if (result && result.modifiedCount) { + logger.info({ userId }, '[LLM] loadProviders: migrated legacy settings to "Imported settings" row (key encrypted)') } - } catch (err) { - logger.warn({ userId, err }, '[LLM] Could not fetch stored settings for model scan') } + catch (err) { + logger.warn({ userId, err: err?.message }, '[LLM] loadProviders: legacy migration persist failed (continuing with virtual row)') + } + return [row, ...providers] } - // 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 (!apiUrl) { - return res.status(400).json({ - success: false, - error: 'API URL is required', - }) + return providers +} + +async function getProvidersJson(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return + const userId = SessionManager.getLoggedInUserId(req.session) + const providers = await loadProviders(userId) + res.json({ ok: true, providers: providers.map(publicRow), maxProviders: MAX_PROVIDERS_PER_USER }) +} + +async function addProvider(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return + const userId = SessionManager.getLoggedInUserId(req.session) + const parsed = rowSchema.safeParse(req.body || {}) + if (!parsed.success) { + const details = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ') + return res.status(400).json({ ok: false, error: 'invalid', details }) } - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 30000) - - try { - const headers = {} - if (typeof apiKey === 'string' && apiKey.length > 0) { - headers.Authorization = `Bearer ${apiKey}` - } - const response = await fetch(`${apiUrl}/models`, { - method: 'GET', - headers, - signal: controller.signal, - }) - - clearTimeout(timeout) - - 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 data = await response.json() - const ids = Array.isArray(data?.data) - ? data.data.map(entry => String(entry.id)).sort() + const user = await User.findById(userId, 'llmProviders llmApiUrl llmModelName') + const existing = Array.isArray(user?.llmProviders) ? user.llmProviders : [] + if (existing.length >= MAX_PROVIDERS_PER_USER) { + return res.status(400).json({ ok: false, error: 'limit', message: `Maximum of ${MAX_PROVIDERS_PER_USER} providers` }) + } + const row = { + ...parsed.data, + id: makeRowId(), + models: [...new Set(parsed.data.models)], + apiKey: parsed.data.apiKey ? encryptSecret(parsed.data.apiKey) : '', + createdAt: new Date().toISOString() + } + // Persisting the legacy migration as row #1 too, so the virtual row gains + // a real id on first write. + const migrated = + existing.length === 0 + ? (await loadProviders(userId)).map(r => ({ ...r, id: r.id === 'legacy' ? makeRowId() : r.id, apiKey: normalizeStoredSecret(r.apiKey) })) : [] - - res.json({ success: true, models: ids }) - } catch (error) { - clearTimeout(timeout) - - if (error.name === 'AbortError') { - return res.status(504).json({ - success: false, - error: 'Connection timeout', - details: 'The LLM API did not respond within 30 seconds', - }) - } - - logger.error({ userId, err: error }, '[LLM] User model scan failed') - res.status(500).json({ success: false, error: 'Model scan failed' }) + const merged = [...migrated, ...existing, row] + // WS5: atomic cap guard — the condition lives in the update itself so two + // concurrent adds cannot both pass the >=MAX check (last writer gets 400). + const capGuard = await User.updateOne( + { + _id: userId, + $expr: { + $lt: [{ $size: { $ifNull: ['$llmProviders', []] } }, MAX_PROVIDERS_PER_USER], + }, + }, + { $set: { llmProviders: merged } } + ) + if (!capGuard.modifiedCount) { + return res.status(400).json({ ok: false, error: 'limit', message: `Maximum of ${MAX_PROVIDERS_PER_USER} providers` }) } + logger.info({ userId, rowId: row.id, name: row.name }, '[LLM] addProvider: row added') + res.status(201).json({ ok: true, provider: publicRow(row) }) } -async function saveLLMSettings(req, res) { +async function updateProvider(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return const userId = SessionManager.getLoggedInUserId(req.session) - const { useOwnLLMSettings, llmApiKey, llmModelName, llmApiUrl, llmCompletionModel } = req.body + const rowId = req.params.id + const user = await User.findById(userId, 'llmProviders llmApiUrl llmApiKey llmModelName llmModels llmModelNames llmCompletionModel llmCompletionModels llmApiType useOwnLLMSettings') + const current = (await loadProviders(userId)).find(r => r.id === rowId) + if (!current) return res.status(404).json({ ok: false, error: 'not-found' }) - logger.debug( - { - userId, - useOwnLLMSettings, - llmModelName, - llmApiUrl, - hasApiKey: !!llmApiKey, - }, - '[LLM] saveLLMSettings: request received' - ) - - try { - if (useOwnLLMSettings) { - // overleaf-lab: the API key is optional (a local llama.cpp server has no - // auth); only the URL and model name are required. When a key IS provided - // it is still encrypted and stored below. - if (!llmApiUrl || !llmModelName) { - return res.status(400).json({ - success: false, - error: - 'API URL and Model Name are required when enabling custom LLM settings', - }) - } - } - - const updateData = { - useOwnLLMSettings: Boolean(useOwnLLMSettings), - llmModelName: llmModelName || '', - llmApiUrl: llmApiUrl || '', - // overleaf-lab: per-user inline-completion model; only meaningful with own settings - llmCompletionModel: (useOwnLLMSettings && llmCompletionModel) ? llmCompletionModel : '', - } - - // Only update API key if a new one is provided - if (llmApiKey && llmApiKey.trim() !== '') { - updateData.llmApiKey = encryptSecret(llmApiKey) // overleaf-lab: encrypt user key at rest - } - - await User.updateOne({ _id: userId }, { $set: updateData }) - - logger.debug({ userId, useOwnLLMSettings }, '[LLM] saveLLMSettings: saved successfully') - - res.json({ - success: true, - message: 'LLM settings saved successfully', - }) - } catch (error) { - logger.error( - { userId, err: error }, - '[LLM] Error saving settings' - ) - - res.status(500).json({ - success: false, - error: 'Failed to save LLM settings', - }) + // Partial update: merge over current row, then validate the full shape. + const merged = { + ...current, + name: req.body?.name ?? current.name, + providerType: req.body?.providerType ?? current.providerType, + baseUrl: req.body?.baseUrl !== undefined ? req.body.baseUrl : (current.baseUrl || ''), + models: req.body?.models ?? current.models, + completionModel: req.body?.completionModel !== undefined ? req.body.completionModel : (current.completionModel || ''), + enabled: req.body?.enabled ?? current.enabled } + const parsed = rowSchema.safeParse(merged) + if (!parsed.success) { + const details = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ') + return res.status(400).json({ ok: false, error: 'invalid', details }) + } + let apiKey = normalizeStoredSecret(current.apiKey || '') + if (req.body?.clearApiKey) apiKey = '' + else if (req.body?.apiKey && req.body.apiKey.trim() !== '') apiKey = encryptSecret(req.body.apiKey) + + const rows = (Array.isArray(user?.llmProviders) ? user.llmProviders : (await loadProviders(userId))).map(r => + (r.id === rowId || r.id === 'legacy') ? { ...parsed.data, id: rowId === 'legacy' ? makeRowId() : rowId, apiKey, createdAt: new Date().toISOString() } : r + ) + await User.updateOne({ _id: userId }, { $set: { llmProviders: rows } }) + logger.info({ userId, rowId }, '[LLM] updateProvider: row updated') + res.json({ ok: true, provider: publicRow({ ...parsed.data, id: rowId, apiKey }) }) +} + +async function deleteProvider(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return + const userId = SessionManager.getLoggedInUserId(req.session) + const rowId = req.params.id + const providers = await loadProviders(userId) + if (!providers.some(r => r.id === rowId)) return res.status(404).json({ ok: false, error: 'not-found' }) + const rows = (await User.findById(userId, 'llmProviders')).llmProviders || providers.map(r => ({ ...r, id: r.id === 'legacy' ? makeRowId() : r.id, apiKey: normalizeStoredSecret(r.apiKey) })) + const remaining = rows.filter(r => r.id !== rowId && !(rowId === 'legacy' && r.name === 'Imported settings')) + // overleaf-lab: deleting the "Imported settings" row must also clear the + // underlying legacy single-connection fields, otherwise loadProviders() + // re-migrates them on the next read and the deleted row comes back to life. + const targetIsLegacy = providers.some( + r => r.id === rowId && (r.id === 'legacy' || r.name === 'Imported settings') + ) || (rowId === 'legacy' && providers.some(r => r.name === 'Imported settings')) + const update = { $set: { llmProviders: remaining } } + if (targetIsLegacy) { + update.$unset = { + llmApiUrl: 1, + llmApiType: 1, + llmApiKey: 1, + llmModelName: 1, + llmModels: 1, + llmModelNames: 1, + llmCompletionModel: 1, + llmCompletionModels: 1, + useOwnLLMSettings: 1 + } + } + await User.updateOne({ _id: userId }, update) + logger.info({ userId, rowId, targetIsLegacy }, '[LLM] deleteProvider: row deleted') + res.json({ ok: true }) +} + +async function checkProviderConnection(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return + const userId = SessionManager.getLoggedInUserId(req.session) + const body = req.body || {} + + // Credentials: explicit body values win, else the referenced row. + let baseUrl = body.baseUrl || '' + let apiKey = body.apiKey || '' + let providerType = body.providerType || '' + const model = body.model || '' + if (body.rowId) { + const row = (await loadProviders(userId)).find(r => r.id === body.rowId) + if (!row) return res.status(404).json({ ok: false, error: 'not-found' }) + baseUrl = baseUrl || row.baseUrl || '' + providerType = providerType || row.providerType + if (!apiKey && row.apiKey) apiKey = storedToPlaintext(row.apiKey) + } + if (!baseUrl && !providerType) return res.status(400).json({ ok: false, error: 'invalid', details: 'baseUrl or rowId is required' }) + // overleaf-lab (reviewer #5): auto-detect the API type. Try the requested + // type first; if it fails, try every other supported type before giving + // up, so an endpoint that is OpenAI-compatible (or Anthropic) is detected + // automatically and the UI can persist the working type on the row. + const requestedType = providerType || detectProviderType(baseUrl) + const candidateTypes = [requestedType, ...PROVIDER_TYPES.filter(t => t !== requestedType)] + + const started = Date.now() + let lastError = null + for (const type of candidateTypes) { + let spec + try { + spec = normalizeProviderSpec({ providerType: type, baseUrl, apiKey }, { model: model || 'qwen' }) + } + catch (error) { + lastError = error + continue + } + try { + const { ids } = await listModels(spec, { timeoutMs: 60000 }) + const models = ids.slice(0, MAX_ROWS_IN_LIST) + if (model && !models.includes(model)) { + // The model check is definitive for the requested type; do not + // fall through to other types just because the model is missing. + logger.warn({ userId, model, count: models.length }, '[LLM] checkProviderConnection: model not in list') + return res.status(404).json({ ok: false, error: 'model-not-found', details: `Model "${model}" not available`, models, duration: `${Date.now() - started}ms` }) + } + // Lightweight chat probe (catches backends whose /models works but + // chat is misconfigured). + await chatText( + normalizeProviderSpec({ providerType: type, baseUrl, apiKey }, { model: model || models[0] || 'qwen' }), + [{ role: 'user', content: 'Reply with the single word OK.' }], + { maxOutputTokens: 16, temperature: 0, timeoutMs: 60000 } + ) + if (type !== requestedType) { + logger.info( + { userId, requestedType, detectedType: type, models: models.length }, + '[LLM] checkProviderConnection: API type auto-detected' + ) + } + return res.json({ + ok: true, + message: 'Connection successful', + models, + providerType: type, + detectedProviderType: type !== requestedType ? type : undefined, + duration: `${Date.now() - started}ms` + }) + } + catch (error) { + lastError = error + logger.debug( + { userId, type, err: error?.message }, + '[LLM] checkProviderConnection: type attempt failed, trying next' + ) + } + } + const info = lastError?.code === 'auth' ? { status: 401 } : (lastError?.status || (OError.getFullInfo(lastError)?.status || 500)) + logger.warn({ userId, providerType: requestedType, err: lastError?.message }, '[LLM] checkProviderConnection: failed') + return res.status(info.status || 500).json({ ok: false, error: lastError?.code || 'llm-error', message: lastError?.message, duration: `${Date.now() - started}ms` }) +} + +async function scanProviderModels(req, res) { + if (!(await requireUserSettingsAllowed(req, res))) return + const userId = SessionManager.getLoggedInUserId(req.session) + const body = req.body || {} + let baseUrl = body.baseUrl || '' + let apiKey = body.apiKey || '' + let providerType = body.providerType || '' + if (body.rowId) { + const row = (await loadProviders(userId)).find(r => r.id === body.rowId) + if (!row) return res.status(404).json({ ok: false, error: 'not-found' }) + baseUrl = baseUrl || row.baseUrl || '' + providerType = providerType || row.providerType + if (!apiKey && row.apiKey) apiKey = storedToPlaintext(row.apiKey) + } + providerType = providerType || detectProviderType(baseUrl) + if (!baseUrl) return res.status(400).json({ ok: false, error: 'invalid', details: 'baseUrl or rowId is required' }) + + // overleaf-lab (reviewer #5): same auto-detection as /check — try the + // requested API type first, then the others, so scanning also works when + // the type field was guessed wrong. + const requestedType = providerType + const candidateTypes = [requestedType, ...PROVIDER_TYPES.filter(t => t !== requestedType)] + let lastError = null + for (const type of candidateTypes) { + const spec = normalizeProviderSpec({ providerType: type, baseUrl, apiKey }, { model: 'scan' }) + try { + const { ids } = await listModels(spec, { timeoutMs: 60000 }) + if (type !== requestedType) { + logger.info({ userId, requestedType, detectedType: type, models: ids.length }, '[LLM] scanProviderModels: API type auto-detected') + } + return res.json({ ok: true, models: ids.slice(0, MAX_ROWS_IN_LIST), providerType: type, detectedProviderType: type !== requestedType ? type : undefined }) + } + catch (error) { + lastError = error + logger.debug({ userId, type, err: error?.message }, '[LLM] scanProviderModels: type attempt failed, trying next') + } + } + const status = lastError?.code === 'auth' ? 401 : (lastError?.status || (OError.getFullInfo(lastError)?.status || 500)) + logger.warn({ userId, providerType: requestedType, err: lastError?.message }, '[LLM] scanProviderModels: failed') + return res.status(status || 500).json({ ok: false, error: lastError?.code || 'llm-error', message: lastError?.message }) +} + +// Redirect: BYO rows live in Account Settings (core section). Kept as a +// redirect so old bookmarks / navbar entries do not 404. +async function llmSettingsPage(req, res) { + // overleaf-lab: dedicated BYO settings page (the Account ▸ 'AI Settings' item + // and the Account Settings card both link here; it used to be a redirect to + // the generic settings page, which showed no LLM UI at all). + const allowUser = !!(Settings.llm && Settings.llm.allowUserSettings) + // WS5: keep the page-level surface consistent with the API — disabled BYO + // deployments answer 403 for the page as well (not just the JSON routes). + if (!allowUser) { + return res.status(403).json({ ok: false, error: 'disabled', message: 'BYO provider settings are disabled on this deployment (LLM_ALLOW_USER_SETTINGS). Use the site backend.' }) + } + const userId = SessionManager.getLoggedInUserId(req.session) + const userDoc = userId ? await User.findOne({ _id: userId }).lean() : null + const context = { + user: { + firstName: userDoc?.first_name || '', + lastName: userDoc?.last_name || '', + email: userDoc?.email || '', + llmSettings: { allowed: allowUser }, + }, + featureFlags: { chatEnabled: true, completionEnabled: true }, + } + res.render(new URL('../../app/views/llm-settings.pug', import.meta.url).pathname, context) +} + +/* + * overleaf-lab (owner request 2026-08-26): user-scoped shared LLM model + * selection. The single "Select LLM Model" choice (File menu) drives every AI + * surface (chat, review, generators, ask-AI). The selection is stored on the + * user profile (llmSelectedModel) so it follows the user across projects and + * browsers — not per project. Value is a model id, a namespaced + * `u::` BYO id, or '' (deployment default). No BYO gate here: + * picking the site model is always allowed; a BYO value simply 403s at run + * time on deployments without LLM_ALLOW_USER_SETTINGS. + */ +const SELECTED_MODEL_MAX = 500 + +async function getSelectedModel(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + return res.status(401).json({ ok: false, error: 'unauthorized', message: 'Login required' }) + } + const userDoc = await User.findOne({ _id: userId }).lean() + const selected = typeof userDoc?.llmSelectedModel === 'string' ? userDoc.llmSelectedModel : '' + res.json({ ok: true, selected }) +} + +async function saveSelectedModel(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + return res.status(401).json({ ok: false, error: 'unauthorized', message: 'Login required' }) + } + const value = typeof req.body?.selected === 'string' ? req.body.selected.trim() : '' + if (value.length > SELECTED_MODEL_MAX) { + return res.status(400).json({ ok: false, error: 'bad-request', message: 'Model value too long' }) + } + await User.updateOne({ _id: userId }, { $set: { llmSelectedModel: value } }) + res.json({ ok: true, selected: value }) +} + +// overleaf-lab (2026-08-27, owner request): USER-SCOPED compliance review +// rubrics. Each user configures their own rubrics under /user/llm-settings; the +// compliance reviewer (LLMComplianceController) reads them per user. Storage is +// the user document (llmComplianceRubrics) — no new collection. +// +// Migration behavior: a user who has never saved their own rubrics inherits the +// deployment-wide rubrics configured by the admin, so existing setups keep +// working on the first visit. +// overleaf-lab (2026-08-27): the internal (no-auth) lookup used by the +// compliance reviewer — the user's own rubrics, falling back to the +// deployment-wide set when the user has never saved their own. +export async function getComplianceRubricsForUser(userId) { + const userDoc = await User.findOne({ _id: userId }).lean().catch(() => null) + const mine = Array.isArray(userDoc?.llmComplianceRubrics) && userDoc.llmComplianceRubrics.length + ? userDoc.llmComplianceRubrics + : null + if (mine) return mine + return getComplianceRubrics() +} + +async function getUserCompliance(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + return res.status(401).json({ ok: false, error: 'unauthorized', message: 'Login required' }) + } + const flags = await getLLMFeatureFlags() + if (!flags.reviewEnabled) { + return res.json({ ok: true, rubrics: [], inherited: false }) + } + const rubrics = await getComplianceRubricsForUser(userId) + const userDoc = await User.findOne({ _id: userId }).lean() + const inherited = !(Array.isArray(userDoc?.llmComplianceRubrics) && userDoc.llmComplianceRubrics.length) + res.json({ ok: true, rubrics, inherited }) +} + +async function saveUserCompliance(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + return res.status(401).json({ ok: false, error: 'unauthorized', message: 'Login required' }) + } + const flags = await getLLMFeatureFlags() + if (!flags.reviewEnabled) { + return res.status(403).json({ ok: false, error: 'disabled', message: 'The compliance review feature is disabled' }) + } + const list = req.body?.rubrics + const sanitized = sanitizeComplianceRubrics(list) + if (sanitized === null) { + return res.status(400).json({ ok: false, error: 'bad-request', message: 'Invalid rubrics' }) + } + const problem = validateComplianceRubrics(Array.isArray(list) ? list : []) + if (problem) { + return res.status(400).json({ ok: false, error: 'bad-request', message: problem }) + } + await User.updateOne({ _id: userId }, { $set: { llmComplianceRubrics: sanitized } }) + res.json({ ok: true, rubrics: sanitized }) } export default { - llmSettingsPage: expressify(llmSettingsPage), - checkLLMConnection: expressify(checkLLMConnection), - scanUserModels: expressify(scanUserModels), - saveLLMSettings: expressify(saveLLMSettings), + isUserSettingsAllowed, + requireUserSettingsAllowed, + MAX_PROVIDERS_PER_USER, + llmSettingsPage, // sync render - must NOT be expressified (no .catch) + getProvidersJson: expressify(getProvidersJson), + addProvider: expressify(addProvider), + updateProvider: expressify(updateProvider), + deleteProvider: expressify(deleteProvider), + checkProviderConnection: expressify(checkProviderConnection), + scanProviderModels: expressify(scanProviderModels), + // overleaf-lab: user-scoped shared model selection (File → "Select LLM Model") + getSelectedModel: expressify(getSelectedModel), + saveSelectedModel: expressify(saveSelectedModel), + // overleaf-lab (2026-08-27): user-scoped compliance review rubrics + getUserCompliance: expressify(getUserCompliance), + saveUserCompliance: expressify(saveUserCompliance) } diff --git a/services/web/modules/llm/app/test/LLMClient.live.mjs b/services/web/modules/llm/app/test/LLMClient.live.mjs new file mode 100644 index 0000000000..0c7f824492 --- /dev/null +++ b/services/web/modules/llm/app/test/LLMClient.live.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/* eslint-disable no-console -- diagnostic CLI; console is its purpose */ +import { z } from 'zod'; +import Client from '../src/LLMClient.mjs'; + +const spec = Client.normalizeProviderSpec( + { providerType: 'openaiCompatible', baseUrl: 'http://172.18.0.1:11434/v1', apiKey: 'ollama' }, + { model: 'qwen3.8:latest' } +); +console.log('spec:', JSON.stringify(spec)); + +const t0 = Date.now(); +const r = await Client.chatText( + spec, + [{ role: 'user', content: 'Reply with exactly one word: HELLO' }], + { maxOutputTokens: 200, temperature: 0 } +); +console.log('chatText:', JSON.stringify(r), `${Date.now() - t0}ms`); + +const l = await Client.listModels(spec); +console.log('listModels:', l.ids.join(', ')); + +try { + await Client.chatText( + { ...spec, model: 'no-such-model-xyz' }, + [{ role: 'user', content: 'hi' }], + { maxOutputTokens: 8, timeoutMs: 15000 } + ); + console.log('bad-model: NO ERROR (unexpected)'); +} +catch (e) { console.log('bad-model -> code:', e.code, '| msg:', e.message.slice(0, 120)); } + +const o = await Client.chatObject( + spec, + [{ role: 'user', content: 'Give me exactly two short review items about LaTeX spacing' }], + z.object({ items: z.array(z.object({ item: z.string(), severity: z.enum(['info', 'error']) }), { min: 2, max: 2 }) }), + { maxOutputTokens: 600 } +); +console.log('chatObject:', JSON.stringify(o.object).slice(0, 220), '| usage:', JSON.stringify(o.usage)); diff --git a/services/web/modules/llm/app/test/llm-client.test.mjs b/services/web/modules/llm/app/test/llm-client.test.mjs new file mode 100644 index 0000000000..d36490e392 --- /dev/null +++ b/services/web/modules/llm/app/test/llm-client.test.mjs @@ -0,0 +1,113 @@ +// overleaf-lab: offline unit tests for the LLM provider seam and pure helpers. +// These never touch the network: they validate spec normalization, provider-type +// detection, response cleanup, token estimation, and the model-ref parser. +// +// Run: node --test app/test/llm-client.test.mjs +// A live end-to-end smoke against a real backend lives separately in +// LLMClient.live.mjs (run it manually, not via `node --test`). + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeProviderSpec, + detectProviderType, + stripThinkTags, + estimateTokens, + assertNonEmpty, + PROVIDER_TYPES +} from '../src/LLMClient.mjs' +import { parseModelRef } from '../src/LLMModelRef.mjs' + +test('PROVIDER_TYPES exposes exactly the three supported adapters', () => { + assert.deepEqual( + [...PROVIDER_TYPES].sort(), + ['anthropic', 'openai', 'openaiCompatible'] + ) +}) + +test('normalizeProviderSpec: openai-compatible URL keeps /v1', () => { + const spec = normalizeProviderSpec( + { providerType: 'openaiCompatible', baseUrl: 'http://host:11434/v1/', apiKey: 'ollama' }, + { model: 'qwen' } + ) + assert.equal(spec.baseUrl, 'http://host:11434/v1') + assert.equal(spec.model, 'qwen') + assert.equal(spec.apiKey, 'ollama') +}) + +test('normalizeProviderSpec: anthropic base URL has /v1 stripped (SDK appends it)', () => { + const spec = normalizeProviderSpec( + { providerType: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', apiKey: 'sk-ant' }, + { model: 'claude' } + ) + assert.equal(spec.baseUrl, 'https://api.anthropic.com') +}) + +test('normalizeProviderSpec: legacy field names (llmApiType/llmApiUrl/llmApiKey) migrate', () => { + const spec = normalizeProviderSpec( + { llmApiType: 'openai', llmApiUrl: 'https://api.openai.com/v1', llmApiKey: 'sk' }, + { model: 'gpt-4o' } + ) + assert.equal(spec.providerType, 'openai') + assert.equal(spec.baseUrl, 'https://api.openai.com/v1') + assert.equal(spec.apiKey, 'sk') +}) + +test('normalizeProviderSpec: missing model is a typed bad-config error', () => { + assert.throws( + () => normalizeProviderSpec({ providerType: 'openai' }, {}), + (err) => err.code === 'llm-bad-config' + ) +}) + +test('normalizeProviderSpec: unknown providerType is a typed bad-config error', () => { + assert.throws( + () => normalizeProviderSpec({ providerType: 'nope' }, { model: 'x' }), + (err) => err.code === 'llm-bad-config' + ) +}) + +test('detectProviderType routes vendor URLs to the right adapter', () => { + assert.equal(detectProviderType('https://api.anthropic.com/v1'), 'anthropic') + assert.equal(detectProviderType('https://api.openai.com/v1'), 'openai') + assert.equal(detectProviderType('http://localhost:11434/v1'), 'openaiCompatible') + assert.equal(detectProviderType(''), 'openaiCompatible') +}) + +test('stripThinkTags removes the tag markers and keeps inner content', () => { + const OPEN = '<' + 'think>' + const CLOSE = '<' + '/think>' + assert.equal(stripThinkTags('a ' + OPEN + 'b' + CLOSE), 'a b') + assert.equal(stripThinkTags(OPEN + 'only' + CLOSE), 'only') + assert.equal(stripThinkTags('no tags'), 'no tags') +}) + +test('estimateTokens is conservative and never zero', () => { + assert.equal(estimateTokens(''), 1) + // ~3.5 chars/token => 35 chars => 10 tokens + assert.equal(estimateTokens('x'.repeat(35)), 10) +}) + +test('assertNonEmpty returns trimmed text and throws a typed error when empty', () => { + assert.equal(assertNonEmpty(' hi '), 'hi') + assert.throws(() => assertNonEmpty(' '), (err) => err.code === 'empty-response') + assert.throws(() => assertNonEmpty(''), (err) => err.code === 'empty-response') +}) + +test('parseModelRef: bare id is the site lane', () => { + assert.deepEqual(parseModelRef('qwen3.8:latest'), { kind: 'site', model: 'qwen3.8:latest' }) +}) + +test('parseModelRef: namespaced id resolves the user row + model', () => { + assert.deepEqual(parseModelRef('u:1234abcd:gpt-4o'), { + kind: 'user', + rowId: '1234abcd', + model: 'gpt-4o' + }) +}) + +test('parseModelRef: empty/undefined falls back to the site default lane', () => { + assert.deepEqual(parseModelRef(''), { kind: 'site', model: '' }) + assert.deepEqual(parseModelRef(undefined), { kind: 'site', model: '' }) +}) diff --git a/services/web/modules/llm/app/test/llm-compile-fix.test.mjs b/services/web/modules/llm/app/test/llm-compile-fix.test.mjs new file mode 100644 index 0000000000..03de266fce --- /dev/null +++ b/services/web/modules/llm/app/test/llm-compile-fix.test.mjs @@ -0,0 +1,165 @@ +// overleaf-lab (2026): AI Error Assist — unit tests for the pure parts of +// the suggested-fix pipeline (schema, post-call validation, prompt builder). +// No web stack needed: only LLMCompileFix.mjs (zod + plain functions). +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + compileFixSchema, + validateCompileFixObject, + buildCompileFixMessages +} from '../src/LLMCompileFix.mjs' + +const validFix = { + explanation: 'Missing backslash before the macro name.', + suggestedOld: '\\begin{tabular}{|l|}', + suggestedNew: '\\begin{tabular}{l|}', + span: [10, 12] +} + +test('schema accepts a well-formed fix', () => { + const parsed = compileFixSchema.safeParse(validFix) + assert.equal(parsed.success, true) + assert.deepEqual(parsed.data.span, [10, 12]) +}) + +test('schema accepts a pure insertion (empty suggestedOld)', () => { + const parsed = compileFixSchema.safeParse({ + explanation: 'Add the missing preamble package.', + suggestedOld: '', + suggestedNew: '\\usepackage{amsmath}' + }) + assert.equal(parsed.success, true) +}) + +test('schema accepts a pure deletion (empty suggestedNew, no explanation)', () => { + // Degenerate-but-meaningful model output observed in the wild: the + // "different fix" was to delete just the stray characters. + const parsed = compileFixSchema.safeParse({ suggestedOld: 'aa', suggestedNew: '', span: [1, 1] }) + assert.equal(parsed.success, true) +}) + +test('validator accepts whitespace-only suggestedNew as a deletion', () => { + const out = validateCompileFixObject({ suggestedOld: 'aa', suggestedNew: ' ' }) + assert.equal(out.suggestedOld, 'aa') + assert.equal(out.suggestedNew, '') + assert.equal(out.explanation, '') +}) + +test('validator rejects a no-op (both sides empty)', () => { + assert.throws( + () => validateCompileFixObject({ suggestionOld: '' , suggestedOld: '', suggestedNew: '' }), + { code: 'llm-bad-fix' } + ) + assert.throws( + () => validateCompileFixObject({ explanation: 'did nothing' }), + { code: 'llm-bad-fix' } + ) +}) + +test('validateCompileFixObject trims and keeps an ordered span', () => { + const out = validateCompileFixObject({ + explanation: ' pad it ', + suggestedOld: ' a ', + suggestedNew: ' b ', + span: [3, 7] + }) + assert.equal(out.explanation, 'pad it') + assert.equal(out.suggestedOld, ' a ') // exact copy: whitespace preserved + assert.equal(out.suggestedNew, 'b') + assert.deepEqual(out.span, [3, 7]) +}) + +test('validateCompileFixObject drops an inverted span', () => { + const out = validateCompileFixObject({ + ...validFix, + span: [9, 2] + }) + assert.equal(out.span, null) +}) + +test('validator rejects non-objects and oversized suggestions', () => { + assert.throws( + () => validateCompileFixObject(null), + { code: 'llm-bad-fix' } + ) + assert.throws( + () => validateCompileFixObject(42), + { code: 'llm-bad-fix' } + ) + assert.throws( + () => validateCompileFixObject({ + explanation: 'x', + suggestedOld: 'a'.repeat(8001), + suggestedNew: 'b' + }), + { code: 'llm-bad-fix' } + ) + // Oversized explanation is TRUNCATED (the fix itself is still useful) + // rather than failing the whole request. + const out = validateCompileFixObject({ + explanation: 'x'.repeat(7000), + suggestedOld: 'a', + suggestedNew: 'b' + }) + assert.ok(out.explanation.length <= 6000) +}) + +test('pure insertion (empty suggestedOld) is valid', () => { + const out = validateCompileFixObject({ + explanation: 'Add the missing package.', + suggestedOld: '', + suggestedNew: '\\usepackage{amsmath}' + }) + assert.equal(out.suggestedOld, '') + assert.equal(out.suggestedNew, '\\usepackage{amsmath}') + assert.equal(out.span, null) +}) + +test('buildCompileFixMessages carries the contract, entry and source window', () => { + const messages = buildCompileFixMessages({ + level: 'error', + file: 'main.tex', + line: 42, + message: '! Missing $ inserted.', + snippet: '>' + ' 42: \\begin{eqnarray}\n 43: b', + hint: '', + adminPrompt: '' + }) + assert.equal(messages.length, 2) + assert.match(messages[0].content, /exact copy of the current text/i) + assert.match(messages[0].content, /MINIMAL/i) + assert.match(messages[0].content, /backslash/i) + assert.match(messages[1].content, /Missing \$ inserted/) + assert.match(messages[1].content, /main\.tex, line 42/) + assert.match(messages[1].content, />\s*42: /) +}) + +test('buildCompileFixMessages includes the avoid-hint on re-roll', () => { + const messages = buildCompileFixMessages({ + level: 'error', + file: 'f.tex', + line: 3, + message: 'err', + snippet: '> 3: x', + hint: { old: 'x', new: 'y' }, + adminPrompt: '' + }) + assert.match(messages[1].content, /DIFFERENT suggestion/i) + assert.match(messages[1].content, /old: x/) + assert.match(messages[1].content, /new: y/) + assert.doesNotMatch(messages[1].content, /Deployment instruction/) +}) + +test('buildCompileFixMessages uses the admin prompt only when given', () => { + const messages = buildCompileFixMessages({ + level: 'error', + file: 'f.tex', + line: 3, + message: 'err', + snippet: '> 3: x', + hint: '', + adminPrompt: 'Prefer minimal patches.' + }) + assert.match(messages[1].content, /Prefer minimal patches/) +}) diff --git a/services/web/modules/llm/app/test/llm-compliance-rubrics.test.mjs b/services/web/modules/llm/app/test/llm-compliance-rubrics.test.mjs new file mode 100644 index 0000000000..8658037ecb --- /dev/null +++ b/services/web/modules/llm/app/test/llm-compliance-rubrics.test.mjs @@ -0,0 +1,64 @@ +// overleaf-lab (2026-08-27): the shared rubric helpers used by BOTH the admin +// save and the new per-user compliance rubric save. They are pure functions, +// so this is a plain node:test file (no app context needed). +import test from 'node:test' +import assert from 'node:assert/strict' +import { + sanitizeComplianceRubrics, + validateComplianceRubrics, +} from '../src/LLMAdminController.mjs' + +test('sanitizeComplianceRubrics: non-array -> null (caller keeps existing)', () => { + assert.equal(sanitizeComplianceRubrics(undefined), null) + assert.equal(sanitizeComplianceRubrics(null), null) + assert.equal(sanitizeComplianceRubrics('nope'), null) +}) + +test('sanitizeComplianceRubrics: drops incomplete entries, caps lengths and count', () => { + const longName = 'x'.repeat(500) + const longGuidelines = 'g'.repeat(50000) + const list = [ + ...Array.from({ length: 12 }, (_, i) => ({ + id: `r${i}`, + name: `rubric ${i}`, + guidelines: '1. be kind', + scanPatterns: 'Wikipedia :: wikipedia', + })), + { id: '', name: 'no id', guidelines: 'x' }, + { id: 'nom', name: '', guidelines: 'x' }, + { id: 'cap', name: longName, guidelines: longGuidelines, scanPatterns: 'p'.repeat(9000) }, + ] + const out = sanitizeComplianceRubrics(list) + assert.equal(out.length, 13) // 15 in - 2 incomplete; well under the cap of 50 + const cap = out.find(r => r.id === 'cap') + assert.equal(cap.name.length, 200) + assert.equal(cap.guidelines.length, 20000) + assert.equal(cap.scanPatterns.length, 4000) +}) + +test('sanitizeComplianceRubrics: caps the list at 50 rubrics', () => { + const big = Array.from({ length: 55 }, (_, i) => ({ id: `r${i}`, name: `rubric ${i}` })) + assert.equal(sanitizeComplianceRubrics(big).length, 50) +}) + +test('validateComplianceRubrics: clean list -> null', () => { + const ok = [ + { name: 'a', scanPatterns: '' }, + { name: 'b', scanPatterns: 'Wikipedia :: wikipedia\nFirst person :: (? { + const badRegex = [{ name: 'r', scanPatterns: 'Broken :: (unclosed' }] + assert.match( + validateComplianceRubrics(badRegex), + /Invalid scan pattern regex in rubric "r"/, + ) + const tooLong = [{ name: 'r', scanPatterns: 'a'.repeat(4001) }] + assert.match( + validateComplianceRubrics(tooLong), + /must be 4000 characters or fewer/, + ) +}) diff --git a/services/web/modules/llm/app/test/llm-crypto.test.mjs b/services/web/modules/llm/app/test/llm-crypto.test.mjs new file mode 100644 index 0000000000..de46b7fc17 --- /dev/null +++ b/services/web/modules/llm/app/test/llm-crypto.test.mjs @@ -0,0 +1,46 @@ +/* + * F20 regression: legacy plaintext keys must be normalized to the enc:v1: + * format on the way into llmProviders, and read back through storedToPlaintext + * without loss — both directions, plus legacy-pass-through and idempotence. + */ +process.env.LLM_KEY_SECRET = 'deadbeef'.repeat(8) + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + encryptSecret, + decryptSecret, + normalizeStoredSecret, + storedToPlaintext, + hasEncPrefix +} from '../src/LLMCrypto.mjs' + +test('normalizeStoredSecret: plaintext becomes enc:v1: and round-trips', () => { + const plain = 'testkey1234567890abcdef' + const stored = normalizeStoredSecret(plain) + assert.ok(hasEncPrefix(stored), 'expected enc:v1: prefix, got: ' + stored) + assert.equal(storedToPlaintext(stored), plain, 'round-trip must return the original key') +}) + +test('normalizeStoredSecret: already-encrypted value is unchanged (idempotent)', () => { + const stored = encryptSecret('secret-key-123') + assert.equal(normalizeStoredSecret(stored), stored) +}) + +test('normalizeStoredSecret: empty value stays empty string', () => { + assert.equal(normalizeStoredSecret(''), '') + assert.equal(normalizeStoredSecret(undefined), '') +}) + +test('storedToPlaintext: legacy plaintext passes through unchanged', () => { + assert.equal(storedToPlaintext('plain-legacy-key'), 'plain-legacy-key') +}) + +test('storedToPlaintext: encrypted value decrypts', () => { + assert.equal(storedToPlaintext(encryptSecret('abc123')), 'abc123') +}) + +test('decryptSecret: wrapper remains backward-compatible', () => { + assert.equal(decryptSecret(encryptSecret('xyz789')), 'xyz789') + assert.equal(decryptSecret('legacy-plain'), 'legacy-plain') +}) diff --git a/services/web/modules/llm/app/views/llm-admin-settings.pug b/services/web/modules/llm/app/views/llm-admin-settings.pug index 2c4de8905a..bc5bc0d472 100644 --- a/services/web/modules/llm/app/views/llm-admin-settings.pug +++ b/services/web/modules/llm/app/views/llm-admin-settings.pug @@ -12,6 +12,7 @@ block append meta 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-knownModels' data-type='string' content=(knownModels || []).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') diff --git a/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx b/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx index a52536131f..44db213874 100644 --- a/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx @@ -9,8 +9,6 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLFormText from '@/shared/components/ol/ol-form-text' import OLFormControl from '@/shared/components/ol/ol-form-control' import OLNotification from '@/shared/components/ol/ol-notification' -import OLRow from '@/shared/components/ol/ol-row' -import OLCol from '@/shared/components/ol/ol-col' import OLBadge from '@/shared/components/ol/ol-badge' import MaterialIcon from '@/shared/components/material-icon' import { @@ -20,6 +18,8 @@ import { DropdownItem, } from '@/shared/components/dropdown/dropdown-menu' import '../../stylesheets/llm-settings.scss' +// overleaf-lab: shared upstream-AI design tokens (--wf-*) used by the settings chrome +import '../../stylesheets/llm-ui.scss' const LLM_API_TYPES = [ { name: 'openai', label: 'OpenAI' }, @@ -71,17 +71,6 @@ const DEFAULT_SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and c Remember: The user is likely frustrated. Be encouraging and clear!` -const statusBadgeStyle = (variant: 'success' | 'error'): React.CSSProperties => ({ - display: 'inline-flex', - alignItems: 'center', - gap: '0.25rem', - fontSize: '0.8125rem', - fontWeight: 500, - color: variant === 'success' - ? 'var(--green-60, #198754)' - : 'var(--red-60, #dc3545)', -}) - // overleaf-lab: a small accessible toggle switch (styled from a button) used for // the per-feature enable/disable controls. function ToggleSwitch({ @@ -99,33 +88,10 @@ function ToggleSwitch({ aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)} - style={{ - position: 'relative', - width: 42, - height: 24, - flexShrink: 0, - borderRadius: 999, - border: 'none', - padding: 0, - cursor: 'pointer', - transition: 'background-color 0.15s', - backgroundColor: checked - ? 'var(--bg-accent-01, #0d6efd)' - : 'var(--border-color-02, #adb5bd)', - }} + className={`ol-llm-admin-settings__switch${checked ? ' is-on' : ''}`} > ) @@ -149,6 +115,9 @@ export default function LLMAdminSettingsPage() { (getMeta('ol-llmApiType') as string) || '' ) const [llmApiKey, setLlmApiKey] = useState('') + // overleaf-lab: true = the user explicitly asked to REMOVE the stored key + // (blank input otherwise keeps the stored key). + const [clearLlmApiKey, setClearLlmApiKey] = useState(false) const [allowedModels, setAllowedModels] = useState( ((getMeta('ol-allowedModels') as string) || '') .split(',') @@ -156,17 +125,19 @@ export default function LLMAdminSettingsPage() { .filter(Boolean) ) const [availableModels, setAvailableModels] = useState([]) - // overleaf-lab: admin-chosen inline-completion model for the shared backend - // ('' = auto, i.e. the first allowed model). Separate from the chat models. - const [completionModel, setCompletionModel] = useState( - (getMeta('ol-completionModel') as string) || '' + // overleaf-lab: item 8 — the FULL model list found by a scan/test (including + // unchecked models), persisted so unchecked models stay visible across reloads. + const [knownModels, setKnownModels] = useState( + ((getMeta('ol-knownModels') as string) || '') + .split(',') + .map(m => m.trim()) + .filter(Boolean) ) - // overleaf-lab: compliance review settings. Rubrics come from a data-type='json' - // meta tag, so getMeta returns the parsed value; guard in case it is not an array. - const rubricsFromMeta = getMeta('ol-complianceRubrics') as Array<{ id: string; name: string; guidelines: string; scanPatterns?: string }> - const initialRubrics = Array.isArray(rubricsFromMeta) ? rubricsFromMeta : [] - const [complianceRubrics, setComplianceRubrics] = useState>(initialRubrics) - const [reviewModel, setReviewModel] = useState((getMeta('ol-reviewModel') as string) || '') + // overleaf-lab (2026-08-27, owner request): the admin "Inline completion + // model" and "Review model" pickers and the rubric editor are GONE. Inline + // completion and the review both run on each user's shared model selection + // (profile → BYO row → site default), and rubrics live per-user under + // /user/llm-settings. const [maxContextTokens, setMaxContextTokens] = useState(parseInt((getMeta('ol-maxContextTokens') as string) || '32000', 10) || 32000) // overleaf-lab: budget for the review's JSON answer (the model's max_tokens and // the room reserved for it in the context check). @@ -198,6 +169,8 @@ export default function LLMAdminSettingsPage() { } = useAsync() const [showSuccess, setShowSuccess] = useState(false) + // F8: surface validation errors returned by the save endpoint (field + message). + const [saveErrors, setSaveErrors] = useState([]) useEffect(() => { if (isSuccess) { setShowSuccess(true) @@ -214,6 +187,7 @@ export default function LLMAdminSettingsPage() { const handleSave = (e: React.FormEvent) => { e.preventDefault() + setSaveErrors([]) runAsync( postJSON('/admin/llm/settings', { body: { @@ -221,10 +195,13 @@ export default function LLMAdminSettingsPage() { llmApiUrl, llmApiType, llmApiKey, + clearLlmApiKey, allowedModels, - completionModel, - complianceRubrics, - reviewModel, + knownModels, + // overleaf-lab (2026-08-27): completionModel / complianceRubrics / + // reviewModel no longer sent — see the state block above. Existing + // values in the admin settings document simply stop being read by + // the lane resolution logic. maxContextTokens, reviewMaxTokens, chatEnabled, @@ -236,17 +213,29 @@ export default function LLMAdminSettingsPage() { askAiActionPrompts, }, }) - ).catch(() => { }) + ).catch((err: any) => { + // F8: show the specific validation errors when the server returns them. + const list = Array.isArray(err?.data?.errors) + ? err.data.errors.map((x: any) => `${x.field}: ${x.message}`) + : [err?.data?.error || err?.message || t('generic_something_went_wrong', 'Something went wrong')] + setSaveErrors(list) + }) } const testConnection = async () => { setTestStatus('testing') try { const resp = await postJSON('/admin/llm/settings/check', { - body: { apiUrl: llmApiUrl, apiKey: llmApiKey }, + body: { apiUrl: llmApiUrl, apiKey: llmApiKey, apiType: llmApiType }, }) if (resp.success) { setTestStatus('success') + // overleaf-lab: item 7 — test = model-list fetch: a successful test + // returns the backend's model list, so adopt it in the same round + // trip (no separate scan needed). + if (Array.isArray(resp.models)) { + setKnownModels(prev => Array.from(new Set([...prev, ...resp.models]))) + } } else { setTestStatus('error') } @@ -258,19 +247,19 @@ export default function LLMAdminSettingsPage() { const scanModels = async () => { setScanStatus('scanning') try { - const params = new URLSearchParams() - if (llmApiUrl) params.set('apiUrl', llmApiUrl) - if (llmApiKey) params.set('apiKey', llmApiKey) - const resp = await fetch(`/admin/llm/models?${params.toString()}`, { - method: 'GET', - credentials: 'same-origin', + const resp = await postJSON('/admin/llm/models', { + body: { + apiUrl: llmApiUrl, + apiKey: llmApiKey, + apiType: llmApiType, + }, }) - const json = await resp.json() - if (json.success && Array.isArray(json.models)) { - setAvailableModels(json.models) + if (resp.success && Array.isArray(resp.models)) { + setAvailableModels(resp.models) setScanStatus('success') + setKnownModels(prev => Array.from(new Set([...prev, ...resp.models]))) setAllowedModels(prev => { - const combined = new Set([...prev, ...json.models]) + const combined = new Set([...prev, ...resp.models]) return Array.from(combined) }) } else { @@ -289,28 +278,18 @@ export default function LLMAdminSettingsPage() { ) } - // overleaf-lab: compliance rubric editing helpers. Each rubric keeps a stable - // client-generated id so React keys and immutable updates stay correct. - const addRubric = () => { - const id = `rubric-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - setComplianceRubrics(prev => [...prev, { id, name: '', guidelines: '', scanPatterns: '' }]) - } + /* overleaf-lab (2026-08-27): rubric editing moved to the user settings page + (/user/llm-settings) — no admin rubric state anymore. */ - const updateRubric = (id: string, field: 'name' | 'guidelines' | 'scanPatterns', value: string) => { - setComplianceRubrics(prev => - prev.map(r => (r.id === id ? { ...r, [field]: value } : r)) - ) - } + const allModels = Array.from(new Set([...knownModels, ...availableModels, ...allowedModels])) - const removeRubric = (id: string) => { - setComplianceRubrics(prev => prev.filter(r => r.id !== id)) - } - - const allModels = Array.from(new Set([...availableModels, ...allowedModels])) -// const allModels = availableModels + // overleaf-lab (owner request 2026-08-26): admin page reorganized like the + // admin/user console — a left sidebar lists the sections, the right column + // shows the active one (CSS hides the rest via data-active/data-sec). + const [activeSection, setActiveSection] = useState('features') return ( -
+
{/* Page header */}

@@ -325,10 +304,35 @@ export default function LLMAdminSettingsPage() {

-
+ {/* overleaf-lab: sidebar navigation (admin/user-style layout) */} + + + {/* ── Section 1: Features ── */} {/* overleaf-lab: master on/off switches per AI feature */} -
+
1 @@ -347,21 +351,14 @@ export default function LLMAdminSettingsPage() { { key: 'chat', on: chatEnabled, set: setChatEnabled, title: t('feature_chat', 'Chat'), help: t('feature_chat_help', 'The AI chat panel and Ask AI on selection.') }, { key: 'completion', on: completionEnabled, set: setCompletionEnabled, title: t('feature_completion', 'Inline completion'), help: t('feature_completion_help', 'Autocomplete suggestions while typing.') }, { key: 'review', on: reviewEnabled, set: setReviewEnabled, title: t('feature_review', 'Compliance review'), help: t('feature_review_help', 'The whole-document review.') }, - ].map((f, i, arr) => ( + ].map(f => (
- {f.title} - {f.help} + {f.title} + {f.help}
@@ -370,13 +367,13 @@ export default function LLMAdminSettingsPage() {
{/* ── Section 2: API Connection ── */} -
+
2 {t('api_connection', 'API Connection')} {testStatus === 'success' && ( - + {t('connected', 'Connected')} )} @@ -402,7 +399,7 @@ export default function LLMAdminSettingsPage() { /> {apiUrlFromEnv && ( - + {t('llm_admin_from_env', 'Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.')} )} @@ -442,8 +439,7 @@ export default function LLMAdminSettingsPage() { {t( 'llm_type_from_env', @@ -453,16 +449,20 @@ export default function LLMAdminSettingsPage() { )} - + {t('llm_api_key', 'API Key')} ) => + onChange={(e: React.ChangeEvent) => { setLlmApiKey(e.target.value) - } + // overleaf-lab: typing a new key cancels a pending "remove" + if (e.target.value) { + setClearLlmApiKey(false) + } + }} placeholder={ hasStoredKey ? t('llm_api_key_placeholder_stored', '•••••••• (stored — leave blank to keep)') @@ -471,17 +471,28 @@ export default function LLMAdminSettingsPage() { /> {hasStoredKey && !llmApiKey && ( - + {t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')} )} - + {t('llm_api_key_optional_local', 'Leave blank for a local server with no auth (e.g. a llama.cpp server).')} + {hasStoredKey && ( + { + setLlmApiKey('') + setClearLlmApiKey(true) + }} + > + {t('llm_api_key_remove', 'Remove stored key')} + + )} -
+
- + {t('test_connection', 'Test Connection')} {testStatus === 'success' && ( @@ -509,13 +520,13 @@ export default function LLMAdminSettingsPage() {
{/* ── Section 3: Model Selection ── */} -
+
3 {t('model_selection', 'Model Selection')} {allModels.length > 0 && ( - + {allowedModels.filter(m => allModels.includes(m)).length}/{allModels.length} {t('selected', 'selected')} )} @@ -527,7 +538,9 @@ export default function LLMAdminSettingsPage() { )}

-
0 ? '1rem' : 0 }}> +
0 ? ' ol-llm-admin-settings__mt-lg' : ''}`} + > - + {t('scan_for_models', 'Scan for Models')} {scanStatus === 'success' && ( @@ -552,7 +565,7 @@ export default function LLMAdminSettingsPage() { )} {!canConnect && scanStatus === null && ( - + {t('configure_api_first', 'Configure the API connection above first')} )} @@ -560,26 +573,11 @@ export default function LLMAdminSettingsPage() { {allModels.length > 0 && ( <> -
- {allModels.map((model, idx) => ( +
+ {allModels.map((model) => ( ))}
-
+
setAllowedModels([...allModels])} - style={{ padding: 0, fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__link-btn" > {t('select_all', 'Select all')} - | + | setAllowedModels([])} - style={{ padding: 0, fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__link-btn" > {t('unselect_all', 'Unselect all')} @@ -619,70 +617,15 @@ export default function LLMAdminSettingsPage() { )} - {/* overleaf-lab: admin picks the single shared inline-completion model */} - 0 ? '1rem' : 0 }}> - - {t('inline_completion_model', 'Inline completion model')} - - - - - {completionModel - ? completionModel === '__disabled__' - ? t( - 'completion_disabled_shared', - 'Disabled (only users with their own API key)' - ) - : completionModel - : t('auto_first_allowed_model', 'Auto (first allowed model)')} - - - - - setCompletionModel('')} - > - {t('auto_first_allowed_model', 'Auto (first allowed model)')} - - - {/* overleaf-lab: turn off shared autocomplete; users can still use their own API key */} - setCompletionModel('__disabled__')} - > - {t( - 'completion_disabled_shared', - 'Disabled (only users with their own API key)' - )} - - - {allModels.map(model => ( - setCompletionModel(model)} - > - {model} - - ))} - - - - {t( - 'inline_completion_model_admin_help', - 'Model used for inline autocomplete on the shared backend. Can differ from the chat models. Set to Disabled to turn off shared autocomplete (users with their own API key still get it).' - )} - - + {/* overleaf-lab (2026-08-27, owner request): the admin + "Inline completion model" picker is GONE — inline + completion runs on each user's shared model selection + (profile -> first BYO row -> site default), managed by + users in File -> "Select LLM Model" and their BYO rows. */}
{/* ── Section 4: System Prompt ── */} -
+
4 @@ -695,7 +638,7 @@ export default function LLMAdminSettingsPage() { )}

- + -
- +
+ {systemPrompt.length}/4000 {t('characters', 'characters')} setSystemPrompt(DEFAULT_SYSTEM_PROMPT)} - style={{ padding: 0, fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__link-btn" > - + {t('reset_to_default', 'Reset to default')}
- {/* ── Section 5: Compliance Review ── */} -
+ {/* overleaf-lab (2026-08-27, owner request): the former admin + Compliance Review section (rubric editor + Review model picker) + is GONE. Rubrics are USER-SCOPED now: each user configures + their own under /user/llm-settings; the reviewer reads the + user's rubrics, inheriting the deployment-wide set until the + user saves their own. The review runs on the user's shared + model selection (profile -> BYO row -> site default), the + same as every other AI surface. The review's token budgets + moved to Section 5 (AI Prompts). */} + + {/* ── Section 6: AI Prompts ── */} + {/* overleaf-lab: editable prompts behind each AI feature; empty means built-in default */} +
5 - - {t('compliance_review', 'Compliance Review')} + + {t('ai_prompts', 'AI Prompts')}

{t( - 'compliance_review_desc', - 'Configure the document compliance review: the guideline rubrics users can check against, the model that runs the review, and the maximum context size.' + 'ai_prompts_desc', + 'Customize the prompts and token budgets behind each AI feature. Leave a field empty to use the built-in default.' )}

- {/* overleaf-lab: (a) rubrics editor */} - {complianceRubrics.length === 0 && ( -

- {t('no_rubrics_yet', 'No rubrics yet. Add one to enable the compliance review for users.')} -

- )} - {complianceRubrics.map(rubric => ( -
- - - {t('rubric_name', 'Rubric name')} - - ) => - updateRubric(rubric.id, 'name', e.target.value) - } - placeholder={t('rubric_name_placeholder', 'e.g. Thesis writing guidelines')} - /> - - - - {t('rubric_guidelines', 'Guidelines')} - - ) => - updateRubric(rubric.id, 'guidelines', e.target.value) - } - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} - /> - - {/* overleaf-lab: per-rubric mechanical scans; policy - patterns live next to the guidelines they verify */} - - - {t('rubric_scan_patterns', 'Scan patterns (optional, one per line)')} - - ) => - updateRubric(rubric.id, 'scanPatterns', e.target.value) - } - placeholder={'First person :: (? - - {t( - 'rubric_scan_patterns_help', - '"Label :: regex" (case-insensitive; a plain word works too). The whole source is scanned in code, exhaustively, and the matches are handed to the model as candidates to judge in context. Use it for the pattern-like requirements of THIS rubric (words to avoid, forbidden constructs), in the language of your guidelines.' - )} - - -
- removeRubric(rubric.id)} - > - - {t('remove', 'Remove')} - -
-
- ))} -
- - - {t('add_rubric', 'Add rubric')} - -
- - {t( - 'compliance_rubrics_help', - 'Paste your thesis or internship writing guidelines. The AI checks the whole document against each rubric and returns a report.' - )} - - - {/* overleaf-lab: (b) review model selector */} - + {/* overleaf-lab (2026-08-27): the compliance review's token budgets + moved here from the former admin Compliance Review section. + These are DEPLOYMENT budgets — the review runs each user's + own rubrics (user settings), but the context window and the + answer budget are site-wide. */} + - {t('review_model', 'Review model')} - - - - - {reviewModel - ? reviewModel - : t('review_model_shared_default', 'Shared chat model (default)')} - - - - - setReviewModel('')} - > - {t('review_model_shared_default', 'Shared chat model (default)')} - - - {allModels.map(model => ( - setReviewModel(model)} - > - {model} - - ))} - - - - {t( - 'review_model_help', - 'Model used to run the compliance review. Pick a large-context model. Defaults to the shared chat model.' - )} - - - - {/* overleaf-lab: (c) max context tokens */} - - - {t('max_context_tokens', 'Max context tokens')} + {t('max_context_tokens', 'Review: max context tokens')} {t( 'max_context_tokens_help', - 'The context window (in tokens) of the review model, as configured on your llama.cpp server (the -c value, divided by --parallel). The review refuses documents that would not fit. No auto-detection.' + 'The context window (in tokens) of the review model, as configured on your backend. The review refuses documents that would not fit. No auto-detection.' )} - {/* overleaf-lab: budget for the review answer itself */} - + {t('review_max_tokens', 'Review answer budget (tokens)')} @@ -930,23 +745,6 @@ export default function LLMAdminSettingsPage() { -
- - {/* ── Section 6: AI Prompts ── */} - {/* overleaf-lab: editable prompts behind each AI feature; empty means built-in default */} -
-
- 6 - - {t('ai_prompts', 'AI Prompts')} -
-

- {t( - 'ai_prompts_desc', - 'Customize the prompts behind each AI feature. Leave a field empty to use the built-in default.' - )} -

- {/* overleaf-lab: (a) the three standalone prompts, each with a reset link */} {[ { @@ -974,8 +772,8 @@ export default function LLMAdminSettingsPage() { help: t('review_system_prompt_help', 'System prompt for the whole-document compliance review.'), }, ].map(field => ( -
- +
+ {field.label} @@ -986,11 +784,11 @@ export default function LLMAdminSettingsPage() { onChange={(e: React.ChangeEvent) => field.set(e.target.value) } - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__mono" /> -
- +
+ {field.help} field.set(field.def || '')} - style={{ padding: 0, fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__link-btn" > - + {t('reset_to_default', 'Reset to default')}
@@ -1008,29 +806,32 @@ export default function LLMAdminSettingsPage() { ))} {/* overleaf-lab: (b) collapsible Ask AI action templates, one textarea per action */} -
+
setShowActions(v => !v)} - style={{ padding: 0, fontSize: '0.875rem' }} + className="ol-llm-admin-settings__small-md" > - + {t('ask_ai_action_templates', 'Ask AI action templates')} {showActions && ( -
- +
+ {t( 'ask_ai_action_help', 'Each template runs on the selected text. Use {{selection}} where the selected text should be inserted; if omitted, it is appended.' )} - {['paraphrase', 'academic', 'concise', 'punchy', 'split', 'join', 'summarize', 'explain', 'title', 'abstract'].map(key => ( -
- + {/* overleaf-lab (audit #7): only the templates the current Ask AI menu actually uses — + punchy/split/join/summarize/explain/mathFix/checkCitations were removed with the + menu rebuild, and title/abstract now flow through the generators endpoint. */} + {['paraphrase', 'academic', 'concise', 'translate', 'synonyms'].map(key => ( +
+ {t(`ask_ai_action_${key}`, key.charAt(0).toUpperCase() + key.slice(1))} @@ -1042,10 +843,10 @@ export default function LLMAdminSettingsPage() { const value = e.target.value setAskAiActionPrompts(prev => ({ ...prev, [key]: value })) }} - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__mono" /> -
+
({ ...prev, [key]: def })) }} - style={{ padding: 0, fontSize: '0.8125rem' }} + className="ol-llm-admin-settings__link-btn" > - + {t('reset_to_default', 'Reset to default')}
@@ -1069,7 +870,7 @@ export default function LLMAdminSettingsPage() { {/* ── Notifications ── */} {showSuccess && ( -
+
)} {isError && ( -
+
0 && ( + + )} - + {t('save_settings', 'Save Settings')} diff --git a/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx b/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx index b07ab844cf..fa310e0e98 100644 --- a/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' import { useLLMChat } from '../hooks/use-llm-chat' import { marked } from 'marked' +import DOMPurify from 'dompurify' import '../../stylesheets/llm-chat.scss' interface LLMChatPaneProps { @@ -23,9 +24,6 @@ const LLMChatPane = React.memo(function LLMChatPane({ stopGeneration, rerunLastMessage, clearMessages, - models, - selectedModel, - setSelectedModel, canRerun, modelsLoaded, hasModels, @@ -56,10 +54,21 @@ const LLMChatPane = React.memo(function LLMChatPane({ rerunLastMessage() } + const [armedClear, setArmedClear] = useState(false) + const armedTimer = useRef(null) + const armClear = () => { + setArmedClear(true) + if (armedTimer.current) window.clearTimeout(armedTimer.current) + armedTimer.current = window.setTimeout(() => setArmedClear(false), 3000) + } const handleClear = () => { - if (confirm(t('clear_conversation_confirm', 'Clear conversation?'))) { - clearMessages() + if (!armedClear) { + armClear() + return } + if (armedTimer.current) window.clearTimeout(armedTimer.current) + setArmedClear(false) + clearMessages() } if (modelsLoaded && !hasModels) { @@ -71,34 +80,15 @@ const LLMChatPane = React.memo(function LLMChatPane({ } const displayMessages = messages.filter(m => m.role !== 'system') - const showModelSelector = models.length > 0 return (