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.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: simple_module_ai
3
+ Version: 0.0.6
4
+ Summary: AI base module: provider-configurable Pydantic AI service layer, encrypted key storage, admin settings UI
5
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python_modules
6
+ Author-email: Anto Subash <antosubash@live.com>
7
+ License-Expression: MIT
8
+ Keywords: ai,embeddings,llm,pydantic-ai,simple-module
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: cryptography>=42.0
11
+ Requires-Dist: pydantic-ai-slim[anthropic,google,openai]<3,>=2.0
12
+ Requires-Dist: pydantic-settings>=2.0
13
+ Requires-Dist: simple-module-core<0.1,>=0.0.25
14
+ Requires-Dist: simple-module-db<0.1,>=0.0.25
15
+ Requires-Dist: simple-module-hosting<0.1,>=0.0.25
16
+ Requires-Dist: simple-module-settings<0.1,>=0.0.25
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: simple-module-test<0.1,>=0.0.25; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # simple_module_ai
24
+
25
+ AI base module for [SimpleModule](https://docs.py.simplemodule.dev) apps: one
26
+ provider-configurable service layer (chat + embeddings) that every other
27
+ module shares, with DB-backed settings, encrypted keys and an admin page.
28
+
29
+ The module owns **connection** configuration — provider, endpoint, key, model
30
+ name. Consuming modules own **behaviour** — agents, prompts, temperature,
31
+ retries, streaming.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install simple_module_ai
37
+ ```
38
+
39
+ Add `simple_module_ai` to your host's dependencies; it is discovered at boot
40
+ via the `simple_module` entry point. There are no tables and no migrations.
41
+
42
+ ## Usage (consuming from another module)
43
+
44
+ Declare the dependency in your module's `ModuleMeta`:
45
+
46
+ ```python
47
+ meta = ModuleMeta(name="MyModule", depends_on=["Ai"], ...)
48
+ ```
49
+
50
+ Then resolve connections wherever you need them:
51
+
52
+ ```python
53
+ from pydantic_ai import Agent
54
+ from sm_ai.contracts import AiNotConfiguredError, resolve_model, resolve_embedder
55
+
56
+ agent = Agent(instructions="...", output_type=MySchema)
57
+ result = await agent.run(text, model=resolve_model())
58
+
59
+ embedder = resolve_embedder() # raises AiNotConfiguredError until the
60
+ result = await embedder.embed_documents(chunks) # embedding slot is set up
61
+ ```
62
+
63
+ `resolve_model(model_name=...)` overrides the configured model name for
64
+ same-endpoint multi-model setups. `embedding_dim()` returns the configured
65
+ vector width. All errors subclass `AiError`.
66
+
67
+ > The import package is `sm_ai`, not `ai` — the bare name belongs to another
68
+ > PyPI project and would collide in hosts that install it.
69
+
70
+ ## Settings
71
+
72
+ Two slots — chat and embeddings — each with provider (`anthropic`, `openai`,
73
+ `google`, `openai_compatible`), model, base URL and API key. The
74
+ `openai_compatible` provider covers vLLM, Ollama and LM Studio (base URL
75
+ required; a blank key is replaced by a protocol-satisfying placeholder).
76
+
77
+ Configure at `/ai/` (permission `ai.manage`) or seed via `SM_AI_*` env vars
78
+ (e.g. `SM_AI_CHAT_MODEL`); DB values win over env. API keys are encrypted at
79
+ rest with a key derived from `SM_SECRET_KEY` — rotating that secret means
80
+ re-entering provider keys (the old ciphertexts surface as unreadable). A
81
+ settings save hot-reloads the worker that handled it; other workers pick
82
+ the change up on restart.
83
+
84
+ ## Routes
85
+
86
+ | Route | Purpose |
87
+ |---|---|
88
+ | `GET /ai/` | admin settings page |
89
+ | `GET`/`PUT /api/ai/settings` | read (secrets masked) / update |
90
+ | `POST /api/ai/test` | probe the chat or embedding slot |
91
+
92
+ Writes require an `X-CSRF-Token` header bound to the session (the admin page
93
+ sends it automatically; the token is mirrored to the `sm_ai_csrf` cookie).
@@ -0,0 +1,24 @@
1
+ sm_ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sm_ai/constants.py,sha256=56-c0qjychrNTdZQUTIsNmJ4fWR0FuilraDGJRN5Okc,2161
3
+ sm_ai/crypto.py,sha256=2hN4Kwrf-zCuOB4FzoyxqaiK0hb4ZW5Zaeh3Z2KnNq0,4238
4
+ sm_ai/deps.py,sha256=GecBSmZvSa5OKc4iIAZ1p8Eow2KtJBcQCyAyH7fERhY,483
5
+ sm_ai/module.py,sha256=8WVZQ4zc5RJ4mDFFij7QxLgGRkjjvi1rZT-A6gawRT8,4105
6
+ sm_ai/resolve.py,sha256=k8VfS5_CJYiVVRHVMbjj-D58VZIs1iB8yw5KnuLqa7g,5272
7
+ sm_ai/security.py,sha256=5DQecl-MhR0g1LrM6fK-4eI7IT1nJjuqBC63-mq78MI,5517
8
+ sm_ai/service.py,sha256=n0DVmclGhY9CefIbv53naN4MHHfeSTAC_Sj3rhIAyu4,3428
9
+ sm_ai/services.py,sha256=gO-epH-3w7wRsHEH94LnvIOJgwu2jw764ao7bAoYyW8,1501
10
+ sm_ai/settings.py,sha256=nxO7tdes3Lp5vVtcHTAI-6rvVOU_a6wvmBrxzd0jqDA,1646
11
+ sm_ai/components/SlotCard.tsx,sha256=BOK9pUhV1JqDLY1OPDYTO_HNXUmssBZ9YsfgKIto9bQ,5923
12
+ sm_ai/contracts/__init__.py,sha256=JIZO7Zak7QUwytvz1AQrgHKfERpMTuFlLZAbiZzvHhc,1559
13
+ sm_ai/contracts/errors.py,sha256=cNNeIF_VRGpkjIJiTdoV7jSTulticq8xc1zSe25mPVc,966
14
+ sm_ai/contracts/schemas.py,sha256=r24Nw6TIt2qRPZUEbVXp9Y7OTyD1T3X2lmVZQgLcE10,4220
15
+ sm_ai/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ sm_ai/endpoints/api.py,sha256=FxcaKgx1uGYPcnFVkfS_xW9XvY2ebMXJad2YRG27NX4,3191
17
+ sm_ai/endpoints/views.py,sha256=OhaS1wSbFeqw-6G6kGWZ3RWEhYthIFmDlhYw3vM3eWY,1006
18
+ sm_ai/pages/Settings.tsx,sha256=x7x8Vg3mmtx51P3zu9V5xbivflpOT19CRd05eB2LhmY,5288
19
+ sm_ai/utils/api.ts,sha256=7ocDHoQ7HchOVL4p9Rjo-C9VzJWhnTGs1Z8ql1iM4Fs,3895
20
+ sm_ai/package.json,sha256=gqIbK95GOiR7d7cVoYv9fU_TQDmuRwymOE3uyDmATYE,391
21
+ simple_module_ai-0.0.6.dist-info/METADATA,sha256=AyS2bojat04XL9inNUG26vqb8BctxuNeArf6M5DluZQ,3600
22
+ simple_module_ai-0.0.6.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
23
+ simple_module_ai-0.0.6.dist-info/entry_points.txt,sha256=KOaurGqTntfvV58qnZLWqtotZuJLgIroH6sd2HElOr4,43
24
+ simple_module_ai-0.0.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ ai = sm_ai.module:AiModule
sm_ai/__init__.py ADDED
File without changes
@@ -0,0 +1,172 @@
1
+ import { Button } from '@simple-module-py/ui/components/ui/button';
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardDescription,
6
+ CardHeader,
7
+ CardTitle,
8
+ } from '@simple-module-py/ui/components/ui/card';
9
+ import { Input } from '@simple-module-py/ui/components/ui/input';
10
+ import { Label } from '@simple-module-py/ui/components/ui/label';
11
+
12
+ import type { SlotValues, TestResult } from '../utils/api';
13
+
14
+ const OPENAI_COMPATIBLE = 'openai_compatible';
15
+
16
+ export function SlotCard({
17
+ title,
18
+ description,
19
+ idPrefix,
20
+ providers,
21
+ optionalSlot,
22
+ values,
23
+ hasStoredKey,
24
+ dirty,
25
+ busy,
26
+ testResult,
27
+ onChange,
28
+ onTest,
29
+ }: {
30
+ title: string;
31
+ description: string;
32
+ idPrefix: string;
33
+ providers: string[];
34
+ /** Embedding-style slot: may be left unconfigured and carries a vector dim. */
35
+ optionalSlot: boolean;
36
+ values: SlotValues;
37
+ hasStoredKey: boolean;
38
+ /** Unsaved edits present — the probe would test the old saved settings. */
39
+ dirty: boolean;
40
+ busy: boolean;
41
+ testResult: TestResult | null;
42
+ onChange: (next: SlotValues) => void;
43
+ onTest: () => void;
44
+ }) {
45
+ const set = (patch: Partial<SlotValues>) => onChange({ ...values, ...patch });
46
+ const needsUrl = values.provider === OPENAI_COMPATIBLE;
47
+
48
+ return (
49
+ <Card>
50
+ <CardHeader>
51
+ <CardTitle>{title}</CardTitle>
52
+ <CardDescription>{description}</CardDescription>
53
+ </CardHeader>
54
+ <CardContent className="space-y-4">
55
+ <div className="space-y-2">
56
+ <Label htmlFor={`${idPrefix}-provider`}>Provider</Label>
57
+ <select
58
+ id={`${idPrefix}-provider`}
59
+ className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
60
+ value={values.provider}
61
+ onChange={(e) => set({ provider: e.target.value })}
62
+ disabled={busy}
63
+ >
64
+ {optionalSlot && <option value="">Not configured</option>}
65
+ {/* An env-seeded value can fall outside the served list; without
66
+ its own option the select would render unselected while every
67
+ save 422s with no visible cause. Showing it makes the fix —
68
+ re-picking a real provider — obvious. */}
69
+ {values.provider !== '' && !providers.includes(values.provider) && (
70
+ <option value={values.provider}>{values.provider} (unknown provider)</option>
71
+ )}
72
+ {!optionalSlot && values.provider === '' && (
73
+ <option value="">— pick a provider —</option>
74
+ )}
75
+ {providers.map((p) => (
76
+ <option key={p} value={p}>
77
+ {p}
78
+ </option>
79
+ ))}
80
+ </select>
81
+ </div>
82
+ <div className="space-y-2">
83
+ <Label htmlFor={`${idPrefix}-model`}>Model</Label>
84
+ <Input
85
+ id={`${idPrefix}-model`}
86
+ value={values.model}
87
+ onChange={(e) => set({ model: e.target.value })}
88
+ placeholder="model name, no provider prefix"
89
+ disabled={busy}
90
+ />
91
+ </div>
92
+ <div className="space-y-2">
93
+ <Label htmlFor={`${idPrefix}-url`}>
94
+ Base URL{needsUrl ? ' (required)' : ' (optional override)'}
95
+ </Label>
96
+ <Input
97
+ id={`${idPrefix}-url`}
98
+ value={values.base_url}
99
+ onChange={(e) => set({ base_url: e.target.value })}
100
+ placeholder={needsUrl ? 'http://your-server:8000/v1' : 'provider default'}
101
+ disabled={busy}
102
+ />
103
+ </div>
104
+ <div className="space-y-2">
105
+ <Label htmlFor={`${idPrefix}-key`}>API key</Label>
106
+ <div className="flex gap-2">
107
+ <Input
108
+ id={`${idPrefix}-key`}
109
+ type="password"
110
+ value={values.api_key}
111
+ onChange={(e) => set({ api_key: e.target.value, clear_key: false })}
112
+ placeholder={hasStoredKey ? 'saved — leave blank to keep' : 'not set'}
113
+ disabled={busy || values.clear_key}
114
+ />
115
+ {hasStoredKey && (
116
+ <Button
117
+ type="button"
118
+ variant={values.clear_key ? 'destructive' : 'ghost'}
119
+ size="sm"
120
+ disabled={busy}
121
+ onClick={() => set({ clear_key: !values.clear_key, api_key: '' })}
122
+ >
123
+ {values.clear_key ? 'Will clear' : 'Clear'}
124
+ </Button>
125
+ )}
126
+ </div>
127
+ </div>
128
+ {optionalSlot && (
129
+ <div className="space-y-2">
130
+ <Label htmlFor={`${idPrefix}-dim`}>Vector dimension</Label>
131
+ <Input
132
+ id={`${idPrefix}-dim`}
133
+ type="number"
134
+ value={values.dim ?? 0}
135
+ onChange={(e) => set({ dim: Number(e.target.value) || 0 })}
136
+ disabled={busy}
137
+ />
138
+ <p className="text-xs text-muted-foreground">
139
+ Vector stores fix collection width to this — changing it means re-indexing.
140
+ </p>
141
+ </div>
142
+ )}
143
+ <div className="flex items-center gap-3">
144
+ <Button
145
+ type="button"
146
+ variant="outline"
147
+ size="sm"
148
+ disabled={busy || dirty}
149
+ onClick={onTest}
150
+ >
151
+ Test connection
152
+ </Button>
153
+ {dirty && (
154
+ <span className="text-xs text-muted-foreground">
155
+ Save your changes first — the test runs against saved settings.
156
+ </span>
157
+ )}
158
+ {!dirty && testResult && (
159
+ <span
160
+ data-testid={`${idPrefix}-test-result`}
161
+ className={testResult.ok ? 'text-sm text-emerald-600' : 'text-sm text-destructive'}
162
+ >
163
+ {testResult.ok
164
+ ? `OK — ${testResult.model} (${testResult.latency_ms} ms)`
165
+ : testResult.error}
166
+ </span>
167
+ )}
168
+ </div>
169
+ </CardContent>
170
+ </Card>
171
+ );
172
+ }
sm_ai/constants.py ADDED
@@ -0,0 +1,66 @@
1
+ """AI module constants.
2
+
3
+ Kept out of the call sites so no module name, route or permission appears as a
4
+ bare literal — see scripts/check_hardcoded_strings.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Final
10
+
11
+ PACKAGE: Final = "sm_ai"
12
+ MODULE_NAME: Final = "Ai"
13
+
14
+ ROUTE_PREFIX_API: Final = "/api/ai"
15
+ VIEW_PREFIX: Final = "/ai"
16
+ MENU_URL: Final = f"{VIEW_PREFIX}/"
17
+ MENU_GROUP: Final = "Administration"
18
+ MENU_ICON: Final = "sparkles"
19
+ MENU_ORDER: Final = 116 # just after Branding's 115
20
+
21
+ # Modules this one depends on (value = that module's ModuleMeta.name).
22
+ _MODULE_SETTINGS: Final = "Settings"
23
+
24
+ # Inertia page identifier, rendered via this constant at the view (the
25
+ # in-repo check_hardcoded_strings convention; the framework's SM003/SM004
26
+ # literal-pairing convention loses that tie-break — see endpoints/views.py).
27
+ # A unit test pins the value to pages/Settings.tsx.
28
+ _PAGE_SETTINGS: Final = f"{MODULE_NAME}/Settings"
29
+
30
+ PERM_MANAGE: Final = "ai.manage"
31
+
32
+ # CSRF channel between the view (mints into the session), the middleware
33
+ # (mirrors to this JS-readable cookie) and the API (verifies this header).
34
+ CSRF_COOKIE: Final = "sm_ai_csrf"
35
+ CSRF_HEADER: Final = "x-csrf-token"
36
+
37
+ # Provider identifiers (stored values — changing them breaks existing rows).
38
+ PROVIDER_ANTHROPIC: Final = "anthropic"
39
+ PROVIDER_OPENAI: Final = "openai"
40
+ PROVIDER_GOOGLE: Final = "google"
41
+ PROVIDER_OPENAI_COMPATIBLE: Final = "openai_compatible"
42
+
43
+ CHAT_PROVIDERS: Final = (
44
+ PROVIDER_ANTHROPIC,
45
+ PROVIDER_OPENAI,
46
+ PROVIDER_GOOGLE,
47
+ PROVIDER_OPENAI_COMPATIBLE,
48
+ )
49
+ # Anthropic offers no embeddings API.
50
+ EMBEDDING_PROVIDERS: Final = (
51
+ PROVIDER_OPENAI,
52
+ PROVIDER_GOOGLE,
53
+ PROVIDER_OPENAI_COMPATIBLE,
54
+ )
55
+
56
+ # vLLM and friends ignore the key but the OpenAI protocol requires a non-empty
57
+ # value — same literal GeoWiki's AiClientFactory substitutes.
58
+ PLACEHOLDER_API_KEY: Final = "not-needed"
59
+
60
+ SLOT_CHAT: Final = "chat"
61
+ SLOT_EMBEDDING: Final = "embedding"
62
+ TEST_SLOTS: Final = (SLOT_CHAT, SLOT_EMBEDDING)
63
+ TEST_PROMPT: Final = "Reply with the single word: ok"
64
+ TEST_TIMEOUT_SECONDS: Final = 30
65
+
66
+ SECRET_FIELDS: Final = frozenset({"chat_api_key", "embedding_api_key"})
@@ -0,0 +1,54 @@
1
+ """Public surface for consuming modules.
2
+
3
+ from sm_ai.contracts import resolve_model, resolve_embedder, embedding_dim
4
+
5
+ agent = Agent(instructions=..., output_type=MySchema)
6
+ result = await agent.run(text, model=resolve_model())
7
+
8
+ The base module resolves *connections*; consumers own *behaviour* —
9
+ temperature, prompts, retries and streaming belong on the consumer's Agent.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from sm_ai.contracts.errors import AiError, AiKeyUnreadableError, AiNotConfiguredError
17
+
18
+ if TYPE_CHECKING:
19
+ from pydantic_ai import Embedder
20
+ from pydantic_ai.models import Model
21
+
22
+
23
+ def resolve_model(model_name: str | None = None) -> Model:
24
+ """A ready chat model built from the host's AI settings."""
25
+ from sm_ai import resolve, services
26
+
27
+ return resolve.build_chat_model(services.current_settings(), model_name)
28
+
29
+
30
+ def resolve_embedder() -> Embedder:
31
+ """A ready ``Embedder`` built from the host's AI settings."""
32
+ from sm_ai import resolve, services
33
+
34
+ return resolve.build_embedder(services.current_settings())
35
+
36
+
37
+ def embedding_dim() -> int:
38
+ """The configured embedding vector width (vector stores need it)."""
39
+ from sm_ai import services
40
+
41
+ dim = services.current_settings().embedding_dim
42
+ if dim <= 0:
43
+ raise AiNotConfiguredError("embedding_dim", "Set the vector width in AI settings.")
44
+ return dim
45
+
46
+
47
+ __all__ = [
48
+ "AiError",
49
+ "AiKeyUnreadableError",
50
+ "AiNotConfiguredError",
51
+ "embedding_dim",
52
+ "resolve_embedder",
53
+ "resolve_model",
54
+ ]
@@ -0,0 +1,30 @@
1
+ """Exceptions consuming modules catch. No imports from the rest of sm_ai —
2
+ this file sits at the bottom of the dependency graph."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ class AiError(Exception):
8
+ """Base class for AI module errors."""
9
+
10
+
11
+ class AiNotConfiguredError(AiError):
12
+ """A required connection setting is missing or invalid."""
13
+
14
+ def __init__(self, field: str, hint: str = "") -> None:
15
+ self.field = field
16
+ message = f"AI is not configured: {field!r} is missing or invalid."
17
+ if hint:
18
+ message = f"{message} {hint}"
19
+ super().__init__(message)
20
+
21
+
22
+ class AiKeyUnreadableError(AiError):
23
+ """A stored key has the enc:v1: prefix but cannot be decrypted."""
24
+
25
+ def __init__(self, field: str) -> None:
26
+ self.field = field
27
+ super().__init__(
28
+ f"The stored key {field!r} cannot be decrypted — SM_SECRET_KEY has "
29
+ "changed. Re-enter the key on the AI settings page."
30
+ )
@@ -0,0 +1,120 @@
1
+ """DTOs for the AI settings API. Secrets never travel outward — reads carry
2
+ ``has_*_api_key`` booleans; writes treat blank as "keep" and ``clear_*`` as
3
+ explicit removal."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pydantic import field_validator
8
+ from sqlmodel import SQLModel
9
+
10
+ from sm_ai import constants
11
+
12
+
13
+ class AiSettingsOut(SQLModel):
14
+ """Current AI settings with secrets reduced to presence flags.
15
+
16
+ Carries the allowed provider ids so the settings page renders its
17
+ dropdowns from the same tuples the validators enforce — the frontend
18
+ holds no provider list of its own.
19
+ """
20
+
21
+ chat_providers: list[str] = list(constants.CHAT_PROVIDERS)
22
+ embedding_providers: list[str] = list(constants.EMBEDDING_PROVIDERS)
23
+ chat_provider: str
24
+ chat_model: str
25
+ chat_base_url: str
26
+ has_chat_api_key: bool
27
+ embedding_provider: str
28
+ embedding_model: str
29
+ embedding_base_url: str
30
+ has_embedding_api_key: bool
31
+ embedding_dim: int
32
+
33
+ @field_validator("chat_provider", "embedding_provider")
34
+ @classmethod
35
+ def _normalize_provider(cls, value: str) -> str:
36
+ # Env-seeded values may be mixed-case ("Anthropic"): resolve.py and
37
+ # the update validator both accept them via strip().lower(), so the
38
+ # read path must normalize the same way or the settings dropdown
39
+ # renders no selection against the lowercase option ids.
40
+ return value.strip().lower()
41
+
42
+
43
+ class AiSettingsUpdate(SQLModel):
44
+ """Partial update. Key fields: blank/omitted = keep the stored key."""
45
+
46
+ chat_provider: str | None = None
47
+ chat_model: str | None = None
48
+ chat_base_url: str | None = None
49
+ chat_api_key: str | None = None
50
+ clear_chat_api_key: bool = False
51
+ embedding_provider: str | None = None
52
+ embedding_model: str | None = None
53
+ embedding_base_url: str | None = None
54
+ embedding_api_key: str | None = None
55
+ clear_embedding_api_key: bool = False
56
+ embedding_dim: int | None = None
57
+
58
+ @field_validator("chat_provider")
59
+ @classmethod
60
+ def _chat_provider_known(cls, value: str | None) -> str | None:
61
+ # Normalize the same way resolve.py reads (strip().lower()): an
62
+ # env-seeded "Anthropic" works at runtime and must not brick every
63
+ # save on the settings page until the provider is re-picked.
64
+ if value is None:
65
+ return value
66
+ value = value.strip().lower()
67
+ if value not in constants.CHAT_PROVIDERS:
68
+ raise ValueError(f"chat_provider must be one of {constants.CHAT_PROVIDERS}")
69
+ return value
70
+
71
+ @field_validator("embedding_provider")
72
+ @classmethod
73
+ def _embedding_provider_known(cls, value: str | None) -> str | None:
74
+ if value is None:
75
+ return value
76
+ value = value.strip().lower()
77
+ if value != "" and value not in constants.EMBEDDING_PROVIDERS:
78
+ raise ValueError(
79
+ f"embedding_provider must be empty or one of {constants.EMBEDDING_PROVIDERS}"
80
+ )
81
+ return value
82
+
83
+ @field_validator("chat_model")
84
+ @classmethod
85
+ def _chat_model_not_blank(cls, value: str | None) -> str | None:
86
+ # The chat slot is required: without this, a whitespace-only value
87
+ # strips to "" in build_changes and silently wipes the model, failing
88
+ # every consumer resolve_model() until an admin re-enters it.
89
+ if value is not None and not value.strip():
90
+ raise ValueError("chat_model must not be blank")
91
+ return value
92
+
93
+ @field_validator("embedding_dim")
94
+ @classmethod
95
+ def _dim_non_negative(cls, value: int | None) -> int | None:
96
+ if value is not None and value < 0:
97
+ raise ValueError("embedding_dim must be >= 0")
98
+ return value
99
+
100
+
101
+ class AiTestRequest(SQLModel):
102
+ """Which slot to test."""
103
+
104
+ slot: str
105
+
106
+ @field_validator("slot")
107
+ @classmethod
108
+ def _slot_known(cls, value: str) -> str:
109
+ if value not in constants.TEST_SLOTS:
110
+ raise ValueError(f"slot must be one of {constants.TEST_SLOTS}")
111
+ return value
112
+
113
+
114
+ class AiTestResult(SQLModel):
115
+ """Outcome of one test call. Failure is a result, never a 500."""
116
+
117
+ ok: bool
118
+ model: str = ""
119
+ latency_ms: int = 0
120
+ error: str = ""
sm_ai/crypto.py ADDED
@@ -0,0 +1,114 @@
1
+ """Fernet encryption for stored provider keys.
2
+
3
+ The key derives from ``SM_SECRET_KEY`` (SHA-256 → urlsafe base64). The
4
+ running app's secret wins when the module is registered normally — hosts may
5
+ inject ``secret_key`` programmatically via ``create_app(settings=...)``, see
6
+ ``set_secret_provider`` — and a private BaseSettings reading the environment
7
+ or the root ``.env`` (the same two sources the host's own BootstrapSettings
8
+ uses) is the fallback. The provider and process env are re-consulted on
9
+ every call (only the ``.env`` file read is cached — it cannot change without
10
+ a restart) and the derived Fernet cached per secret, so an early call
11
+ (before the app's live secret is available) can never pin the fallback
12
+ secret for the process.
13
+ Stored format is ``enc:v1:<fernet token>``:
14
+
15
+ - prefix present and decrypts → plaintext key
16
+ - prefix present, decryption fails → AiKeyUnreadableError (secret key changed)
17
+ - no prefix → treated as plaintext, warned once
18
+ (covers keys pasted through the generic settings UI or seeded from env)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ from base64 import urlsafe_b64encode
26
+ from collections.abc import Callable
27
+ from functools import lru_cache
28
+ from hashlib import sha256
29
+
30
+ from cryptography.fernet import Fernet, InvalidToken
31
+ from pydantic_settings import BaseSettings, SettingsConfigDict
32
+
33
+ from sm_ai.contracts.errors import AiKeyUnreadableError
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ ENC_PREFIX = "enc:v1:"
38
+
39
+ # Fields already warned about this process — one line per field is signal,
40
+ # one per read is noise.
41
+ _warned_plaintext: set[str] = set()
42
+
43
+
44
+ class _CryptoEnv(BaseSettings):
45
+ # ``env_file=".env"`` mirrors the host's BootstrapSettings: in the
46
+ # documented dev flow the secret lives only in the root .env and is never
47
+ # exported, so reading process env alone would 500 every key save.
48
+ model_config = SettingsConfigDict(
49
+ env_prefix="SM_", env_file=".env", extra="ignore"
50
+ )
51
+
52
+ secret_key: str = ""
53
+
54
+
55
+ # Installed at module registration: returns the running app's live secret so
56
+ # encryption always matches the app; empty/None falls back to the env read.
57
+ _secret_provider: Callable[[], str] | None = None
58
+
59
+
60
+ def set_secret_provider(provider: Callable[[], str]) -> None:
61
+ """Install the live-secret source."""
62
+ global _secret_provider
63
+ _secret_provider = provider
64
+
65
+
66
+ def _fernet() -> Fernet:
67
+ secret = _secret_provider() if _secret_provider is not None else ""
68
+ # Process env first (free), then the cached .env read: the file cannot
69
+ # change without a restart, and an uncached BaseSettings would re-open
70
+ # and re-parse it on every encrypt/decrypt inside async handlers.
71
+ secret = secret or os.environ.get("SM_SECRET_KEY", "") or _dotenv_secret()
72
+ if not secret:
73
+ raise RuntimeError(
74
+ "SM_SECRET_KEY is not set — the AI module needs it to encrypt "
75
+ "stored provider keys."
76
+ )
77
+ return _fernet_for(secret)
78
+
79
+
80
+ @lru_cache(maxsize=1)
81
+ def _dotenv_secret() -> str:
82
+ return _CryptoEnv().secret_key
83
+
84
+
85
+ @lru_cache(maxsize=4)
86
+ def _fernet_for(secret: str) -> Fernet:
87
+ # Keyed by secret, not cached bare: the provider is consulted per call,
88
+ # so the first caller racing module registration cannot freeze the
89
+ # env/.env fallback in for the whole process.
90
+ return Fernet(urlsafe_b64encode(sha256(secret.encode()).digest()))
91
+
92
+
93
+ def encrypt_value(plain: str) -> str:
94
+ """Encrypt a provider key for storage."""
95
+ return ENC_PREFIX + _fernet().encrypt(plain.encode()).decode()
96
+
97
+
98
+ def decrypt_value(stored: str, field: str) -> str:
99
+ """Return the plaintext key for a stored value (see module docstring)."""
100
+ if not stored:
101
+ return ""
102
+ if stored.startswith(ENC_PREFIX):
103
+ try:
104
+ return _fernet().decrypt(stored[len(ENC_PREFIX) :].encode()).decode()
105
+ except InvalidToken as exc:
106
+ raise AiKeyUnreadableError(field) from exc
107
+ if field not in _warned_plaintext:
108
+ _warned_plaintext.add(field)
109
+ logger.warning(
110
+ "AI setting %r is stored unencrypted; re-save it via the AI "
111
+ "settings page to encrypt it.",
112
+ field,
113
+ )
114
+ return stored