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.
- simple_module_ai-0.0.6.dist-info/METADATA +93 -0
- simple_module_ai-0.0.6.dist-info/RECORD +24 -0
- simple_module_ai-0.0.6.dist-info/WHEEL +4 -0
- simple_module_ai-0.0.6.dist-info/entry_points.txt +2 -0
- sm_ai/__init__.py +0 -0
- sm_ai/components/SlotCard.tsx +172 -0
- sm_ai/constants.py +66 -0
- sm_ai/contracts/__init__.py +54 -0
- sm_ai/contracts/errors.py +30 -0
- sm_ai/contracts/schemas.py +120 -0
- sm_ai/crypto.py +114 -0
- sm_ai/deps.py +21 -0
- sm_ai/endpoints/__init__.py +0 -0
- sm_ai/endpoints/api.py +85 -0
- sm_ai/endpoints/views.py +28 -0
- sm_ai/module.py +100 -0
- sm_ai/package.json +17 -0
- sm_ai/pages/Settings.tsx +157 -0
- sm_ai/resolve.py +131 -0
- sm_ai/security.py +133 -0
- sm_ai/service.py +89 -0
- sm_ai/services.py +53 -0
- sm_ai/settings.py +41 -0
- sm_ai/utils/api.ts +115 -0
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
|
+
)
|
sm_ai/endpoints/views.py
ADDED
|
@@ -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
|
+
}
|
sm_ai/pages/Settings.tsx
ADDED
|
@@ -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
|