simple-module-ai 0.0.6__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.
sm_ai/deps.py ADDED
@@ -0,0 +1,21 @@
1
+ """FastAPI dependencies for the AI module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends, Request
8
+ from simple_module_db.deps import get_db
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from sm_ai.service import AiService
12
+
13
+
14
+ async def get_ai_service(
15
+ request: Request,
16
+ db: AsyncSession = Depends(get_db),
17
+ ) -> AiService:
18
+ return AiService(request.app, db)
19
+
20
+
21
+ AiServiceDep = Annotated[AiService, Depends(get_ai_service)]
File without changes
sm_ai/endpoints/api.py ADDED
@@ -0,0 +1,85 @@
1
+ """REST API for AI settings. Everything requires ai.manage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+
9
+ from fastapi import APIRouter, Depends
10
+ from simple_module_hosting.permissions import RequiresPermission
11
+
12
+ from sm_ai import constants, services
13
+ from sm_ai.contracts.schemas import (
14
+ AiSettingsOut,
15
+ AiSettingsUpdate,
16
+ AiTestRequest,
17
+ AiTestResult,
18
+ )
19
+ from sm_ai.deps import AiServiceDep
20
+ from sm_ai.service import AiService
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter()
25
+
26
+ _MANAGE = Depends(RequiresPermission(constants.PERM_MANAGE))
27
+
28
+
29
+ @router.get("/settings", response_model=AiSettingsOut, dependencies=[_MANAGE])
30
+ async def get_settings() -> AiSettingsOut:
31
+ # No AiServiceDep: current() only reads the module-global holder, and the
32
+ # dependency would open a DB session the read never touches.
33
+ return AiService.current()
34
+
35
+
36
+ @router.put("/settings", response_model=AiSettingsOut, dependencies=[_MANAGE])
37
+ async def update_settings(data: AiSettingsUpdate, service: AiServiceDep) -> AiSettingsOut:
38
+ changes = AiService.build_changes(data)
39
+ if not changes:
40
+ return service.current()
41
+ return await service.apply(changes)
42
+
43
+
44
+ @router.post("/test", response_model=AiTestResult, dependencies=[_MANAGE])
45
+ async def test_connection(data: AiTestRequest) -> AiTestResult:
46
+ """One tiny real call through the configured slot.
47
+
48
+ The only place this module itself calls a provider. Every failure —
49
+ missing config, unreadable key, bad URL, auth error, timeout — comes back
50
+ as ``ok: false`` with the message; a wrong URL is a result, not a 500.
51
+ """
52
+ from pydantic_ai import Agent
53
+
54
+ from sm_ai import resolve
55
+
56
+ started = time.monotonic()
57
+ label = ""
58
+ try:
59
+ # One snapshot for both the label and the probe — a concurrent save
60
+ # must not make the reported model describe a different configuration
61
+ # than the one actually exercised.
62
+ settings = services.current_settings()
63
+ async with asyncio.timeout(constants.TEST_TIMEOUT_SECONDS):
64
+ if data.slot == constants.SLOT_CHAT:
65
+ label = settings.chat_model
66
+ agent = Agent(resolve.build_chat_model(settings))
67
+ await agent.run(constants.TEST_PROMPT)
68
+ else:
69
+ label = settings.embedding_model
70
+ await resolve.build_embedder(settings).embed_query(constants.TEST_PROMPT)
71
+ except TimeoutError:
72
+ return AiTestResult(
73
+ ok=False,
74
+ model=label,
75
+ error=f"Timed out after {constants.TEST_TIMEOUT_SECONDS}s.",
76
+ )
77
+ except Exception as exc: # a failed probe is a result, not a bug
78
+ # ... but log it server-side with the traceback: without this an
79
+ # actual code defect (e.g. a pydantic-ai API change) is
80
+ # indistinguishable from a config problem in the inline error string.
81
+ logger.warning("AI %s test probe failed", data.slot, exc_info=exc)
82
+ return AiTestResult(ok=False, model=label, error=str(exc) or type(exc).__name__)
83
+ return AiTestResult(
84
+ ok=True, model=label, latency_ms=int((time.monotonic() - started) * 1000)
85
+ )
@@ -0,0 +1,28 @@
1
+ """Inertia view endpoints for the AI module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from inertia import InertiaResponse
7
+ from simple_module_hosting.inertia_deps import InertiaDep
8
+ from simple_module_hosting.permissions import RequiresPermission
9
+
10
+ from sm_ai import constants
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get(
16
+ "/",
17
+ response_model=None,
18
+ dependencies=[Depends(RequiresPermission(constants.PERM_MANAGE))],
19
+ )
20
+ async def settings_page(inertia: InertiaDep) -> InertiaResponse:
21
+ # Settings are fetched client-side from /api/ai/settings so a save can
22
+ # refresh without a full Inertia round trip.
23
+ #
24
+ # The page name comes from the constant, matching news and this repo's
25
+ # check_hardcoded_strings. The framework's own modules inline the literal
26
+ # instead, for the SM003/SM004 static-AST pairing — the two conventions
27
+ # disagree, and an in-repo module follows the in-repo linter.
28
+ return await inertia.render(constants._PAGE_SETTINGS)
sm_ai/module.py ADDED
@@ -0,0 +1,100 @@
1
+ """AI base module — connection configuration and the resolve service layer.
2
+
3
+ Provides no AI features of its own. Other modules depend on it for
4
+ ``sm_ai.contracts`` (resolve_model / resolve_embedder) and its settings page;
5
+ consumers own agents, prompts and behaviour. There are no tables and no
6
+ migrations: configuration lives in the shared settings store.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.metadata
12
+ import weakref
13
+
14
+ from fastapi import APIRouter, Depends, FastAPI
15
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
16
+ from simple_module_core.module import ModuleBase, ModuleMeta
17
+ from simple_module_core.permissions import PermissionRegistry
18
+
19
+ from sm_ai import constants
20
+
21
+ _VERSION = importlib.metadata.version("simple_module_ai")
22
+
23
+
24
+ class AiModule(ModuleBase):
25
+ meta = ModuleMeta(
26
+ name=constants.MODULE_NAME,
27
+ route_prefix=constants.ROUTE_PREFIX_API,
28
+ view_prefix=constants.VIEW_PREFIX,
29
+ depends_on=[constants._MODULE_SETTINGS],
30
+ version=_VERSION,
31
+ # The framework API version (1.0.0) is decoupled from the framework
32
+ # package version (0.0.x) — this range is correct as written.
33
+ requires_framework=">=1.0,<2.0",
34
+ )
35
+
36
+ def register_settings(self, app: FastAPI) -> None:
37
+ from settings.registration import register_module_settings
38
+
39
+ from sm_ai import crypto, services
40
+ from sm_ai.settings import AiSettings
41
+
42
+ # The factory installs the instance in the module-global holder AND
43
+ # returns it for app.state.sm_ai — one shared object, so hydration and
44
+ # hot reload (which assign .settings on it) are visible to contracts.
45
+ register_module_settings(
46
+ app,
47
+ constants.PACKAGE,
48
+ AiSettings,
49
+ lambda s: services.install(services.AiServices(settings=s)),
50
+ )
51
+
52
+ # Hosts may inject ``secret_key`` programmatically
53
+ # (``create_app(settings=...)``) rather than via env/.env; crypto must
54
+ # encrypt under the secret the running app actually holds or stored
55
+ # keys become unreadable. app.state.sm is assigned after this phase,
56
+ # so resolve lazily — through a weakref, because the provider is a
57
+ # process-global and a strong closure would keep a discarded app's
58
+ # whole object graph alive (app-factory test suites build many).
59
+ app_ref = weakref.ref(app)
60
+
61
+ def _live_secret() -> str:
62
+ live_app = app_ref()
63
+ if live_app is None:
64
+ return ""
65
+ sm = getattr(live_app.state, "sm", None)
66
+ return getattr(getattr(sm, "settings", None), "secret_key", "") or ""
67
+
68
+ crypto.set_secret_provider(_live_secret)
69
+
70
+ def register_permissions(self, registry: PermissionRegistry) -> None:
71
+ registry.add_group(constants.MODULE_NAME, [constants.PERM_MANAGE])
72
+
73
+ def register_menu_items(self, registry: MenuRegistry) -> None:
74
+ registry.add(
75
+ MenuItem(
76
+ label="AI",
77
+ url=constants.MENU_URL,
78
+ icon=constants.MENU_ICON,
79
+ order=constants.MENU_ORDER,
80
+ section=MenuSection.SIDEBAR,
81
+ group=constants.MENU_GROUP,
82
+ roles=["admin"],
83
+ )
84
+ )
85
+
86
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
87
+ from sm_ai.endpoints.api import router as api
88
+ from sm_ai.endpoints.views import router as views
89
+ from sm_ai.security import mint_csrf_token, verify_csrf
90
+
91
+ # The write surface can redirect chat_base_url and thereby point the
92
+ # stored provider key at an attacker's server — CSRF is enforced on
93
+ # the API (unsafe methods) and the token minted on the admin view.
94
+ api_router.include_router(api, dependencies=[Depends(verify_csrf)])
95
+ view_router.include_router(views, dependencies=[Depends(mint_csrf_token)])
96
+
97
+ def register_middleware(self, app: FastAPI) -> None:
98
+ from sm_ai.security import CsrfCookieMiddleware
99
+
100
+ app.add_middleware(CsrfCookieMiddleware)
sm_ai/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@simple-module-py/ai",
3
+ "version": "0.0.6",
4
+ "private": true,
5
+ "description": "Frontend assets for the AI module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*",
11
+ "sonner": "^2.0.0"
12
+ },
13
+ "devDependencies": {
14
+ "@simple-module-py/tsconfig": "*"
15
+ },
16
+ "dependencies": {}
17
+ }
@@ -0,0 +1,157 @@
1
+ import { Head } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
5
+ import { useCallback, useEffect, useMemo, useState } from 'react';
6
+ import { toast } from 'sonner';
7
+
8
+ import { SlotCard } from '../components/SlotCard';
9
+ import {
10
+ type AiSettingsOut,
11
+ loadSettings,
12
+ type SlotValues,
13
+ saveSettings,
14
+ type TestResult,
15
+ testSlot,
16
+ } from '../utils/api';
17
+
18
+ const emptySlot = (): SlotValues => ({
19
+ provider: '',
20
+ model: '',
21
+ base_url: '',
22
+ api_key: '',
23
+ clear_key: false,
24
+ });
25
+
26
+ function fromSettings(s: AiSettingsOut): { chat: SlotValues; embedding: SlotValues } {
27
+ return {
28
+ chat: {
29
+ provider: s.chat_provider,
30
+ model: s.chat_model,
31
+ base_url: s.chat_base_url,
32
+ api_key: '',
33
+ clear_key: false,
34
+ },
35
+ embedding: {
36
+ provider: s.embedding_provider,
37
+ model: s.embedding_model,
38
+ base_url: s.embedding_base_url,
39
+ api_key: '',
40
+ clear_key: false,
41
+ dim: s.embedding_dim,
42
+ },
43
+ };
44
+ }
45
+
46
+ export default function Settings() {
47
+ const [loaded, setLoaded] = useState<AiSettingsOut | null>(null);
48
+ const [chat, setChat] = useState<SlotValues>(emptySlot());
49
+ const [embedding, setEmbedding] = useState<SlotValues>(emptySlot());
50
+ const [busy, setBusy] = useState(false);
51
+ const [chatTest, setChatTest] = useState<TestResult | null>(null);
52
+ const [embeddingTest, setEmbeddingTest] = useState<TestResult | null>(null);
53
+ const [error, setError] = useState<string | null>(null);
54
+
55
+ // One hydration path for both the initial load and the post-save read-back,
56
+ // so the editable state cannot drift from `loaded` in just one of them.
57
+ const hydrate = useCallback((s: AiSettingsOut) => {
58
+ setLoaded(s);
59
+ const slots = fromSettings(s);
60
+ setChat(slots.chat);
61
+ setEmbedding(slots.embedding);
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ loadSettings()
66
+ .then(hydrate)
67
+ .catch((err) => setError((err as Error).message));
68
+ }, [hydrate]);
69
+
70
+ async function run(work: () => Promise<void>, errorMsg: string) {
71
+ setBusy(true);
72
+ try {
73
+ await work();
74
+ } catch (err) {
75
+ toast.error(`${errorMsg}: ${(err as Error).message}`);
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ }
80
+
81
+ const save = () =>
82
+ run(async () => {
83
+ hydrate(await saveSettings(chat, embedding));
84
+ // Results describe the previous configuration — drop them.
85
+ setChatTest(null);
86
+ setEmbeddingTest(null);
87
+ toast.success('AI settings saved');
88
+ }, 'Save failed');
89
+
90
+ // The probe runs against *saved* settings; testing with unsaved edits
91
+ // would report on the old configuration and mislead either way.
92
+ // Field-by-field, not JSON.stringify: serialized equality is key-order and
93
+ // missing-vs-undefined sensitive (chat slots carry no `dim`), so equal
94
+ // states could compare unequal and disable Test with no visible reason.
95
+ const isDirty = (slot: SlotValues, saved: SlotValues) =>
96
+ slot.provider !== saved.provider ||
97
+ slot.model !== saved.model ||
98
+ slot.base_url !== saved.base_url ||
99
+ slot.api_key !== '' ||
100
+ slot.clear_key ||
101
+ (slot.dim ?? 0) !== (saved.dim ?? 0);
102
+ const savedSlots = useMemo(() => (loaded ? fromSettings(loaded) : null), [loaded]);
103
+
104
+ const test = (slot: 'chat' | 'embedding') =>
105
+ run(async () => {
106
+ const result = await testSlot(slot);
107
+ (slot === 'chat' ? setChatTest : setEmbeddingTest)(result);
108
+ }, 'Test failed');
109
+
110
+ return (
111
+ <AuthenticatedLayout>
112
+ <Head title="AI Settings" />
113
+ <PageShell
114
+ title="AI"
115
+ description="Connection settings for the chat and embedding providers every module shares."
116
+ >
117
+ {error && <p className="text-sm text-destructive">{error}</p>}
118
+ {!loaded && !error && <p className="text-sm text-muted-foreground">Loading…</p>}
119
+ {loaded && savedSlots && (
120
+ <div className="space-y-6">
121
+ <SlotCard
122
+ title="Chat"
123
+ description="The model modules use for text generation."
124
+ idPrefix="ai-chat"
125
+ providers={loaded.chat_providers}
126
+ optionalSlot={false}
127
+ values={chat}
128
+ hasStoredKey={loaded.has_chat_api_key}
129
+ dirty={isDirty(chat, savedSlots.chat)}
130
+ busy={busy}
131
+ testResult={chatTest}
132
+ onChange={setChat}
133
+ onTest={() => test('chat')}
134
+ />
135
+ <SlotCard
136
+ title="Embeddings"
137
+ description="Optional second endpoint for vector embeddings."
138
+ idPrefix="ai-embedding"
139
+ providers={loaded.embedding_providers}
140
+ optionalSlot={true}
141
+ values={embedding}
142
+ hasStoredKey={loaded.has_embedding_api_key}
143
+ dirty={isDirty(embedding, savedSlots.embedding)}
144
+ busy={busy}
145
+ testResult={embeddingTest}
146
+ onChange={setEmbedding}
147
+ onTest={() => test('embedding')}
148
+ />
149
+ <Button type="button" disabled={busy} onClick={save}>
150
+ Save
151
+ </Button>
152
+ </div>
153
+ )}
154
+ </PageShell>
155
+ </AuthenticatedLayout>
156
+ );
157
+ }
sm_ai/resolve.py ADDED
@@ -0,0 +1,131 @@
1
+ """Build Pydantic AI model / embedder objects from AiSettings.
2
+
3
+ Pure construction — no network I/O. Provider and auth failures surface in the
4
+ consumer's ``agent.run()`` where they belong. Objects are built per call:
5
+ construction is cheap, pydantic-ai's shared HTTP client keeps pooling, and a
6
+ settings save applies on the very next call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from sm_ai import constants, crypto
14
+ from sm_ai.contracts.errors import AiNotConfiguredError
15
+
16
+ if TYPE_CHECKING:
17
+ from pydantic_ai import Embedder
18
+ from pydantic_ai.models import Model
19
+
20
+ from sm_ai.settings import AiSettings
21
+
22
+ def _slot_inputs(
23
+ settings: AiSettings,
24
+ slot: str,
25
+ allowed: tuple[str, ...],
26
+ model_override: str | None = None,
27
+ ) -> tuple[str, str, str, str]:
28
+ """Validate one slot's settings; returns (provider_id, model, key, base_url).
29
+
30
+ The single validation path for both slots — provider membership, model
31
+ presence, key decryption, and the openai_compatible base_url requirement
32
+ (with the placeholder-key substitution) live here once.
33
+ """
34
+ provider_id = getattr(settings, f"{slot}_provider").strip().lower()
35
+ if not provider_id and slot == constants.SLOT_EMBEDDING:
36
+ raise AiNotConfiguredError(
37
+ "embedding_provider", "The embedding slot is not configured."
38
+ )
39
+ if provider_id not in allowed:
40
+ hint = (
41
+ "Anthropic does not offer an embeddings API."
42
+ if slot == constants.SLOT_EMBEDDING
43
+ and provider_id == constants.PROVIDER_ANTHROPIC
44
+ else f"Unknown provider {provider_id!r}; expected one of "
45
+ f"{', '.join(allowed)}."
46
+ )
47
+ raise AiNotConfiguredError(f"{slot}_provider", hint)
48
+ model = (model_override or getattr(settings, f"{slot}_model")).strip()
49
+ if not model:
50
+ raise AiNotConfiguredError(f"{slot}_model")
51
+
52
+ key = crypto.decrypt_value(getattr(settings, f"{slot}_api_key"), f"{slot}_api_key")
53
+ base_url = getattr(settings, f"{slot}_base_url").strip()
54
+
55
+ if provider_id == constants.PROVIDER_OPENAI_COMPATIBLE:
56
+ if not base_url:
57
+ raise AiNotConfiguredError(
58
+ f"{slot}_base_url",
59
+ "The openai_compatible provider needs the server's /v1 URL.",
60
+ )
61
+ # vLLM and friends ignore the key but the protocol wants a value.
62
+ key = key or constants.PLACEHOLDER_API_KEY
63
+ elif not key:
64
+ raise AiNotConfiguredError(f"{slot}_api_key")
65
+ return provider_id, model, key, base_url
66
+
67
+
68
+ def build_chat_model(settings: AiSettings, model_name: str | None = None) -> Model:
69
+ """A ready chat model for the configured provider.
70
+
71
+ ``model_name`` overrides the configured name for same-endpoint
72
+ multi-model setups (e.g. a vision and a text model on one vLLM server).
73
+ """
74
+ provider_id, name, key, base_url = _slot_inputs(
75
+ settings, constants.SLOT_CHAT, constants.CHAT_PROVIDERS, model_name
76
+ )
77
+
78
+ if provider_id in (constants.PROVIDER_OPENAI, constants.PROVIDER_OPENAI_COMPATIBLE):
79
+ return _openai_chat(name, base_url, key)
80
+
81
+ if provider_id == constants.PROVIDER_ANTHROPIC:
82
+ from pydantic_ai.models.anthropic import AnthropicModel
83
+ from pydantic_ai.providers.anthropic import AnthropicProvider
84
+
85
+ kwargs = {"base_url": base_url} if base_url else {}
86
+ return AnthropicModel(name, provider=AnthropicProvider(api_key=key, **kwargs))
87
+
88
+ # google — GoogleProvider wires base_url into the genai client's
89
+ # http_options, so a gateway override routes exactly like the other
90
+ # providers' plain kwarg.
91
+ from pydantic_ai.models.google import GoogleModel
92
+ from pydantic_ai.providers.google import GoogleProvider
93
+
94
+ kwargs = {"base_url": base_url} if base_url else {}
95
+ return GoogleModel(name, provider=GoogleProvider(api_key=key, **kwargs))
96
+
97
+
98
+ def _openai_chat(name: str, base_url: str, key: str) -> Model:
99
+ from pydantic_ai.models.openai import OpenAIChatModel
100
+ from pydantic_ai.providers.openai import OpenAIProvider
101
+
102
+ kwargs = {"base_url": base_url} if base_url else {}
103
+ return OpenAIChatModel(name, provider=OpenAIProvider(api_key=key, **kwargs))
104
+
105
+
106
+ def build_embedder(settings: AiSettings) -> Embedder:
107
+ """A ready ``Embedder`` for the configured embedding slot."""
108
+ from pydantic_ai import Embedder
109
+
110
+ provider_id, name, key, base_url = _slot_inputs(
111
+ settings, constants.SLOT_EMBEDDING, constants.EMBEDDING_PROVIDERS
112
+ )
113
+
114
+ if provider_id in (constants.PROVIDER_OPENAI, constants.PROVIDER_OPENAI_COMPATIBLE):
115
+ return Embedder(_openai_embedding(name, base_url, key))
116
+
117
+ from pydantic_ai.embeddings.google import GoogleEmbeddingModel
118
+ from pydantic_ai.providers.google import GoogleProvider
119
+
120
+ kwargs = {"base_url": base_url} if base_url else {}
121
+ return Embedder(
122
+ GoogleEmbeddingModel(name, provider=GoogleProvider(api_key=key, **kwargs))
123
+ )
124
+
125
+
126
+ def _openai_embedding(name: str, base_url: str, key: str):
127
+ from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
128
+ from pydantic_ai.providers.openai import OpenAIProvider
129
+
130
+ kwargs = {"base_url": base_url} if base_url else {}
131
+ return OpenAIEmbeddingModel(name, provider=OpenAIProvider(api_key=key, **kwargs))
sm_ai/security.py ADDED
@@ -0,0 +1,133 @@
1
+ """CSRF protection for the AI settings endpoints.
2
+
3
+ This module stores provider API keys, and its write surface can redirect
4
+ ``chat_base_url`` — a forged cross-site PUT could point the stored key at an
5
+ attacker's server. The admin API therefore enforces ``X-CSRF-Token`` the same
6
+ way pagebuilder's does: a per-session token minted on the admin view, mirrored
7
+ to a JS-readable cookie for the fetch client, and verified in constant time on
8
+ every unsafe method.
9
+
10
+ Adapted from ``pagebuilder.security`` rather than imported — a base module
11
+ must not depend on a sibling optional module at runtime. Kept always-on and
12
+ without the Inertia shared-prop channel: the settings page talks to the API
13
+ via fetch, so the cookie is the only client channel it needs. Enforcement is
14
+ skipped when no session is mounted (bare-FastAPI test harnesses) because
15
+ without a session there is nothing to bind the token to.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import secrets
22
+
23
+ from fastapi import HTTPException, Request
24
+ from starlette.datastructures import MutableHeaders
25
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
26
+
27
+ from sm_ai import constants
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _CSRF_SESSION_KEY = "sm_ai_csrf_token"
32
+ _warned_no_session = False
33
+ _UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
34
+
35
+
36
+ def mint_csrf_token(request: Request) -> None:
37
+ """View-router dependency: ensure the session carries a CSRF token.
38
+
39
+ ``CsrfCookieMiddleware`` mirrors it to the JS-readable cookie on the
40
+ response. No-op without a session — there is no anchor to bind to.
41
+ """
42
+ session = request.scope.get("session")
43
+ if session is None:
44
+ return
45
+ if not session.get(_CSRF_SESSION_KEY):
46
+ session[_CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
47
+
48
+
49
+ async def verify_csrf(request: Request) -> None:
50
+ """API-router dependency: enforce ``X-CSRF-Token`` on unsafe methods."""
51
+ if request.method not in _UNSAFE_METHODS:
52
+ return
53
+ session = request.scope.get("session")
54
+ if session is None:
55
+ # Fail-open by design (no session, nothing to bind a token to — see
56
+ # module docstring), but never silently: a host that mounts these
57
+ # routers without SessionMiddleware should know writes are unguarded.
58
+ global _warned_no_session
59
+ if not _warned_no_session:
60
+ _warned_no_session = True
61
+ logger.warning(
62
+ "CSRF enforcement skipped: no session middleware is mounted, "
63
+ "so the AI settings write API is unprotected against "
64
+ "cross-site requests."
65
+ )
66
+ return
67
+ expected = session.get(_CSRF_SESSION_KEY)
68
+ received = request.headers.get(constants.CSRF_HEADER)
69
+ if (
70
+ not expected
71
+ or not received
72
+ # Bytes, not str: compare_digest raises TypeError on non-ASCII str
73
+ # (headers decode as latin-1), which would turn a bad token into a
74
+ # 500 instead of this 403.
75
+ or not secrets.compare_digest(
76
+ str(expected).encode(), received.encode("latin-1")
77
+ )
78
+ ):
79
+ raise HTTPException(status_code=403, detail="Invalid or missing CSRF token")
80
+
81
+
82
+ class CsrfCookieMiddleware:
83
+ """Mirror the session's CSRF token to a JS-readable cookie.
84
+
85
+ A middleware (not the mint dep) because FastAPI doesn't merge a
86
+ dependency's cookie writes into a ``Response`` returned by the handler.
87
+ Requests outside the module's prefixes skip the wrapper, and no
88
+ ``Set-Cookie`` is emitted when the request already carried the current
89
+ token.
90
+ """
91
+
92
+ def __init__(self, app: ASGIApp) -> None:
93
+ self.app = app
94
+ self._prefixes = (constants.ROUTE_PREFIX_API, constants.VIEW_PREFIX)
95
+ self._cookie_attr = f"{constants.CSRF_COOKIE}="
96
+
97
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
98
+ path = scope.get("path", "")
99
+ # Segment boundary, not bare startswith - "/ai" must not claim a
100
+ # future "/ai-other" route's responses.
101
+ if scope["type"] != "http" or not any(
102
+ path == p or path.startswith(p + "/") for p in self._prefixes
103
+ ):
104
+ await self.app(scope, receive, send)
105
+ return
106
+
107
+ async def send_wrapper(message: Message) -> None:
108
+ if message["type"] == "http.response.start":
109
+ session = scope.get("session")
110
+ token = (
111
+ session.get(_CSRF_SESSION_KEY) if isinstance(session, dict) else None
112
+ )
113
+ if token and _request_cookie_value(scope, self._cookie_attr) != token:
114
+ headers = MutableHeaders(scope=message)
115
+ cookie = f"{constants.CSRF_COOKIE}={token}; Path=/; SameSite=Strict"
116
+ if scope.get("scheme") == "https":
117
+ # Not unconditional: dev runs plain HTTP on loopback.
118
+ cookie += "; Secure"
119
+ headers.append("set-cookie", cookie)
120
+ await send(message)
121
+
122
+ await self.app(scope, receive, send_wrapper)
123
+
124
+
125
+ def _request_cookie_value(scope: Scope, attr: str) -> str | None:
126
+ """Read one cookie value without re-parsing the whole header per request."""
127
+ for name, value in scope.get("headers", ()):
128
+ if name == b"cookie":
129
+ for piece in value.decode("latin-1").split(";"):
130
+ trimmed = piece.lstrip()
131
+ if trimmed.startswith(attr):
132
+ return trimmed[len(attr) :]
133
+ return None