strands-compose-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.
Files changed (83) hide show
  1. strands_compose_chat/__init__.py +1 -0
  2. strands_compose_chat/admin/__init__.py +1 -0
  3. strands_compose_chat/admin/auth.py +132 -0
  4. strands_compose_chat/admin/templates/dashboard.html +18 -0
  5. strands_compose_chat/admin/templates/sqladmin/index.html +45 -0
  6. strands_compose_chat/admin/templates/sqladmin/layout.html +132 -0
  7. strands_compose_chat/admin/views/__init__.py +23 -0
  8. strands_compose_chat/admin/views/agent.py +234 -0
  9. strands_compose_chat/admin/views/api_key.py +134 -0
  10. strands_compose_chat/admin/views/base.py +84 -0
  11. strands_compose_chat/admin/views/chat_message.py +161 -0
  12. strands_compose_chat/admin/views/chat_session.py +148 -0
  13. strands_compose_chat/admin/views/dashboard.py +44 -0
  14. strands_compose_chat/admin/views/group.py +49 -0
  15. strands_compose_chat/admin/views/model_pricing.py +51 -0
  16. strands_compose_chat/admin/views/token_usage.py +215 -0
  17. strands_compose_chat/admin/views/user.py +250 -0
  18. strands_compose_chat/agents/__init__.py +5 -0
  19. strands_compose_chat/agents/client.py +52 -0
  20. strands_compose_chat/agents/invocation.py +195 -0
  21. strands_compose_chat/agents/payload.py +169 -0
  22. strands_compose_chat/agents/routes.py +45 -0
  23. strands_compose_chat/agents/service.py +117 -0
  24. strands_compose_chat/agents/streaming.py +428 -0
  25. strands_compose_chat/alembic.ini +46 -0
  26. strands_compose_chat/analytics/__init__.py +3 -0
  27. strands_compose_chat/analytics/routes_admin.py +135 -0
  28. strands_compose_chat/analytics/routes_me.py +42 -0
  29. strands_compose_chat/analytics/service.py +1428 -0
  30. strands_compose_chat/app.py +160 -0
  31. strands_compose_chat/auth/__init__.py +1 -0
  32. strands_compose_chat/auth/api_key.py +167 -0
  33. strands_compose_chat/auth/current_user.py +190 -0
  34. strands_compose_chat/auth/jit.py +165 -0
  35. strands_compose_chat/auth/oidc.py +211 -0
  36. strands_compose_chat/auth/passwords.py +70 -0
  37. strands_compose_chat/auth/routes.py +377 -0
  38. strands_compose_chat/auth/service.py +194 -0
  39. strands_compose_chat/bootstrap.py +55 -0
  40. strands_compose_chat/cli.py +95 -0
  41. strands_compose_chat/config.py +222 -0
  42. strands_compose_chat/db/__init__.py +1 -0
  43. strands_compose_chat/db/base.py +65 -0
  44. strands_compose_chat/db/migrations/env.py +68 -0
  45. strands_compose_chat/db/migrations/script.py.mako +27 -0
  46. strands_compose_chat/db/migrations/versions/0001_initial_schema.py +256 -0
  47. strands_compose_chat/db/models/__init__.py +24 -0
  48. strands_compose_chat/db/models/agent.py +69 -0
  49. strands_compose_chat/db/models/api_key.py +53 -0
  50. strands_compose_chat/db/models/associations.py +63 -0
  51. strands_compose_chat/db/models/chat_message.py +79 -0
  52. strands_compose_chat/db/models/chat_session.py +80 -0
  53. strands_compose_chat/db/models/group.py +39 -0
  54. strands_compose_chat/db/models/model_pricing.py +33 -0
  55. strands_compose_chat/db/models/token_usage.py +65 -0
  56. strands_compose_chat/db/models/user.py +69 -0
  57. strands_compose_chat/deps.py +37 -0
  58. strands_compose_chat/errors.py +161 -0
  59. strands_compose_chat/frontend.py +169 -0
  60. strands_compose_chat/logging.py +109 -0
  61. strands_compose_chat/media/__init__.py +6 -0
  62. strands_compose_chat/media/blocks.py +117 -0
  63. strands_compose_chat/media/routes.py +26 -0
  64. strands_compose_chat/media/service.py +44 -0
  65. strands_compose_chat/middleware/__init__.py +7 -0
  66. strands_compose_chat/middleware/security_headers.py +45 -0
  67. strands_compose_chat/schemas/__init__.py +9 -0
  68. strands_compose_chat/schemas/agents.py +43 -0
  69. strands_compose_chat/schemas/analytics.py +330 -0
  70. strands_compose_chat/schemas/auth.py +71 -0
  71. strands_compose_chat/schemas/invocations.py +44 -0
  72. strands_compose_chat/schemas/media.py +46 -0
  73. strands_compose_chat/schemas/sessions.py +138 -0
  74. strands_compose_chat/sessions/__init__.py +1 -0
  75. strands_compose_chat/sessions/routes.py +156 -0
  76. strands_compose_chat/sessions/service.py +227 -0
  77. strands_compose_chat/templates/app.html +16 -0
  78. strands_compose_chat/templates/login.html +16 -0
  79. strands_compose_chat-0.1.0.dist-info/METADATA +25 -0
  80. strands_compose_chat-0.1.0.dist-info/RECORD +83 -0
  81. strands_compose_chat-0.1.0.dist-info/WHEEL +4 -0
  82. strands_compose_chat-0.1.0.dist-info/entry_points.txt +3 -0
  83. strands_compose_chat-0.1.0.dist-info/licenses/LICENSE +174 -0
