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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- 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)
|