deeptutor-server 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2503 @@
1
+ """
2
+ Settings API Router
3
+ ===================
4
+
5
+ UI preferences, configuration catalog management, and detailed streamed tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Iterator
12
+ import contextlib
13
+ from copy import deepcopy
14
+ import json
15
+ import logging
16
+ import time
17
+ from typing import Any, List, Literal, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Query, Request, status
20
+ from fastapi.responses import Response, StreamingResponse
21
+ from pydantic import BaseModel, Field
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ from deeptutor.multi_user.context import get_current_user
26
+ from deeptutor.multi_user.model_access import allowed_llm_options
27
+ from deeptutor.services.codebuddy_auth import get_codebuddy_auth_service
28
+ from deeptutor.services.codex_auth import (
29
+ CodexAuthError,
30
+ get_codex_oauth_service,
31
+ reconcile_codex_catalog_update,
32
+ )
33
+ from deeptutor.services.config import (
34
+ CATALOG_SECRET_MASK,
35
+ get_config_test_runner,
36
+ get_model_catalog_service,
37
+ get_runtime_settings_service,
38
+ redact_catalog_secrets,
39
+ restore_catalog_secrets,
40
+ )
41
+ from deeptutor.services.config.image_description import ImageDescriptionModelSelection
42
+ from deeptutor.services.config.origins import normalize_origins
43
+ from deeptutor.services.config.runtime_settings import (
44
+ CHAT_ATTACHMENT_CHARS_RANGE,
45
+ CHAT_ATTACHMENT_MAX_FILE_MB_RANGE,
46
+ CHAT_ATTACHMENT_MAX_TOTAL_MB_RANGE,
47
+ compute_ws_max_size,
48
+ )
49
+ from deeptutor.services.config.settings_draft import (
50
+ get_settings_draft_service,
51
+ is_empty_draft,
52
+ merge_draft_secrets,
53
+ redact_draft,
54
+ )
55
+ from deeptutor.services.config.settings_presets import (
56
+ SETTINGS_PRESETS_SCHEMA_VERSION,
57
+ get_settings_preset,
58
+ list_settings_presets,
59
+ )
60
+ from deeptutor.services.config.settings_profile import (
61
+ SettingsProfileError,
62
+ export_settings_profile,
63
+ review_settings_profile_import,
64
+ )
65
+ from deeptutor.services.llm.config import clear_llm_config_cache
66
+ from deeptutor.services.model_selection import list_llm_options
67
+ from deeptutor.services.path_service import get_path_service
68
+ from deeptutor.services.session.usage_statistics import UsageStatistics
69
+ from deeptutor.services.settings.interface_settings import (
70
+ DEFAULT_UI_SETTINGS as INTERFACE_DEFAULTS,
71
+ )
72
+ from deeptutor.services.settings.interface_settings import (
73
+ UiLanguage,
74
+ atomic_update,
75
+ resolve_languages,
76
+ sanitize_enabled_tools,
77
+ )
78
+ from deeptutor.services.settings.interface_settings import (
79
+ get_enabled_optional_tools as _get_enabled_optional_tools,
80
+ )
81
+ from deeptutor.services.settings.page_customization import (
82
+ ChatPageCustomization,
83
+ LearningPageCustomization,
84
+ PageCustomization,
85
+ )
86
+ from deeptutor.services.settings.starter_settings import (
87
+ TRACE_COUNT_RANGE as STARTER_TRACE_COUNT_RANGE,
88
+ )
89
+ from deeptutor.tools.builtin import USER_TOGGLEABLE_TOOL_NAMES
90
+
91
+ router = APIRouter()
92
+ # Public UI-settings router. The app shell bootstraps the interface language
93
+ # from GET /api/settings/ui, and auth pages (/register, /login) must be
94
+ # able to do the same *before* a session exists — so this one read endpoint
95
+ # is intentionally mounted outside the ``_auth`` dependency (see main.py).
96
+ # It only exposes non-sensitive UI preferences (theme/language), never the
97
+ # model catalog, provider credentials, or runtime configuration.
98
+ public_router = APIRouter()
99
+
100
+ TOUR_CACHE = None
101
+
102
+ # Reader-facing model output supports more languages than the interface.
103
+ ResponseLanguage = Literal[
104
+ "en",
105
+ "zh",
106
+ "zh-tw",
107
+ "ja",
108
+ "ko",
109
+ "es",
110
+ "fr",
111
+ "de",
112
+ "ru",
113
+ "pt",
114
+ "it",
115
+ "ar",
116
+ "pl",
117
+ "uk",
118
+ "ms",
119
+ ]
120
+
121
+
122
+ def get_enabled_optional_tools() -> list[str]:
123
+ """Compatibility export; the source of truth lives in the service layer."""
124
+
125
+ return _get_enabled_optional_tools()
126
+
127
+
128
+ def _settings_file():
129
+ return get_path_service().get_settings_file("interface")
130
+
131
+
132
+ def _tour_cache_file():
133
+ if TOUR_CACHE is not None:
134
+ return TOUR_CACHE
135
+ return get_path_service().get_settings_dir() / ".tour_cache.json"
136
+
137
+
138
+ DEFAULT_SIDEBAR_NAV_ORDER = {
139
+ "start": ["/", "/history", "/knowledge-bases", "/notebooks"],
140
+ "learnResearch": ["/question", "/solver", "/research", "/co_writer"],
141
+ }
142
+
143
+ DEFAULT_UI_SETTINGS = {
144
+ # theme / language / response_language come from the module that owns
145
+ # interface.json, so the two readers of that file can't drift on what a
146
+ # fresh install defaults to.
147
+ **INTERFACE_DEFAULTS,
148
+ "sidebar_description": "✨ Data Intelligence Lab @ HKU",
149
+ "sidebar_nav_order": DEFAULT_SIDEBAR_NAV_ORDER,
150
+ # User-toggleable chat tools. Default = all on; the /settings/tools page
151
+ # is the single switchboard. Removed names (e.g. tools that ship later
152
+ # and the user hasn't seen yet) are ignored on read; missing names from a
153
+ # legacy file fall back to the default (all on).
154
+ "enabled_optional_tools": list(USER_TOGGLEABLE_TOOL_NAMES),
155
+ # When true, chat auto-plays each assistant reply via TTS. Per-user UI
156
+ # preference (not catalog); the chat surface also keeps a per-session
157
+ # override on top of this global default.
158
+ "voice_autoplay": False,
159
+ # When true, TTS verbalizes LaTeX into spoken math. When false, formulas
160
+ # are unwrapped from $ / $$ only. Default true (matches INTERFACE_DEFAULTS).
161
+ "voice_math_speak": True,
162
+ # Seconds the chat UI waits for any turn event before declaring the
163
+ # connection timed out. Bumped from 60 → 180 so slow tools (image/video
164
+ # generation) don't trip it; user-adjustable in Settings > Network.
165
+ "chat_response_timeout": 180,
166
+ }
167
+
168
+ # Bounds for the chat idle timeout (seconds): long enough for video renders,
169
+ # capped so a typo can't wedge a turn open forever.
170
+ CHAT_RESPONSE_TIMEOUT_MIN = 30
171
+ CHAT_RESPONSE_TIMEOUT_MAX = 1800
172
+
173
+
174
+ class SidebarNavOrder(BaseModel):
175
+ start: List[str]
176
+ learnResearch: List[str]
177
+
178
+
179
+ class UISettings(BaseModel):
180
+ theme: Literal["light", "dark", "glass", "snow"] = "snow"
181
+ language: UiLanguage = "en"
182
+ response_language: ResponseLanguage = "en"
183
+ sidebar_description: Optional[str] = None
184
+ sidebar_nav_order: Optional[SidebarNavOrder] = None
185
+ code_block_theme: Optional[str] = None
186
+ code_block_show_line_numbers: Optional[bool] = None
187
+ code_block_wrap_long_lines: Optional[bool] = None
188
+
189
+
190
+ class UISettingsUpdate(BaseModel):
191
+ """Partial UI settings for user-initiated PATCH/PUT updates via /api/settings/ui.
192
+
193
+ All fields have None defaults so `model_dump(exclude_unset=True)` naturally
194
+ excludes fields not provided in the frontend payload, while explicitly provided
195
+ defaults (e.g., `theme: "snow"`) still update the backend. This separates
196
+ the semantic contract: `/ui` endpoint only merges whatever explicitly arrives
197
+ from the frontend.
198
+ """
199
+
200
+ # Same Literal domains as UISettings — a None default keeps them optional
201
+ # for exclude_unset partial merges, but an explicit value is still validated
202
+ # so PUT /ui cannot persist a theme/language the app can't render.
203
+ theme: Literal["light", "dark", "glass", "snow"] | None = None
204
+ language: UiLanguage | None = None
205
+ response_language: ResponseLanguage | None = None
206
+ sidebar_description: str | None = None
207
+ sidebar_nav_order: SidebarNavOrder | None = None
208
+ code_block_theme: str | None = None
209
+ code_block_show_line_numbers: bool | None = None
210
+ code_block_wrap_long_lines: bool | None = None
211
+
212
+
213
+ class VoiceAutoplayUpdate(BaseModel):
214
+ voice_autoplay: bool
215
+
216
+
217
+ class VoiceMathSpeakUpdate(BaseModel):
218
+ voice_math_speak: bool
219
+
220
+
221
+ class ChatResponseTimeoutUpdate(BaseModel):
222
+ chat_response_timeout: int = Field(ge=CHAT_RESPONSE_TIMEOUT_MIN, le=CHAT_RESPONSE_TIMEOUT_MAX)
223
+
224
+
225
+ class ThemeUpdate(BaseModel):
226
+ theme: Literal["light", "dark", "glass", "snow"]
227
+
228
+
229
+ class LanguageUpdate(BaseModel):
230
+ language: UiLanguage
231
+
232
+
233
+ class SidebarDescriptionUpdate(BaseModel):
234
+ description: str
235
+
236
+
237
+ class SidebarNavOrderUpdate(BaseModel):
238
+ nav_order: SidebarNavOrder
239
+
240
+
241
+ class EnabledToolsUpdate(BaseModel):
242
+ enabled_tools: List[str]
243
+
244
+
245
+ class VoicePreviewPayload(BaseModel):
246
+ catalog: dict[str, Any]
247
+ profile_id: str
248
+ model_id: str
249
+ text: str = Field(min_length=1, max_length=500)
250
+
251
+
252
+ class VoiceDiscoveryPayload(BaseModel):
253
+ catalog: dict[str, Any]
254
+ profile_id: str
255
+ model_id: str
256
+
257
+
258
+ class ServicePreviewPayload(BaseModel):
259
+ catalog: dict[str, Any]
260
+ profile_id: str
261
+ model_id: str | None = None
262
+ text: str = Field(default="", max_length=2000)
263
+ audio: str = Field(default="", max_length=11184812)
264
+ content_type: str = ""
265
+
266
+
267
+ class CatalogPayload(BaseModel):
268
+ catalog: dict[str, Any]
269
+
270
+
271
+ class ProviderEditPayload(BaseModel):
272
+ service: Literal["llm", "task", "embedding", "search", "tts", "stt", "imagegen", "videogen"]
273
+ profile: dict[str, Any]
274
+ connection: dict[str, Any] | None = None
275
+ active_model_id: str | None = None
276
+ activate: bool = False
277
+
278
+
279
+ class RegistryEditPayload(BaseModel):
280
+ kind: Literal["provider", "model", "default", "task_choice"]
281
+ task: dict[str, Any] | None = None
282
+ ref: dict[str, Any] | None = None
283
+ fields: dict[str, Any] | None = None
284
+ service: (
285
+ Literal["llm", "task", "embedding", "search", "tts", "stt", "imagegen", "videogen"] | None
286
+ ) = None
287
+ profile_id: str | None = None
288
+ model_id: str | None = None
289
+ model: dict[str, Any] | None = None
290
+ config: dict[str, Any] | None = None
291
+ delete: bool = False
292
+
293
+
294
+ class CatalogServicePayload(BaseModel):
295
+ """One model-catalog service to promote without touching other drafts."""
296
+
297
+ service: Literal[
298
+ "llm",
299
+ "task",
300
+ "embedding",
301
+ "search",
302
+ "tts",
303
+ "stt",
304
+ "imagegen",
305
+ "videogen",
306
+ ]
307
+ config: dict[str, Any]
308
+
309
+
310
+ class SettingsDraftPayload(BaseModel):
311
+ """The unapplied settings envelope, as the settings UI holds it."""
312
+
313
+ catalog: dict[str, Any] | None = None
314
+ # Opaque per-page state, keyed by the string the page registers with.
315
+ extensions: dict[str, Any] = Field(default_factory=dict)
316
+
317
+
318
+ class SettingsProfileImportPayload(BaseModel):
319
+ """An exported value-free settings profile submitted for review only."""
320
+
321
+ schema_version: str
322
+ profile: dict[str, Any]
323
+
324
+
325
+ class SettingsPresetDraftRequest(SettingsDraftPayload):
326
+ """The current draft envelope submitted alongside a named preset."""
327
+
328
+
329
+ class CodexReasoningEffortUpdate(BaseModel):
330
+ model: str = Field(min_length=1)
331
+ reasoning_effort: str | None = None
332
+
333
+
334
+ class FetchModelsPayload(BaseModel):
335
+ binding: str = ""
336
+ base_url: str = ""
337
+ api_key: Optional[str] = None
338
+ profile_id: Optional[str] = None
339
+ # Which LLM-shaped service the profile lives in (for resolving a masked key).
340
+ service: Literal["llm", "task"] = "llm"
341
+ # The profile's API format; decides whether /models takes Anthropic headers.
342
+ api_format: Optional[str] = None
343
+
344
+
345
+ class ModelCapabilitiesQuery(BaseModel):
346
+ binding: str = ""
347
+ model: str = ""
348
+
349
+
350
+ class ProviderProbePayload(BaseModel):
351
+ binding: str = "openai"
352
+ base_url: str = ""
353
+ api_key: str | list[str] | None = None
354
+ api_format: str = "auto"
355
+ api_version: str = ""
356
+ extra_headers: dict[str, str] | str | None = None
357
+ proxy: str = ""
358
+ service: Literal["llm", "task", "embedding", "search", "tts", "stt", "imagegen", "videogen"] = (
359
+ "llm"
360
+ )
361
+ profile_id: str | None = None
362
+ connection_id: str | None = None
363
+
364
+
365
+ class NetworkSettingsUpdate(BaseModel):
366
+ backend_port: int = Field(ge=1, le=65535)
367
+ frontend_port: int = Field(ge=1, le=65535)
368
+ public_api_base: str = ""
369
+ cors_origins: list[str] = Field(default_factory=list)
370
+
371
+
372
+ class ChatAttachmentSettingsUpdate(BaseModel):
373
+ """Chat attachment policy (size caps + extraction budgets).
374
+
375
+ Bounds mirror the normalization clamps in
376
+ ``runtime_settings.CHAT_ATTACHMENT_*_RANGE`` so the API rejects loudly
377
+ what the file layer would silently clamp.
378
+ """
379
+
380
+ max_file_mb: int = Field(
381
+ ge=CHAT_ATTACHMENT_MAX_FILE_MB_RANGE[0], le=CHAT_ATTACHMENT_MAX_FILE_MB_RANGE[1]
382
+ )
383
+ max_total_mb: int = Field(
384
+ ge=CHAT_ATTACHMENT_MAX_TOTAL_MB_RANGE[0], le=CHAT_ATTACHMENT_MAX_TOTAL_MB_RANGE[1]
385
+ )
386
+ max_chars_per_doc: int = Field(
387
+ ge=CHAT_ATTACHMENT_CHARS_RANGE[0], le=CHAT_ATTACHMENT_CHARS_RANGE[1]
388
+ )
389
+ max_chars_total: int = Field(
390
+ ge=CHAT_ATTACHMENT_CHARS_RANGE[0], le=CHAT_ATTACHMENT_CHARS_RANGE[1]
391
+ )
392
+
393
+
394
+ class ChatStarterSettingsUpdate(BaseModel):
395
+ """How much recent activity shapes the home screen's starting points.
396
+
397
+ Bounds mirror ``starter_settings.TRACE_COUNT_RANGE`` so the API rejects
398
+ loudly what the file layer would silently clamp.
399
+ """
400
+
401
+ trace_count: int = Field(ge=STARTER_TRACE_COUNT_RANGE[0], le=STARTER_TRACE_COUNT_RANGE[1])
402
+
403
+
404
+ class MinerUSettingsUpdate(BaseModel):
405
+ """MinerU document-parsing backend settings.
406
+
407
+ ``api_token`` is tri-state: ``None`` keeps the stored token (the UI sends
408
+ None when the user didn't edit the secret field), ``""`` clears it, and a
409
+ non-empty string or string array replaces it. The GET payload never echoes
410
+ the raw token.
411
+ """
412
+
413
+ mode: Literal["local", "cloud"] = "local"
414
+ api_base_url: str = "https://mineru.net"
415
+ api_token: Optional[str | list[str]] = None
416
+ local_cli_path: str = ""
417
+ model_download_source: Literal["huggingface", "modelscope"] = "huggingface"
418
+ model_download_endpoint: str = ""
419
+ model_version: Literal["pipeline", "vlm"] = "pipeline"
420
+ language: str = "auto"
421
+ enable_formula: bool = True
422
+ enable_table: bool = True
423
+ is_ocr: bool = False
424
+ # Off by default → a local parse fails fast rather than silently pulling
425
+ # multi-GB model weights on first run.
426
+ allow_local_model_download: bool = False
427
+
428
+
429
+ class MinerUModelDownloadPayload(BaseModel):
430
+ """One-click model download request (draft form values, like /test)."""
431
+
432
+ model_type: Literal["pipeline", "vlm", "all"] = "pipeline"
433
+ source: Literal["huggingface", "modelscope"] = "huggingface"
434
+ endpoint: str = ""
435
+ local_cli_path: str = ""
436
+
437
+
438
+ class DocumentParsingUpdate(BaseModel):
439
+ """Document-parsing settings update (the multi-engine control panel).
440
+
441
+ ``engine`` (when provided) switches the active parse engine. ``engines``
442
+ carries partial per-engine updates merged over the stored slices. For the
443
+ MinerU engine, ``api_token`` stays tri-state: omit it (or send ``None``) to
444
+ keep the stored token, ``""`` clears it, a non-empty string replaces it.
445
+ The MinerU engine's own knobs can also be edited via the legacy
446
+ ``/mineru`` endpoints; both preserve the other engines' settings.
447
+ """
448
+
449
+ engine: Optional[str] = None
450
+ engines: Optional[dict[str, dict]] = None
451
+ # Toggle for vision-model captions of embedded images (None = keep stored).
452
+ image_caption: Optional[bool] = None
453
+ # Omit to keep the selection; null restores the main LLM.
454
+ image_description_model: Optional[ImageDescriptionModelSelection] = None
455
+
456
+
457
+ class DocumentParsingTest(BaseModel):
458
+ """Readiness test for one engine (defaults to the active engine)."""
459
+
460
+ engine: Optional[str] = None
461
+
462
+
463
+ class DoclingRemoteTest(BaseModel):
464
+ """Draft Docling remote-server test. ``api_token`` is tri-state: ``None``
465
+ falls back to the stored key, ``""`` clears it, a string supplies it (so
466
+ the user can verify an unsaved key before saving)."""
467
+
468
+ api_base_url: str = "http://localhost:5001"
469
+ api_token: Optional[str] = None
470
+
471
+
472
+ class TikaRemoteTest(BaseModel):
473
+ """Draft Tika server test. Tests the unsaved URL so the user can verify
474
+ before saving."""
475
+
476
+ server_url: str = "http://localhost:9998"
477
+
478
+
479
+ class DocumentParsingInstall(BaseModel):
480
+ """One-click pip install of an optional parser engine's package(s)."""
481
+
482
+ engine: str
483
+
484
+
485
+ @contextlib.contextmanager
486
+ def _runtime_catalog_write() -> Iterator[None]:
487
+ """Bracket a write to the model catalog, resetting clients if it landed.
488
+
489
+ The LLM and embedding clients are process-wide singletons that resolve
490
+ from this one file, so resetting them affects any user turn mid-flight on
491
+ another worker; admins issuing Apply during active sessions accept that,
492
+ and the WARNING keeps the cause in the audit trail.
493
+
494
+ An apply that saved the catalog already on disk changes nothing those
495
+ clients would read. That is the shape a settings visit re-applying the
496
+ same configuration takes, and each redundant reset flipped the backend
497
+ client out from under an in-flight turn and dropped it into the provider
498
+ retry ladder — the cold start whose first message sat in a loop until a
499
+ restart (#1421).
500
+
501
+ Whether the catalog changed is a property of the file, not of the caller,
502
+ so both sides are read here. Five endpoints each assembling their own
503
+ before/after pair is five chances to pair the wrong two and a sixth
504
+ endpoint's chance to forget; wrapping the write is the whole contract.
505
+
506
+ A write that raises leaves the block without resetting, which is what a
507
+ failed write should do: there is nothing new for the clients to read.
508
+ """
509
+ service = get_model_catalog_service()
510
+ before = service.load()
511
+ try:
512
+ yield
513
+ except PermissionError as exc:
514
+ # #1984: filesystem denial is not an authentication failure. Preserve
515
+ # the atomic writer's failure boundary and the unapplied draft; do not
516
+ # blindly retry a stale catalog over another writer's newer settings.
517
+ logger.exception("Filesystem access denied while saving the model catalog")
518
+ raise HTTPException(
519
+ status_code=503,
520
+ detail=(
521
+ "The server could not save model settings because filesystem access was denied. "
522
+ "Check the server service account's permissions on the settings folder and "
523
+ "whether another process or security software is holding model_catalog.json, "
524
+ "then retry."
525
+ ),
526
+ ) from exc
527
+ if service.load() == before:
528
+ return
529
+ logger.warning(
530
+ "Admin applied catalog; resetting global LLM/embedding clients. "
531
+ "In-flight user turns may flip backend client mid-call."
532
+ )
533
+ from deeptutor.services.embedding.client import reset_embedding_client
534
+ from deeptutor.services.llm.client import reset_llm_client
535
+
536
+ clear_llm_config_cache()
537
+ reset_llm_client()
538
+ reset_embedding_client()
539
+
540
+
541
+ def load_ui_settings() -> dict[str, Any]:
542
+ settings_file = _settings_file()
543
+ if settings_file.exists():
544
+ try:
545
+ with open(settings_file, encoding="utf-8") as handle:
546
+ saved = json.load(handle)
547
+ # resolve_languages owns the legacy migration (a file predating
548
+ # the UI/response split inherits its one language into both).
549
+ merged = {**DEFAULT_UI_SETTINGS, **saved, **resolve_languages(saved)}
550
+ # Filter persisted enabled_optional_tools to current
551
+ # toggleable set so retired tool names can't leak into
552
+ # the per-turn payload.
553
+ merged["enabled_optional_tools"] = sanitize_enabled_tools(
554
+ merged.get("enabled_optional_tools")
555
+ )
556
+ return merged
557
+ except Exception:
558
+ pass
559
+ return DEFAULT_UI_SETTINGS.copy()
560
+
561
+
562
+ def save_ui_settings(settings: dict[str, Any]) -> None:
563
+ """Replace the whole stored UI settings document.
564
+
565
+ Writes through ``atomic_update`` rather than opening the file here, so this
566
+ module and the setup capability — which both write ``interface.json`` —
567
+ share one lock and one atomic replace. A lock held by only one of two
568
+ writers protects nothing: measured with the old direct write, six concurrent
569
+ router saves alongside six capability writes lost every one of the router's.
570
+
571
+ Prefer :func:`patch_ui_settings` for changing individual fields. This
572
+ replaces the whole document, so a caller that builds it from
573
+ ``load_ui_settings`` writes the merged defaults back as stored values and
574
+ stops following later changes to any of them.
575
+ """
576
+ payload = dict(settings)
577
+ atomic_update(_settings_file(), lambda _stored: payload)
578
+
579
+
580
+ def patch_ui_settings(**fields: Any) -> None:
581
+ """Change individual UI fields without rewriting the rest of the document."""
582
+ atomic_update(_settings_file(), lambda stored: {**stored, **fields})
583
+
584
+
585
+ def _require_settings_admin() -> None:
586
+ if not get_current_user().is_admin:
587
+ raise HTTPException(
588
+ status_code=status.HTTP_403_FORBIDDEN,
589
+ detail="Model configuration is managed by an administrator.",
590
+ )
591
+
592
+
593
+ def _require_codex_oauth_actor() -> None:
594
+ """Gate the Codex OAuth lifecycle: personal, not administrative.
595
+
596
+ Every one of these endpoints acts on the *caller's own* credentials —
597
+ ``get_codex_oauth_service()`` resolves the store, the model catalog, and
598
+ the callback route from owner scope — so requiring an administrator was
599
+ what left ordinary users unable to use Codex at all: an owner-bound
600
+ profile is (correctly) never grantable, and they could not sign in for
601
+ themselves either (#781).
602
+
603
+ A partner is refused: it is a synthetic user whose owner is a real
604
+ account, so letting one in would mean acting on that person's login —
605
+ including signing them out. Partners inherit the owner's login at call
606
+ time and need no lifecycle of their own.
607
+ """
608
+ from deeptutor.core.identities import is_partner_user_id
609
+
610
+ if is_partner_user_id(get_current_user().id):
611
+ raise HTTPException(
612
+ status_code=status.HTTP_403_FORBIDDEN,
613
+ detail="A partner uses the Codex login of the account that owns it.",
614
+ )
615
+
616
+
617
+ def _codex_http_exception(error: CodexAuthError) -> HTTPException:
618
+ return HTTPException(
619
+ status_code=error.http_status,
620
+ detail={
621
+ "code": error.code,
622
+ "message": error.public_message,
623
+ },
624
+ )
625
+
626
+
627
+ def _provider_choices() -> dict[str, list[dict[str, Any]]]:
628
+ """Build dropdown options for provider selection, keyed by service type."""
629
+ from deeptutor.services.config.provider_runtime import (
630
+ DEPRECATED_SEARCH_PROVIDERS,
631
+ EMBEDDING_PROVIDERS,
632
+ IMAGEGEN_PROVIDERS,
633
+ SEARCH_PROVIDERS,
634
+ STT_PROVIDERS,
635
+ TTS_PROVIDERS,
636
+ VIDEOGEN_PROVIDERS,
637
+ )
638
+ from deeptutor.services.provider_registry import PROVIDERS
639
+
640
+ llm = sorted(
641
+ [
642
+ {
643
+ "value": s.name,
644
+ "label": (
645
+ "Custom (OpenAI API)"
646
+ if s.name == "custom"
647
+ else "Custom (Anthropic API)"
648
+ if s.name == "custom_anthropic"
649
+ else s.label
650
+ ),
651
+ "base_url": s.default_api_base,
652
+ "auth_mode": s.auth_mode,
653
+ "supports_wire_api_selection": s.supports_wire_api_selection,
654
+ # Which protocols a profile on this vendor may pick, and the
655
+ # vendor endpoint each one lives at when that differs.
656
+ "api_formats": list(s.api_formats),
657
+ "default_api_format": s.default_api_format,
658
+ "base_urls": {
659
+ api_format: s.default_api_base_for(api_format)
660
+ for api_format in s.api_formats
661
+ if s.default_api_base_for(api_format)
662
+ },
663
+ # Legacy entries stay resolvable for stored catalogs but are
664
+ # not offered for new profiles; the same thing is expressed
665
+ # today as a provider plus an API format.
666
+ "status": "legacy" if s.is_legacy else "supported",
667
+ }
668
+ for s in PROVIDERS
669
+ ],
670
+ key=lambda p: p["label"].lower(),
671
+ )
672
+ embedding = sorted(
673
+ [
674
+ {
675
+ "value": name,
676
+ "label": spec.label,
677
+ "base_url": spec.default_api_base,
678
+ "default_dim": str(spec.default_dim) if spec.default_dim else "",
679
+ }
680
+ for name, spec in EMBEDDING_PROVIDERS.items()
681
+ if name != "custom_openai_sdk"
682
+ ],
683
+ key=lambda p: p["label"].lower(),
684
+ )
685
+ # Derived from SEARCH_PROVIDERS so the dropdown, the connection-field form
686
+ # and the provider warnings the web app renders all follow the backend spec
687
+ # table. No search provider ships a default base_url — only SearXNG takes
688
+ # one, and it is the user's own instance.
689
+ search = [
690
+ {
691
+ "value": name,
692
+ "label": spec.label,
693
+ "base_url": "",
694
+ "requires_api_key": spec.requires_api_key,
695
+ "requires_base_url": spec.requires_base_url,
696
+ "soft_fallback": spec.soft_fallback,
697
+ "status": "supported",
698
+ }
699
+ for name, spec in SEARCH_PROVIDERS.items()
700
+ ]
701
+ # Retired providers ride along marked rather than offered, so a stale
702
+ # catalog can be told apart from a typo without a second name table in the
703
+ # web app. The dropdown filters them out; only the warning text uses them.
704
+ search += [
705
+ {
706
+ "value": name,
707
+ "label": name,
708
+ "base_url": "",
709
+ "requires_api_key": False,
710
+ "requires_base_url": False,
711
+ "soft_fallback": True,
712
+ "status": "deprecated",
713
+ }
714
+ for name in sorted(DEPRECATED_SEARCH_PROVIDERS)
715
+ ]
716
+ from deeptutor.services.voice.options import voice_options
717
+
718
+ tts = sorted(
719
+ [
720
+ {
721
+ "value": name,
722
+ "label": spec.label,
723
+ "base_url": spec.default_api_base,
724
+ "default_model": spec.default_model,
725
+ "default_voice": spec.default_voice,
726
+ "voice_options": voice_options(name, "tts"),
727
+ }
728
+ for name, spec in TTS_PROVIDERS.items()
729
+ ],
730
+ key=lambda p: p["label"].lower(),
731
+ )
732
+ stt = sorted(
733
+ [
734
+ {
735
+ "value": name,
736
+ "label": spec.label,
737
+ "base_url": spec.default_api_base,
738
+ "default_model": spec.default_model,
739
+ "voice_options": voice_options(name, "stt"),
740
+ }
741
+ for name, spec in STT_PROVIDERS.items()
742
+ ],
743
+ key=lambda p: p["label"].lower(),
744
+ )
745
+ imagegen = sorted(
746
+ [
747
+ {
748
+ "value": name,
749
+ "label": spec.label,
750
+ "base_url": spec.default_api_base,
751
+ "default_model": spec.default_model,
752
+ }
753
+ for name, spec in IMAGEGEN_PROVIDERS.items()
754
+ ],
755
+ key=lambda p: p["label"].lower(),
756
+ )
757
+ videogen = sorted(
758
+ [
759
+ {
760
+ "value": name,
761
+ "label": spec.label,
762
+ "base_url": spec.default_api_base,
763
+ "default_model": spec.default_model,
764
+ }
765
+ for name, spec in VIDEOGEN_PROVIDERS.items()
766
+ ],
767
+ key=lambda p: p["label"].lower(),
768
+ )
769
+ return {
770
+ "llm": llm,
771
+ # Same shape, same vendors: the task service stands in for the LLM.
772
+ "task": llm,
773
+ "embedding": embedding,
774
+ "search": search,
775
+ "tts": tts,
776
+ "stt": stt,
777
+ "imagegen": imagegen,
778
+ "videogen": videogen,
779
+ }
780
+
781
+
782
+ def _match_service_provider(
783
+ provider: str,
784
+ table: dict[str, Any],
785
+ ) -> tuple[str, Any] | None:
786
+ """Find *provider*'s entry in one service's provider table.
787
+
788
+ Vendors are not named identically across tables — the LLM registry calls
789
+ Alibaba's endpoint ``dashscope`` while the embedding table calls it
790
+ ``aliyun`` — so an exact key miss falls back to the spec's own keywords
791
+ rather than to a second hand-maintained name map.
792
+ """
793
+ if provider in table:
794
+ return provider, table[provider]
795
+ for name, spec in table.items():
796
+ if provider in getattr(spec, "keywords", ()):
797
+ return name, spec
798
+ return None
799
+
800
+
801
+ def _connection_targets() -> list[dict[str, Any]]:
802
+ """Which services one vendor credential can configure, and with what.
803
+
804
+ The connection UI needs to answer "if I paste an OpenRouter key here, what
805
+ does it get me?" — so this joins the six per-service provider tables on the
806
+ vendor and reports, per service, the provider value and the prefills a
807
+ profile created from that connection should start with. Derived rather than
808
+ duplicated: adding a vendor to a service table is enough to make it
809
+ connectable, and the web app never keeps a second copy of the tables.
810
+ """
811
+ from deeptutor.services.config.provider_runtime import (
812
+ EMBEDDING_PROVIDERS,
813
+ IMAGEGEN_PROVIDERS,
814
+ STT_PROVIDERS,
815
+ TTS_PROVIDERS,
816
+ VIDEOGEN_PROVIDERS,
817
+ )
818
+ from deeptutor.services.provider_registry import PROVIDERS
819
+
820
+ service_tables: dict[str, dict[str, Any]] = {
821
+ "embedding": {k: v for k, v in EMBEDDING_PROVIDERS.items() if k != "custom_openai_sdk"},
822
+ "tts": TTS_PROVIDERS,
823
+ "stt": STT_PROVIDERS,
824
+ "imagegen": IMAGEGEN_PROVIDERS,
825
+ "videogen": VIDEOGEN_PROVIDERS,
826
+ }
827
+
828
+ targets: list[dict[str, Any]] = []
829
+ for spec in PROVIDERS:
830
+ # OAuth vendors sign in through their own flow; there is no key to
831
+ # share, so offering them here would promise something untrue.
832
+ if spec.is_oauth:
833
+ continue
834
+ services: dict[str, dict[str, Any]] = {
835
+ "llm": {
836
+ "provider": spec.name,
837
+ "base_url": spec.default_api_base,
838
+ "default_model": "",
839
+ }
840
+ }
841
+ for service_name, table in service_tables.items():
842
+ match = _match_service_provider(spec.name, table)
843
+ if match is None:
844
+ continue
845
+ name, service_spec = match
846
+ entry: dict[str, Any] = {
847
+ "provider": name,
848
+ "base_url": service_spec.default_api_base,
849
+ "default_model": service_spec.default_model,
850
+ }
851
+ if service_name == "embedding":
852
+ entry["default_dim"] = (
853
+ str(service_spec.default_dim) if service_spec.default_dim else ""
854
+ )
855
+ if service_name == "tts":
856
+ entry["default_voice"] = service_spec.default_voice
857
+ services[service_name] = entry
858
+ targets.append(
859
+ {
860
+ "provider": spec.name,
861
+ "label": (
862
+ "Custom (OpenAI API)"
863
+ if spec.name == "custom"
864
+ else "Custom (Anthropic API)"
865
+ if spec.name == "custom_anthropic"
866
+ else spec.label
867
+ ),
868
+ "default_base_url": spec.default_api_base,
869
+ "services": services,
870
+ }
871
+ )
872
+ return sorted(targets, key=lambda item: str(item["label"]).lower())
873
+
874
+
875
+ def _api_base_source(system: dict[str, Any]) -> str:
876
+ if system.get("next_public_api_base_external"):
877
+ return "next_public_api_base_external"
878
+ if system.get("next_public_api_base"):
879
+ return "next_public_api_base"
880
+ return "default_backend_url"
881
+
882
+
883
+ def _network_settings_payload() -> dict[str, Any]:
884
+ service = get_runtime_settings_service()
885
+ file_system = service.load_system(include_process_overrides=False)
886
+ effective_system = service.load_system(include_process_overrides=True)
887
+ auth = service.load_auth(include_process_overrides=True)
888
+ backend_url = f"http://localhost:{effective_system['backend_port']}"
889
+ browser_api_base = (
890
+ effective_system["next_public_api_base_external"]
891
+ or effective_system["next_public_api_base"]
892
+ or backend_url
893
+ )
894
+ cors_origins = normalize_origins(
895
+ [effective_system["cors_origin"], effective_system["cors_origins"]]
896
+ )
897
+ auth_enabled = bool(auth["enabled"])
898
+ cookie_secure = bool(auth["cookie_secure"])
899
+ return {
900
+ "settings": {
901
+ "backend_port": file_system["backend_port"],
902
+ "frontend_port": file_system["frontend_port"],
903
+ "public_api_base": file_system["next_public_api_base_external"],
904
+ "cors_origins": normalize_origins(
905
+ [file_system["cors_origin"], file_system["cors_origins"]]
906
+ ),
907
+ },
908
+ "effective": {
909
+ "backend_url": backend_url,
910
+ "frontend_url": f"http://localhost:{effective_system['frontend_port']}",
911
+ "browser_api_base": browser_api_base,
912
+ "api_base_source": _api_base_source(effective_system),
913
+ "cors_mode": "explicit" if auth_enabled else "permissive",
914
+ "cors_origins": cors_origins,
915
+ "allow_remote_http_origins": not auth_enabled,
916
+ },
917
+ "auth": {
918
+ "enabled": auth_enabled,
919
+ "cookie_secure": cookie_secure,
920
+ "cookie_samesite": "none" if cookie_secure else "lax",
921
+ "cross_site_cookie_ready": bool(auth_enabled and cookie_secure),
922
+ },
923
+ "restart_required": True,
924
+ }
925
+
926
+
927
+ @router.get("")
928
+ async def get_settings():
929
+ user = get_current_user()
930
+ if not user.is_admin:
931
+ # Non-admins never see the catalog (provider URLs/keys); their model
932
+ # choices come from /settings/llm-options (grant-filtered).
933
+ return {"ui": load_ui_settings()}
934
+ from deeptutor.services.model_selection.tasks import task_kind_payload
935
+
936
+ return {
937
+ "ui": load_ui_settings(),
938
+ "catalog": redact_catalog_secrets(get_model_catalog_service().load()),
939
+ "providers": _provider_choices(),
940
+ "connection_targets": _connection_targets(),
941
+ # What actually runs on the background task model. The list comes from
942
+ # the call sites themselves (one TaskKind each), so the task models page
943
+ # cannot list a task DeepTutor no longer makes — or miss a new one.
944
+ "task_kinds": task_kind_payload(),
945
+ }
946
+
947
+
948
+ @router.post("/providers/openai-codex/oauth/start")
949
+ async def start_openai_codex_oauth() -> dict[str, Any]:
950
+ _require_codex_oauth_actor()
951
+ try:
952
+ return await get_codex_oauth_service().start_login()
953
+ except CodexAuthError as exc:
954
+ raise _codex_http_exception(exc) from None
955
+
956
+
957
+ @router.get("/providers/openai-codex/oauth/status")
958
+ async def get_openai_codex_oauth_status() -> dict[str, Any]:
959
+ _require_codex_oauth_actor()
960
+ try:
961
+ return get_codex_oauth_service().public_status()
962
+ except CodexAuthError as exc:
963
+ raise _codex_http_exception(exc) from None
964
+
965
+
966
+ class CodexOAuthCallbackPayload(BaseModel):
967
+ callback_url: str
968
+
969
+
970
+ @router.post("/providers/openai-codex/oauth/complete")
971
+ async def complete_openai_codex_oauth(payload: CodexOAuthCallbackPayload) -> dict[str, Any]:
972
+ """Finish a waiting Codex login from a callback address the user pasted.
973
+
974
+ The provider redirects the browser to a loopback listener. In Docker that
975
+ listener lives in the container and the published ports do not include it,
976
+ so the browser shows a failed page while the sign-in waits forever
977
+ (#1252). This is the way back in without a tunnel: the address is parsed
978
+ for its OAuth result and discarded, and the exchange is the same one the
979
+ listener would have driven.
980
+ """
981
+ _require_codex_oauth_actor()
982
+ try:
983
+ return await get_codex_oauth_service().complete_login_with_callback_url(
984
+ payload.callback_url
985
+ )
986
+ except CodexAuthError as exc:
987
+ raise _codex_http_exception(exc) from None
988
+
989
+
990
+ @router.post("/providers/openai-codex/oauth/cancel")
991
+ async def cancel_openai_codex_oauth() -> dict[str, Any]:
992
+ _require_codex_oauth_actor()
993
+ try:
994
+ return await get_codex_oauth_service().cancel_login()
995
+ except CodexAuthError as exc:
996
+ raise _codex_http_exception(exc) from None
997
+
998
+
999
+ @router.post("/providers/openai-codex/oauth/logout")
1000
+ async def logout_openai_codex_oauth() -> dict[str, Any]:
1001
+ _require_codex_oauth_actor()
1002
+ try:
1003
+ return await get_codex_oauth_service().logout()
1004
+ except CodexAuthError as exc:
1005
+ raise _codex_http_exception(exc) from None
1006
+
1007
+
1008
+ @router.post("/providers/openai-codex/models/refresh")
1009
+ async def refresh_openai_codex_models() -> dict[str, Any]:
1010
+ _require_codex_oauth_actor()
1011
+ try:
1012
+ return await get_codex_oauth_service().refresh_models()
1013
+ except CodexAuthError as exc:
1014
+ raise _codex_http_exception(exc) from None
1015
+
1016
+
1017
+ @router.get("/providers/codebuddy/auth/status")
1018
+ async def get_codebuddy_auth_status() -> dict[str, Any]:
1019
+ _require_settings_admin()
1020
+ return await get_codebuddy_auth_service().status()
1021
+
1022
+
1023
+ @router.post("/providers/codebuddy/auth/start")
1024
+ async def start_codebuddy_auth() -> dict[str, Any]:
1025
+ _require_settings_admin()
1026
+ return await get_codebuddy_auth_service().start_login()
1027
+
1028
+
1029
+ @router.post("/providers/codebuddy/auth/cancel")
1030
+ async def cancel_codebuddy_auth() -> dict[str, Any]:
1031
+ _require_settings_admin()
1032
+ return await get_codebuddy_auth_service().cancel_login()
1033
+
1034
+
1035
+ @router.post("/providers/codebuddy/auth/logout")
1036
+ async def logout_codebuddy_auth() -> dict[str, Any]:
1037
+ _require_settings_admin()
1038
+ return await get_codebuddy_auth_service().logout()
1039
+
1040
+
1041
+ @router.post("/providers/openai-codex/models/reasoning-effort")
1042
+ async def update_openai_codex_reasoning_effort(
1043
+ payload: CodexReasoningEffortUpdate,
1044
+ ) -> dict[str, Any]:
1045
+ _require_codex_oauth_actor()
1046
+ # The codex service writes the catalog the runtime resolves against, like
1047
+ # every other catalog write here — unbracketed, the next turn would keep
1048
+ # the old effort until something else happened to reset the clients.
1049
+ with _runtime_catalog_write():
1050
+ try:
1051
+ status_payload = await get_codex_oauth_service().set_reasoning_effort(
1052
+ payload.model,
1053
+ payload.reasoning_effort,
1054
+ )
1055
+ except CodexAuthError as exc:
1056
+ raise _codex_http_exception(exc) from None
1057
+ return status_payload
1058
+
1059
+
1060
+ @router.get("/catalog")
1061
+ async def get_catalog():
1062
+ _require_settings_admin()
1063
+ return {"catalog": redact_catalog_secrets(get_model_catalog_service().load())}
1064
+
1065
+
1066
+ @router.get("/network")
1067
+ async def get_network_settings():
1068
+ _require_settings_admin()
1069
+ return _network_settings_payload()
1070
+
1071
+
1072
+ @router.put("/network")
1073
+ async def update_network_settings(payload: NetworkSettingsUpdate):
1074
+ _require_settings_admin()
1075
+ service = get_runtime_settings_service()
1076
+ current = service.load_system(include_process_overrides=False)
1077
+ service.save_system(
1078
+ {
1079
+ **current,
1080
+ "backend_port": payload.backend_port,
1081
+ "frontend_port": payload.frontend_port,
1082
+ "next_public_api_base_external": payload.public_api_base.strip(),
1083
+ "cors_origin": "",
1084
+ "cors_origins": normalize_origins(payload.cors_origins),
1085
+ }
1086
+ )
1087
+ return _network_settings_payload()
1088
+
1089
+
1090
+ def _chat_attachments_payload() -> dict[str, Any]:
1091
+ service = get_runtime_settings_service()
1092
+ stored = service.load_system(include_process_overrides=False)
1093
+ effective = service.load_system(include_process_overrides=True)
1094
+ max_total_bytes = int(effective["chat_attachment_max_total_mb"]) * 1024 * 1024
1095
+ return {
1096
+ "settings": {
1097
+ "max_file_mb": stored["chat_attachment_max_file_mb"],
1098
+ "max_total_mb": stored["chat_attachment_max_total_mb"],
1099
+ "max_chars_per_doc": stored["chat_attachment_max_chars_per_doc"],
1100
+ "max_chars_total": stored["chat_attachment_max_chars_total"],
1101
+ },
1102
+ "effective": {
1103
+ "max_file_bytes": int(effective["chat_attachment_max_file_mb"]) * 1024 * 1024,
1104
+ "max_total_bytes": max_total_bytes,
1105
+ "max_chars_per_doc": effective["chat_attachment_max_chars_per_doc"],
1106
+ "max_chars_total": effective["chat_attachment_max_chars_total"],
1107
+ "ws_max_size": compute_ws_max_size(max_total_bytes),
1108
+ },
1109
+ "bounds": {
1110
+ "max_file_mb": list(CHAT_ATTACHMENT_MAX_FILE_MB_RANGE),
1111
+ "max_total_mb": list(CHAT_ATTACHMENT_MAX_TOTAL_MB_RANGE),
1112
+ "chars": list(CHAT_ATTACHMENT_CHARS_RANGE),
1113
+ },
1114
+ # Size caps and char budgets are re-read on every message, but the WS
1115
+ # frame ceiling is fixed at process start — uploads bigger than the
1116
+ # old ceiling need a backend restart to actually go through.
1117
+ "restart_required_for_larger_uploads": True,
1118
+ }
1119
+
1120
+
1121
+ @router.get("/chat-attachments")
1122
+ async def get_chat_attachment_settings():
1123
+ """Chat attachment policy. Readable by any user — the composer needs the
1124
+ caps to gate file picks client-side; only the PUT is admin-gated."""
1125
+ return _chat_attachments_payload()
1126
+
1127
+
1128
+ @router.get("/chat-starters")
1129
+ async def get_chat_starter_settings():
1130
+ """How many recent activities shape the home screen's starting points.
1131
+
1132
+ Per user and not admin-gated, unlike the attachment caps next door: this
1133
+ changes the size of one prompt built from the caller's own memory, not any
1134
+ resource other people share.
1135
+ """
1136
+ from deeptutor.services.settings.starter_settings import (
1137
+ TRACE_COUNT_RANGE,
1138
+ get_starter_settings,
1139
+ )
1140
+
1141
+ return {"settings": get_starter_settings(), "bounds": {"trace_count": TRACE_COUNT_RANGE}}
1142
+
1143
+
1144
+ @router.put("/chat-starters")
1145
+ async def update_chat_starter_settings(payload: ChatStarterSettingsUpdate):
1146
+ from deeptutor.services.settings.starter_settings import (
1147
+ TRACE_COUNT_RANGE,
1148
+ save_starter_settings,
1149
+ )
1150
+
1151
+ saved = save_starter_settings({"trace_count": payload.trace_count})
1152
+ return {"settings": saved, "bounds": {"trace_count": TRACE_COUNT_RANGE}}
1153
+
1154
+
1155
+ @router.put("/chat-attachments")
1156
+ async def update_chat_attachment_settings(payload: ChatAttachmentSettingsUpdate):
1157
+ _require_settings_admin()
1158
+ service = get_runtime_settings_service()
1159
+ current = service.load_system(include_process_overrides=False)
1160
+ service.save_system(
1161
+ {
1162
+ **current,
1163
+ "chat_attachment_max_file_mb": payload.max_file_mb,
1164
+ "chat_attachment_max_total_mb": payload.max_total_mb,
1165
+ "chat_attachment_max_chars_per_doc": payload.max_chars_per_doc,
1166
+ "chat_attachment_max_chars_total": payload.max_chars_total,
1167
+ }
1168
+ )
1169
+ return _chat_attachments_payload()
1170
+
1171
+
1172
+ def _mineru_settings_payload() -> dict[str, Any]:
1173
+ """MinerU settings for the UI, with the API token redacted to a boolean.
1174
+
1175
+ ``local_cli`` is a fast PATH probe (no subprocess) so the page can show
1176
+ install status at config time instead of failing at parse time; the
1177
+ definitive ``--version`` check runs behind the explicit Test button.
1178
+ """
1179
+ from deeptutor.services.parsing.engines.mineru.backend import local_cli_probe
1180
+
1181
+ service = get_runtime_settings_service()
1182
+ settings = service.load_mineru(include_process_overrides=True)
1183
+ public = {key: value for key, value in settings.items() if key != "api_token"}
1184
+ return {
1185
+ "settings": public,
1186
+ "api_token_set": bool(settings.get("api_token")),
1187
+ "local_cli": local_cli_probe(str(settings.get("local_cli_path") or "")),
1188
+ }
1189
+
1190
+
1191
+ def _document_parsing_payload() -> dict[str, Any]:
1192
+ """State for the Document Parsing settings page: active engine, all engine
1193
+ slices (MinerU token redacted), engine availability, and per-engine
1194
+ readiness (so the UI can surface the "models not downloaded" gate)."""
1195
+ from deeptutor.services.parsing.engines._install import (
1196
+ installable_engines,
1197
+ model_downloadable_engines,
1198
+ )
1199
+ from deeptutor.services.parsing.engines.factory import (
1200
+ get_parser,
1201
+ list_engines,
1202
+ )
1203
+ from deeptutor.services.parsing.engines.mineru.backend import local_cli_probe
1204
+
1205
+ service = get_runtime_settings_service()
1206
+ full = service.load_document_parsing(include_process_overrides=True)
1207
+ engines = full.get("engines", {})
1208
+
1209
+ redacted: dict[str, Any] = {}
1210
+ for name, slice_ in engines.items():
1211
+ clean = dict(slice_)
1212
+ clean.pop("api_token", None)
1213
+ redacted[name] = clean
1214
+
1215
+ readiness: dict[str, Any] = {}
1216
+ available = list_engines()
1217
+ for entry in available:
1218
+ try:
1219
+ parser = get_parser(entry["id"])
1220
+ report = parser.is_ready(parser.resolve_config())
1221
+ readiness[entry["id"]] = {
1222
+ "ready": report.ready,
1223
+ "reason": report.reason,
1224
+ "message": report.message,
1225
+ }
1226
+ except Exception: # pragma: no cover - defensive
1227
+ continue
1228
+
1229
+ mineru_slice = engines.get("mineru", {})
1230
+ docling_slice = engines.get("docling", {})
1231
+ return {
1232
+ "engine": full.get("engine"),
1233
+ "image_caption": bool(full.get("image_caption", False)),
1234
+ "image_description_model": full.get("image_description_model"),
1235
+ "engines": redacted,
1236
+ "available_engines": available,
1237
+ "readiness": readiness,
1238
+ # Engine ids that support one-click pip install / model download here.
1239
+ "installable": sorted(installable_engines()),
1240
+ "model_downloadable": sorted(model_downloadable_engines()),
1241
+ # MinerU-specific UI state (token presence + CLI probe).
1242
+ "mineru": {
1243
+ "api_token_set": bool(mineru_slice.get("api_token")),
1244
+ "local_cli": local_cli_probe(str(mineru_slice.get("local_cli_path") or "")),
1245
+ },
1246
+ # Docling UI state (token presence for remote mode).
1247
+ "docling": {
1248
+ "api_token_set": bool(docling_slice.get("api_token")),
1249
+ },
1250
+ }
1251
+
1252
+
1253
+ @router.get("/mineru")
1254
+ async def get_mineru_settings():
1255
+ _require_settings_admin()
1256
+ return _mineru_settings_payload()
1257
+
1258
+
1259
+ @router.put("/mineru")
1260
+ async def update_mineru_settings(payload: MinerUSettingsUpdate):
1261
+ _require_settings_admin()
1262
+ service = get_runtime_settings_service()
1263
+ current = service.load_mineru(include_process_overrides=False)
1264
+ # Tri-state token: None keeps the stored value, anything else replaces it.
1265
+ token = current.get("api_token", "")
1266
+ if payload.api_token is not None:
1267
+ token = (
1268
+ [value.strip() for value in payload.api_token if value.strip()]
1269
+ if isinstance(payload.api_token, list)
1270
+ else payload.api_token.strip()
1271
+ )
1272
+ service.save_mineru(
1273
+ {
1274
+ "mode": payload.mode,
1275
+ "api_base_url": payload.api_base_url,
1276
+ "api_token": token,
1277
+ "local_cli_path": payload.local_cli_path,
1278
+ "model_download_source": payload.model_download_source,
1279
+ "model_download_endpoint": payload.model_download_endpoint,
1280
+ "model_version": payload.model_version,
1281
+ "language": payload.language,
1282
+ "enable_formula": payload.enable_formula,
1283
+ "enable_table": payload.enable_table,
1284
+ "is_ocr": payload.is_ocr,
1285
+ "max_pages_per_part": current.get("max_pages_per_part", 180),
1286
+ "normalize_tiny_scans": current.get("normalize_tiny_scans", False),
1287
+ "allow_local_model_download": payload.allow_local_model_download,
1288
+ }
1289
+ )
1290
+ return _mineru_settings_payload()
1291
+
1292
+
1293
+ @router.get("/document-parsing")
1294
+ async def get_document_parsing_settings():
1295
+ _require_settings_admin()
1296
+ return _document_parsing_payload()
1297
+
1298
+
1299
+ @router.get("/readiness")
1300
+ async def get_settings_readiness():
1301
+ """Return the value-free cross-setting capability readiness matrix."""
1302
+
1303
+ _require_settings_admin()
1304
+ from deeptutor.services.config.readiness import build_settings_readiness
1305
+
1306
+ return await build_settings_readiness()
1307
+
1308
+
1309
+ @router.get("/profile")
1310
+ async def get_settings_profile():
1311
+ """Export effective settings with credentials and deployment values removed."""
1312
+
1313
+ _require_settings_admin()
1314
+ return export_settings_profile()
1315
+
1316
+
1317
+ @router.post("/profile/diff")
1318
+ async def diff_settings_profile(payload: SettingsProfileImportPayload):
1319
+ """Review an imported profile without changing effective settings."""
1320
+
1321
+ _require_settings_admin()
1322
+ try:
1323
+ return review_settings_profile_import(payload.model_dump())
1324
+ except SettingsProfileError as exc:
1325
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from None
1326
+
1327
+
1328
+ @router.get("/presets")
1329
+ async def get_settings_presets():
1330
+ """List value-free starting points for a reviewable draft."""
1331
+
1332
+ _require_settings_admin()
1333
+ return {
1334
+ "schema_version": SETTINGS_PRESETS_SCHEMA_VERSION,
1335
+ "presets": list_settings_presets(),
1336
+ }
1337
+
1338
+
1339
+ async def _stage_settings_preset(
1340
+ preset_id: str,
1341
+ payload: SettingsPresetDraftRequest,
1342
+ ) -> dict[str, Any]:
1343
+ """Merge one preset into the unapplied draft; never touch live settings."""
1344
+
1345
+ preset = get_settings_preset(preset_id)
1346
+ if preset is None:
1347
+ raise HTTPException(status.HTTP_404_NOT_FOUND, f"Unknown preset '{preset_id}'.")
1348
+
1349
+ draft_service = get_settings_draft_service()
1350
+ stored = draft_service.load()
1351
+ incoming = payload.model_dump()
1352
+
1353
+ # The browser sends its whole envelope, so unrelated unsaved edits survive.
1354
+ # When a caller omits extensions, existing extension drafts must survive too.
1355
+ extensions = deepcopy(stored.get("extensions") or {})
1356
+ extensions.update(deepcopy(incoming.get("extensions") or {}))
1357
+ incoming["extensions"] = extensions
1358
+
1359
+ merged = merge_draft_secrets(
1360
+ incoming,
1361
+ stored,
1362
+ get_model_catalog_service().load(),
1363
+ )
1364
+ preset_draft = preset.draft_extensions()
1365
+ tools = preset_draft["tools"]["enabled_tools"]
1366
+ preset_draft["tools"]["enabled_tools"] = sanitize_enabled_tools(tools)
1367
+ merged["extensions"].update(preset_draft)
1368
+
1369
+ return {
1370
+ "preset": preset.public_dict(),
1371
+ "draft": redact_draft(draft_service.save(merged)),
1372
+ }
1373
+
1374
+
1375
+ @router.post("/presets/{preset_id}/draft")
1376
+ async def stage_settings_preset(
1377
+ preset_id: str,
1378
+ payload: SettingsPresetDraftRequest,
1379
+ ) -> dict[str, Any]:
1380
+ """Load a named preset into the existing draft for review."""
1381
+
1382
+ _require_settings_admin()
1383
+ return await _stage_settings_preset(preset_id, payload)
1384
+
1385
+
1386
+ @router.put("/document-parsing")
1387
+ async def update_document_parsing_settings(payload: DocumentParsingUpdate):
1388
+ _require_settings_admin()
1389
+ service = get_runtime_settings_service()
1390
+ full = service.load_document_parsing(include_process_overrides=False)
1391
+ engines = {name: dict(slice_) for name, slice_ in full.get("engines", {}).items()}
1392
+
1393
+ for name, update in (payload.engines or {}).items():
1394
+ if name not in engines:
1395
+ continue
1396
+ merged = dict(update or {})
1397
+ # Token tri-state for engines with a secret (MinerU, Docling remote):
1398
+ # omitted / None keeps the stored token; "" clears it; a string
1399
+ # replaces it.
1400
+ if "api_token" in (engines[name] or {}) and merged.get("api_token") is None:
1401
+ merged.pop("api_token", None)
1402
+ engines[name].update(merged)
1403
+
1404
+ new_engine = payload.engine or full.get("engine")
1405
+ image_model = full.get("image_description_model")
1406
+ if "image_description_model" in payload.model_fields_set:
1407
+ image_model = (
1408
+ payload.image_description_model.model_dump()
1409
+ if payload.image_description_model is not None
1410
+ else None
1411
+ )
1412
+ if image_model is not None:
1413
+ from deeptutor.services.llm.image_description import resolve_image_description_config
1414
+
1415
+ try:
1416
+ resolve_image_description_config(image_model)
1417
+ except ValueError as exc:
1418
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1419
+ image_caption = (
1420
+ payload.image_caption
1421
+ if payload.image_caption is not None
1422
+ else bool(full.get("image_caption", False))
1423
+ )
1424
+ service.save_document_parsing(
1425
+ {
1426
+ "engine": new_engine,
1427
+ "image_caption": image_caption,
1428
+ "image_description_model": image_model,
1429
+ "engines": engines,
1430
+ }
1431
+ )
1432
+ return _document_parsing_payload()
1433
+
1434
+
1435
+ @router.post("/document-parsing/test")
1436
+ async def test_document_parsing(payload: DocumentParsingTest):
1437
+ """Readiness test for an engine. For MinerU's deeper checks (live cloud
1438
+ token / CLI ``--version``) the UI uses ``/mineru/test``; this generic test
1439
+ covers engine availability + model readiness for all engines."""
1440
+ _require_settings_admin()
1441
+ from deeptutor.services.parsing.engines.factory import get_parser, is_engine_available
1442
+
1443
+ service = get_runtime_settings_service()
1444
+ engine = payload.engine or service.load_document_parsing().get("engine") or ""
1445
+ if not is_engine_available(engine):
1446
+ return {"ok": False, "message": f"The '{engine}' parsing engine isn't installed."}
1447
+ try:
1448
+ parser = get_parser(engine)
1449
+ config = parser.resolve_config()
1450
+ report = parser.is_ready(config)
1451
+ # Remote engines get a live connectivity check (e.g. Docling Serve
1452
+ # /health) rather than a config-only readiness gate.
1453
+ verify = getattr(parser, "verify", None)
1454
+ if verify is not None and report.ready and callable(verify):
1455
+ ok, message = verify(config)
1456
+ return {"ok": ok, "message": message or ("Ready to parse." if ok else "Not ready.")}
1457
+ except Exception as exc: # noqa: BLE001 - surface as a test result
1458
+ return {"ok": False, "message": str(exc)}
1459
+ return {
1460
+ "ok": report.ready,
1461
+ "message": report.message or ("Ready to parse." if report.ready else "Not ready."),
1462
+ }
1463
+
1464
+
1465
+ @router.post("/document-parsing/docling/test")
1466
+ async def test_docling_remote_connection(payload: DoclingRemoteTest):
1467
+ """Live connectivity check for the Docling remote-server draft values.
1468
+ Pings the server health + version endpoints and returns ``ok`` + a
1469
+ human-readable detail. Tests draft form values so the user can verify the
1470
+ URL/key before saving; falls back to the stored key when the secret field
1471
+ is untouched."""
1472
+ _require_settings_admin()
1473
+ from deeptutor.services.parsing.engines.docling.config import (
1474
+ DoclingConfig,
1475
+ resolve_docling_config,
1476
+ )
1477
+ from deeptutor.services.parsing.engines.docling.remote import verify_remote
1478
+
1479
+ stored = resolve_docling_config()
1480
+ base_url = payload.api_base_url.strip().rstrip("/") or "http://localhost:5001"
1481
+ token = stored.api_token if payload.api_token is None else payload.api_token.strip()
1482
+ config = DoclingConfig(
1483
+ mode="remote",
1484
+ api_base_url=base_url,
1485
+ api_token=token,
1486
+ do_ocr=stored.do_ocr,
1487
+ do_table_structure=stored.do_table_structure,
1488
+ )
1489
+ ok, detail = await asyncio.to_thread(verify_remote, config)
1490
+ return {"ok": ok, "message": detail or ("Ready to parse." if ok else "Not ready.")}
1491
+
1492
+
1493
+ @router.post("/document-parsing/tika/test")
1494
+ async def test_tika_remote_connection(payload: TikaRemoteTest):
1495
+ """Live connectivity check for the Tika server draft URL. Pings ``/version``
1496
+ so the user can verify the URL before saving."""
1497
+ _require_settings_admin()
1498
+ from deeptutor.services.parsing.engines.tika.config import TikaConfig
1499
+ from deeptutor.services.parsing.engines.tika.remote import verify_remote
1500
+
1501
+ server_url = payload.server_url.strip().rstrip("/") or "http://localhost:9998"
1502
+ config = TikaConfig(server_url=server_url)
1503
+ ok, detail = await asyncio.to_thread(verify_remote, config)
1504
+ return {"ok": ok, "message": detail or ("Ready to parse." if ok else "Not ready.")}
1505
+
1506
+
1507
+ def _normalize_engine_name(name: str) -> str:
1508
+ return (name or "").strip().lower().replace("-", "_").replace(" ", "_")
1509
+
1510
+
1511
+ @router.post("/document-parsing/install")
1512
+ async def start_document_parsing_install(payload: DocumentParsingInstall):
1513
+ """Kick off a one-click ``pip install`` of an optional engine's package(s).
1514
+
1515
+ Returns ``{ok, message}`` immediately; progress is polled via the shared job
1516
+ status endpoint. Only one job runs at a time (process-wide singleton). The
1517
+ engine must be in the install allow-list (``ENGINE_PIP_SPECS``)."""
1518
+ _require_settings_admin()
1519
+ from deeptutor.services.parsing.engines._install import (
1520
+ ENGINE_PIP_SPECS,
1521
+ get_background_job_manager,
1522
+ )
1523
+
1524
+ engine = _normalize_engine_name(payload.engine)
1525
+ specs = ENGINE_PIP_SPECS.get(engine)
1526
+ if not specs:
1527
+ return {"ok": False, "message": f"No installable package for engine '{engine}'."}
1528
+ return get_background_job_manager().start_install(engine=engine, specs=specs)
1529
+
1530
+
1531
+ @router.post("/document-parsing/models/download")
1532
+ async def start_document_parsing_model_download(payload: DocumentParsingInstall):
1533
+ """Kick off a one-click model-weight download for an engine (e.g. Docling).
1534
+
1535
+ Runs the engine's downloader console script (``docling-tools models
1536
+ download``) as a background subprocess; progress is polled via the shared job
1537
+ status endpoint. The engine must be in ``ENGINE_MODEL_DOWNLOADERS`` and its
1538
+ script reachable next to the server's python or on PATH."""
1539
+ _require_settings_admin()
1540
+ from deeptutor.services.parsing.engines._install import (
1541
+ get_background_job_manager,
1542
+ model_downloadable_engines,
1543
+ resolve_model_downloader,
1544
+ )
1545
+
1546
+ engine = _normalize_engine_name(payload.engine)
1547
+ if engine not in model_downloadable_engines():
1548
+ return {"ok": False, "message": f"No model download for engine '{engine}'."}
1549
+ cmd = resolve_model_downloader(engine)
1550
+ if not cmd:
1551
+ return {
1552
+ "ok": False,
1553
+ "message": (
1554
+ f"The {engine} model downloader wasn't found. Reinstall the engine "
1555
+ f"(pip install deeptutor[parse-{engine}]) so its CLI is on PATH."
1556
+ ),
1557
+ }
1558
+ return get_background_job_manager().start_model_download(engine=engine, cmd=cmd)
1559
+
1560
+
1561
+ @router.get("/document-parsing/job/status")
1562
+ async def document_parsing_job_status(cursor: int = 0):
1563
+ _require_settings_admin()
1564
+ from deeptutor.services.parsing.engines._install import get_background_job_manager
1565
+
1566
+ return get_background_job_manager().status(cursor)
1567
+
1568
+
1569
+ @router.post("/document-parsing/job/cancel")
1570
+ async def cancel_document_parsing_job():
1571
+ _require_settings_admin()
1572
+ from deeptutor.services.parsing.engines._install import get_background_job_manager
1573
+
1574
+ return get_background_job_manager().cancel()
1575
+
1576
+
1577
+ @router.post("/mineru/models/download")
1578
+ async def start_mineru_models_download(payload: MinerUModelDownloadPayload):
1579
+ """Kick off a one-click model download via ``mineru-models-download``.
1580
+
1581
+ Returns ``{ok, message}`` immediately; progress is polled via the status
1582
+ endpoint. Only one download runs at a time (process-wide singleton).
1583
+ """
1584
+ _require_settings_admin()
1585
+ from deeptutor.services.parsing.engines.mineru.models import (
1586
+ get_model_download_manager,
1587
+ resolve_models_downloader,
1588
+ )
1589
+
1590
+ resolved = resolve_models_downloader(payload.local_cli_path)
1591
+ if not resolved["found"]:
1592
+ if resolved["path"]:
1593
+ message = (
1594
+ f"mineru-models-download not found next to the configured CLI "
1595
+ f"(expected {resolved['path']}). The configured install may be "
1596
+ "legacy magic-pdf — upgrade to MinerU >= 3.4.5 for one-click downloads."
1597
+ )
1598
+ else:
1599
+ message = (
1600
+ "mineru-models-download not found on the server PATH. Install "
1601
+ 'current MinerU first (uv pip install -U "mineru[all]>=3.4.5") or set '
1602
+ "the CLI path."
1603
+ )
1604
+ return {"ok": False, "message": message}
1605
+
1606
+ return get_model_download_manager().start(
1607
+ downloader=resolved["path"],
1608
+ model_type=payload.model_type,
1609
+ source=payload.source,
1610
+ endpoint=payload.endpoint,
1611
+ )
1612
+
1613
+
1614
+ @router.get("/mineru/models/download/status")
1615
+ async def mineru_models_download_status(cursor: int = 0):
1616
+ _require_settings_admin()
1617
+ from deeptutor.services.parsing.engines.mineru.models import get_model_download_manager
1618
+
1619
+ return get_model_download_manager().status(cursor)
1620
+
1621
+
1622
+ @router.post("/mineru/models/download/cancel")
1623
+ async def cancel_mineru_models_download():
1624
+ _require_settings_admin()
1625
+ from deeptutor.services.parsing.engines.mineru.models import get_model_download_manager
1626
+
1627
+ return get_model_download_manager().cancel()
1628
+
1629
+
1630
+ @router.post("/mineru/test")
1631
+ async def test_mineru_connection(payload: MinerUSettingsUpdate):
1632
+ """Validate the active backend. ``mode == "local"`` checks the CLI install
1633
+ (PATH probe + ``--version``); cloud mode validates the token against the
1634
+ live MinerU API (no parse quota consumed). Tests the draft form values so
1635
+ the user can verify before saving; falls back to the stored token when the
1636
+ secret field is untouched."""
1637
+ _require_settings_admin()
1638
+ from deeptutor.services.parsing.engines.mineru.cloud import verify_credentials
1639
+ from deeptutor.services.parsing.engines.mineru.config import MinerUConfig, MinerUError
1640
+
1641
+ if payload.mode == "local":
1642
+ from deeptutor.services.parsing.engines.mineru.backend import (
1643
+ local_cli_probe,
1644
+ local_cli_version,
1645
+ )
1646
+ from deeptutor.services.parsing.engines.mineru.formats import (
1647
+ MIN_MINERU_VERSION,
1648
+ mineru_version_is_current,
1649
+ )
1650
+
1651
+ probe = local_cli_probe(payload.local_cli_path)
1652
+ if not probe["found"]:
1653
+ if probe.get("source") == "configured":
1654
+ return {
1655
+ "ok": False,
1656
+ "message": (
1657
+ f"Configured CLI path is not an executable file: {probe['path']}. "
1658
+ "Fix the path or clear it to auto-detect from PATH."
1659
+ ),
1660
+ }
1661
+ return {
1662
+ "ok": False,
1663
+ "message": (
1664
+ "MinerU CLI not found on the server PATH. Install it "
1665
+ '(uv pip install -U "mineru[all]>=3.4.5"), set an explicit CLI path, '
1666
+ "or switch to cloud mode."
1667
+ ),
1668
+ }
1669
+ # For a configured path, run --version against the path itself (the
1670
+ # bare command name may not be on this process's PATH).
1671
+ version_target = (
1672
+ probe["path"] if probe.get("source") == "configured" else str(probe["command"])
1673
+ )
1674
+ version = await asyncio.to_thread(local_cli_version, version_target)
1675
+ if not mineru_version_is_current(version):
1676
+ detail = version or "an unknown version"
1677
+ return {
1678
+ "ok": False,
1679
+ "message": (
1680
+ f"Local MinerU CLI reported {detail}. DeepTutor needs MinerU >= "
1681
+ f"{MIN_MINERU_VERSION}; upgrade with "
1682
+ f"`pip install -U 'mineru[all]>={MIN_MINERU_VERSION}'`."
1683
+ ),
1684
+ }
1685
+ return {
1686
+ "ok": True,
1687
+ "message": f"Local MinerU CLI detected: {probe['command']} ({version})",
1688
+ }
1689
+
1690
+ service = get_runtime_settings_service()
1691
+ stored = service.load_mineru(include_process_overrides=False)
1692
+ token = stored.get("api_token", "")
1693
+ if payload.api_token is not None:
1694
+ token = (
1695
+ [value.strip() for value in payload.api_token if value.strip()]
1696
+ if isinstance(payload.api_token, list)
1697
+ else payload.api_token.strip()
1698
+ )
1699
+ config = MinerUConfig(
1700
+ mode="cloud",
1701
+ api_base_url=(payload.api_base_url or "").strip().rstrip("/") or "https://mineru.net",
1702
+ api_token=token,
1703
+ model_version=payload.model_version,
1704
+ language=payload.language or "auto",
1705
+ enable_formula=payload.enable_formula,
1706
+ enable_table=payload.enable_table,
1707
+ is_ocr=payload.is_ocr,
1708
+ )
1709
+ try:
1710
+ await asyncio.to_thread(verify_credentials, config)
1711
+ except MinerUError as exc:
1712
+ return {"ok": False, "message": str(exc)}
1713
+ except Exception as exc: # noqa: BLE001 — report any provider error to the UI
1714
+ logger.exception("MinerU connectivity test failed")
1715
+ return {"ok": False, "message": f"Unexpected error: {exc}"}
1716
+ return {"ok": True, "message": "MinerU API token is valid."}
1717
+
1718
+
1719
+ @router.get("/llm-options")
1720
+ async def get_llm_options():
1721
+ if not get_current_user().is_admin:
1722
+ return allowed_llm_options()
1723
+ return list_llm_options(get_model_catalog_service().load())
1724
+
1725
+
1726
+ @router.put("/catalog")
1727
+ async def update_catalog(payload: CatalogPayload):
1728
+ _require_settings_admin()
1729
+ service = get_model_catalog_service()
1730
+ current = service.load()
1731
+ restored = restore_catalog_secrets(payload.catalog, current)
1732
+ proposed = reconcile_codex_catalog_update(current, restored)
1733
+ with _runtime_catalog_write():
1734
+ catalog = service.save(proposed)
1735
+ return {"catalog": redact_catalog_secrets(catalog)}
1736
+
1737
+
1738
+ @router.post("/apply/registry")
1739
+ async def apply_registry_edit(payload: RegistryEditPayload):
1740
+ """Promote one provider, model, or default without replacing other drafts."""
1741
+ _require_settings_admin()
1742
+ from deeptutor.services.settings.registry_edit import merge_registry_edit
1743
+
1744
+ service = get_model_catalog_service()
1745
+ draft_service = get_settings_draft_service()
1746
+ stored = draft_service.load()
1747
+ current = service.load()
1748
+ edit = payload.model_dump(exclude_none=True)
1749
+ try:
1750
+ proposed = merge_registry_edit(current, edit, stored_draft=stored.get("catalog"))
1751
+ proposed = reconcile_codex_catalog_update(current, proposed)
1752
+ draft_catalog = stored.get("catalog")
1753
+ if isinstance(draft_catalog, dict):
1754
+ stored["catalog"] = merge_registry_edit(draft_catalog, edit, stored_draft=proposed)
1755
+ except ValueError as exc:
1756
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1757
+ with _runtime_catalog_write():
1758
+ service.apply(proposed)
1759
+ catalog = service.load()
1760
+ if stored.get("catalog") == catalog:
1761
+ stored["catalog"] = None
1762
+ if is_empty_draft(stored):
1763
+ draft_service.clear()
1764
+ public_draft = None
1765
+ else:
1766
+ public_draft = redact_draft(draft_service.save(stored))
1767
+ return {"catalog": redact_catalog_secrets(catalog), "draft": public_draft}
1768
+
1769
+
1770
+ @router.post("/apply/provider")
1771
+ async def apply_provider_edit(payload: ProviderEditPayload):
1772
+ """Save the provider on screen, preserving every unrelated live/draft entry."""
1773
+ _require_settings_admin()
1774
+ from deeptutor.services.settings.provider_edit import merge_provider_edit
1775
+
1776
+ service = get_model_catalog_service()
1777
+ draft_service = get_settings_draft_service()
1778
+ stored = draft_service.load()
1779
+ current = service.load()
1780
+ try:
1781
+ proposed = merge_provider_edit(
1782
+ current,
1783
+ payload.service,
1784
+ payload.profile,
1785
+ connection=payload.connection,
1786
+ active_model_id=payload.active_model_id,
1787
+ activate=payload.activate,
1788
+ stored_draft=stored.get("catalog"),
1789
+ )
1790
+ except ValueError as exc:
1791
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1792
+ reconciled = reconcile_codex_catalog_update(current, proposed)
1793
+ with _runtime_catalog_write():
1794
+ service.apply(reconciled)
1795
+ catalog = service.load()
1796
+ draft_catalog = stored.get("catalog")
1797
+ if isinstance(draft_catalog, dict):
1798
+ # Advance only the addressed profile in a saved draft, preserving edits
1799
+ # to its sibling providers and to all non-model settings.
1800
+ saved_profile = next(
1801
+ item
1802
+ for item in catalog["services"][payload.service]["profiles"]
1803
+ if item["id"] == payload.profile["id"]
1804
+ )
1805
+ saved_connection = next(
1806
+ (
1807
+ item
1808
+ for item in catalog.get("connections", [])
1809
+ if item["id"] == (payload.connection or {}).get("id")
1810
+ ),
1811
+ None,
1812
+ )
1813
+ stored["catalog"] = merge_provider_edit(
1814
+ draft_catalog,
1815
+ payload.service,
1816
+ saved_profile,
1817
+ connection=saved_connection,
1818
+ activate=payload.activate,
1819
+ active_model_id=payload.active_model_id,
1820
+ )
1821
+ if stored["catalog"] == catalog:
1822
+ stored["catalog"] = None
1823
+ if is_empty_draft(stored):
1824
+ draft_service.clear()
1825
+ public_draft = None
1826
+ else:
1827
+ public_draft = redact_draft(draft_service.save(stored))
1828
+ return {"catalog": redact_catalog_secrets(catalog), "draft": public_draft}
1829
+
1830
+
1831
+ @router.post("/apply/service")
1832
+ async def apply_catalog_service(payload: CatalogServicePayload):
1833
+ """Apply one model service while leaving every other draft untouched.
1834
+
1835
+ Provider dialogs use this narrower commit path for their Done action. A
1836
+ user may still have unrelated edits elsewhere in Settings, and closing an
1837
+ STT dialog must not silently promote those edits too.
1838
+ """
1839
+
1840
+ _require_settings_admin()
1841
+ service = get_model_catalog_service()
1842
+ current = service.load()
1843
+ draft_service = get_settings_draft_service()
1844
+ stored_draft = draft_service.load()
1845
+ proposed = deepcopy(current)
1846
+ proposed.setdefault("services", {})[payload.service] = deepcopy(payload.config)
1847
+ restored = restore_catalog_secrets(proposed, stored_draft.get("catalog") or current)
1848
+ restored = restore_catalog_secrets(restored, current)
1849
+ if payload.service == "task" and payload.config.get("mode") == "reference":
1850
+ from deeptutor.services.model_selection import apply_llm_selection_to_catalog
1851
+
1852
+ try:
1853
+ selection = payload.config.get("selection")
1854
+ if not selection or not selection.get("profile_id") or not selection.get("model_id"):
1855
+ raise ValueError("Choose a configured task model first.")
1856
+ apply_llm_selection_to_catalog(restored, selection)
1857
+ except (ValueError, AttributeError) as exc:
1858
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1859
+ reconciled = reconcile_codex_catalog_update(current, restored)
1860
+ with _runtime_catalog_write():
1861
+ runtime = service.apply(reconciled)
1862
+ catalog = service.load()
1863
+
1864
+ # A previously saved draft contains a full catalog. Keep it, but advance
1865
+ # this one service to the value that is now live; otherwise reloading the
1866
+ # page would resurrect the pre-apply STT configuration over the live one.
1867
+ draft_catalog = stored_draft.get("catalog")
1868
+ if isinstance(draft_catalog, dict):
1869
+ draft_catalog.setdefault("services", {})[payload.service] = deepcopy(
1870
+ catalog["services"][payload.service]
1871
+ )
1872
+ stored_draft["catalog"] = None if draft_catalog == catalog else draft_catalog
1873
+
1874
+ if is_empty_draft(stored_draft):
1875
+ draft_service.clear()
1876
+ public_draft = None
1877
+ else:
1878
+ public_draft = redact_draft(draft_service.save(stored_draft))
1879
+
1880
+ return {
1881
+ "message": f"{payload.service} settings applied to runtime.",
1882
+ "catalog": redact_catalog_secrets(catalog),
1883
+ "draft": public_draft,
1884
+ "runtime": runtime,
1885
+ }
1886
+
1887
+
1888
+ @router.get("/draft")
1889
+ async def get_settings_draft():
1890
+ """Return the unapplied draft, or nothing when there is none.
1891
+
1892
+ The draft is deliberately invisible to every other read path: nothing that
1893
+ resolves runtime configuration looks here, which is the whole difference
1894
+ between saving a draft and applying it.
1895
+ """
1896
+ draft = get_settings_draft_service().load()
1897
+ if is_empty_draft(draft):
1898
+ return {"draft": None}
1899
+ return {"draft": redact_draft(draft)}
1900
+
1901
+
1902
+ @router.put("/draft")
1903
+ async def update_settings_draft(payload: SettingsDraftPayload):
1904
+ if payload.catalog is not None:
1905
+ _require_settings_admin()
1906
+ service = get_settings_draft_service()
1907
+ stored = service.load()
1908
+ merged = merge_draft_secrets(
1909
+ payload.model_dump(),
1910
+ stored,
1911
+ get_model_catalog_service().load(),
1912
+ )
1913
+ if is_empty_draft(merged):
1914
+ service.clear()
1915
+ return {"draft": None}
1916
+ saved = service.save(merged)
1917
+ return {"draft": redact_draft(saved)}
1918
+
1919
+
1920
+ @router.delete("/draft")
1921
+ async def discard_settings_draft():
1922
+ get_settings_draft_service().clear()
1923
+ return {"draft": None}
1924
+
1925
+
1926
+ @router.post("/apply")
1927
+ async def apply_catalog(payload: CatalogPayload | None = None):
1928
+ """Move settings into the files the runtime reads, and clear the draft.
1929
+
1930
+ With no body this promotes the stored draft's catalog, which is how the
1931
+ settings UI applies: credentials that only ever existed in a draft would
1932
+ otherwise have to round-trip through the browser as placeholders and come
1933
+ back resolving to the previous key.
1934
+ """
1935
+ _require_settings_admin()
1936
+ service = get_model_catalog_service()
1937
+ draft_service = get_settings_draft_service()
1938
+ current = service.load()
1939
+ if payload is not None:
1940
+ proposed = restore_catalog_secrets(payload.catalog, current)
1941
+ else:
1942
+ draft_catalog = draft_service.load().get("catalog")
1943
+ proposed = (
1944
+ restore_catalog_secrets(draft_catalog, current)
1945
+ if isinstance(draft_catalog, dict)
1946
+ else current
1947
+ )
1948
+ catalog = reconcile_codex_catalog_update(current, proposed)
1949
+ with _runtime_catalog_write():
1950
+ applied = service.apply(catalog)
1951
+ catalog_after = service.load()
1952
+ draft_service.clear()
1953
+ return {
1954
+ "message": "Catalog applied to runtime settings.",
1955
+ "catalog": redact_catalog_secrets(catalog_after),
1956
+ "runtime": applied,
1957
+ }
1958
+
1959
+
1960
+ @router.post("/test-provider")
1961
+ async def test_provider_connection(payload: ProviderProbePayload):
1962
+ """Test current form credentials, resolving masks from draft/live settings by ID."""
1963
+ _require_settings_admin()
1964
+ from deeptutor.services.settings.provider_probe import probe_provider, probe_search_provider
1965
+
1966
+ current = get_model_catalog_service().load()
1967
+ saved = get_settings_draft_service().load().get("catalog")
1968
+ source = restore_catalog_secrets(saved, current) if isinstance(saved, dict) else current
1969
+ fields = payload.model_dump()
1970
+ if payload.connection_id:
1971
+ fields["id"] = payload.connection_id
1972
+ proposed = {"connections": [fields]}
1973
+ resolved = restore_catalog_secrets(proposed, source)["connections"][0]
1974
+ else:
1975
+ fields["id"] = payload.profile_id
1976
+ proposed = {"services": {payload.service: {"profiles": [fields]}}}
1977
+ resolved = restore_catalog_secrets(proposed, source)["services"][payload.service][
1978
+ "profiles"
1979
+ ][0]
1980
+ from deeptutor.services.keypool import primary_api_key
1981
+
1982
+ key = primary_api_key(resolved.get("api_key"))
1983
+ headers = resolved.get("extra_headers") or {}
1984
+ if isinstance(headers, str):
1985
+ try:
1986
+ headers = json.loads(headers)
1987
+ except (ValueError, TypeError) as exc:
1988
+ raise HTTPException(status_code=400, detail="Invalid extra headers JSON.") from exc
1989
+ if not isinstance(headers, dict) or any(not isinstance(v, str) for v in headers.values()):
1990
+ raise HTTPException(status_code=400, detail="Extra headers must contain text values.")
1991
+ if (
1992
+ (payload.api_key == CATALOG_SECRET_MASK and not key)
1993
+ or key == CATALOG_SECRET_MASK
1994
+ or any(v == CATALOG_SECRET_MASK for v in headers.values())
1995
+ ):
1996
+ raise HTTPException(
1997
+ status_code=400, detail="Saved credentials were not found. Enter the key again."
1998
+ )
1999
+ if payload.service == "search":
2000
+ return await probe_search_provider(
2001
+ payload.binding, payload.base_url, key, proxy=payload.proxy
2002
+ )
2003
+ return await probe_provider(
2004
+ payload.binding, payload.base_url, key, payload.api_format, headers, payload.api_version
2005
+ )
2006
+
2007
+
2008
+ @router.post("/fetch-models")
2009
+ async def fetch_models_from_provider(payload: FetchModelsPayload):
2010
+ """List selectable model IDs using the provider's own authentication.
2011
+
2012
+ Thin HTTP surface over ``factory.fetch_models`` so the settings UI can
2013
+ populate a model picker. Copilot uses the caller's owner-private CLI login,
2014
+ not the profile's API key or base URL.
2015
+ """
2016
+ _require_settings_admin()
2017
+ from deeptutor.services.llm.factory import fetch_models as fetch_llm_models
2018
+ from deeptutor.services.provider_registry import canonical_provider_name
2019
+
2020
+ base_url = (payload.base_url or "").strip()
2021
+ binding = (payload.binding or "").strip().lower() or "openai"
2022
+ is_copilot = canonical_provider_name(binding) == "github_copilot"
2023
+ if not base_url and binding != "codebuddy" and not is_copilot:
2024
+ raise HTTPException(
2025
+ status_code=status.HTTP_400_BAD_REQUEST,
2026
+ detail="base_url is required for this provider.",
2027
+ )
2028
+
2029
+ api_key = payload.api_key
2030
+ api_format = (payload.api_format or "").strip().lower()
2031
+ if is_copilot:
2032
+ base_url, api_key, api_format = "", None, "auto"
2033
+ elif payload.profile_id and (api_key == CATALOG_SECRET_MASK or not api_format):
2034
+ service = get_model_catalog_service().load().get("services", {}).get(payload.service, {})
2035
+ profile = next(
2036
+ (item for item in service.get("profiles", []) if item.get("id") == payload.profile_id),
2037
+ None,
2038
+ )
2039
+ if api_key == CATALOG_SECRET_MASK:
2040
+ api_key = profile.get("api_key") if profile else None
2041
+ if not api_format and profile:
2042
+ api_format = str(profile.get("api_format") or "")
2043
+
2044
+ try:
2045
+ model_ids = await fetch_llm_models(binding, base_url, api_key, api_format or "auto")
2046
+ except Exception as exc: # noqa: BLE001 — surface any provider error as 502
2047
+ if is_copilot:
2048
+ # OAuth exceptions can contain credential-bearing URLs or responses.
2049
+ # Never echo or log their raw text at this public HTTP boundary.
2050
+ raise HTTPException(
2051
+ status_code=status.HTTP_502_BAD_GATEWAY,
2052
+ detail=(
2053
+ "Could not list GitHub Copilot models. "
2054
+ "Run: deeptutor provider login github-copilot "
2055
+ "for this account in the server's DeepTutor home, "
2056
+ "then retry. If already logged in, check Copilot access and connectivity."
2057
+ ),
2058
+ ) from None
2059
+ logger.exception("Failed to fetch models from %s", base_url)
2060
+ raise HTTPException(
2061
+ status_code=status.HTTP_502_BAD_GATEWAY,
2062
+ detail=f"Provider request failed: {exc}",
2063
+ ) from exc
2064
+
2065
+ return {"models": [{"id": model_id, "name": model_id} for model_id in model_ids]}
2066
+
2067
+
2068
+ @router.post("/model-capabilities")
2069
+ async def resolve_model_capabilities(payload: ModelCapabilitiesQuery):
2070
+ """What the built-in capability tables assume for one provider/model pair.
2071
+
2072
+ The settings UI shows these as the value "Auto" resolves to next to each
2073
+ per-model override, so a user can see what they are overriding.
2074
+ """
2075
+ _require_settings_admin()
2076
+ from deeptutor.services.llm.capabilities import effective_capabilities
2077
+
2078
+ binding = (payload.binding or "").strip().lower() or "openai"
2079
+ model = (payload.model or "").strip()
2080
+ return {
2081
+ "binding": binding,
2082
+ "model": model,
2083
+ "defaults": effective_capabilities(binding, model),
2084
+ }
2085
+
2086
+
2087
+ @router.put("/theme")
2088
+ async def update_theme(update: ThemeUpdate):
2089
+ patch_ui_settings(theme=update.theme)
2090
+ return {"theme": update.theme}
2091
+
2092
+
2093
+ @router.put("/language")
2094
+ async def update_language(update: LanguageUpdate):
2095
+ patch_ui_settings(language=update.language)
2096
+ return {"language": update.language}
2097
+
2098
+
2099
+ @router.put("/voice-autoplay")
2100
+ async def update_voice_autoplay(update: VoiceAutoplayUpdate):
2101
+ """Persist the global default for auto-playing chat replies via TTS.
2102
+
2103
+ A personal UI preference (any authenticated user); the chat surface layers
2104
+ a per-session override on top of this value.
2105
+ """
2106
+ patch_ui_settings(voice_autoplay=update.voice_autoplay)
2107
+ return {"voice_autoplay": update.voice_autoplay}
2108
+
2109
+
2110
+ @router.put("/voice-math-speak")
2111
+ async def update_voice_math_speak(update: VoiceMathSpeakUpdate):
2112
+ """Persist whether TTS verbalizes LaTeX as spoken math.
2113
+
2114
+ A personal UI preference (any authenticated user). The voice router reads
2115
+ this on each synthesis call; chat does not send a per-request override.
2116
+ Dollar-sign delimiters are stripped even when this is off.
2117
+ """
2118
+ patch_ui_settings(voice_math_speak=update.voice_math_speak)
2119
+ return {"voice_math_speak": update.voice_math_speak}
2120
+
2121
+
2122
+ @router.put("/chat-response-timeout")
2123
+ async def update_chat_response_timeout(update: ChatResponseTimeoutUpdate):
2124
+ """Persist how long the chat UI waits for a turn event before timing out.
2125
+
2126
+ A personal UI preference (any authenticated user). Slow tools like image /
2127
+ video generation can take longer than the old 60s default, so this is
2128
+ user-adjustable; the chat surface reads it client-side.
2129
+ """
2130
+ patch_ui_settings(chat_response_timeout=update.chat_response_timeout)
2131
+ return {"chat_response_timeout": update.chat_response_timeout}
2132
+
2133
+
2134
+ # The UI preferences a page can need before it knows who is asking. All three
2135
+ # describe the person's own presentation and output choices; none of them say
2136
+ # anything about how the deployment is configured.
2137
+ PRESESSION_UI_FIELDS = ("theme", "language", "response_language")
2138
+
2139
+
2140
+ @public_router.get("/ui")
2141
+ async def get_ui_settings():
2142
+ """Return the pre-session UI preferences: theme and the two languages.
2143
+
2144
+ Public by design, which is why it is a narrow projection rather than the
2145
+ saved ``ui`` blob. The app shell — and the statically prerendered auth
2146
+ pages, which have no session at all — adopt the persisted languages here
2147
+ during bootstrap. Theme rides along so those pages can paint in the right
2148
+ one instead of flashing.
2149
+
2150
+ Everything else under ``ui`` (sidebar_nav_order, enabled_optional_tools,
2151
+ chat_response_timeout, …) describes what the deployment has turned on, so
2152
+ it stays behind auth: read it from the ``ui`` key of GET /settings.
2153
+ """
2154
+ settings = load_ui_settings()
2155
+ return {field: settings.get(field) for field in PRESESSION_UI_FIELDS}
2156
+
2157
+
2158
+ # Private: user-authored starting prompts must never enter the public /ui payload.
2159
+ def _page_customization() -> PageCustomization:
2160
+ return PageCustomization.model_validate(load_ui_settings().get("page_customization") or {})
2161
+
2162
+
2163
+ @router.get("/page-customization")
2164
+ async def get_page_customization():
2165
+ return _page_customization().model_dump()
2166
+
2167
+
2168
+ def _save_page_section(section: str, value: BaseModel) -> dict[str, Any]:
2169
+ def update(stored):
2170
+ current = PageCustomization.model_validate(stored.get("page_customization") or {})
2171
+ setattr(current, section, value)
2172
+ return {**stored, "page_customization": current.model_dump()}
2173
+
2174
+ atomic_update(_settings_file(), update)
2175
+ return _page_customization().model_dump()
2176
+
2177
+
2178
+ @router.put("/page-customization/chat")
2179
+ async def update_chat_page_customization(update: ChatPageCustomization):
2180
+ return _save_page_section("chat", update)
2181
+
2182
+
2183
+ @router.put("/page-customization/learning")
2184
+ async def update_learning_page_customization(update: LearningPageCustomization):
2185
+ return _save_page_section("learning", update)
2186
+
2187
+
2188
+ @router.put("/ui")
2189
+ async def update_ui_settings(update: UISettingsUpdate):
2190
+ """Merge frontend partial update into current UI settings.
2191
+
2192
+ Uses exclude_unset=True semantics so that only fields explicitly provided
2193
+ by the frontend override saved values. Fields not in the frontend payload
2194
+ (even if they equal the model defaults) are omitted from the merge.
2195
+ """
2196
+ dump = update.model_dump(exclude_unset=True) # Only merge explicitly provided fields
2197
+ # Merged into the stored document, not into the defaults-merged view: saving
2198
+ # that view back would freeze today's defaults as this user's explicit
2199
+ # choices. The response keeps returning the merged view clients expect.
2200
+ patch_ui_settings(**dump)
2201
+ return load_ui_settings()
2202
+
2203
+
2204
+ @router.post("/reset")
2205
+ async def reset_settings():
2206
+ save_ui_settings(DEFAULT_UI_SETTINGS)
2207
+ return DEFAULT_UI_SETTINGS
2208
+
2209
+
2210
+ @router.get("/themes")
2211
+ async def get_themes():
2212
+ return {
2213
+ "themes": [
2214
+ {"id": "snow", "name": "Default"},
2215
+ {"id": "light", "name": "Cream"},
2216
+ {"id": "dark", "name": "Dark"},
2217
+ {"id": "glass", "name": "Glass"},
2218
+ ]
2219
+ }
2220
+
2221
+
2222
+ @router.get("/sidebar")
2223
+ async def get_sidebar_settings():
2224
+ current_ui = load_ui_settings()
2225
+ return {
2226
+ "description": current_ui.get(
2227
+ "sidebar_description", DEFAULT_UI_SETTINGS["sidebar_description"]
2228
+ ),
2229
+ "nav_order": current_ui.get("sidebar_nav_order", DEFAULT_UI_SETTINGS["sidebar_nav_order"]),
2230
+ }
2231
+
2232
+
2233
+ @router.put("/sidebar/description")
2234
+ async def update_sidebar_description(update: SidebarDescriptionUpdate):
2235
+ patch_ui_settings(sidebar_description=update.description)
2236
+ return {"description": update.description}
2237
+
2238
+
2239
+ @router.put("/sidebar/nav-order")
2240
+ async def update_sidebar_nav_order(update: SidebarNavOrderUpdate):
2241
+ patch_ui_settings(sidebar_nav_order=update.nav_order.model_dump())
2242
+ return {"nav_order": update.nav_order.model_dump()}
2243
+
2244
+
2245
+ @router.put("/enabled-tools")
2246
+ async def update_enabled_tools(update: EnabledToolsUpdate):
2247
+ sanitized = sanitize_enabled_tools(update.enabled_tools)
2248
+ patch_ui_settings(enabled_optional_tools=sanitized)
2249
+ return {"enabled_optional_tools": sanitized}
2250
+
2251
+
2252
+ @router.post("/voice/voices")
2253
+ async def list_voice_choices(payload: VoiceDiscoveryPayload):
2254
+ """Read live account voices for a draft selection, without applying it."""
2255
+ _require_settings_admin()
2256
+ from deeptutor.services.voice.discovery import discover_voices
2257
+
2258
+ service = get_model_catalog_service()
2259
+ current = service.load()
2260
+ saved = get_settings_draft_service().load().get("catalog")
2261
+ source = restore_catalog_secrets(saved, current) if isinstance(saved, dict) else current
2262
+ catalog = service.resolve_connections(restore_catalog_secrets(payload.catalog, source))
2263
+ try:
2264
+ result = await discover_voices(catalog, payload.profile_id, payload.model_id)
2265
+ except ValueError as exc:
2266
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2267
+ return Response(
2268
+ json.dumps(result), media_type="application/json", headers={"Cache-Control": "no-store"}
2269
+ )
2270
+
2271
+
2272
+ @router.post(
2273
+ "/voice/preview",
2274
+ response_class=Response,
2275
+ responses={200: {"content": {"audio/wav": {}, "audio/mpeg": {}}}},
2276
+ )
2277
+ async def preview_voice(payload: VoicePreviewPayload) -> Response:
2278
+ """Audition the model being edited without saving or activating its catalog."""
2279
+ _require_settings_admin()
2280
+ from deeptutor.services.voice.base import VoiceProviderError, VoiceProviderTimeout
2281
+ from deeptutor.services.voice.preview import preview_failure_message, synthesize_preview
2282
+
2283
+ service = get_model_catalog_service()
2284
+ current = service.load()
2285
+ saved = get_settings_draft_service().load().get("catalog")
2286
+ # Restore the saved draft against live first; a missing draft secret must
2287
+ # not replace an incoming mask with None before live can resolve it.
2288
+ source = restore_catalog_secrets(saved, current) if isinstance(saved, dict) else current
2289
+ catalog = restore_catalog_secrets(payload.catalog, source)
2290
+ catalog = service.resolve_connections(catalog)
2291
+ try:
2292
+ audio, content_type = await synthesize_preview(
2293
+ catalog, payload.profile_id, payload.model_id, payload.text
2294
+ )
2295
+ except ValueError as exc:
2296
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2297
+ except VoiceProviderTimeout as exc:
2298
+ raise HTTPException(status_code=504, detail=str(exc)) from exc
2299
+ except VoiceProviderError as exc:
2300
+ raise HTTPException(
2301
+ status_code=502,
2302
+ detail=preview_failure_message(exc),
2303
+ ) from exc
2304
+ return Response(audio, media_type=content_type, headers={"Cache-Control": "no-store"})
2305
+
2306
+
2307
+ @router.post("/services/{service}/preview")
2308
+ async def preview_service(service: str, payload: ServicePreviewPayload):
2309
+ """Stream a real result from the selected draft without applying it."""
2310
+ _require_settings_admin()
2311
+ from deeptutor.services.settings.service_preview import preview_events, validate_input
2312
+ from deeptutor.services.voice.discovery import selected_catalog
2313
+
2314
+ try:
2315
+ audio = validate_input(service, payload.text, payload.audio, payload.content_type)
2316
+ catalog_service = get_model_catalog_service()
2317
+ current = catalog_service.load()
2318
+ saved = get_settings_draft_service().load().get("catalog")
2319
+ source = restore_catalog_secrets(saved, current) if isinstance(saved, dict) else current
2320
+ catalog = catalog_service.resolve_connections(
2321
+ restore_catalog_secrets(payload.catalog, source)
2322
+ )
2323
+ selected_catalog(catalog, service, payload.profile_id, payload.model_id)
2324
+ except ValueError as exc:
2325
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2326
+
2327
+ async def stream():
2328
+ async for event in preview_events(
2329
+ catalog,
2330
+ service,
2331
+ payload.profile_id,
2332
+ payload.model_id,
2333
+ payload.text,
2334
+ audio,
2335
+ payload.content_type,
2336
+ ):
2337
+ yield json.dumps(event, ensure_ascii=False) + "\n"
2338
+
2339
+ return StreamingResponse(
2340
+ stream(),
2341
+ media_type="application/x-ndjson",
2342
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
2343
+ )
2344
+
2345
+
2346
+ @router.post("/tests/{service}/start")
2347
+ async def start_service_test(service: str, payload: CatalogPayload | None = None):
2348
+ _require_settings_admin()
2349
+ catalog = None
2350
+ if payload is not None:
2351
+ catalog_service = get_model_catalog_service()
2352
+ current = catalog_service.load()
2353
+ saved = get_settings_draft_service().load().get("catalog")
2354
+ catalog = (
2355
+ restore_catalog_secrets(payload.catalog, saved)
2356
+ if isinstance(saved, dict)
2357
+ else payload.catalog
2358
+ )
2359
+ catalog = restore_catalog_secrets(catalog, current)
2360
+ catalog = catalog_service.resolve_connections(catalog)
2361
+ run = get_config_test_runner().start(service, catalog)
2362
+ return {"run_id": run.id}
2363
+
2364
+
2365
+ @router.get("/tests/{service}/{run_id}/events")
2366
+ async def stream_service_test_events(service: str, run_id: str, request: Request):
2367
+ _require_settings_admin()
2368
+ runner = get_config_test_runner()
2369
+ run = runner.get(run_id)
2370
+
2371
+ async def event_stream():
2372
+ sent = 0
2373
+ while True:
2374
+ if await request.is_disconnected():
2375
+ return
2376
+ events = run.snapshot(sent)
2377
+ if events:
2378
+ for event in events:
2379
+ yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
2380
+ sent += len(events)
2381
+ if events[-1]["type"] in {"completed", "failed"}:
2382
+ return
2383
+ else:
2384
+ yield "event: heartbeat\ndata: {}\n\n"
2385
+ await asyncio.sleep(0.35)
2386
+
2387
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
2388
+
2389
+
2390
+ @router.post("/tests/{service}/{run_id}/cancel")
2391
+ async def cancel_service_test(service: str, run_id: str):
2392
+ _require_settings_admin()
2393
+ get_config_test_runner().cancel(run_id)
2394
+ return {"message": "Cancelled"}
2395
+
2396
+
2397
+ @router.get("/tour/status")
2398
+ async def tour_status():
2399
+ tour_cache = _tour_cache_file()
2400
+ if tour_cache.exists():
2401
+ try:
2402
+ cache = json.loads(tour_cache.read_text(encoding="utf-8"))
2403
+ return {
2404
+ "active": True,
2405
+ "status": cache.get("status", "unknown"),
2406
+ "launch_at": cache.get("launch_at"),
2407
+ "redirect_at": cache.get("redirect_at"),
2408
+ }
2409
+ except Exception:
2410
+ pass
2411
+ return {"active": False, "status": "none", "launch_at": None, "redirect_at": None}
2412
+
2413
+
2414
+ class TourCompletePayload(BaseModel):
2415
+ catalog: dict[str, Any] | None = None
2416
+ test_results: dict[str, str] | None = None
2417
+
2418
+
2419
+ @router.post("/tour/complete")
2420
+ async def complete_tour(payload: TourCompletePayload | None = None):
2421
+ _require_settings_admin()
2422
+ service = get_model_catalog_service()
2423
+ current = service.load()
2424
+ catalog = (
2425
+ restore_catalog_secrets(payload.catalog, current)
2426
+ if payload and payload.catalog
2427
+ else current
2428
+ )
2429
+ with _runtime_catalog_write():
2430
+ applied = service.apply(catalog)
2431
+ now = int(time.time())
2432
+ launch_at = now + 3
2433
+ redirect_at = now + 5
2434
+
2435
+ tour_cache = _tour_cache_file()
2436
+ if tour_cache.exists():
2437
+ try:
2438
+ cache = json.loads(tour_cache.read_text(encoding="utf-8"))
2439
+ except Exception:
2440
+ cache = {}
2441
+ cache["status"] = "completed"
2442
+ cache["launch_at"] = launch_at
2443
+ cache["redirect_at"] = redirect_at
2444
+ if payload and payload.test_results:
2445
+ cache["test_results"] = payload.test_results
2446
+ tour_cache.write_text(json.dumps(cache, indent=2), encoding="utf-8")
2447
+
2448
+ return {
2449
+ "status": "completed",
2450
+ "message": "Configuration saved. DeepTutor will restart shortly.",
2451
+ "launch_at": launch_at,
2452
+ "redirect_at": redirect_at,
2453
+ "runtime": applied,
2454
+ }
2455
+
2456
+
2457
+ @router.post("/tour/reopen")
2458
+ async def reopen_tour():
2459
+ return {
2460
+ "message": "Run the terminal setup guide from the project root to re-open the guided setup.",
2461
+ "command": "deeptutor init",
2462
+ }
2463
+
2464
+
2465
+ @router.get("/usage", response_model=UsageStatistics)
2466
+ async def get_usage_statistics(
2467
+ year: int = Query(..., ge=1970, le=9998),
2468
+ timezone: str = Query("UTC", max_length=100),
2469
+ ) -> UsageStatistics:
2470
+ from datetime import datetime
2471
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2472
+
2473
+ from deeptutor.services.session import get_session_store
2474
+ from deeptutor.services.session.usage_statistics import aggregate_usage
2475
+
2476
+ try:
2477
+ zone = ZoneInfo(timezone)
2478
+ except (ZoneInfoNotFoundError, ValueError) as exc:
2479
+ raise HTTPException(status_code=400, detail="Invalid timezone") from exc
2480
+ start = datetime(year, 1, 1, tzinfo=zone).timestamp()
2481
+ end = datetime(year + 1, 1, 1, tzinfo=zone).timestamp()
2482
+ from deeptutor.services.workspace import get_content_workspace_service
2483
+ from deeptutor.services.workspace.activity import data_activity
2484
+ from deeptutor.services.workspace.context import workspace_context
2485
+ from deeptutor.services.workspace.models import WorkspaceError
2486
+
2487
+ records = []
2488
+ with data_activity():
2489
+ workspace_ids = [""] + [
2490
+ row["workspace_id"]
2491
+ for row in get_content_workspace_service()._catalog()
2492
+ if row.get("kind") == "workspace"
2493
+ ]
2494
+ for workspace_id in workspace_ids:
2495
+ try:
2496
+ with workspace_context(workspace_id):
2497
+ records.extend(await get_session_store().usage_records(start, end))
2498
+ except WorkspaceError:
2499
+ continue
2500
+ from deeptutor.services.llm.usage_ledger import combined_usage_records
2501
+
2502
+ combined = await asyncio.to_thread(combined_usage_records, records, start, end)
2503
+ return await asyncio.to_thread(aggregate_usage, combined, year=year, timezone=timezone)