crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
"""Configuration and context routes — /config/*, /context, /tools, /skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
import inspect
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
18
|
+
from fastapi.responses import StreamingResponse
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from crabcode_core.config.manager import ConfigManager
|
|
22
|
+
from crabcode_core.filesystem import replace_with_retry
|
|
23
|
+
from crabcode_core.paths import get_config_home
|
|
24
|
+
from crabcode_core.skills.loader import load_skills
|
|
25
|
+
from crabcode_core.text_io import normalize_newlines, read_utf8_text
|
|
26
|
+
from crabcode_gateway.session_registry import get_session_lock
|
|
27
|
+
from crabcode_gateway.schemas import (
|
|
28
|
+
ContextPushRequest,
|
|
29
|
+
GoalRequest,
|
|
30
|
+
GoalState,
|
|
31
|
+
ModelInfo,
|
|
32
|
+
ModelSettingsEntry,
|
|
33
|
+
ModelSettingsMutationRequest,
|
|
34
|
+
ModelSettingsResponse,
|
|
35
|
+
ModelSettingsSource,
|
|
36
|
+
PromptSectionInfo,
|
|
37
|
+
PromptSettingsMutationRequest,
|
|
38
|
+
PromptSettingsResponse,
|
|
39
|
+
PromptTemplateView,
|
|
40
|
+
RuntimeSettingsMutationRequest,
|
|
41
|
+
RuntimeSettingsResponse,
|
|
42
|
+
UserAppendPromptView,
|
|
43
|
+
SetPermissionModeRequest,
|
|
44
|
+
SetReasoningEffortRequest,
|
|
45
|
+
SetUltraModeRequest,
|
|
46
|
+
SkillExpandRequest,
|
|
47
|
+
SkillExpansion,
|
|
48
|
+
SkillInfo,
|
|
49
|
+
LogsResponse,
|
|
50
|
+
SwitchModeRequest,
|
|
51
|
+
SwitchModelRequest,
|
|
52
|
+
ToolInfo,
|
|
53
|
+
)
|
|
54
|
+
from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
|
|
55
|
+
|
|
56
|
+
router = APIRouter(tags=["config"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ModelTestRequest(BaseModel):
|
|
60
|
+
name: str
|
|
61
|
+
cwd: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _model_test_error(exc: Exception) -> str:
|
|
65
|
+
# Do not return raw provider messages: they may contain keys, headers or URLs.
|
|
66
|
+
from crabcode_core.api.network import certificate_failure
|
|
67
|
+
if certificate_failure(exc):
|
|
68
|
+
return "证书校验失败,请检查证书或网络拦截;未自动重试"
|
|
69
|
+
status = getattr(exc, "status_code", None)
|
|
70
|
+
if status:
|
|
71
|
+
reason = {
|
|
72
|
+
400: "请求参数或 API 格式不兼容",
|
|
73
|
+
401: "认证失败,请检查 API Key",
|
|
74
|
+
403: "没有访问权限,请检查密钥权限或服务区域限制",
|
|
75
|
+
404: "模型或 API 地址不存在,请检查模型 ID 和 Base URL",
|
|
76
|
+
429: "请求限流或额度不足,请检查服务商额度",
|
|
77
|
+
}.get(status, "模型服务返回错误,请检查服务商状态与配置")
|
|
78
|
+
return f"HTTP {status}:{reason}"
|
|
79
|
+
name = type(exc).__name__
|
|
80
|
+
if isinstance(exc, (TimeoutError, asyncio.TimeoutError)) or "Timeout" in name:
|
|
81
|
+
return "测试超时(最多 30 秒),请检查网络、代理和模型服务状态"
|
|
82
|
+
if "Connection" in name or isinstance(exc, OSError):
|
|
83
|
+
return "连接失败,请检查 Base URL、网络、代理和 TLS 证书"
|
|
84
|
+
if "credential" in str(exc).lower() or "api_key" in str(exc).lower():
|
|
85
|
+
return "缺少认证信息,请检查 API Key 环境变量是否已配置,并重启 Gateway"
|
|
86
|
+
if str(exc) == "Empty model response":
|
|
87
|
+
return "连接已建立,但模型没有返回文本,请检查模型能力和 API 格式"
|
|
88
|
+
return f"模型初始化或响应失败({name}),请检查 Provider、API 格式和模型配置"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _probe_model(config: Any, cwd: str | None = None) -> None:
|
|
92
|
+
from crabcode_core.api import ModelConfig, create_adapter
|
|
93
|
+
from crabcode_core.usage import tracked_stream_message
|
|
94
|
+
from crabcode_core.types.message import create_user_message
|
|
95
|
+
|
|
96
|
+
adapter = None
|
|
97
|
+
stream = None
|
|
98
|
+
try:
|
|
99
|
+
config.request_max_retries = 0
|
|
100
|
+
config.max_retries = 0
|
|
101
|
+
config.unbounded_connection_retries = False
|
|
102
|
+
config.timeout = 30
|
|
103
|
+
adapter = create_adapter(config)
|
|
104
|
+
client = getattr(adapter, "client", None)
|
|
105
|
+
if callable(getattr(client, "with_options", None)):
|
|
106
|
+
adapter.client = client.with_options(max_retries=0, timeout=30)
|
|
107
|
+
stream = tracked_stream_message(
|
|
108
|
+
adapter,
|
|
109
|
+
messages=[create_user_message("Reply with OK.")],
|
|
110
|
+
system=[], tools=[],
|
|
111
|
+
config=ModelConfig(model=config.model, max_tokens=256,
|
|
112
|
+
thinking_enabled=False, thinking_budget=0, timeout=30),
|
|
113
|
+
cwd=cwd, purpose="model_test",
|
|
114
|
+
)
|
|
115
|
+
async for chunk in stream:
|
|
116
|
+
if chunk.type == "error":
|
|
117
|
+
raise RuntimeError(chunk.error or "Model stream error")
|
|
118
|
+
if chunk.type == "text" and chunk.text.strip():
|
|
119
|
+
return
|
|
120
|
+
raise RuntimeError("Empty model response")
|
|
121
|
+
finally:
|
|
122
|
+
for resource in (stream, getattr(adapter, "client", None)):
|
|
123
|
+
close = getattr(resource, "aclose", None) or getattr(resource, "close", None)
|
|
124
|
+
if callable(close):
|
|
125
|
+
try:
|
|
126
|
+
result = close()
|
|
127
|
+
if inspect.isawaitable(result):
|
|
128
|
+
await asyncio.wait_for(result, timeout=2)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.post("/config/test-model")
|
|
134
|
+
async def test_model(req: ModelTestRequest, request: Request) -> dict[str, Any]:
|
|
135
|
+
cwd = _resolve_model_settings_cwd(request, req.cwd)
|
|
136
|
+
settings = ConfigManager(cwd=cwd).load()
|
|
137
|
+
if req.name not in settings.models:
|
|
138
|
+
raise HTTPException(status_code=404, detail="模型配置不存在,请刷新模型目录")
|
|
139
|
+
config = settings.get_api_config(req.name).model_copy(deep=True)
|
|
140
|
+
if not config.model:
|
|
141
|
+
return {"ok": False, "message": "未配置模型 ID"}
|
|
142
|
+
started = time.monotonic()
|
|
143
|
+
try:
|
|
144
|
+
await asyncio.wait_for(_probe_model(config, cwd), timeout=30)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
return {"ok": False, "message": _model_test_error(exc)}
|
|
147
|
+
return {"ok": True, "message": "连接成功,已收到模型回复",
|
|
148
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _switch_model(session: Any, name: str) -> bool:
|
|
152
|
+
return bool(session.switch_model(name))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _switch_mode(session: Any, mode: str) -> bool:
|
|
156
|
+
return bool(session.switch_mode(mode))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _set_reasoning_effort(session: Any, effort: str) -> bool:
|
|
160
|
+
await session.initialize()
|
|
161
|
+
return bool(session.set_reasoning_effort(effort))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _set_ultra_mode(session: Any, enabled: bool | None) -> bool:
|
|
165
|
+
await session.initialize()
|
|
166
|
+
return bool(session.set_ultra_mode(enabled))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _set_permission_mode(session: Any, mode: str) -> bool:
|
|
170
|
+
await session.initialize()
|
|
171
|
+
return bool(session.set_client_permission_mode(mode))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _manage_goal(session: Any, req: GoalRequest) -> dict[str, Any] | None:
|
|
175
|
+
action = req.action
|
|
176
|
+
if action in {"set", "edit"}:
|
|
177
|
+
if not req.objective or not req.objective.strip():
|
|
178
|
+
raise ValueError("objective is required for set/edit")
|
|
179
|
+
if action == "set":
|
|
180
|
+
goal = session.create_goal(
|
|
181
|
+
req.objective,
|
|
182
|
+
token_budget=req.token_budget,
|
|
183
|
+
)
|
|
184
|
+
elif "token_budget" in req.model_fields_set:
|
|
185
|
+
goal = session.edit_goal(
|
|
186
|
+
req.objective,
|
|
187
|
+
token_budget=req.token_budget,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
goal = session.edit_goal(req.objective)
|
|
191
|
+
return goal.to_dict()
|
|
192
|
+
if action == "clear":
|
|
193
|
+
session.clear_goal()
|
|
194
|
+
return None
|
|
195
|
+
status = {
|
|
196
|
+
"pause": "paused",
|
|
197
|
+
"resume": "active",
|
|
198
|
+
}.get(action, action)
|
|
199
|
+
return session.update_goal(status).to_dict()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _store_context(request: Request, session: Any, req: ContextPushRequest) -> None:
|
|
203
|
+
contexts: dict = request.app.state.client_contexts
|
|
204
|
+
contexts[session.session_id] = req.model_dump()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_session(request: Request, session_id: str | None = None):
|
|
208
|
+
sessions: dict = request.app.state.sessions
|
|
209
|
+
# ``None`` means the caller omitted a selector and may use the legacy
|
|
210
|
+
# process default. An explicitly supplied (even malformed) id is
|
|
211
|
+
# authoritative and must never fall through to another conversation.
|
|
212
|
+
sid = (
|
|
213
|
+
request.app.state.default_session_id
|
|
214
|
+
if session_id is None
|
|
215
|
+
else session_id
|
|
216
|
+
)
|
|
217
|
+
if not sid or sid not in sessions:
|
|
218
|
+
return None
|
|
219
|
+
if sid in getattr(request.app.state, "closing_sessions", set()):
|
|
220
|
+
return None
|
|
221
|
+
return sessions[sid]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _list_models_from_settings(cwd: str = ".") -> list[ModelInfo]:
|
|
225
|
+
"""Read model list directly from settings (works without a session)."""
|
|
226
|
+
settings = ConfigManager(cwd=cwd).load()
|
|
227
|
+
result: list[ModelInfo] = []
|
|
228
|
+
for name in settings.models:
|
|
229
|
+
cfg = settings.get_api_config(name)
|
|
230
|
+
parts = []
|
|
231
|
+
if cfg.provider:
|
|
232
|
+
parts.append(cfg.provider)
|
|
233
|
+
if cfg.model:
|
|
234
|
+
parts.append(cfg.model)
|
|
235
|
+
desc = "/".join(parts) if parts else "(no model set)"
|
|
236
|
+
result.append(
|
|
237
|
+
ModelInfo(
|
|
238
|
+
name=name,
|
|
239
|
+
description=desc,
|
|
240
|
+
group=cfg.group or "default",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_MODEL_SETTING_KEYS = ("default_model", "groups", "models")
|
|
247
|
+
_SENSITIVE_CONFIG_KEYS = (
|
|
248
|
+
"api_key",
|
|
249
|
+
"apikey",
|
|
250
|
+
"authorization",
|
|
251
|
+
"cookie",
|
|
252
|
+
"password",
|
|
253
|
+
"secret",
|
|
254
|
+
"token",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _merge_model_settings(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
259
|
+
"""Merge model settings with the same nested-object semantics as ConfigManager."""
|
|
260
|
+
result = dict(base)
|
|
261
|
+
for key, value in override.items():
|
|
262
|
+
normalized_key = str(key).lower().replace("-", "_")
|
|
263
|
+
if (
|
|
264
|
+
value == "[redacted]"
|
|
265
|
+
and not normalized_key.endswith("_env")
|
|
266
|
+
and not normalized_key.endswith("_path")
|
|
267
|
+
and any(part in normalized_key for part in _SENSITIVE_CONFIG_KEYS)
|
|
268
|
+
):
|
|
269
|
+
# GET responses redact secrets. Treating that marker as an update
|
|
270
|
+
# would destroy the original credential during an otherwise
|
|
271
|
+
# unrelated edit, including nested headers/extra_body objects.
|
|
272
|
+
continue
|
|
273
|
+
current = result.get(key)
|
|
274
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
275
|
+
result[key] = _merge_model_settings(current, value)
|
|
276
|
+
elif isinstance(current, list) and isinstance(value, list):
|
|
277
|
+
merged: list[Any] = []
|
|
278
|
+
seen: set[str] = set()
|
|
279
|
+
for item in current + value:
|
|
280
|
+
marker = (
|
|
281
|
+
json.dumps(item, sort_keys=True)
|
|
282
|
+
if isinstance(item, dict)
|
|
283
|
+
else str(item)
|
|
284
|
+
)
|
|
285
|
+
if marker not in seen:
|
|
286
|
+
seen.add(marker)
|
|
287
|
+
merged.append(item)
|
|
288
|
+
result[key] = merged
|
|
289
|
+
else:
|
|
290
|
+
result[key] = value
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _redact_model_settings(value: Any, key: str = "") -> Any:
|
|
295
|
+
"""Keep useful configuration visible without echoing embedded credentials."""
|
|
296
|
+
normalized_key = key.lower().replace("-", "_")
|
|
297
|
+
is_reference = normalized_key.endswith("_env") or normalized_key.endswith("_path")
|
|
298
|
+
if key and not is_reference and any(part in normalized_key for part in _SENSITIVE_CONFIG_KEYS):
|
|
299
|
+
return "[redacted]"
|
|
300
|
+
if isinstance(value, dict):
|
|
301
|
+
return {
|
|
302
|
+
child_key: _redact_model_settings(child, str(child_key))
|
|
303
|
+
for child_key, child in value.items()
|
|
304
|
+
}
|
|
305
|
+
if isinstance(value, list):
|
|
306
|
+
return [_redact_model_settings(child) for child in value]
|
|
307
|
+
return value
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_writable_settings_path(path: Path) -> bool:
|
|
311
|
+
candidate = path
|
|
312
|
+
while not candidate.exists() and candidate != candidate.parent:
|
|
313
|
+
candidate = candidate.parent
|
|
314
|
+
return os.access(candidate, os.W_OK)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _model_settings_from_files(cwd: str) -> ModelSettingsResponse:
|
|
318
|
+
"""Read raw model settings by layer, then resolve their effective values."""
|
|
319
|
+
from crabcode_core.config.manager import SETTING_SOURCES
|
|
320
|
+
from crabcode_core.types.config import CrabCodeSettings
|
|
321
|
+
from pydantic import ValidationError
|
|
322
|
+
|
|
323
|
+
manager = ConfigManager(cwd=cwd)
|
|
324
|
+
merged: dict[str, Any] = {}
|
|
325
|
+
sources: list[str] = []
|
|
326
|
+
model_sources: dict[str, list[str]] = {}
|
|
327
|
+
group_sources: dict[str, list[str]] = {}
|
|
328
|
+
|
|
329
|
+
for source_name in SETTING_SOURCES:
|
|
330
|
+
raw = manager.get_settings_for_source(source_name)
|
|
331
|
+
if raw is None:
|
|
332
|
+
continue
|
|
333
|
+
relevant = {key: raw[key] for key in _MODEL_SETTING_KEYS if key in raw}
|
|
334
|
+
if not relevant:
|
|
335
|
+
continue
|
|
336
|
+
source_path = manager.settings_file_paths.get(source_name)
|
|
337
|
+
if source_path:
|
|
338
|
+
sources.append(source_path)
|
|
339
|
+
raw_models = raw.get("models")
|
|
340
|
+
if isinstance(raw_models, dict):
|
|
341
|
+
for model_name in raw_models:
|
|
342
|
+
model_sources.setdefault(str(model_name), []).append(source_path)
|
|
343
|
+
raw_groups_for_source = raw.get("groups")
|
|
344
|
+
if isinstance(raw_groups_for_source, dict):
|
|
345
|
+
for group_name in raw_groups_for_source:
|
|
346
|
+
group_sources.setdefault(str(group_name), []).append(source_path)
|
|
347
|
+
merged = _merge_model_settings(merged, relevant)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
settings = CrabCodeSettings.model_validate(merged)
|
|
351
|
+
except ValidationError as exc:
|
|
352
|
+
messages = []
|
|
353
|
+
for error in exc.errors(include_input=False, include_url=False)[:5]:
|
|
354
|
+
location = ".".join(str(part) for part in error.get("loc", ()))
|
|
355
|
+
message = str(error.get("msg", "invalid value"))
|
|
356
|
+
messages.append(f"{location}: {message}" if location else message)
|
|
357
|
+
raise HTTPException(
|
|
358
|
+
status_code=422,
|
|
359
|
+
detail="模型配置无效:" + "; ".join(messages),
|
|
360
|
+
) from exc
|
|
361
|
+
|
|
362
|
+
raw_groups = merged.get("groups") if isinstance(merged.get("groups"), dict) else {}
|
|
363
|
+
raw_models = merged.get("models") if isinstance(merged.get("models"), dict) else {}
|
|
364
|
+
warnings: list[str] = []
|
|
365
|
+
entries: list[ModelSettingsEntry] = []
|
|
366
|
+
|
|
367
|
+
for name, configured_value in raw_models.items():
|
|
368
|
+
configured = configured_value if isinstance(configured_value, dict) else {}
|
|
369
|
+
group = configured.get("group") if isinstance(configured.get("group"), str) else None
|
|
370
|
+
if group and group not in raw_groups:
|
|
371
|
+
warnings.append(f"模型“{name}”引用了不存在的配置组“{group}”")
|
|
372
|
+
effective = settings.get_api_config(str(name)).model_dump(exclude_none=True)
|
|
373
|
+
entries.append(
|
|
374
|
+
ModelSettingsEntry(
|
|
375
|
+
name=str(name),
|
|
376
|
+
group=group,
|
|
377
|
+
is_default=str(name) == settings.default_model,
|
|
378
|
+
configured=_redact_model_settings(configured),
|
|
379
|
+
effective=_redact_model_settings(effective),
|
|
380
|
+
overridden_fields=[str(field) for field in configured if field != "group"],
|
|
381
|
+
sources=model_sources.get(str(name), []),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if settings.default_model and settings.default_model not in raw_models:
|
|
386
|
+
warnings.append(f"默认模型“{settings.default_model}”不存在")
|
|
387
|
+
|
|
388
|
+
editable_sources = []
|
|
389
|
+
for source_name, label in (
|
|
390
|
+
("userSettings", "用户配置"),
|
|
391
|
+
("projectSettings", "项目配置"),
|
|
392
|
+
("localSettings", "项目本地配置"),
|
|
393
|
+
):
|
|
394
|
+
source_path = manager.settings_file_paths.get(source_name)
|
|
395
|
+
if not source_path:
|
|
396
|
+
continue
|
|
397
|
+
path = Path(source_path)
|
|
398
|
+
editable_sources.append(
|
|
399
|
+
ModelSettingsSource(
|
|
400
|
+
id=source_name,
|
|
401
|
+
label=label,
|
|
402
|
+
path=str(path),
|
|
403
|
+
exists=path.is_file(),
|
|
404
|
+
writable=_is_writable_settings_path(path),
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return ModelSettingsResponse(
|
|
409
|
+
cwd=cwd,
|
|
410
|
+
default_model=settings.default_model,
|
|
411
|
+
sources=sources,
|
|
412
|
+
groups=_redact_model_settings(raw_groups),
|
|
413
|
+
group_sources=group_sources,
|
|
414
|
+
models=entries,
|
|
415
|
+
warnings=warnings,
|
|
416
|
+
editable_sources=editable_sources,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _runtime_settings_from_files(
|
|
421
|
+
cwd: str, pending: tuple[str, dict[str, Any]] | None = None,
|
|
422
|
+
) -> RuntimeSettingsResponse:
|
|
423
|
+
"""Read effective runtime settings by configuration layer."""
|
|
424
|
+
from crabcode_core.config.manager import SETTING_SOURCES, _merge_settings
|
|
425
|
+
from crabcode_core.permissions.manager import PermissionManager
|
|
426
|
+
from crabcode_core.types.config import CrabCodeSettings
|
|
427
|
+
from pydantic import ValidationError
|
|
428
|
+
|
|
429
|
+
manager = ConfigManager(cwd=cwd)
|
|
430
|
+
merged: dict[str, Any] = {}
|
|
431
|
+
sources: list[str] = []
|
|
432
|
+
extra_tools_by_source: dict[str, list[str]] = {}
|
|
433
|
+
|
|
434
|
+
for source_name in SETTING_SOURCES:
|
|
435
|
+
raw = (
|
|
436
|
+
pending[1] if pending is not None and pending[0] == source_name
|
|
437
|
+
else manager.get_settings_for_source(source_name)
|
|
438
|
+
)
|
|
439
|
+
if raw is None:
|
|
440
|
+
continue
|
|
441
|
+
relevant = {
|
|
442
|
+
key: raw[key]
|
|
443
|
+
for key in (
|
|
444
|
+
"snapshot", "computer_use", "extra_tools", "permissions",
|
|
445
|
+
"auto_compact_enabled", "compact_buffer_tokens", "max_context_length",
|
|
446
|
+
)
|
|
447
|
+
if key in raw
|
|
448
|
+
}
|
|
449
|
+
if not relevant:
|
|
450
|
+
continue
|
|
451
|
+
source_path = manager.settings_file_paths.get(source_name)
|
|
452
|
+
if source_path:
|
|
453
|
+
sources.append(source_path)
|
|
454
|
+
raw_tools = raw.get("extra_tools")
|
|
455
|
+
if isinstance(raw_tools, list):
|
|
456
|
+
extra_tools_by_source[source_name] = [
|
|
457
|
+
item for item in raw_tools if isinstance(item, str)
|
|
458
|
+
]
|
|
459
|
+
merged = _merge_settings(merged, relevant)
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
settings = CrabCodeSettings.model_validate(merged)
|
|
463
|
+
except ValidationError as exc:
|
|
464
|
+
messages = []
|
|
465
|
+
for error in exc.errors(include_input=False, include_url=False)[:5]:
|
|
466
|
+
location = ".".join(str(part) for part in error.get("loc", ()))
|
|
467
|
+
message = str(error.get("msg", "invalid value"))
|
|
468
|
+
messages.append(f"{location}: {message}" if location else message)
|
|
469
|
+
raise HTTPException(
|
|
470
|
+
status_code=422,
|
|
471
|
+
detail="运行与工具配置无效:" + "; ".join(messages),
|
|
472
|
+
) from exc
|
|
473
|
+
|
|
474
|
+
editable_sources = []
|
|
475
|
+
for source_name, label in (
|
|
476
|
+
("userSettings", "用户配置"),
|
|
477
|
+
("projectSettings", "项目配置"),
|
|
478
|
+
("localSettings", "项目本地配置"),
|
|
479
|
+
):
|
|
480
|
+
source_path = manager.settings_file_paths.get(source_name)
|
|
481
|
+
if not source_path:
|
|
482
|
+
continue
|
|
483
|
+
path = Path(source_path)
|
|
484
|
+
editable_sources.append(
|
|
485
|
+
ModelSettingsSource(
|
|
486
|
+
id=source_name,
|
|
487
|
+
label=label,
|
|
488
|
+
path=str(path),
|
|
489
|
+
exists=path.is_file(),
|
|
490
|
+
writable=_is_writable_settings_path(path),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return RuntimeSettingsResponse(
|
|
495
|
+
cwd=cwd,
|
|
496
|
+
permission_policy=PermissionManager(settings.permissions).describe_policy(),
|
|
497
|
+
snapshot_enabled=settings.snapshot.enabled,
|
|
498
|
+
snapshot_max_size_mb=settings.snapshot.max_size_mb,
|
|
499
|
+
auto_compact_enabled=settings.auto_compact_enabled,
|
|
500
|
+
compact_buffer_tokens=settings.compact_buffer_tokens,
|
|
501
|
+
max_context_length=settings.max_context_length,
|
|
502
|
+
computer_use_mode=settings.computer_use.mode,
|
|
503
|
+
computer_use_target_scope=settings.computer_use.target_scope,
|
|
504
|
+
computer_use_delivery_policy=settings.computer_use.delivery_policy,
|
|
505
|
+
extra_tools=list(settings.extra_tools),
|
|
506
|
+
extra_tools_by_source=extra_tools_by_source,
|
|
507
|
+
sources=sources,
|
|
508
|
+
warnings=[],
|
|
509
|
+
editable_sources=editable_sources,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _resolve_model_settings_cwd(request: Request, cwd: str | None) -> str:
|
|
514
|
+
resolved_cwd = os.getcwd()
|
|
515
|
+
if cwd:
|
|
516
|
+
from crabcode_gateway.routes.workspace import _resolve_directory, _workspace_roots
|
|
517
|
+
|
|
518
|
+
resolved_cwd = str(_resolve_directory(cwd, _workspace_roots(request)))
|
|
519
|
+
return resolved_cwd
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _validate_model_settings_name(name: str | None) -> str:
|
|
523
|
+
if not name or not name.strip():
|
|
524
|
+
raise HTTPException(status_code=400, detail="name is required")
|
|
525
|
+
value = name.strip()
|
|
526
|
+
if value in {".", ".."} or any(char in value for char in "/\\"):
|
|
527
|
+
raise HTTPException(status_code=400, detail="name must be a simple configuration name")
|
|
528
|
+
if len(value) > 120:
|
|
529
|
+
raise HTTPException(status_code=400, detail="name is too long")
|
|
530
|
+
return value
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _settings_mutation_path(cwd: str, source: str) -> Path:
|
|
534
|
+
manager = ConfigManager(cwd=cwd)
|
|
535
|
+
path_str = manager.settings_file_paths.get(source)
|
|
536
|
+
if not path_str or source in {"flagSettings", "policySettings"}:
|
|
537
|
+
raise HTTPException(status_code=400, detail="settings source is not writable")
|
|
538
|
+
path = Path(path_str)
|
|
539
|
+
# Project layers must remain inside the selected workspace. User settings
|
|
540
|
+
# intentionally live in the Gateway user's home directory.
|
|
541
|
+
if source in {"projectSettings", "localSettings"}:
|
|
542
|
+
expected_parent = (Path(cwd).resolve() / ".crabcode").resolve()
|
|
543
|
+
try:
|
|
544
|
+
path.parent.resolve().relative_to(expected_parent)
|
|
545
|
+
except ValueError as exc:
|
|
546
|
+
raise HTTPException(status_code=403, detail="settings source is outside the workspace") from exc
|
|
547
|
+
return path
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _read_settings_object(path: Path) -> dict[str, Any]:
|
|
551
|
+
if not path.exists():
|
|
552
|
+
return {}
|
|
553
|
+
try:
|
|
554
|
+
value = json.loads(read_utf8_text(path).text)
|
|
555
|
+
except (json.JSONDecodeError, OSError, UnicodeError) as exc:
|
|
556
|
+
raise HTTPException(status_code=422, detail="settings file is not valid JSON") from exc
|
|
557
|
+
if not isinstance(value, dict):
|
|
558
|
+
raise HTTPException(status_code=422, detail="settings file must contain a JSON object")
|
|
559
|
+
return value
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _atomic_write_settings(path: Path, value: dict[str, Any]) -> None:
|
|
563
|
+
payload = json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
564
|
+
newline = "\n"
|
|
565
|
+
has_bom = False
|
|
566
|
+
if path.exists():
|
|
567
|
+
source = read_utf8_text(path)
|
|
568
|
+
newline = source.newline or newline
|
|
569
|
+
has_bom = source.has_bom
|
|
570
|
+
payload = normalize_newlines(payload, newline)
|
|
571
|
+
raw_payload = payload.encode("utf-8")
|
|
572
|
+
if has_bom:
|
|
573
|
+
raw_payload = codecs.BOM_UTF8 + raw_payload
|
|
574
|
+
temporary: Path | None = None
|
|
575
|
+
try:
|
|
576
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
577
|
+
with tempfile.NamedTemporaryFile(
|
|
578
|
+
"wb",
|
|
579
|
+
dir=path.parent,
|
|
580
|
+
prefix=f".{path.name}.",
|
|
581
|
+
suffix=".tmp",
|
|
582
|
+
delete=False,
|
|
583
|
+
) as handle:
|
|
584
|
+
temporary = Path(handle.name)
|
|
585
|
+
handle.write(raw_payload)
|
|
586
|
+
handle.flush()
|
|
587
|
+
os.fsync(handle.fileno())
|
|
588
|
+
if path.exists():
|
|
589
|
+
temporary.chmod(stat.S_IMODE(path.stat().st_mode))
|
|
590
|
+
replace_with_retry(temporary, path)
|
|
591
|
+
except OSError as exc:
|
|
592
|
+
if temporary is not None:
|
|
593
|
+
try:
|
|
594
|
+
temporary.unlink(missing_ok=True)
|
|
595
|
+
except OSError:
|
|
596
|
+
pass
|
|
597
|
+
raise HTTPException(status_code=403, detail="settings file is not writable") from exc
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _mutate_model_settings(request: Request, req: ModelSettingsMutationRequest) -> ModelSettingsResponse:
|
|
601
|
+
cwd = _resolve_model_settings_cwd(request, req.cwd)
|
|
602
|
+
path = _settings_mutation_path(cwd, req.source)
|
|
603
|
+
current = _read_settings_object(path)
|
|
604
|
+
action = req.action
|
|
605
|
+
|
|
606
|
+
if action in {"upsert_model", "delete_model"}:
|
|
607
|
+
name = _validate_model_settings_name(req.name)
|
|
608
|
+
models = current.get("models")
|
|
609
|
+
if models is None:
|
|
610
|
+
models = {}
|
|
611
|
+
current["models"] = models
|
|
612
|
+
if not isinstance(models, dict):
|
|
613
|
+
raise HTTPException(status_code=422, detail="models must be a JSON object")
|
|
614
|
+
if action == "delete_model":
|
|
615
|
+
if name not in models:
|
|
616
|
+
raise HTTPException(status_code=404, detail=f"模型“{name}”不在所选配置层中")
|
|
617
|
+
models.pop(name, None)
|
|
618
|
+
if not models:
|
|
619
|
+
current.pop("models", None)
|
|
620
|
+
if current.get("default_model") == name:
|
|
621
|
+
current["default_model"] = None
|
|
622
|
+
else:
|
|
623
|
+
config = req.config or {}
|
|
624
|
+
if not isinstance(config, dict):
|
|
625
|
+
raise HTTPException(status_code=422, detail="model config must be a JSON object")
|
|
626
|
+
existing = models.get(name, {})
|
|
627
|
+
if not isinstance(existing, dict):
|
|
628
|
+
existing = {}
|
|
629
|
+
merged = _merge_model_settings(existing, config)
|
|
630
|
+
for field_name in req.remove_fields:
|
|
631
|
+
if isinstance(field_name, str):
|
|
632
|
+
merged.pop(field_name, None)
|
|
633
|
+
try:
|
|
634
|
+
from crabcode_core.types.config import ApiConfig
|
|
635
|
+
|
|
636
|
+
ApiConfig.model_validate(merged)
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
raise HTTPException(status_code=422, detail=f"模型配置无效:{exc}") from exc
|
|
639
|
+
models[name] = merged
|
|
640
|
+
if req.previous_name and req.previous_name.strip() != name:
|
|
641
|
+
models.pop(_validate_model_settings_name(req.previous_name), None)
|
|
642
|
+
elif action in {"upsert_group", "delete_group"}:
|
|
643
|
+
name = _validate_model_settings_name(req.name)
|
|
644
|
+
groups = current.get("groups")
|
|
645
|
+
if groups is None:
|
|
646
|
+
groups = {}
|
|
647
|
+
current["groups"] = groups
|
|
648
|
+
if not isinstance(groups, dict):
|
|
649
|
+
raise HTTPException(status_code=422, detail="groups must be a JSON object")
|
|
650
|
+
if action == "delete_group":
|
|
651
|
+
if name not in groups:
|
|
652
|
+
raise HTTPException(status_code=404, detail=f"配置组“{name}”不在所选配置层中")
|
|
653
|
+
groups.pop(name, None)
|
|
654
|
+
if not groups:
|
|
655
|
+
current.pop("groups", None)
|
|
656
|
+
else:
|
|
657
|
+
config = req.config or {}
|
|
658
|
+
existing = groups.get(name, {})
|
|
659
|
+
if not isinstance(existing, dict):
|
|
660
|
+
existing = {}
|
|
661
|
+
merged = _merge_model_settings(existing, config)
|
|
662
|
+
for field_name in req.remove_fields:
|
|
663
|
+
if isinstance(field_name, str):
|
|
664
|
+
merged.pop(field_name, None)
|
|
665
|
+
try:
|
|
666
|
+
from crabcode_core.types.config import ApiConfig
|
|
667
|
+
|
|
668
|
+
ApiConfig.model_validate(merged)
|
|
669
|
+
except Exception as exc:
|
|
670
|
+
raise HTTPException(status_code=422, detail=f"配置组无效:{exc}") from exc
|
|
671
|
+
groups[name] = merged
|
|
672
|
+
if req.previous_name and req.previous_name.strip() != name:
|
|
673
|
+
previous_name = _validate_model_settings_name(req.previous_name)
|
|
674
|
+
groups.pop(previous_name, None)
|
|
675
|
+
models = current.get("models")
|
|
676
|
+
if isinstance(models, dict):
|
|
677
|
+
for model_config in models.values():
|
|
678
|
+
if isinstance(model_config, dict) and model_config.get("group") == previous_name:
|
|
679
|
+
model_config["group"] = name
|
|
680
|
+
elif action == "set_default_model":
|
|
681
|
+
name = _validate_model_settings_name(req.name)
|
|
682
|
+
preview = _model_settings_from_files(cwd)
|
|
683
|
+
if not any(model.name == name for model in preview.models):
|
|
684
|
+
raise HTTPException(status_code=400, detail=f"模型“{name}”不存在")
|
|
685
|
+
current["default_model"] = name
|
|
686
|
+
elif action == "clear_default_model":
|
|
687
|
+
# Keep an explicit null in this layer so a lower-priority default does
|
|
688
|
+
# not silently become active again after the user clears it here.
|
|
689
|
+
current["default_model"] = None
|
|
690
|
+
|
|
691
|
+
_atomic_write_settings(path, current)
|
|
692
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
693
|
+
return _model_settings_from_files(cwd)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _validate_extra_tool_path(tool_path: str | None) -> str:
|
|
697
|
+
if not tool_path or not tool_path.strip():
|
|
698
|
+
raise HTTPException(status_code=400, detail="tool_path is required")
|
|
699
|
+
value = tool_path.strip()
|
|
700
|
+
if any(char.isspace() for char in value) or len(value) > 240:
|
|
701
|
+
raise HTTPException(status_code=400, detail="tool_path must be a compact import path")
|
|
702
|
+
return value
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _mutate_runtime_settings(
|
|
706
|
+
request: Request,
|
|
707
|
+
req: RuntimeSettingsMutationRequest,
|
|
708
|
+
) -> RuntimeSettingsResponse:
|
|
709
|
+
cwd = _resolve_model_settings_cwd(request, req.cwd)
|
|
710
|
+
path = _settings_mutation_path(cwd, req.source)
|
|
711
|
+
current = _read_settings_object(path)
|
|
712
|
+
|
|
713
|
+
if req.action == "set_compaction":
|
|
714
|
+
if req.auto_compact_enabled is not None:
|
|
715
|
+
current["auto_compact_enabled"] = req.auto_compact_enabled
|
|
716
|
+
if req.compact_buffer_tokens is not None:
|
|
717
|
+
current["compact_buffer_tokens"] = req.compact_buffer_tokens
|
|
718
|
+
if "max_context_length" in req.model_fields_set:
|
|
719
|
+
# Explicit null clears the earlier threshold, including an inherited one.
|
|
720
|
+
current["max_context_length"] = req.max_context_length
|
|
721
|
+
_runtime_settings_from_files(cwd, pending=(req.source, current))
|
|
722
|
+
elif req.action == "set_snapshot":
|
|
723
|
+
snapshot = current.get("snapshot")
|
|
724
|
+
if snapshot is None:
|
|
725
|
+
snapshot = {}
|
|
726
|
+
current["snapshot"] = snapshot
|
|
727
|
+
if not isinstance(snapshot, dict):
|
|
728
|
+
raise HTTPException(status_code=422, detail="snapshot must be a JSON object")
|
|
729
|
+
if req.snapshot_enabled is not None:
|
|
730
|
+
snapshot["enabled"] = req.snapshot_enabled
|
|
731
|
+
if req.snapshot_max_size_mb is not None:
|
|
732
|
+
snapshot["max_size_mb"] = req.snapshot_max_size_mb
|
|
733
|
+
try:
|
|
734
|
+
from crabcode_core.types.config import SnapshotSettings
|
|
735
|
+
|
|
736
|
+
SnapshotSettings.model_validate(snapshot)
|
|
737
|
+
except Exception as exc:
|
|
738
|
+
raise HTTPException(status_code=422, detail=f"快照配置无效:{exc}") from exc
|
|
739
|
+
elif req.action in {"set_computer_use_mode", "set_computer_use_options"}:
|
|
740
|
+
computer_use = current.get("computer_use")
|
|
741
|
+
if computer_use is None:
|
|
742
|
+
computer_use = {}
|
|
743
|
+
current["computer_use"] = computer_use
|
|
744
|
+
if not isinstance(computer_use, dict):
|
|
745
|
+
raise HTTPException(status_code=422, detail="computer_use must be a JSON object")
|
|
746
|
+
if req.computer_use_mode is not None:
|
|
747
|
+
computer_use["target_scope"] = (
|
|
748
|
+
"desktop" if req.computer_use_mode == "foreground_desktop" else "app_window"
|
|
749
|
+
)
|
|
750
|
+
if req.computer_use_target_scope is not None:
|
|
751
|
+
computer_use["target_scope"] = req.computer_use_target_scope
|
|
752
|
+
if req.computer_use_delivery_policy is not None:
|
|
753
|
+
computer_use["delivery_policy"] = req.computer_use_delivery_policy
|
|
754
|
+
if "mode" in computer_use:
|
|
755
|
+
computer_use.setdefault(
|
|
756
|
+
"target_scope", "desktop" if computer_use["mode"] == "foreground_desktop" else "app_window"
|
|
757
|
+
)
|
|
758
|
+
computer_use.pop("mode")
|
|
759
|
+
try:
|
|
760
|
+
from crabcode_core.types.config import ComputerUseSettings
|
|
761
|
+
|
|
762
|
+
ComputerUseSettings.model_validate(computer_use)
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
raise HTTPException(status_code=422, detail=f"Invalid Computer Use configuration: {exc}") from exc
|
|
765
|
+
else:
|
|
766
|
+
tool_path = _validate_extra_tool_path(req.tool_path)
|
|
767
|
+
extra_tools = current.get("extra_tools")
|
|
768
|
+
if extra_tools is None:
|
|
769
|
+
extra_tools = []
|
|
770
|
+
current["extra_tools"] = extra_tools
|
|
771
|
+
if (
|
|
772
|
+
not isinstance(extra_tools, list)
|
|
773
|
+
or any(not isinstance(item, str) for item in extra_tools)
|
|
774
|
+
):
|
|
775
|
+
raise HTTPException(status_code=422, detail="extra_tools must be a list of import paths")
|
|
776
|
+
if req.action == "add_extra_tool":
|
|
777
|
+
if tool_path not in extra_tools:
|
|
778
|
+
extra_tools.append(tool_path)
|
|
779
|
+
else:
|
|
780
|
+
extra_tools[:] = [item for item in extra_tools if item != tool_path]
|
|
781
|
+
if not extra_tools:
|
|
782
|
+
current.pop("extra_tools", None)
|
|
783
|
+
|
|
784
|
+
if req.action in {"set_computer_use_mode", "set_computer_use_options"}:
|
|
785
|
+
effective = _runtime_settings_from_files(cwd, pending=(req.source, current))
|
|
786
|
+
if (
|
|
787
|
+
effective.computer_use_target_scope == "desktop"
|
|
788
|
+
and effective.computer_use_delivery_policy == "strict_background"
|
|
789
|
+
):
|
|
790
|
+
raise HTTPException(
|
|
791
|
+
status_code=422,
|
|
792
|
+
detail="Desktop scope requires allow_foreground; strict_background supports app-window scope only.",
|
|
793
|
+
)
|
|
794
|
+
_atomic_write_settings(path, current)
|
|
795
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
796
|
+
return _runtime_settings_from_files(cwd)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _prompt_source_map(manager: ConfigManager, key: str) -> dict[str, str]:
|
|
800
|
+
from crabcode_core.config.manager import SETTING_SOURCES
|
|
801
|
+
|
|
802
|
+
sources: dict[str, str] = {}
|
|
803
|
+
for source_name in SETTING_SOURCES:
|
|
804
|
+
raw = manager.get_settings_for_source(source_name) or {}
|
|
805
|
+
items = raw.get(key)
|
|
806
|
+
if not isinstance(items, list):
|
|
807
|
+
continue
|
|
808
|
+
for item in items:
|
|
809
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
|
|
810
|
+
sources[item["id"].strip()] = source_name
|
|
811
|
+
return sources
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _editable_prompt_sources(cwd: str) -> list[ModelSettingsSource]:
|
|
815
|
+
manager = ConfigManager(cwd=cwd)
|
|
816
|
+
editable_sources = []
|
|
817
|
+
for source_name, label in (
|
|
818
|
+
("userSettings", "用户配置"),
|
|
819
|
+
("projectSettings", "项目配置"),
|
|
820
|
+
("localSettings", "项目本地配置"),
|
|
821
|
+
):
|
|
822
|
+
source_path = manager.settings_file_paths.get(source_name)
|
|
823
|
+
if not source_path:
|
|
824
|
+
continue
|
|
825
|
+
path = Path(source_path)
|
|
826
|
+
editable_sources.append(
|
|
827
|
+
ModelSettingsSource(
|
|
828
|
+
id=source_name,
|
|
829
|
+
label=label,
|
|
830
|
+
path=str(path),
|
|
831
|
+
exists=path.is_file(),
|
|
832
|
+
writable=_is_writable_settings_path(path),
|
|
833
|
+
)
|
|
834
|
+
)
|
|
835
|
+
return editable_sources
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _prompt_settings_from_files(cwd: str) -> PromptSettingsResponse:
|
|
839
|
+
from crabcode_core.prompts.library import PROMPT_SECTIONS, last_by_id
|
|
840
|
+
from crabcode_core.prompts.system import get_default_prompt_sections
|
|
841
|
+
|
|
842
|
+
default_sections = get_default_prompt_sections()
|
|
843
|
+
manager = ConfigManager(cwd=cwd)
|
|
844
|
+
settings = manager.load()
|
|
845
|
+
templates = last_by_id(list(settings.prompt_templates))
|
|
846
|
+
user_prompts = last_by_id(list(settings.user_append_prompts))
|
|
847
|
+
template_sources = _prompt_source_map(manager, "prompt_templates")
|
|
848
|
+
prompt_sources = _prompt_source_map(manager, "user_append_prompts")
|
|
849
|
+
template_ids = {item.id for item in templates}
|
|
850
|
+
active = settings.active_prompt_template
|
|
851
|
+
warnings: list[str] = []
|
|
852
|
+
if active and active not in template_ids:
|
|
853
|
+
warnings.append("已选择的提示词模版不存在,已回退为默认。")
|
|
854
|
+
active = None
|
|
855
|
+
return PromptSettingsResponse(
|
|
856
|
+
cwd=cwd,
|
|
857
|
+
active_template_id=active,
|
|
858
|
+
templates=[
|
|
859
|
+
PromptTemplateView(
|
|
860
|
+
id=item.id,
|
|
861
|
+
name=item.name,
|
|
862
|
+
sections=dict(item.sections),
|
|
863
|
+
source=template_sources.get(item.id, "userSettings"),
|
|
864
|
+
)
|
|
865
|
+
for item in templates
|
|
866
|
+
],
|
|
867
|
+
user_prompts=[
|
|
868
|
+
UserAppendPromptView(
|
|
869
|
+
id=item.id,
|
|
870
|
+
text=item.text,
|
|
871
|
+
enabled=item.enabled,
|
|
872
|
+
source=prompt_sources.get(item.id, "userSettings"),
|
|
873
|
+
)
|
|
874
|
+
for item in user_prompts
|
|
875
|
+
],
|
|
876
|
+
sections=[
|
|
877
|
+
PromptSectionInfo(
|
|
878
|
+
key=key,
|
|
879
|
+
label=label,
|
|
880
|
+
description=(
|
|
881
|
+
"用于手动和自动压缩,随模版生效。留空使用内置默认;/compact 的临时要求会继续追加。"
|
|
882
|
+
if key == "compact_prompt" else None
|
|
883
|
+
),
|
|
884
|
+
default_text=default_sections.get(key),
|
|
885
|
+
)
|
|
886
|
+
for key, label in PROMPT_SECTIONS
|
|
887
|
+
],
|
|
888
|
+
warnings=warnings,
|
|
889
|
+
editable_sources=_editable_prompt_sources(cwd),
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _stored_prompt_items(current: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
894
|
+
items = current.get(key)
|
|
895
|
+
if items is None:
|
|
896
|
+
items = []
|
|
897
|
+
current[key] = items
|
|
898
|
+
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
|
|
899
|
+
raise HTTPException(status_code=422, detail=f"{key} must be a list of objects")
|
|
900
|
+
return items
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _clean_prompt_sections(sections: dict[str, str] | None) -> dict[str, str]:
|
|
904
|
+
from crabcode_core.prompts.library import PROMPT_SECTION_KEYS
|
|
905
|
+
|
|
906
|
+
cleaned: dict[str, str] = {}
|
|
907
|
+
for key, value in (sections or {}).items():
|
|
908
|
+
if key not in PROMPT_SECTION_KEYS or not isinstance(value, str):
|
|
909
|
+
continue
|
|
910
|
+
text = value.strip()
|
|
911
|
+
if not text:
|
|
912
|
+
continue
|
|
913
|
+
if len(text) > 20000:
|
|
914
|
+
raise HTTPException(status_code=422, detail=f"{key} is too long")
|
|
915
|
+
cleaned[key] = text
|
|
916
|
+
return cleaned
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
_PROMPT_LAYERS = ("localSettings", "projectSettings", "userSettings")
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _layers_containing_prompt(cwd: str, key: str, item_id: str) -> list[tuple[str, Path, dict[str, Any], list[Any]]]:
|
|
923
|
+
found = []
|
|
924
|
+
for source in _PROMPT_LAYERS:
|
|
925
|
+
path = _settings_mutation_path(cwd, source)
|
|
926
|
+
if not path.is_file():
|
|
927
|
+
continue
|
|
928
|
+
current = _read_settings_object(path)
|
|
929
|
+
items = current.get(key)
|
|
930
|
+
if not isinstance(items, list):
|
|
931
|
+
continue
|
|
932
|
+
if any(isinstance(item, dict) and item.get("id") == item_id for item in items):
|
|
933
|
+
found.append((source, path, current, items))
|
|
934
|
+
return found
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _remove_prompt_id(cwd: str, key: str, item_id: str, *, clear_active: bool) -> None:
|
|
938
|
+
layers = _layers_containing_prompt(cwd, key, item_id)
|
|
939
|
+
if not layers:
|
|
940
|
+
label = "模版" if key == "prompt_templates" else "用户提示"
|
|
941
|
+
raise HTTPException(status_code=404, detail=f"找不到要删除的{label}")
|
|
942
|
+
for _source, path, current, items in layers:
|
|
943
|
+
kept = [item for item in items if not (isinstance(item, dict) and item.get("id") == item_id)]
|
|
944
|
+
if kept:
|
|
945
|
+
current[key] = kept
|
|
946
|
+
else:
|
|
947
|
+
current.pop(key, None)
|
|
948
|
+
if clear_active and current.get("active_prompt_template") == item_id:
|
|
949
|
+
current["active_prompt_template"] = None
|
|
950
|
+
_atomic_write_settings(path, current)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _set_user_prompt_enabled(cwd: str, prompt_id: str, enabled: bool) -> None:
|
|
954
|
+
layers = _layers_containing_prompt(cwd, "user_append_prompts", prompt_id)
|
|
955
|
+
if not layers:
|
|
956
|
+
raise HTTPException(status_code=404, detail="找不到要更新的用户提示")
|
|
957
|
+
for _source, path, current, items in layers:
|
|
958
|
+
for item in items:
|
|
959
|
+
if isinstance(item, dict) and item.get("id") == prompt_id:
|
|
960
|
+
item["enabled"] = enabled
|
|
961
|
+
current["user_append_prompts"] = items
|
|
962
|
+
_atomic_write_settings(path, current)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _mutate_prompt_settings(
|
|
966
|
+
request: Request,
|
|
967
|
+
req: PromptSettingsMutationRequest,
|
|
968
|
+
) -> PromptSettingsResponse:
|
|
969
|
+
import uuid
|
|
970
|
+
|
|
971
|
+
from crabcode_core.types.config import PromptTemplateConfig, UserAppendPromptConfig
|
|
972
|
+
|
|
973
|
+
cwd = _resolve_model_settings_cwd(request, req.cwd)
|
|
974
|
+
path = _settings_mutation_path(cwd, req.source)
|
|
975
|
+
current = _read_settings_object(path)
|
|
976
|
+
|
|
977
|
+
if req.action == "save_template":
|
|
978
|
+
name = req.template_name or ""
|
|
979
|
+
if name == "默认":
|
|
980
|
+
raise HTTPException(status_code=400, detail="模版名称不能是「默认」")
|
|
981
|
+
sections = _clean_prompt_sections(req.sections)
|
|
982
|
+
template_id = (req.template_id or "").strip() or uuid.uuid4().hex
|
|
983
|
+
preview = _prompt_settings_from_files(cwd)
|
|
984
|
+
if any(item.name == name and item.id != template_id for item in preview.templates):
|
|
985
|
+
raise HTTPException(status_code=409, detail=f"模版「{name}」已存在")
|
|
986
|
+
try:
|
|
987
|
+
stored = PromptTemplateConfig(id=template_id, name=name, sections=sections).model_dump()
|
|
988
|
+
except Exception as exc:
|
|
989
|
+
raise HTTPException(status_code=422, detail=f"提示词模版无效:{exc}") from exc
|
|
990
|
+
templates = _stored_prompt_items(current, "prompt_templates")
|
|
991
|
+
if len(templates) >= 40 and all(item.get("id") != template_id for item in templates):
|
|
992
|
+
raise HTTPException(status_code=422, detail="提示词模版数量已达上限")
|
|
993
|
+
replaced = False
|
|
994
|
+
for index, item in enumerate(templates):
|
|
995
|
+
if item.get("id") == template_id:
|
|
996
|
+
templates[index] = stored
|
|
997
|
+
replaced = True
|
|
998
|
+
break
|
|
999
|
+
if not replaced:
|
|
1000
|
+
templates.append(stored)
|
|
1001
|
+
current["active_prompt_template"] = template_id
|
|
1002
|
+
elif req.action == "delete_template":
|
|
1003
|
+
_remove_prompt_id(cwd, "prompt_templates", req.template_id or "", clear_active=True)
|
|
1004
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
1005
|
+
return _prompt_settings_from_files(cwd)
|
|
1006
|
+
elif req.action == "set_active_template":
|
|
1007
|
+
template_id = req.template_id
|
|
1008
|
+
if template_id:
|
|
1009
|
+
preview = _prompt_settings_from_files(cwd)
|
|
1010
|
+
if template_id not in {item.id for item in preview.templates}:
|
|
1011
|
+
raise HTTPException(status_code=400, detail="提示词模版不存在")
|
|
1012
|
+
current["active_prompt_template"] = template_id
|
|
1013
|
+
elif req.action == "add_user_prompt":
|
|
1014
|
+
prompts = _stored_prompt_items(current, "user_append_prompts")
|
|
1015
|
+
if len(prompts) >= 80:
|
|
1016
|
+
raise HTTPException(status_code=422, detail="用户提示数量已达上限")
|
|
1017
|
+
try:
|
|
1018
|
+
stored = UserAppendPromptConfig(
|
|
1019
|
+
id=uuid.uuid4().hex,
|
|
1020
|
+
text=req.prompt_text or "",
|
|
1021
|
+
enabled=True,
|
|
1022
|
+
).model_dump()
|
|
1023
|
+
except Exception as exc:
|
|
1024
|
+
raise HTTPException(status_code=422, detail=f"用户提示无效:{exc}") from exc
|
|
1025
|
+
prompts.append(stored)
|
|
1026
|
+
elif req.action == "set_user_prompt_enabled":
|
|
1027
|
+
_set_user_prompt_enabled(cwd, req.prompt_id or "", bool(req.enabled))
|
|
1028
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
1029
|
+
return _prompt_settings_from_files(cwd)
|
|
1030
|
+
else:
|
|
1031
|
+
_remove_prompt_id(cwd, "user_append_prompts", req.prompt_id or "", clear_active=False)
|
|
1032
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
1033
|
+
return _prompt_settings_from_files(cwd)
|
|
1034
|
+
|
|
1035
|
+
_atomic_write_settings(path, current)
|
|
1036
|
+
ConfigManager(cwd=cwd).reset_cache()
|
|
1037
|
+
return _prompt_settings_from_files(cwd)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _reload_prompt_settings_for_source(request: Request, cwd: str, source: str) -> None:
|
|
1041
|
+
sessions = getattr(request.app.state, "sessions", None)
|
|
1042
|
+
if not isinstance(sessions, dict):
|
|
1043
|
+
return
|
|
1044
|
+
changed_path = _settings_mutation_path(cwd, source).resolve()
|
|
1045
|
+
for session in sessions.values():
|
|
1046
|
+
session_cwd = getattr(session, "cwd", None)
|
|
1047
|
+
if not isinstance(session_cwd, str) or not session_cwd:
|
|
1048
|
+
continue
|
|
1049
|
+
session_path = ConfigManager(cwd=session_cwd).settings_file_paths.get(source)
|
|
1050
|
+
if not session_path or Path(session_path).resolve() != changed_path:
|
|
1051
|
+
continue
|
|
1052
|
+
reload_prompt = getattr(session, "reload_prompt_settings", None)
|
|
1053
|
+
if callable(reload_prompt):
|
|
1054
|
+
reload_prompt()
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
@router.get("/config/prompt-settings", response_model=PromptSettingsResponse)
|
|
1058
|
+
async def get_prompt_settings(
|
|
1059
|
+
request: Request,
|
|
1060
|
+
cwd: str | None = None,
|
|
1061
|
+
) -> PromptSettingsResponse:
|
|
1062
|
+
"""Inspect prompt templates and prompts that can be appended to user input."""
|
|
1063
|
+
return _prompt_settings_from_files(_resolve_model_settings_cwd(request, cwd))
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
@router.post("/config/prompt-settings", response_model=PromptSettingsResponse)
|
|
1067
|
+
async def mutate_prompt_settings(
|
|
1068
|
+
req: PromptSettingsMutationRequest,
|
|
1069
|
+
request: Request,
|
|
1070
|
+
) -> PromptSettingsResponse:
|
|
1071
|
+
"""Save a prompt template or change which user prompts are appended."""
|
|
1072
|
+
lock = getattr(request.app.state, "model_settings_lock", None)
|
|
1073
|
+
if lock is None:
|
|
1074
|
+
lock = asyncio.Lock()
|
|
1075
|
+
request.app.state.model_settings_lock = lock
|
|
1076
|
+
async with lock:
|
|
1077
|
+
async with get_session_lock(request.app.state):
|
|
1078
|
+
result = _mutate_prompt_settings(request, req)
|
|
1079
|
+
_reload_prompt_settings_for_source(request, result.cwd, req.source)
|
|
1080
|
+
return result
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
@router.get("/config/models", response_model=list[ModelInfo])
|
|
1084
|
+
async def list_models(
|
|
1085
|
+
request: Request,
|
|
1086
|
+
session_id: str | None = None,
|
|
1087
|
+
cwd: str | None = None,
|
|
1088
|
+
) -> list[ModelInfo]:
|
|
1089
|
+
"""List available named models.
|
|
1090
|
+
|
|
1091
|
+
An explicit cwd reads that project's catalog independently of sessions.
|
|
1092
|
+
Legacy callers try the active session first, then process-cwd settings.
|
|
1093
|
+
"""
|
|
1094
|
+
if cwd is not None:
|
|
1095
|
+
if session_id is not None:
|
|
1096
|
+
raise HTTPException(status_code=400, detail="Specify cwd or session_id, not both")
|
|
1097
|
+
return _list_models_from_settings(_resolve_model_settings_cwd(request, cwd))
|
|
1098
|
+
async with get_session_lock(request.app.state):
|
|
1099
|
+
session = _get_session(request, session_id)
|
|
1100
|
+
if session_id is not None and session is None:
|
|
1101
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1102
|
+
if session:
|
|
1103
|
+
models = dict(session.list_models())
|
|
1104
|
+
else:
|
|
1105
|
+
models = None
|
|
1106
|
+
if models is not None:
|
|
1107
|
+
session_settings = getattr(session, "settings", None)
|
|
1108
|
+
return [
|
|
1109
|
+
ModelInfo(
|
|
1110
|
+
name=name,
|
|
1111
|
+
description=desc,
|
|
1112
|
+
group=(
|
|
1113
|
+
getattr(session_settings.get_api_config(name), "group", None)
|
|
1114
|
+
if session_settings is not None
|
|
1115
|
+
and hasattr(session_settings, "get_api_config")
|
|
1116
|
+
else None
|
|
1117
|
+
)
|
|
1118
|
+
or "default",
|
|
1119
|
+
)
|
|
1120
|
+
for name, desc in models.items()
|
|
1121
|
+
]
|
|
1122
|
+
return _list_models_from_settings()
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@router.get("/config/model-settings", response_model=ModelSettingsResponse)
|
|
1126
|
+
async def get_model_settings(
|
|
1127
|
+
request: Request,
|
|
1128
|
+
cwd: str | None = None,
|
|
1129
|
+
) -> ModelSettingsResponse:
|
|
1130
|
+
"""Inspect named model settings and available mutation layers."""
|
|
1131
|
+
return _model_settings_from_files(_resolve_model_settings_cwd(request, cwd))
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@router.post("/config/model-settings", response_model=ModelSettingsResponse)
|
|
1135
|
+
async def mutate_model_settings(
|
|
1136
|
+
req: ModelSettingsMutationRequest,
|
|
1137
|
+
request: Request,
|
|
1138
|
+
) -> ModelSettingsResponse:
|
|
1139
|
+
"""Create, update, or remove a named model/group configuration."""
|
|
1140
|
+
lock = getattr(request.app.state, "model_settings_lock", None)
|
|
1141
|
+
if lock is None:
|
|
1142
|
+
lock = asyncio.Lock()
|
|
1143
|
+
request.app.state.model_settings_lock = lock
|
|
1144
|
+
async with lock:
|
|
1145
|
+
return _mutate_model_settings(request, req)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@router.get("/config/runtime-settings", response_model=RuntimeSettingsResponse)
|
|
1149
|
+
async def get_runtime_settings(
|
|
1150
|
+
request: Request,
|
|
1151
|
+
cwd: str | None = None,
|
|
1152
|
+
) -> RuntimeSettingsResponse:
|
|
1153
|
+
"""Inspect runtime settings and available mutation layers."""
|
|
1154
|
+
return _runtime_settings_from_files(_resolve_model_settings_cwd(request, cwd))
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
@router.post("/config/runtime-settings", response_model=RuntimeSettingsResponse)
|
|
1158
|
+
async def mutate_runtime_settings(
|
|
1159
|
+
req: RuntimeSettingsMutationRequest,
|
|
1160
|
+
request: Request,
|
|
1161
|
+
) -> RuntimeSettingsResponse:
|
|
1162
|
+
"""Update runtime settings in a selected layer."""
|
|
1163
|
+
lock = getattr(request.app.state, "model_settings_lock", None)
|
|
1164
|
+
if lock is None:
|
|
1165
|
+
lock = asyncio.Lock()
|
|
1166
|
+
request.app.state.model_settings_lock = lock
|
|
1167
|
+
async with lock:
|
|
1168
|
+
if req.action not in {"set_compaction", "set_computer_use_mode", "set_computer_use_options"}:
|
|
1169
|
+
return _mutate_runtime_settings(request, req)
|
|
1170
|
+
|
|
1171
|
+
# Session registration uses this lock too. Keep the file write and
|
|
1172
|
+
# in-memory refresh together so a newly registered session cannot
|
|
1173
|
+
# retain settings read just before the write.
|
|
1174
|
+
async with get_session_lock(request.app.state):
|
|
1175
|
+
result = _mutate_runtime_settings(request, req)
|
|
1176
|
+
changed_path = _settings_mutation_path(result.cwd, req.source).resolve()
|
|
1177
|
+
for session in request.app.state.sessions.values():
|
|
1178
|
+
session_path = ConfigManager(cwd=session.cwd).settings_file_paths.get(req.source)
|
|
1179
|
+
if session_path and Path(session_path).resolve() == changed_path:
|
|
1180
|
+
if req.action == "set_compaction":
|
|
1181
|
+
session.reload_compaction_settings()
|
|
1182
|
+
else:
|
|
1183
|
+
session.reload_computer_use_settings()
|
|
1184
|
+
return result
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@router.post("/config/switch-model")
|
|
1188
|
+
async def switch_model(req: SwitchModelRequest, request: Request):
|
|
1189
|
+
"""Switch to a named model."""
|
|
1190
|
+
session = _get_session(request, req.session_id)
|
|
1191
|
+
if not session:
|
|
1192
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1193
|
+
|
|
1194
|
+
try:
|
|
1195
|
+
ok = await run_session_operation(
|
|
1196
|
+
request.app.state,
|
|
1197
|
+
session,
|
|
1198
|
+
lambda: _switch_model(session, req.name),
|
|
1199
|
+
)
|
|
1200
|
+
except SessionOperationRejected as exc:
|
|
1201
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1202
|
+
if not ok:
|
|
1203
|
+
raise HTTPException(status_code=400, detail=f"Model '{req.name}' not found")
|
|
1204
|
+
return {"status": "ok"}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@router.post("/config/switch-mode")
|
|
1208
|
+
async def switch_mode(req: SwitchModeRequest, request: Request):
|
|
1209
|
+
"""Switch between agent and plan mode."""
|
|
1210
|
+
session = _get_session(request, req.session_id)
|
|
1211
|
+
if not session:
|
|
1212
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1213
|
+
|
|
1214
|
+
try:
|
|
1215
|
+
ok = await run_session_operation(
|
|
1216
|
+
request.app.state,
|
|
1217
|
+
session,
|
|
1218
|
+
lambda: _switch_mode(session, req.mode),
|
|
1219
|
+
)
|
|
1220
|
+
except SessionOperationRejected as exc:
|
|
1221
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1222
|
+
if not ok:
|
|
1223
|
+
raise HTTPException(status_code=400, detail=f"Invalid mode '{req.mode}'")
|
|
1224
|
+
return {"status": "ok", "mode": req.mode}
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
@router.post("/config/reasoning-effort")
|
|
1228
|
+
async def set_reasoning_effort(req: SetReasoningEffortRequest, request: Request):
|
|
1229
|
+
"""Set the active session's reasoning effort for subsequent requests."""
|
|
1230
|
+
session = _get_session(request, req.session_id)
|
|
1231
|
+
if not session:
|
|
1232
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1233
|
+
|
|
1234
|
+
try:
|
|
1235
|
+
ok = await run_session_operation(
|
|
1236
|
+
request.app.state,
|
|
1237
|
+
session,
|
|
1238
|
+
lambda: _set_reasoning_effort(session, req.effort),
|
|
1239
|
+
)
|
|
1240
|
+
except SessionOperationRejected as exc:
|
|
1241
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1242
|
+
if not ok:
|
|
1243
|
+
raise HTTPException(status_code=400, detail=f"Invalid reasoning effort '{req.effort}'")
|
|
1244
|
+
return {"status": "ok", "reasoning_effort": session.reasoning_effort}
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
@router.post("/config/ultra-mode")
|
|
1248
|
+
async def set_ultra_mode(req: SetUltraModeRequest, request: Request):
|
|
1249
|
+
"""Set ultra mode, or toggle it when ``enabled`` is omitted."""
|
|
1250
|
+
session = _get_session(request, req.session_id)
|
|
1251
|
+
if not session:
|
|
1252
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1253
|
+
|
|
1254
|
+
try:
|
|
1255
|
+
enabled = await run_session_operation(
|
|
1256
|
+
request.app.state,
|
|
1257
|
+
session,
|
|
1258
|
+
lambda: _set_ultra_mode(session, req.enabled),
|
|
1259
|
+
)
|
|
1260
|
+
except SessionOperationRejected as exc:
|
|
1261
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1262
|
+
return {"status": "ok", "ultra_mode": enabled}
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
@router.post("/config/permission-mode")
|
|
1266
|
+
async def set_permission_mode(req: SetPermissionModeRequest, request: Request):
|
|
1267
|
+
"""Set the per-client tool permission override for a session."""
|
|
1268
|
+
session = _get_session(request, req.session_id)
|
|
1269
|
+
if not session:
|
|
1270
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1271
|
+
try:
|
|
1272
|
+
ok = await run_session_operation(
|
|
1273
|
+
request.app.state,
|
|
1274
|
+
session,
|
|
1275
|
+
lambda: _set_permission_mode(session, req.mode),
|
|
1276
|
+
)
|
|
1277
|
+
except SessionOperationRejected as exc:
|
|
1278
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1279
|
+
if not ok:
|
|
1280
|
+
raise HTTPException(status_code=400, detail=f"Invalid permission mode '{req.mode}'")
|
|
1281
|
+
return {"status": "ok", "permission_mode": getattr(session, "client_permission_mode", req.mode)}
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
@router.get("/config/goal", response_model=GoalState)
|
|
1285
|
+
async def get_goal(request: Request, session_id: str | None = None) -> GoalState:
|
|
1286
|
+
"""Return the current session goal."""
|
|
1287
|
+
async with get_session_lock(request.app.state):
|
|
1288
|
+
session = _get_session(request, session_id)
|
|
1289
|
+
if not session:
|
|
1290
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1291
|
+
goal = session.get_goal()
|
|
1292
|
+
data = goal.to_dict() if goal is not None else None
|
|
1293
|
+
return GoalState(goal=data)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
@router.post("/config/goal", response_model=GoalState)
|
|
1297
|
+
async def manage_goal(req: GoalRequest, request: Request) -> GoalState:
|
|
1298
|
+
"""Set, edit, pause, resume, finish, block, or clear a session goal."""
|
|
1299
|
+
session = _get_session(request, req.session_id)
|
|
1300
|
+
if not session:
|
|
1301
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1302
|
+
try:
|
|
1303
|
+
data = await run_session_operation(
|
|
1304
|
+
request.app.state,
|
|
1305
|
+
session,
|
|
1306
|
+
lambda: _manage_goal(session, req),
|
|
1307
|
+
)
|
|
1308
|
+
except SessionOperationRejected as exc:
|
|
1309
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1310
|
+
except (RuntimeError, ValueError) as exc:
|
|
1311
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1312
|
+
return GoalState(goal=data)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
@router.get("/tools", response_model=list[ToolInfo])
|
|
1316
|
+
async def list_tools(
|
|
1317
|
+
request: Request,
|
|
1318
|
+
session_id: str | None = None,
|
|
1319
|
+
cwd: str | None = None,
|
|
1320
|
+
) -> list[ToolInfo]:
|
|
1321
|
+
"""List enabled tools for the active session or a workspace."""
|
|
1322
|
+
async with get_session_lock(request.app.state):
|
|
1323
|
+
session = _get_session(request, session_id)
|
|
1324
|
+
if session_id is not None and session is None:
|
|
1325
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1326
|
+
|
|
1327
|
+
if session:
|
|
1328
|
+
async def _list_initialized_tools() -> list[ToolInfo]:
|
|
1329
|
+
initializer = getattr(session, "initialize", None)
|
|
1330
|
+
if callable(initializer):
|
|
1331
|
+
await initializer()
|
|
1332
|
+
return [
|
|
1333
|
+
ToolInfo(
|
|
1334
|
+
name=t.name,
|
|
1335
|
+
description=t.description or "",
|
|
1336
|
+
is_read_only=t.is_read_only,
|
|
1337
|
+
is_enabled=t.is_enabled,
|
|
1338
|
+
is_loaded=(t.name in session.last_prompt_budget.get("loaded_names", [])
|
|
1339
|
+
if getattr(session, "last_prompt_budget", None) else None),
|
|
1340
|
+
)
|
|
1341
|
+
for t in session.tools
|
|
1342
|
+
if bool(getattr(t, "is_enabled", True))
|
|
1343
|
+
]
|
|
1344
|
+
|
|
1345
|
+
try:
|
|
1346
|
+
return await run_session_operation(
|
|
1347
|
+
request.app.state,
|
|
1348
|
+
session,
|
|
1349
|
+
_list_initialized_tools,
|
|
1350
|
+
)
|
|
1351
|
+
except SessionOperationRejected as exc:
|
|
1352
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1353
|
+
|
|
1354
|
+
# Plugin discovery is also used before the first chat is opened. Return
|
|
1355
|
+
# the built-in registry in that case; session initialization can still add
|
|
1356
|
+
# MCP and project-specific tools later.
|
|
1357
|
+
from crabcode_core.tools import get_default_tools
|
|
1358
|
+
|
|
1359
|
+
return [
|
|
1360
|
+
ToolInfo(
|
|
1361
|
+
name=t.name,
|
|
1362
|
+
description=t.description or "",
|
|
1363
|
+
is_read_only=t.is_read_only,
|
|
1364
|
+
is_enabled=t.is_enabled,
|
|
1365
|
+
)
|
|
1366
|
+
for t in get_default_tools()
|
|
1367
|
+
if bool(getattr(t, "is_enabled", True))
|
|
1368
|
+
]
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
@router.get("/skills", response_model=list[SkillInfo])
|
|
1372
|
+
async def list_skills(
|
|
1373
|
+
request: Request,
|
|
1374
|
+
session_id: str | None = None,
|
|
1375
|
+
cwd: str | None = None,
|
|
1376
|
+
) -> list[SkillInfo]:
|
|
1377
|
+
"""List all skills visible from the current working directory."""
|
|
1378
|
+
async with get_session_lock(request.app.state):
|
|
1379
|
+
session = _get_session(request, session_id)
|
|
1380
|
+
if session_id is not None and session is None:
|
|
1381
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1382
|
+
session_cwd = getattr(session, "cwd", None) if session else None
|
|
1383
|
+
if not cwd and session and hasattr(session, "skills") and session.skills:
|
|
1384
|
+
skills = [
|
|
1385
|
+
SkillInfo(name=s.name, description=s.description or "")
|
|
1386
|
+
for s in session.skills
|
|
1387
|
+
]
|
|
1388
|
+
else:
|
|
1389
|
+
skills = None
|
|
1390
|
+
if skills is not None:
|
|
1391
|
+
return skills
|
|
1392
|
+
# Fallback: load from cwd when no session is active yet
|
|
1393
|
+
import os
|
|
1394
|
+
|
|
1395
|
+
skill_cwd = cwd or session_cwd or os.getcwd()
|
|
1396
|
+
if cwd:
|
|
1397
|
+
from crabcode_gateway.routes.workspace import _resolve_directory, _workspace_roots
|
|
1398
|
+
|
|
1399
|
+
skill_cwd = str(_resolve_directory(cwd, _workspace_roots(request)))
|
|
1400
|
+
skills = load_skills(skill_cwd)
|
|
1401
|
+
return [SkillInfo(name=s.name, description=s.description or "") for s in skills]
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
@router.post("/skills/expand", response_model=SkillExpansion)
|
|
1405
|
+
async def expand_skill(req: SkillExpandRequest, request: Request) -> SkillExpansion:
|
|
1406
|
+
"""Expand a slash-invoked skill deterministically, matching the CLI."""
|
|
1407
|
+
async with get_session_lock(request.app.state):
|
|
1408
|
+
session = _get_session(request, req.session_id)
|
|
1409
|
+
if req.session_id is not None and session is None:
|
|
1410
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1411
|
+
skills = list(getattr(session, "skills", ())) if session else []
|
|
1412
|
+
cwd = getattr(session, "cwd", None) if session else None
|
|
1413
|
+
|
|
1414
|
+
if not skills:
|
|
1415
|
+
import os
|
|
1416
|
+
|
|
1417
|
+
skills = load_skills(cwd or os.getcwd())
|
|
1418
|
+
skill = next((item for item in skills if item.name == req.name), None)
|
|
1419
|
+
if skill is None:
|
|
1420
|
+
raise HTTPException(status_code=404, detail=f"Skill {req.name} not found")
|
|
1421
|
+
|
|
1422
|
+
prompt = skill.content
|
|
1423
|
+
user_input = req.user_input.strip()
|
|
1424
|
+
if user_input:
|
|
1425
|
+
if "$USER_INPUT" in prompt:
|
|
1426
|
+
prompt = prompt.replace("$USER_INPUT", user_input)
|
|
1427
|
+
else:
|
|
1428
|
+
prompt = f"{prompt}\n\nUser input: {user_input}"
|
|
1429
|
+
return SkillExpansion(name=skill.name, prompt=prompt)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
@router.post("/context")
|
|
1433
|
+
async def push_context(req: ContextPushRequest, request: Request):
|
|
1434
|
+
"""Push workspace context from a client (e.g. VSCode extension).
|
|
1435
|
+
|
|
1436
|
+
The gateway stores this per-session so that it can be injected
|
|
1437
|
+
into the system prompt or tool context as needed.
|
|
1438
|
+
"""
|
|
1439
|
+
session = _get_session(request, req.session_id)
|
|
1440
|
+
if session is None:
|
|
1441
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1442
|
+
try:
|
|
1443
|
+
await run_session_operation(
|
|
1444
|
+
request.app.state,
|
|
1445
|
+
session,
|
|
1446
|
+
lambda: _store_context(request, session, req),
|
|
1447
|
+
)
|
|
1448
|
+
except SessionOperationRejected as exc:
|
|
1449
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1450
|
+
return {"status": "ok"}
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
@router.get("/context/{session_id}")
|
|
1454
|
+
async def get_context(session_id: str, request: Request):
|
|
1455
|
+
"""Retrieve the current client-pushed context for a session."""
|
|
1456
|
+
async with get_session_lock(request.app.state):
|
|
1457
|
+
contexts: dict = request.app.state.client_contexts
|
|
1458
|
+
sessions: dict = request.app.state.sessions
|
|
1459
|
+
if (
|
|
1460
|
+
session_id not in sessions
|
|
1461
|
+
or session_id in getattr(request.app.state, "closing_sessions", set())
|
|
1462
|
+
):
|
|
1463
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1464
|
+
context = contexts.get(session_id)
|
|
1465
|
+
if context is not None:
|
|
1466
|
+
# Context payloads are plain dictionaries but may contain nested
|
|
1467
|
+
# client-owned lists; copy the outer mapping so archive/updates do
|
|
1468
|
+
# not mutate the response while it is serialized.
|
|
1469
|
+
context = dict(context)
|
|
1470
|
+
if context is None:
|
|
1471
|
+
return {"active_file": None, "selected_text": None, "open_files": []}
|
|
1472
|
+
return context
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
@router.get("/config/plan-status")
|
|
1476
|
+
async def plan_status(request: Request):
|
|
1477
|
+
"""Return the current plan mode status and plan content if available."""
|
|
1478
|
+
async with get_session_lock(request.app.state):
|
|
1479
|
+
session = _get_session(request, request.query_params.get("session_id"))
|
|
1480
|
+
if not session:
|
|
1481
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1482
|
+
mode = getattr(session, "agent_mode", getattr(session, "mode", "agent"))
|
|
1483
|
+
plan = getattr(session, "current_plan", None)
|
|
1484
|
+
if isinstance(plan, dict):
|
|
1485
|
+
plan = dict(plan)
|
|
1486
|
+
return {
|
|
1487
|
+
"mode": mode,
|
|
1488
|
+
"in_plan_mode": mode == "plan",
|
|
1489
|
+
"plan": plan,
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def _logs_cwd(request: Request, session_id: str | None) -> Path:
|
|
1494
|
+
sessions = getattr(request.app.state, "sessions", {})
|
|
1495
|
+
sid = getattr(request.app.state, "default_session_id", None) if session_id is None else session_id
|
|
1496
|
+
session = sessions.get(sid) if sid else None
|
|
1497
|
+
if (
|
|
1498
|
+
session is not None
|
|
1499
|
+
and sid not in getattr(request.app.state, "closing_sessions", set())
|
|
1500
|
+
and not getattr(request.app.state, "gateway_closing", False)
|
|
1501
|
+
):
|
|
1502
|
+
return Path(getattr(session, "cwd", os.getcwd())).resolve()
|
|
1503
|
+
if session_id is not None:
|
|
1504
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1505
|
+
return Path(os.getcwd()).resolve()
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _discover_logs(cwd: Path) -> dict[str, Path]:
|
|
1509
|
+
"""Read the shared log index used by core and background tools."""
|
|
1510
|
+
result: dict[str, Path] = {}
|
|
1511
|
+
lexical_root = cwd / ".crabcode" / "logs"
|
|
1512
|
+
try:
|
|
1513
|
+
logs_root = lexical_root.resolve()
|
|
1514
|
+
# A repository-controlled symlink must not turn the log index into a
|
|
1515
|
+
# capability for files outside the dedicated project log directory.
|
|
1516
|
+
root_is_safe = logs_root == lexical_root.absolute()
|
|
1517
|
+
except OSError:
|
|
1518
|
+
logs_root = lexical_root
|
|
1519
|
+
root_is_safe = False
|
|
1520
|
+
|
|
1521
|
+
raw: Any = {}
|
|
1522
|
+
if root_is_safe:
|
|
1523
|
+
index_path = logs_root / "index.json"
|
|
1524
|
+
try:
|
|
1525
|
+
with _open_regular_log(index_path, os.O_RDONLY) as handle:
|
|
1526
|
+
raw = json.load(handle)
|
|
1527
|
+
except (OSError, json.JSONDecodeError):
|
|
1528
|
+
raw = {}
|
|
1529
|
+
if isinstance(raw, dict):
|
|
1530
|
+
for name, value in raw.items():
|
|
1531
|
+
if not (
|
|
1532
|
+
isinstance(name, str)
|
|
1533
|
+
and 0 < len(name) <= 64
|
|
1534
|
+
and all(char.isalnum() or char in "._-" for char in name)
|
|
1535
|
+
and isinstance(value, str)
|
|
1536
|
+
):
|
|
1537
|
+
continue
|
|
1538
|
+
candidate = Path(value).expanduser()
|
|
1539
|
+
if not candidate.is_absolute():
|
|
1540
|
+
candidate = logs_root / candidate
|
|
1541
|
+
try:
|
|
1542
|
+
resolved = candidate.resolve(strict=True)
|
|
1543
|
+
if resolved.parent != logs_root or candidate.is_symlink():
|
|
1544
|
+
continue
|
|
1545
|
+
with _open_regular_log(resolved, os.O_RDONLY):
|
|
1546
|
+
pass
|
|
1547
|
+
except OSError:
|
|
1548
|
+
continue
|
|
1549
|
+
result[name] = resolved
|
|
1550
|
+
# Keep compatibility with older search versions that wrote this path
|
|
1551
|
+
# without registering it in the shared index.
|
|
1552
|
+
legacy = cwd / ".crabcode" / "search" / "background.log"
|
|
1553
|
+
safe_legacy = _known_log_path(legacy)
|
|
1554
|
+
if safe_legacy is not None:
|
|
1555
|
+
result.setdefault("search", safe_legacy)
|
|
1556
|
+
# Gateway startup logs are useful even before a CoreSession exists.
|
|
1557
|
+
candidates = [get_config_home() / "gateway.log"]
|
|
1558
|
+
if os.name != "nt":
|
|
1559
|
+
candidates.append(Path("/tmp/crabcode-gateway.log"))
|
|
1560
|
+
for candidate in candidates:
|
|
1561
|
+
safe_candidate = _known_log_path(candidate)
|
|
1562
|
+
if safe_candidate is not None:
|
|
1563
|
+
result.setdefault("gateway", safe_candidate)
|
|
1564
|
+
return result
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _open_regular_log(path: Path, flags: int):
|
|
1568
|
+
"""Open one regular, single-link log without following a final symlink."""
|
|
1569
|
+
open_flags = flags | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1570
|
+
fd = os.open(path, open_flags)
|
|
1571
|
+
try:
|
|
1572
|
+
file_stat = os.fstat(fd)
|
|
1573
|
+
if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1:
|
|
1574
|
+
raise OSError("Log path is not a single-link regular file")
|
|
1575
|
+
mode = "r" if flags == os.O_RDONLY else "w"
|
|
1576
|
+
return os.fdopen(fd, mode, encoding="utf-8", errors="replace")
|
|
1577
|
+
except BaseException:
|
|
1578
|
+
os.close(fd)
|
|
1579
|
+
raise
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _known_log_path(path: Path) -> Path | None:
|
|
1583
|
+
"""Validate a fixed, application-owned log location."""
|
|
1584
|
+
try:
|
|
1585
|
+
if path.is_symlink():
|
|
1586
|
+
return None
|
|
1587
|
+
resolved = path.resolve(strict=True)
|
|
1588
|
+
with _open_regular_log(resolved, os.O_RDONLY):
|
|
1589
|
+
pass
|
|
1590
|
+
return resolved
|
|
1591
|
+
except OSError:
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _tail_log(path: Path, count: int) -> tuple[list[str], bool]:
|
|
1596
|
+
try:
|
|
1597
|
+
with _open_regular_log(path, os.O_RDONLY) as handle:
|
|
1598
|
+
all_lines = handle.read().splitlines()
|
|
1599
|
+
except OSError:
|
|
1600
|
+
return [], False
|
|
1601
|
+
return all_lines[-count:], len(all_lines) > count
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _clear_log(path: Path) -> None:
|
|
1605
|
+
# Open without O_TRUNC, validate the descriptor, then truncate that exact
|
|
1606
|
+
# inode. This avoids truncating a swapped symlink before validation.
|
|
1607
|
+
with _open_regular_log(path, os.O_WRONLY) as handle:
|
|
1608
|
+
os.ftruncate(handle.fileno(), 0)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
@router.get("/logs", response_model=LogsResponse)
|
|
1612
|
+
async def get_logs(
|
|
1613
|
+
request: Request,
|
|
1614
|
+
lines: int = 100,
|
|
1615
|
+
tail: int | None = None,
|
|
1616
|
+
name: str | None = None,
|
|
1617
|
+
clear: bool = False,
|
|
1618
|
+
session_id: str | None = None,
|
|
1619
|
+
) -> LogsResponse:
|
|
1620
|
+
"""List logs or read/clear a named log, matching the CLI surface."""
|
|
1621
|
+
cwd = _logs_cwd(request, session_id)
|
|
1622
|
+
logs = _discover_logs(cwd)
|
|
1623
|
+
if not name:
|
|
1624
|
+
if clear:
|
|
1625
|
+
raise HTTPException(status_code=400, detail="name is required when clear=true")
|
|
1626
|
+
entries = []
|
|
1627
|
+
for key, path in sorted(logs.items()):
|
|
1628
|
+
try:
|
|
1629
|
+
updated = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
|
|
1630
|
+
except OSError:
|
|
1631
|
+
updated = None
|
|
1632
|
+
state = None
|
|
1633
|
+
if key == "search":
|
|
1634
|
+
status_path = cwd / ".crabcode" / "search" / "background-status.json"
|
|
1635
|
+
try:
|
|
1636
|
+
raw_status = json.loads(status_path.read_text(encoding="utf-8"))
|
|
1637
|
+
state = raw_status.get("state") if isinstance(raw_status, dict) else None
|
|
1638
|
+
except (OSError, json.JSONDecodeError):
|
|
1639
|
+
pass
|
|
1640
|
+
entries.append({"name": key, "path": str(path), "updated_at": updated, "state": state})
|
|
1641
|
+
return LogsResponse(logs=entries)
|
|
1642
|
+
|
|
1643
|
+
path = logs.get(name)
|
|
1644
|
+
if path is None:
|
|
1645
|
+
raise HTTPException(status_code=404, detail=f"Unknown log: {name}")
|
|
1646
|
+
if clear:
|
|
1647
|
+
try:
|
|
1648
|
+
_clear_log(path)
|
|
1649
|
+
except OSError as exc:
|
|
1650
|
+
raise HTTPException(status_code=500, detail=f"Failed to clear log: {exc}") from exc
|
|
1651
|
+
count = max(1, min(10_000, int(tail if tail is not None else lines)))
|
|
1652
|
+
body, truncated = _tail_log(path, count)
|
|
1653
|
+
return LogsResponse(
|
|
1654
|
+
name=name,
|
|
1655
|
+
path=str(path),
|
|
1656
|
+
lines=body,
|
|
1657
|
+
truncated=truncated,
|
|
1658
|
+
note="Log is empty" if not body else None,
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
@router.get("/logs/follow")
|
|
1663
|
+
async def follow_log(
|
|
1664
|
+
request: Request,
|
|
1665
|
+
name: str,
|
|
1666
|
+
session_id: str | None = None,
|
|
1667
|
+
) -> StreamingResponse:
|
|
1668
|
+
"""Stream appended lines from a named log as server-sent events."""
|
|
1669
|
+
cwd = _logs_cwd(request, session_id)
|
|
1670
|
+
path = _discover_logs(cwd).get(name)
|
|
1671
|
+
if path is None:
|
|
1672
|
+
raise HTTPException(status_code=404, detail=f"Unknown log: {name}")
|
|
1673
|
+
|
|
1674
|
+
async def _generate():
|
|
1675
|
+
try:
|
|
1676
|
+
with _open_regular_log(path, os.O_RDONLY) as handle:
|
|
1677
|
+
position = os.fstat(handle.fileno()).st_size
|
|
1678
|
+
except OSError:
|
|
1679
|
+
position = 0
|
|
1680
|
+
while True:
|
|
1681
|
+
if await request.is_disconnected():
|
|
1682
|
+
break
|
|
1683
|
+
try:
|
|
1684
|
+
with _open_regular_log(path, os.O_RDONLY) as handle:
|
|
1685
|
+
current_size = os.fstat(handle.fileno()).st_size
|
|
1686
|
+
if current_size < position:
|
|
1687
|
+
# The file was cleared or rotated while following it.
|
|
1688
|
+
position = 0
|
|
1689
|
+
handle.seek(position)
|
|
1690
|
+
chunk = handle.readlines()
|
|
1691
|
+
position = handle.tell()
|
|
1692
|
+
except OSError:
|
|
1693
|
+
chunk = []
|
|
1694
|
+
for line in chunk:
|
|
1695
|
+
yield f"data: {json.dumps(line.rstrip(chr(10)), ensure_ascii=False)}\n\n"
|
|
1696
|
+
await asyncio.sleep(0.5)
|
|
1697
|
+
|
|
1698
|
+
return StreamingResponse(_generate(), media_type="text/event-stream")
|