atlas-chat 0.1.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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Unified logging & OpenTelemetry setup.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- Structured JSON logging with optional trace/span identifiers
|
|
5
|
+
- Environment or config-derived log level
|
|
6
|
+
- Standard file output (project_root/logs/app.jsonl) with APP_LOG_DIR override
|
|
7
|
+
- FastAPI & HTTPX instrumentation hooks
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from opentelemetry import trace
|
|
20
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
21
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
22
|
+
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
|
23
|
+
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION, Resource
|
|
24
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JSONFormatter(logging.Formatter):
|
|
28
|
+
"""Format log records as JSON lines."""
|
|
29
|
+
|
|
30
|
+
def format(self, record: logging.LogRecord) -> str: # noqa: D401
|
|
31
|
+
span = trace.get_current_span()
|
|
32
|
+
trace_id = span_id = None
|
|
33
|
+
if span and span.is_recording():
|
|
34
|
+
sc = span.get_span_context()
|
|
35
|
+
if sc.is_valid:
|
|
36
|
+
trace_id = f"{sc.trace_id:032x}"
|
|
37
|
+
span_id = f"{sc.span_id:016x}"
|
|
38
|
+
|
|
39
|
+
entry: Dict[str, Any] = {
|
|
40
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
41
|
+
"level": record.levelname,
|
|
42
|
+
"logger": record.name,
|
|
43
|
+
"message": record.getMessage(),
|
|
44
|
+
"module": record.module,
|
|
45
|
+
"function": record.funcName,
|
|
46
|
+
"line": record.lineno,
|
|
47
|
+
"process_id": os.getpid(),
|
|
48
|
+
"thread_id": record.thread,
|
|
49
|
+
"thread_name": record.threadName,
|
|
50
|
+
}
|
|
51
|
+
if trace_id:
|
|
52
|
+
entry["trace_id"] = trace_id
|
|
53
|
+
if span_id:
|
|
54
|
+
entry["span_id"] = span_id
|
|
55
|
+
if record.exc_info:
|
|
56
|
+
entry["exception"] = self.formatException(record.exc_info)
|
|
57
|
+
|
|
58
|
+
excluded = {
|
|
59
|
+
"name","msg","args","levelname","levelno","pathname","filename","module","lineno",
|
|
60
|
+
"funcName","created","msecs","relativeCreated","thread","threadName","processName","process",
|
|
61
|
+
"exc_info","exc_text","stack_info","getMessage"
|
|
62
|
+
}
|
|
63
|
+
for k, v in record.__dict__.items():
|
|
64
|
+
if k not in excluded:
|
|
65
|
+
entry[f"extra_{k}"] = v
|
|
66
|
+
return json.dumps(entry, default=str)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenTelemetryConfig:
|
|
70
|
+
"""Configure OpenTelemetry + structured logging."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, service_name: str = "atlas-ui-3-backend", service_version: str = "1.0.0") -> None:
|
|
73
|
+
self.service_name = service_name
|
|
74
|
+
self.service_version = service_version
|
|
75
|
+
self.is_development = self._is_development()
|
|
76
|
+
self.log_level = self._get_log_level()
|
|
77
|
+
# Resolve logs directory robustly: use config manager
|
|
78
|
+
self.logs_dir = self._get_logs_dir()
|
|
79
|
+
self.log_file = self.logs_dir / "app.jsonl"
|
|
80
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self._setup_telemetry()
|
|
82
|
+
self._setup_logging()
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Internals
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
def _get_logs_dir(self) -> Path:
|
|
88
|
+
"""Get logs directory from config manager or default to project_root/logs."""
|
|
89
|
+
try:
|
|
90
|
+
from atlas.modules.config import config_manager
|
|
91
|
+
if config_manager.app_settings.app_log_dir:
|
|
92
|
+
return Path(config_manager.app_settings.app_log_dir)
|
|
93
|
+
except Exception:
|
|
94
|
+
# Config manager may not be initialized during early startup or tests.
|
|
95
|
+
# Fall back to default logs directory without logging (avoid circular deps).
|
|
96
|
+
pass
|
|
97
|
+
# Fallback: project_root/logs
|
|
98
|
+
project_root = Path(__file__).resolve().parents[2]
|
|
99
|
+
return project_root / "logs"
|
|
100
|
+
|
|
101
|
+
def _is_development(self) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
from atlas.modules.config import config_manager
|
|
104
|
+
settings = config_manager.app_settings
|
|
105
|
+
return (
|
|
106
|
+
settings.debug_mode
|
|
107
|
+
or settings.environment.lower() in {"dev", "development"}
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
# Fallback to environment variables if config not available
|
|
111
|
+
return (
|
|
112
|
+
os.getenv("DEBUG_MODE", "false").lower() == "true"
|
|
113
|
+
or os.getenv("ENVIRONMENT", "production").lower() in {"dev", "development"}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _get_log_level(self) -> int:
|
|
117
|
+
try:
|
|
118
|
+
from atlas.modules.config import config_manager
|
|
119
|
+
level_name = config_manager.app_settings.log_level.upper()
|
|
120
|
+
except Exception:
|
|
121
|
+
# Fallback to environment variable if config not available
|
|
122
|
+
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
123
|
+
level = getattr(logging, level_name, None)
|
|
124
|
+
return level if isinstance(level, int) else logging.INFO
|
|
125
|
+
|
|
126
|
+
def _setup_telemetry(self) -> None:
|
|
127
|
+
resource = Resource.create(
|
|
128
|
+
{
|
|
129
|
+
SERVICE_NAME: self.service_name,
|
|
130
|
+
SERVICE_VERSION: self.service_version,
|
|
131
|
+
"environment": "development" if self.is_development else "production",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
trace.set_tracer_provider(TracerProvider(resource=resource))
|
|
135
|
+
|
|
136
|
+
def _setup_logging(self) -> None:
|
|
137
|
+
root = logging.getLogger()
|
|
138
|
+
for h in root.handlers[:]:
|
|
139
|
+
root.removeHandler(h)
|
|
140
|
+
|
|
141
|
+
json_formatter = JSONFormatter()
|
|
142
|
+
file_handler = logging.FileHandler(self.log_file, encoding="utf-8")
|
|
143
|
+
file_handler.setFormatter(json_formatter)
|
|
144
|
+
file_handler.setLevel(self.log_level)
|
|
145
|
+
root.addHandler(file_handler)
|
|
146
|
+
root.setLevel(self.log_level)
|
|
147
|
+
|
|
148
|
+
# Reduce noise from third-party libraries at INFO.
|
|
149
|
+
# We still want their warnings/errors, and their debug output remains available
|
|
150
|
+
# when LOG_LEVEL=DEBUG.
|
|
151
|
+
if self.log_level > logging.DEBUG:
|
|
152
|
+
for noisy in (
|
|
153
|
+
"httpx",
|
|
154
|
+
"httpcore",
|
|
155
|
+
"LiteLLM",
|
|
156
|
+
"litellm",
|
|
157
|
+
):
|
|
158
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
159
|
+
|
|
160
|
+
if self.is_development:
|
|
161
|
+
console = logging.StreamHandler()
|
|
162
|
+
console.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
|
163
|
+
console.setLevel(logging.WARNING)
|
|
164
|
+
root.addHandler(console)
|
|
165
|
+
for noisy in (
|
|
166
|
+
"httpx",
|
|
167
|
+
"urllib3.connectionpool",
|
|
168
|
+
"auth_utils",
|
|
169
|
+
"message_processor",
|
|
170
|
+
"session",
|
|
171
|
+
"callbacks",
|
|
172
|
+
"utils",
|
|
173
|
+
"banner_client",
|
|
174
|
+
"middleware",
|
|
175
|
+
"mcp_client",
|
|
176
|
+
):
|
|
177
|
+
logging.getLogger(noisy).setLevel(logging.DEBUG)
|
|
178
|
+
|
|
179
|
+
LoggingInstrumentor().instrument(set_logging_format=False)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Public helpers
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
def instrument_fastapi(self, app) -> None: # noqa: ANN001
|
|
185
|
+
FastAPIInstrumentor.instrument_app(app)
|
|
186
|
+
|
|
187
|
+
def instrument_httpx(self) -> None:
|
|
188
|
+
HTTPXClientInstrumentor().instrument()
|
|
189
|
+
|
|
190
|
+
def get_log_file_path(self) -> Path:
|
|
191
|
+
return self.log_file
|
|
192
|
+
|
|
193
|
+
def read_logs(self, lines: int = 100) -> list[Dict[str, Any]]:
|
|
194
|
+
if not self.log_file.exists():
|
|
195
|
+
return []
|
|
196
|
+
out: list[Dict[str, Any]] = []
|
|
197
|
+
try:
|
|
198
|
+
with self.log_file.open("r", encoding="utf-8") as f:
|
|
199
|
+
data = f.readlines()[-lines:]
|
|
200
|
+
for ln in data:
|
|
201
|
+
ln = ln.strip()
|
|
202
|
+
if not ln:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
out.append(json.loads(ln))
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
continue
|
|
208
|
+
except Exception as e: # noqa: BLE001
|
|
209
|
+
logging.getLogger(__name__).error(f"Error reading logs: {e}")
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
def get_log_stats(self) -> Dict[str, Any]:
|
|
213
|
+
if not self.log_file.exists():
|
|
214
|
+
return {"file_exists": False, "file_size": 0, "line_count": 0, "last_modified": None}
|
|
215
|
+
try:
|
|
216
|
+
stat = self.log_file.stat()
|
|
217
|
+
with self.log_file.open("r", encoding="utf-8") as f:
|
|
218
|
+
line_count = sum(1 for _ in f)
|
|
219
|
+
return {
|
|
220
|
+
"file_exists": True,
|
|
221
|
+
"file_size": stat.st_size,
|
|
222
|
+
"line_count": line_count,
|
|
223
|
+
"last_modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
224
|
+
"file_path": str(self.log_file),
|
|
225
|
+
}
|
|
226
|
+
except Exception as e: # noqa: BLE001
|
|
227
|
+
logging.getLogger(__name__).error(f"Error getting log stats: {e}")
|
|
228
|
+
return {"file_exists": True, "error": str(e)}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Global instance
|
|
232
|
+
otel_config: Optional[OpenTelemetryConfig] = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def setup_opentelemetry(service_name: str = "atlas-ui-3-backend", service_version: str = "1.0.0") -> OpenTelemetryConfig:
|
|
236
|
+
global otel_config
|
|
237
|
+
otel_config = OpenTelemetryConfig(service_name, service_version)
|
|
238
|
+
return otel_config
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_otel_config() -> Optional[OpenTelemetryConfig]:
|
|
242
|
+
return otel_config
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt injection risk heuristics and structured logging.
|
|
3
|
+
|
|
4
|
+
Scope: lightweight, configurable thresholds; used for user input and RAG results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_thresholds() -> Dict[str, int]:
|
|
23
|
+
"""Get prompt injection risk thresholds from config manager."""
|
|
24
|
+
try:
|
|
25
|
+
from atlas.modules.config import config_manager
|
|
26
|
+
settings = config_manager.app_settings
|
|
27
|
+
return {
|
|
28
|
+
"low": settings.pi_threshold_low,
|
|
29
|
+
"medium": settings.pi_threshold_medium,
|
|
30
|
+
"high": settings.pi_threshold_high,
|
|
31
|
+
}
|
|
32
|
+
except Exception:
|
|
33
|
+
# Fallback to defaults if config not available
|
|
34
|
+
return {
|
|
35
|
+
"low": 30,
|
|
36
|
+
"medium": 50,
|
|
37
|
+
"high": 80,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calculate_prompt_injection_risk(message: str, *, mode: str = "general") -> Dict[str, object]:
|
|
42
|
+
"""
|
|
43
|
+
Calculate a heuristic risk score for prompt injection attempts.
|
|
44
|
+
|
|
45
|
+
Returns: { 'score': int, 'risk_level': str, 'triggers': list[str] }
|
|
46
|
+
"""
|
|
47
|
+
score = 0
|
|
48
|
+
triggers: List[str] = []
|
|
49
|
+
|
|
50
|
+
msg_lower = (message or "").lower()
|
|
51
|
+
|
|
52
|
+
# 1) Suspicious patterns
|
|
53
|
+
patterns = {
|
|
54
|
+
"override_instructions": (r"ignore\s+(previous|all|everything|above|prior)", 40),
|
|
55
|
+
"disregard": (r"disregard\s+(previous|all|everything|above|prior)", 40),
|
|
56
|
+
"new_instructions": (r"new\s+instructions?\s*:\s*", 35),
|
|
57
|
+
"system_role": (r"\b(system|assistant|user)\s*:\s*", 30),
|
|
58
|
+
"act_as": (r"act\s+as\s+(if\s+)?you\s+(are|were)", 25),
|
|
59
|
+
"pretend": (r"pretend\s+(to\s+be|you\s+are)", 25),
|
|
60
|
+
"role_change": (r"your?\s+(new\s+)?role\s+(is|now)", 30),
|
|
61
|
+
"forget": (r"forget\s+(everything|all|previous)", 35),
|
|
62
|
+
"override": (r"override\s+(previous|default|system)", 35),
|
|
63
|
+
"jailbreak": (r"(jailbreak|developer\s+mode|god\s+mode)", 45),
|
|
64
|
+
}
|
|
65
|
+
for name, (pat, pts) in patterns.items():
|
|
66
|
+
if re.search(pat, msg_lower):
|
|
67
|
+
score += pts
|
|
68
|
+
triggers.append(name)
|
|
69
|
+
|
|
70
|
+
# 2) Encodings/obfuscation
|
|
71
|
+
if _detect_encoding(message):
|
|
72
|
+
score += 30
|
|
73
|
+
triggers.append("encoding_detected")
|
|
74
|
+
|
|
75
|
+
# 3) Statistical anomalies
|
|
76
|
+
# Delimiter density (triple quotes, fences, etc.)
|
|
77
|
+
delimiters = len(re.findall(r"[#*\-_=]{3,}|[\"\"\"''']{3,}", message or ""))
|
|
78
|
+
if delimiters >= 3:
|
|
79
|
+
score += 25
|
|
80
|
+
triggers.append("excessive_delimiters")
|
|
81
|
+
elif delimiters >= 1:
|
|
82
|
+
score += 10
|
|
83
|
+
|
|
84
|
+
# High entropy (possible encoded blob)
|
|
85
|
+
if len(message or "") > 10:
|
|
86
|
+
ent = _calculate_entropy(message)
|
|
87
|
+
if ent > 4.5:
|
|
88
|
+
score += 20
|
|
89
|
+
triggers.append("high_entropy")
|
|
90
|
+
|
|
91
|
+
# Excessive caps
|
|
92
|
+
if len(message or "") > 20:
|
|
93
|
+
caps_ratio = sum(1 for c in (message or "") if c.isupper()) / max(1, len(message or ""))
|
|
94
|
+
if caps_ratio > 0.3:
|
|
95
|
+
score += 15
|
|
96
|
+
triggers.append("excessive_caps")
|
|
97
|
+
|
|
98
|
+
# 4) Context-breaking attempts
|
|
99
|
+
if (message or "").count("\n") > 5 or re.search(r"\s{10,}", message or ""):
|
|
100
|
+
score += 15
|
|
101
|
+
triggers.append("formatting_abuse")
|
|
102
|
+
|
|
103
|
+
if re.search(r"(human|user|assistant):\s*\n", msg_lower):
|
|
104
|
+
score += 25
|
|
105
|
+
triggers.append("fake_conversation")
|
|
106
|
+
|
|
107
|
+
if (len(message or "") > 50) and re.search(r"<[^>]+>.*</[^>]+>|[{}\[\]]", message or ""):
|
|
108
|
+
score += 20
|
|
109
|
+
triggers.append("structured_injection")
|
|
110
|
+
|
|
111
|
+
# 5) Length penalty
|
|
112
|
+
if len(message or "") > 1000:
|
|
113
|
+
score += 15
|
|
114
|
+
triggers.append("excessive_length")
|
|
115
|
+
|
|
116
|
+
# Context-aware normalization (reduce false positives)
|
|
117
|
+
if mode in ("code", "logs"):
|
|
118
|
+
# Code/logs: braces, fences common; soften penalties
|
|
119
|
+
if "excessive_delimiters" in triggers:
|
|
120
|
+
score -= 10
|
|
121
|
+
if "structured_injection" in triggers:
|
|
122
|
+
score -= 10
|
|
123
|
+
score = max(0, score)
|
|
124
|
+
|
|
125
|
+
# Risk buckets - get thresholds from config
|
|
126
|
+
thresholds = _get_thresholds()
|
|
127
|
+
if score >= thresholds["high"]:
|
|
128
|
+
level = "high"
|
|
129
|
+
elif score >= thresholds["medium"]:
|
|
130
|
+
level = "medium"
|
|
131
|
+
elif score >= thresholds["low"]:
|
|
132
|
+
level = "low"
|
|
133
|
+
else:
|
|
134
|
+
level = "minimal"
|
|
135
|
+
|
|
136
|
+
return {"score": int(score), "risk_level": level, "triggers": triggers}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _detect_encoding(text: str) -> bool:
|
|
140
|
+
clean = re.sub(r"\s", "", text or "")
|
|
141
|
+
# Base64-like
|
|
142
|
+
if len(clean) > 20 and len(clean) % 4 == 0 and re.match(r"^[A-Za-z0-9+/=]+$", clean or ""):
|
|
143
|
+
try:
|
|
144
|
+
base64.b64decode(clean, validate=True)
|
|
145
|
+
return True
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
# Hex
|
|
149
|
+
if re.match(r"^(0x)?[0-9a-fA-F]+$", clean or "") and len(clean) > 20:
|
|
150
|
+
return True
|
|
151
|
+
# Escape sequences
|
|
152
|
+
if re.search(r"\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}", text or ""):
|
|
153
|
+
return True
|
|
154
|
+
# Zero-width/unusual unicode
|
|
155
|
+
if re.search(r"[\u200B-\u200D\uFEFF\u2060]", text or ""):
|
|
156
|
+
return True
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _calculate_entropy(text: str) -> float:
|
|
161
|
+
if not text:
|
|
162
|
+
return 0.0
|
|
163
|
+
counts = Counter(text)
|
|
164
|
+
n = len(text)
|
|
165
|
+
ent = 0.0
|
|
166
|
+
for c in counts.values():
|
|
167
|
+
p = c / n
|
|
168
|
+
ent -= p * math.log2(max(p, 1e-12))
|
|
169
|
+
return ent
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def log_high_risk_event(*, source: str, user: Optional[str], content: str, score: int, risk_level: str, triggers: List[str], extra: Optional[Dict[str, object]] = None) -> None:
|
|
173
|
+
"""Append a JSONL record for medium/high events to logs/security_high_risk.jsonl."""
|
|
174
|
+
try:
|
|
175
|
+
# Only log medium/high
|
|
176
|
+
if risk_level not in ("medium", "high"):
|
|
177
|
+
return
|
|
178
|
+
base_dir = Path(__file__).resolve().parents[2]
|
|
179
|
+
log_path = base_dir / "logs" / "security_high_risk.jsonl"
|
|
180
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
record = {
|
|
182
|
+
"ts": datetime.now(timezone.utc).isoformat() + "Z",
|
|
183
|
+
"type": "prompt_risk",
|
|
184
|
+
"source": source,
|
|
185
|
+
"user": user,
|
|
186
|
+
"score": score,
|
|
187
|
+
"risk_level": risk_level,
|
|
188
|
+
"triggers": triggers,
|
|
189
|
+
}
|
|
190
|
+
if extra:
|
|
191
|
+
record.update(extra)
|
|
192
|
+
# include a small snippet only
|
|
193
|
+
snippet = (content or "")
|
|
194
|
+
if len(snippet) > 240:
|
|
195
|
+
snippet = snippet[:240] + "…"
|
|
196
|
+
record["snippet"] = snippet
|
|
197
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
198
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.debug("Failed to write high risk log: %s", e)
|
atlas/core/rate_limit.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Simple in-memory rate limit middleware.
|
|
2
|
+
|
|
3
|
+
Fixed-window counter per client IP (and optionally per-path) to throttle requests.
|
|
4
|
+
This is a lightweight safeguard suitable for single-process deployments and tests.
|
|
5
|
+
|
|
6
|
+
Configuration is sourced from ConfigManager (AppSettings) with optional env overrides:
|
|
7
|
+
- app_settings.rate_limit_rpm (env: RATE_LIMIT_RPM, default: 600)
|
|
8
|
+
- app_settings.rate_limit_window_seconds (env: RATE_LIMIT_WINDOW_SECONDS, default: 60)
|
|
9
|
+
- app_settings.rate_limit_per_path (env: RATE_LIMIT_PER_PATH, default: false)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import typing as t
|
|
14
|
+
|
|
15
|
+
from fastapi import Request
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.responses import JSONResponse, Response
|
|
18
|
+
|
|
19
|
+
from atlas.modules.config import config_manager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
def __init__(self, app) -> None:
|
|
24
|
+
super().__init__(app)
|
|
25
|
+
settings = config_manager.app_settings
|
|
26
|
+
# Pull from centralized config with sane defaults
|
|
27
|
+
self.window_seconds = int(getattr(settings, "rate_limit_window_seconds", 60))
|
|
28
|
+
self.max_requests = int(getattr(settings, "rate_limit_rpm", 600))
|
|
29
|
+
self.per_path = bool(getattr(settings, "rate_limit_per_path", False))
|
|
30
|
+
# state: key -> (window_start_epoch, count)
|
|
31
|
+
self._buckets: dict[str, t.Tuple[int, int]] = {}
|
|
32
|
+
|
|
33
|
+
def _key_for(self, request: Request) -> str:
|
|
34
|
+
client_ip = getattr(request.client, "host", "unknown") if request.client else "unknown"
|
|
35
|
+
if self.per_path:
|
|
36
|
+
return f"{client_ip}:{request.url.path}"
|
|
37
|
+
return client_ip
|
|
38
|
+
|
|
39
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
40
|
+
now = int(time.time())
|
|
41
|
+
key = self._key_for(request)
|
|
42
|
+
win = self.window_seconds
|
|
43
|
+
start, count = self._buckets.get(key, (now, 0))
|
|
44
|
+
|
|
45
|
+
# Move window if expired
|
|
46
|
+
if now - start >= win:
|
|
47
|
+
start, count = now, 0
|
|
48
|
+
|
|
49
|
+
count += 1
|
|
50
|
+
self._buckets[key] = (start, count)
|
|
51
|
+
|
|
52
|
+
if count > self.max_requests:
|
|
53
|
+
retry_after = max(1, win - (now - start))
|
|
54
|
+
return JSONResponse(
|
|
55
|
+
status_code=429,
|
|
56
|
+
content={
|
|
57
|
+
"detail": "Rate limit exceeded. Please try again later.",
|
|
58
|
+
"limit": self.max_requests,
|
|
59
|
+
"window_seconds": self.window_seconds,
|
|
60
|
+
},
|
|
61
|
+
headers={"Retry-After": str(retry_after)},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return await call_next(request)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Security headers middleware with ConfigManager-based toggles.
|
|
2
|
+
|
|
3
|
+
Sets common security headers:
|
|
4
|
+
- Content-Security-Policy (CSP)
|
|
5
|
+
- X-Frame-Options (XFO)
|
|
6
|
+
- X-Content-Type-Options: nosniff
|
|
7
|
+
- Referrer-Policy
|
|
8
|
+
|
|
9
|
+
Each header is individually togglable via AppSettings. HSTS is intentionally omitted.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
from starlette.types import ASGIApp
|
|
16
|
+
|
|
17
|
+
from atlas.modules.config import config_manager
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
22
|
+
super().__init__(app)
|
|
23
|
+
self.settings = config_manager.app_settings
|
|
24
|
+
|
|
25
|
+
async def dispatch(self, request: Request, call_next):
|
|
26
|
+
response: Response = await call_next(request)
|
|
27
|
+
|
|
28
|
+
# X-Content-Type-Options
|
|
29
|
+
if getattr(self.settings, "security_nosniff_enabled", True):
|
|
30
|
+
if "X-Content-Type-Options" not in response.headers:
|
|
31
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
32
|
+
|
|
33
|
+
# X-Frame-Options
|
|
34
|
+
if getattr(self.settings, "security_xfo_enabled", True):
|
|
35
|
+
xfo_value = getattr(self.settings, "security_xfo_value", "SAMEORIGIN")
|
|
36
|
+
if "X-Frame-Options" not in response.headers:
|
|
37
|
+
response.headers["X-Frame-Options"] = xfo_value
|
|
38
|
+
|
|
39
|
+
# Referrer-Policy
|
|
40
|
+
if getattr(self.settings, "security_referrer_policy_enabled", True):
|
|
41
|
+
ref_value = getattr(self.settings, "security_referrer_policy_value", "no-referrer")
|
|
42
|
+
if "Referrer-Policy" not in response.headers:
|
|
43
|
+
response.headers["Referrer-Policy"] = ref_value
|
|
44
|
+
|
|
45
|
+
# Content-Security-Policy
|
|
46
|
+
if getattr(self.settings, "security_csp_enabled", True):
|
|
47
|
+
csp_value = getattr(self.settings, "security_csp_value", None)
|
|
48
|
+
if csp_value and "Content-Security-Policy" not in response.headers:
|
|
49
|
+
response.headers["Content-Security-Policy"] = csp_value
|
|
50
|
+
|
|
51
|
+
return response
|
atlas/domain/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Domain layer - pure business models and logic."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
AuthorizationError,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
DomainError,
|
|
8
|
+
LLMError,
|
|
9
|
+
MessageError,
|
|
10
|
+
SessionError,
|
|
11
|
+
ToolError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from .messages.models import ConversationHistory, Message, MessageRole, MessageType, ToolCall, ToolResult
|
|
15
|
+
from .sessions.models import Session
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Errors
|
|
19
|
+
"DomainError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"SessionError",
|
|
22
|
+
"MessageError",
|
|
23
|
+
"AuthenticationError",
|
|
24
|
+
"AuthorizationError",
|
|
25
|
+
"ConfigurationError",
|
|
26
|
+
"LLMError",
|
|
27
|
+
"ToolError",
|
|
28
|
+
# Messages
|
|
29
|
+
"Message",
|
|
30
|
+
"MessageRole",
|
|
31
|
+
"MessageType",
|
|
32
|
+
"ToolCall",
|
|
33
|
+
"ToolResult",
|
|
34
|
+
"ConversationHistory",
|
|
35
|
+
# Sessions
|
|
36
|
+
"Session",
|
|
37
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain models for chat operations."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Data Transfer Objects for chat operations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ChatRequest:
|
|
10
|
+
"""
|
|
11
|
+
Request DTO for chat operations.
|
|
12
|
+
|
|
13
|
+
Contains all parameters needed for different chat modes (plain, tools, RAG, agent).
|
|
14
|
+
"""
|
|
15
|
+
session_id: UUID
|
|
16
|
+
content: str
|
|
17
|
+
model: str
|
|
18
|
+
user_email: Optional[str] = None
|
|
19
|
+
selected_tools: Optional[List[str]] = None
|
|
20
|
+
selected_prompts: Optional[List[str]] = None
|
|
21
|
+
selected_data_sources: Optional[List[str]] = None
|
|
22
|
+
only_rag: bool = False
|
|
23
|
+
tool_choice_required: bool = False
|
|
24
|
+
agent_mode: bool = False
|
|
25
|
+
temperature: float = 0.7
|
|
26
|
+
agent_max_steps: int = 30
|
|
27
|
+
agent_loop_strategy: Optional[str] = None
|
|
28
|
+
files: Optional[Dict[str, Any]] = None
|
|
29
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ChatResponse:
|
|
34
|
+
"""
|
|
35
|
+
Response DTO for chat operations.
|
|
36
|
+
|
|
37
|
+
Contains the result of a chat interaction.
|
|
38
|
+
"""
|
|
39
|
+
type: str
|
|
40
|
+
message: str
|
|
41
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
44
|
+
"""Convert to dictionary format for API response."""
|
|
45
|
+
return {
|
|
46
|
+
"type": self.type,
|
|
47
|
+
"message": self.message,
|
|
48
|
+
**self.metadata
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LLMMessage:
|
|
54
|
+
"""
|
|
55
|
+
Type-safe message format for LLM interactions.
|
|
56
|
+
|
|
57
|
+
Normalizes message structure across different chat modes.
|
|
58
|
+
"""
|
|
59
|
+
role: str # "user", "assistant", "system", "tool"
|
|
60
|
+
content: str
|
|
61
|
+
name: Optional[str] = None
|
|
62
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None
|
|
63
|
+
tool_call_id: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
66
|
+
"""Convert to dictionary format for LLM API."""
|
|
67
|
+
result = {"role": self.role, "content": self.content}
|
|
68
|
+
if self.name:
|
|
69
|
+
result["name"] = self.name
|
|
70
|
+
if self.tool_calls:
|
|
71
|
+
result["tool_calls"] = self.tool_calls
|
|
72
|
+
if self.tool_call_id:
|
|
73
|
+
result["tool_call_id"] = self.tool_call_id
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "LLMMessage":
|
|
78
|
+
"""Create from dictionary format."""
|
|
79
|
+
return cls(
|
|
80
|
+
role=data["role"],
|
|
81
|
+
content=data.get("content", ""),
|
|
82
|
+
name=data.get("name"),
|
|
83
|
+
tool_calls=data.get("tool_calls"),
|
|
84
|
+
tool_call_id=data.get("tool_call_id"),
|
|
85
|
+
)
|