@@ -0,0 +1 @@
1
+ """Chat Backend API — async ASGI service replacing proxy.py."""
@@ -0,0 +1 @@
1
+ """Admin panel: sqladmin views and authentication backend."""
@@ -0,0 +1,132 @@
1
+ """sqladmin AuthenticationBackend and shared admin identity resolution.
2
+
3
+ Two independent identities grant access:
4
+
5
+ 1. A dedicated admin-panel login (``/admin/login``) that authenticates an active
6
+ local superuser against its password hash. Works regardless of external OIDC
7
+ configuration so an operator can always reach the panel.
8
+
9
+ 2. The application session cookie, when it belongs to an active superuser.
10
+ Lets a superuser authenticated via OIDC reach the panel without a second login.
11
+
12
+ ``resolve_admin_user`` is the single source of truth for admin identity resolution,
13
+ shared by both ``AdminAuthBackend`` and the FastAPI ``get_admin_user`` dependency.
14
+ """
15
+
16
+ import structlog
17
+ from fastapi import Request, Response
18
+ from fastapi.responses import RedirectResponse
19
+ from sqladmin.authentication import AuthenticationBackend
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+
23
+ from ..auth.passwords import dummy_verify, verify_password
24
+ from ..config import Settings
25
+ from ..db.base import AsyncSessionLocal
26
+ from ..db.models import User
27
+
28
+ logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
29
+
30
+ _ADMIN_SESSION_KEY = "admin_user_id"
31
+ _USER_SESSION_KEY = "user_id"
32
+
33
+
34
+ async def resolve_admin_user(request: Request, db: AsyncSession) -> User | None:
35
+ """Return the active superuser from either admin session or app session.
36
+
37
+ Checks request.session["admin_user_id"] first. If absent or stale, falls
38
+ back to request.session["user_id"] + is_superuser check. Returns None
39
+ when neither path yields an active superuser.
40
+
41
+ Args:
42
+ request: The incoming FastAPI/Starlette request.
43
+ db: Async database session.
44
+
45
+ Returns:
46
+ Active superuser User instance, or None.
47
+ """
48
+ admin_user_id = request.session.get(_ADMIN_SESSION_KEY)
49
+ if admin_user_id:
50
+ result = await db.execute(select(User).where(User.id == str(admin_user_id)))
51
+ user = result.scalar_one_or_none()
52
+ if user and user.is_active and user.is_superuser:
53
+ return user
54
+
55
+ user_id = request.session.get(_USER_SESSION_KEY)
56
+ if user_id:
57
+ result = await db.execute(select(User).where(User.id == str(user_id)))
58
+ user = result.scalar_one_or_none()
59
+ if user and user.is_active and user.is_superuser:
60
+ return user
61
+
62
+ return None
63
+
64
+
65
+ class AdminAuthBackend(AuthenticationBackend):
66
+ """sqladmin AuthenticationBackend enforcing superuser access."""
67
+
68
+ def __init__(self, settings: Settings) -> None:
69
+ super().__init__(secret_key=settings.SESSION_SECRET_KEY)
70
+ # Don't install a second SessionMiddleware on the admin sub-app.
71
+ # Two nested instances share scope["session"]; the inner one emptying it
72
+ # causes the outer send_wrapper to clear the main session cookie on logout.
73
+ self.middlewares = []
74
+ self._settings = settings
75
+
76
+ @property
77
+ def _login_url(self) -> str:
78
+ return f"{self._settings.URL_PREFIX}/admin/login"
79
+
80
+ async def login(self, request: Request) -> bool:
81
+ """Authenticate an active local superuser from the admin login form.
82
+
83
+ ``dummy_verify`` runs when no matching user is found so all failure
84
+ paths take the same time, preventing username enumeration via timing.
85
+ """
86
+ form = await request.form()
87
+ username = str(form.get("username") or "")
88
+ password = str(form.get("password") or "")
89
+
90
+ user = await self._load_local_superuser(username)
91
+ if user is None:
92
+ dummy_verify(self._settings)
93
+ password_ok = False
94
+ else:
95
+ password_ok = verify_password(password, user.password_hash or "", self._settings)
96
+
97
+ if user is None or not password_ok:
98
+ logger.warning("Admin login failed", username=username)
99
+ return False
100
+
101
+ request.session[_ADMIN_SESSION_KEY] = user.id
102
+ logger.info("Admin login succeeded", user_id=user.id, username=user.username)
103
+ return True
104
+
105
+ async def logout(self, request: Request) -> Response:
106
+ """Pop only the admin session entry; the application SSO identity is left intact."""
107
+ request.session.pop(_ADMIN_SESSION_KEY, None)
108
+ return RedirectResponse(url=self._login_url, status_code=302)
109
+
110
+ async def authenticate(self, request: Request) -> Response | bool:
111
+ """Grant access via the admin session or the app session (superuser only)."""
112
+ async with AsyncSessionLocal() as db:
113
+ user = await resolve_admin_user(request, db)
114
+ return user is not None
115
+
116
+ async def _load_local_superuser(self, username: str) -> User | None:
117
+ """Return an active local superuser by username, or None."""
118
+ if not username:
119
+ return None
120
+
121
+ async with AsyncSessionLocal() as db:
122
+ result = await db.execute(
123
+ select(User).where(
124
+ User.username == username,
125
+ User.auth_provider == "local",
126
+ )
127
+ )
128
+ user = result.scalar_one_or_none()
129
+
130
+ if user is None or not user.is_active or not user.is_superuser:
131
+ return None
132
+ return user
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="app-base" content="{{ url_prefix }}">
6
+ <link rel="icon" type="image/svg+xml" href="{{ url_prefix }}/static/favicon.svg" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>Admin Dashboard</title>
9
+ <link rel="stylesheet" href="{{ url_prefix }}/static/css/index.css" />
10
+ <link rel="stylesheet" href="{{ url_prefix }}/static/css/contexts.css" />
11
+ <link rel="stylesheet" href="{{ url_prefix }}/static/css/components.css" />
12
+ <link rel="stylesheet" href="{{ url_prefix }}/static/css/admin.css" />
13
+ <script type="module" src="{{ url_prefix }}/static/js/admin.js"></script>
14
+ </head>
15
+ <body style="margin:0">
16
+ <div id="admin-root"></div>
17
+ </body>
18
+ </html>
@@ -0,0 +1,45 @@
1
+ {% extends "sqladmin/layout.html" %}
2
+
3
+ {% block head %}
4
+ <style>
5
+ /* Remove the page-header container */
6
+ .page-wrapper > .container-fluid:first-child {
7
+ display: none;
8
+ }
9
+ /* Strip padding from body/row so iframe fills everything */
10
+ .page-body {
11
+ padding: 0 !important;
12
+ overflow: hidden;
13
+ margin: 0;
14
+ height: 100vh;
15
+ }
16
+ .page-body > .container-fluid {
17
+ height: 100%;
18
+ padding: 0;
19
+ max-width: 100%;
20
+ }
21
+ .page-body > .container-fluid > .row {
22
+ height: 100%;
23
+ margin: 0;
24
+ }
25
+
26
+ .iframe-admin {
27
+ width: 100%;
28
+ height: 100vh;
29
+ border: none;
30
+ margin: 0;
31
+ padding: 0;
32
+ }
33
+ </style>
34
+ {% endblock %}
35
+
36
+ {% block content_header %}{% endblock %}
37
+
38
+ {% block content %}
39
+ <iframe
40
+ src="{{ admin.base_url }}/dashboard"
41
+ class="iframe-admin"
42
+ title="Admin Dashboard"
43
+ id="admin-app-iframe"
44
+ ></iframe>
45
+ {% endblock %}
@@ -0,0 +1,132 @@
1
+ {% extends "sqladmin_original/layout.html" %}
2
+
3
+ {% block head %}
4
+ {{ super() }}
5
+ <script>
6
+ (function () {
7
+ var stored = sessionStorage.getItem("strands_chat_color_mode");
8
+ var theme = stored === "dark" || stored === "light"
9
+ ? stored
10
+ : window.matchMedia("(prefers-color-scheme: dark)").matches
11
+ ? "dark"
12
+ : "light";
13
+ document.documentElement.setAttribute("data-bs-theme", theme);
14
+ })();
15
+ </script>
16
+ <style>
17
+ /*
18
+ * Select2 dark mode overrides.
19
+ *
20
+ * Select2 renders its own DOM and hardcodes light colours — it does not
21
+ * respond to Bootstrap's data-bs-theme. These rules re-skin every Select2
22
+ * surface to match Tabler's dark palette when data-bs-theme="dark" is on
23
+ */
24
+
25
+ /* Selection box — single and multiple */
26
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--single,
27
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--multiple {
28
+ background-color: #1f2937;
29
+ border-color: #374151;
30
+ color: #e5e7eb;
31
+ }
32
+
33
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__rendered {
34
+ color: #e5e7eb;
35
+ }
36
+
37
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__placeholder {
38
+ color: #9ca3af;
39
+ }
40
+
41
+ /* Dropdown arrow */
42
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__arrow b {
43
+ border-color: #9ca3af transparent transparent transparent;
44
+ }
45
+
46
+ [data-bs-theme="dark"] .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b {
47
+ border-color: transparent transparent #9ca3af transparent;
48
+ }
49
+
50
+ /* Selected choice tags (multiple) */
51
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice {
52
+ background-color: #374151;
53
+ border-color: #4b5563;
54
+ color: #e5e7eb;
55
+ }
56
+
57
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
58
+ color: #9ca3af;
59
+ border-color: #4b5563;
60
+ }
61
+
62
+ [data-bs-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
63
+ background-color: #4b5563;
64
+ color: #f9fafb;
65
+ }
66
+
67
+ /* Inline search input inside the multiselect box */
68
+ [data-bs-theme="dark"] .select2-container--default .select2-search--inline .select2-search__field {
69
+ color: #e5e7eb;
70
+ background: transparent;
71
+ }
72
+
73
+ /* Dropdown panel */
74
+ [data-bs-theme="dark"] .select2-dropdown {
75
+ background-color: #1f2937;
76
+ border-color: #374151;
77
+ color: #e5e7eb;
78
+ }
79
+
80
+ /* Search box inside the dropdown */
81
+ [data-bs-theme="dark"] .select2-container--default .select2-search--dropdown .select2-search__field {
82
+ background-color: #111827;
83
+ border-color: #374151;
84
+ color: #e5e7eb;
85
+ }
86
+
87
+ /* Result options in the dropdown list */
88
+ [data-bs-theme="dark"] .select2-container--default .select2-results__option {
89
+ color: #e5e7eb;
90
+ }
91
+
92
+ [data-bs-theme="dark"] .select2-container--default .select2-results__option[aria-selected="true"] {
93
+ background-color: #374151;
94
+ color: #e5e7eb;
95
+ }
96
+
97
+ [data-bs-theme="dark"] .select2-container--default .select2-results__option--highlighted[aria-selected] {
98
+ background-color: #1d4ed8;
99
+ color: #ffffff;
100
+ }
101
+
102
+ [data-bs-theme="dark"] .select2-container--default .select2-results__option[aria-disabled="true"] {
103
+ color: #6b7280;
104
+ }
105
+
106
+ /* Focus ring on the multiple container */
107
+ [data-bs-theme="dark"] .select2-container--default.select2-container--focus .select2-selection--multiple {
108
+ border-color: #3b82f6;
109
+ }
110
+
111
+ [data-bs-theme="dark"] .btn-light {
112
+ background-color: #1f2937;
113
+ border-color: #374151;
114
+ color: #e5e7eb;
115
+ }
116
+
117
+ [data-bs-theme="dark"] .btn-light:hover,
118
+ [data-bs-theme="dark"] .btn-light:focus,
119
+ [data-bs-theme="dark"] .btn-light:active,
120
+ [data-bs-theme="dark"] .btn-light.show {
121
+ background-color: #374151;
122
+ border-color: #4b5563;
123
+ color: #f9fafb;
124
+ }
125
+
126
+ [data-bs-theme="dark"] .btn-light:disabled {
127
+ background-color: #1f2937;
128
+ border-color: #374151;
129
+ color: #6b7280;
130
+ }
131
+ </style>
132
+ {% endblock %}
@@ -0,0 +1,23 @@
1
+ """Admin ModelView definitions, one class per file."""
2
+
3
+ from .agent import AgentAdmin
4
+ from .api_key import ApiKeyAdmin
5
+ from .chat_message import ChatMessageAdmin
6
+ from .chat_session import ChatSessionAdmin
7
+ from .dashboard import DashboardView
8
+ from .group import GroupAdmin
9
+ from .model_pricing import ModelPricingAdmin
10
+ from .token_usage import TokenUsageAdmin
11
+ from .user import UserAdmin
12
+
13
+ __all__ = [
14
+ "AgentAdmin",
15
+ "ApiKeyAdmin",
16
+ "ChatMessageAdmin",
17
+ "ChatSessionAdmin",
18
+ "DashboardView",
19
+ "GroupAdmin",
20
+ "ModelPricingAdmin",
21
+ "TokenUsageAdmin",
22
+ "UserAdmin",
23
+ ]
@@ -0,0 +1,234 @@
1
+ """Admin view for the Agent model."""
2
+
3
+ import re
4
+ from typing import Any
5
+ from urllib.parse import urlparse
6
+
7
+ from markupsafe import Markup
8
+ from wtforms import SelectField, TextAreaField
9
+ from wtforms.validators import DataRequired
10
+
11
+ from ...db.models import Agent
12
+ from ...deps import get_settings
13
+ from .base import BaseModelView, _badge
14
+
15
+
16
+ def _format_description(model: Any, _prop: Any) -> str:
17
+ """Truncate agent description to 60 characters in the list view."""
18
+ desc: str = getattr(model, "description", "") or ""
19
+ return desc[:60] + "…" if len(desc) > 60 else desc
20
+
21
+
22
+ def _format_agent_kind(model: Any, _prop: Any) -> Markup:
23
+ """Render agent_kind as a coloured badge."""
24
+ kind: str = getattr(model, "agent_kind", "") or ""
25
+ colour_map = {
26
+ "agentcore_runtime": "#6f42c1",
27
+ "local": "#0d6efd",
28
+ }
29
+ label_map = {
30
+ "agentcore_runtime": "AgentCore",
31
+ "local": "Local",
32
+ }
33
+ return _badge(label_map.get(kind, kind or "—"), colour_map.get(kind, "#6c757d"))
34
+
35
+
36
+ class SuggestedQuestionsField(TextAreaField):
37
+ """WTForms field that stores a JSON list of strings as newline-separated text.
38
+
39
+ The textarea shows one question per line. On submit the value is split on
40
+ newlines, stripped, and empty lines are discarded before being stored as a
41
+ JSON array. On load a ``list`` from the DB JSON column is joined back to
42
+ newline-separated text so the textarea is pre-populated correctly.
43
+ """
44
+
45
+ def process_formdata(self, valuelist: list[str]) -> None:
46
+ if valuelist:
47
+ lines = [line.strip() for line in valuelist[0].splitlines()]
48
+ self.data = [line for line in lines if line]
49
+ else:
50
+ self.data = None
51
+
52
+ def _value(self) -> str:
53
+ if isinstance(self.data, list):
54
+ return "\n".join(self.data)
55
+ return self.data or ""
56
+
57
+
58
+ class AgentAdmin(BaseModelView, model=Agent):
59
+ """Admin view for the agents table.
60
+
61
+ Soft-delete is used instead of hard delete to preserve chat_sessions
62
+ foreign-key references: ``delete_model`` sets ``enabled=False``.
63
+
64
+ ``agent_kind`` is rendered as a dropdown. Kind-specific fields carry
65
+ description hints so the admin user knows which apply to which type.
66
+ ``suggested_questions`` is a newline-separated textarea that serialises
67
+ to a JSON array on the model.
68
+ """
69
+
70
+ name = "Agent"
71
+ name_plural = "Agents"
72
+ icon = "fa-solid fa-robot"
73
+
74
+ column_list = [
75
+ Agent.name,
76
+ Agent.description,
77
+ Agent.access_groups,
78
+ Agent.enabled,
79
+ Agent.multimodal,
80
+ Agent.agent_kind,
81
+ Agent.created_at,
82
+ Agent.updated_at,
83
+ ]
84
+ column_details_list = [
85
+ Agent.name,
86
+ Agent.description,
87
+ Agent.access_groups,
88
+ Agent.enabled,
89
+ Agent.multimodal,
90
+ Agent.agent_kind,
91
+ Agent.agent_runtime_arn,
92
+ Agent.region,
93
+ Agent.endpoint_url,
94
+ Agent.suggested_questions,
95
+ Agent.created_at,
96
+ Agent.updated_at,
97
+ ]
98
+ form_columns = [
99
+ Agent.name,
100
+ Agent.description,
101
+ Agent.access_groups,
102
+ Agent.enabled,
103
+ Agent.multimodal,
104
+ Agent.agent_kind,
105
+ Agent.agent_runtime_arn,
106
+ Agent.region,
107
+ Agent.endpoint_url,
108
+ Agent.suggested_questions,
109
+ ]
110
+
111
+ column_searchable_list = [Agent.name, Agent.id]
112
+ column_default_sort = [(Agent.name, False)]
113
+
114
+ form_defaults = {"enabled": True, "multimodal": False}
115
+
116
+ # access_groups renders as badge list via BaseModelView.get_list_value / get_detail_value
117
+ _badge_relation_props = {"access_groups": "#d79750"}
118
+
119
+ column_formatters = { # type: ignore[assignment]
120
+ Agent.description: _format_description,
121
+ Agent.agent_kind: _format_agent_kind,
122
+ }
123
+ column_formatters_detail = { # type: ignore[assignment]
124
+ Agent.description: _format_description,
125
+ Agent.agent_kind: _format_agent_kind,
126
+ }
127
+
128
+ form_overrides = {
129
+ "agent_kind": SelectField,
130
+ "description": TextAreaField,
131
+ "suggested_questions": SuggestedQuestionsField,
132
+ }
133
+ form_args = {
134
+ "agent_kind": {
135
+ "choices": [
136
+ ("agentcore_runtime", "Agentcore Runtime"),
137
+ ("local", "Local"),
138
+ ]
139
+ },
140
+ "description": {
141
+ "label": "Description",
142
+ "validators": [DataRequired()],
143
+ "description": "Shown above the chat composer on the welcome page. Use newlines for multiple lines.",
144
+ "render_kw": {
145
+ "rows": 4,
146
+ "placeholder": "This agent can help you with...",
147
+ "class": "form-control w-100",
148
+ "style": "width:100%!important;box-sizing:border-box;resize:vertical",
149
+ },
150
+ },
151
+ "agent_runtime_arn": {
152
+ "description": "Required for Agentcore Runtime agents only.",
153
+ },
154
+ "region": {
155
+ "description": "Required for Agentcore Runtime agents only.",
156
+ },
157
+ "endpoint_url": {
158
+ "description": "Required for Local agents only.",
159
+ },
160
+ "suggested_questions": {
161
+ "label": "Suggested Questions",
162
+ "description": "One question per line. Shown as quick-start prompts on the welcome page.",
163
+ "render_kw": {
164
+ "rows": 4,
165
+ "placeholder": "What can you help me with?\nSummarise the latest changes",
166
+ "class": "form-control w-100",
167
+ "style": "width:100%!important;box-sizing:border-box;resize:vertical",
168
+ },
169
+ },
170
+ }
171
+
172
+ form_ajax_refs = {
173
+ "access_groups": {
174
+ "fields": ("name",),
175
+ "order_by": "name",
176
+ }
177
+ }
178
+
179
+ async def on_model_delete(self, model: Any, request: Any) -> None:
180
+ """Soft-delete: set enabled=False instead of deleting the row."""
181
+ model.enabled = False
182
+
183
+ async def on_model_change(self, data: dict, model: Any, is_created: bool, request: Any) -> None:
184
+ """Validate kind-specific required fields.
185
+
186
+ In dev, ``http://`` is allowed only for localhost/127.0.0.1/*.local endpoints.
187
+ In prod, ``https://`` is enforced on all local-kind agents.
188
+ """
189
+ kind = data.get("agent_kind") or getattr(model, "agent_kind", None)
190
+
191
+ if kind == "agentcore_runtime":
192
+ arn = (data.get("agent_runtime_arn") or "").strip()
193
+ region = (data.get("region") or "").strip()
194
+ missing = []
195
+ if not arn:
196
+ missing.append("Agent Runtime ARN")
197
+ if not region:
198
+ missing.append("Region")
199
+ if missing:
200
+ raise ValueError(f"Agentcore Runtime agents require: {', '.join(missing)}.")
201
+
202
+ if kind == "local":
203
+ endpoint_url = (data.get("endpoint_url") or "").strip()
204
+ if not endpoint_url:
205
+ raise ValueError("Local agents require an Endpoint URL.")
206
+ app_env = get_settings().APP_ENV
207
+ parsed = urlparse(endpoint_url)
208
+ scheme = parsed.scheme.lower()
209
+ host = parsed.hostname or ""
210
+ if scheme != "https":
211
+ if app_env == "prod":
212
+ raise ValueError(
213
+ f"endpoint_url scheme must be 'https' in prod; got {endpoint_url!r}"
214
+ )
215
+ if scheme != "http" or (
216
+ host not in {"localhost", "127.0.0.1"}
217
+ and not re.match(r"^[a-zA-Z0-9\-]+\.local$", host)
218
+ ):
219
+ raise ValueError(
220
+ f"endpoint_url with http:// is only allowed for localhost, 127.0.0.1, "
221
+ f"or *.local hosts in dev; got host={host!r}"
222
+ )
223
+
224
+ async def delete_model(self, request: Any, pk: Any) -> None:
225
+ """Override hard-delete with a soft-delete (enabled=False)."""
226
+ from sqlalchemy import select # noqa: PLC0415
227
+
228
+ async with self.session_maker() as session:
229
+ stmt = select(Agent).where(Agent.id == pk)
230
+ result = await session.execute(stmt)
231
+ agent = result.scalar_one_or_none()
232
+ if agent is not None:
233
+ agent.enabled = False
234
+ await session.commit()