simple-module-ai 0.0.6__tar.gz

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 (33) hide show
  1. simple_module_ai-0.0.6/.gitignore +43 -0
  2. simple_module_ai-0.0.6/PKG-INFO +93 -0
  3. simple_module_ai-0.0.6/README.md +71 -0
  4. simple_module_ai-0.0.6/package.json +17 -0
  5. simple_module_ai-0.0.6/pyproject.toml +47 -0
  6. simple_module_ai-0.0.6/sm_ai/__init__.py +0 -0
  7. simple_module_ai-0.0.6/sm_ai/components/SlotCard.tsx +172 -0
  8. simple_module_ai-0.0.6/sm_ai/constants.py +66 -0
  9. simple_module_ai-0.0.6/sm_ai/contracts/__init__.py +54 -0
  10. simple_module_ai-0.0.6/sm_ai/contracts/errors.py +30 -0
  11. simple_module_ai-0.0.6/sm_ai/contracts/schemas.py +120 -0
  12. simple_module_ai-0.0.6/sm_ai/crypto.py +114 -0
  13. simple_module_ai-0.0.6/sm_ai/deps.py +21 -0
  14. simple_module_ai-0.0.6/sm_ai/endpoints/__init__.py +0 -0
  15. simple_module_ai-0.0.6/sm_ai/endpoints/api.py +85 -0
  16. simple_module_ai-0.0.6/sm_ai/endpoints/views.py +28 -0
  17. simple_module_ai-0.0.6/sm_ai/module.py +100 -0
  18. simple_module_ai-0.0.6/sm_ai/pages/Settings.tsx +157 -0
  19. simple_module_ai-0.0.6/sm_ai/resolve.py +131 -0
  20. simple_module_ai-0.0.6/sm_ai/security.py +133 -0
  21. simple_module_ai-0.0.6/sm_ai/service.py +89 -0
  22. simple_module_ai-0.0.6/sm_ai/services.py +53 -0
  23. simple_module_ai-0.0.6/sm_ai/settings.py +41 -0
  24. simple_module_ai-0.0.6/sm_ai/utils/api.ts +115 -0
  25. simple_module_ai-0.0.6/tests/ai_test_stubs.py +25 -0
  26. simple_module_ai-0.0.6/tests/conftest.py +29 -0
  27. simple_module_ai-0.0.6/tests/test_ai_api.py +184 -0
  28. simple_module_ai-0.0.6/tests/test_ai_csrf.py +121 -0
  29. simple_module_ai-0.0.6/tests/test_ai_module.py +55 -0
  30. simple_module_ai-0.0.6/tests/test_crypto.py +62 -0
  31. simple_module_ai-0.0.6/tests/test_resolve.py +158 -0
  32. simple_module_ai-0.0.6/tests/test_settings.py +83 -0
  33. simple_module_ai-0.0.6/tsconfig.json +10 -0
@@ -0,0 +1,43 @@
1
+ .claude/settings.local.json
2
+
3
+ # Python
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.egg-info/
8
+
9
+ # Node
10
+ node_modules/
11
+ dist/
12
+
13
+ # Built module frontend bundles (force-included into wheels at build time)
14
+ modules/*/*/static/dist/
15
+
16
+ # Host build output and the module-pages manifest regenerated by `make gen-pages`
17
+ host/static/dist/
18
+ host/client_app/modules.manifest.json
19
+ host/client_app/modules.generated.ts
20
+ host/client_app/modules.generated.css
21
+
22
+ # Local databases
23
+ host/*.db
24
+
25
+ # Runtime upload storage (PagebuilderSettings.media_root)
26
+ var/
27
+
28
+ # Env
29
+ .env
30
+
31
+ # Playwright
32
+ playwright-report/
33
+ test-results/
34
+ .playwright-mcp/
35
+
36
+ # macOS
37
+ .DS_Store
38
+
39
+ # QA / verify pipeline artifacts (/qa, /vf)
40
+ .qa/
41
+ .verify/
42
+ # Scratch database a /qa run boots against, so it never touches host/app.db.
43
+ qa.db*
@@ -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,71 @@
1
+ # simple_module_ai
2
+
3
+ AI base module for [SimpleModule](https://docs.py.simplemodule.dev) apps: one
4
+ provider-configurable service layer (chat + embeddings) that every other
5
+ module shares, with DB-backed settings, encrypted keys and an admin page.
6
+
7
+ The module owns **connection** configuration — provider, endpoint, key, model
8
+ name. Consuming modules own **behaviour** — agents, prompts, temperature,
9
+ retries, streaming.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install simple_module_ai
15
+ ```
16
+
17
+ Add `simple_module_ai` to your host's dependencies; it is discovered at boot
18
+ via the `simple_module` entry point. There are no tables and no migrations.
19
+
20
+ ## Usage (consuming from another module)
21
+
22
+ Declare the dependency in your module's `ModuleMeta`:
23
+
24
+ ```python
25
+ meta = ModuleMeta(name="MyModule", depends_on=["Ai"], ...)
26
+ ```
27
+
28
+ Then resolve connections wherever you need them:
29
+
30
+ ```python
31
+ from pydantic_ai import Agent
32
+ from sm_ai.contracts import AiNotConfiguredError, resolve_model, resolve_embedder
33
+
34
+ agent = Agent(instructions="...", output_type=MySchema)
35
+ result = await agent.run(text, model=resolve_model())
36
+
37
+ embedder = resolve_embedder() # raises AiNotConfiguredError until the
38
+ result = await embedder.embed_documents(chunks) # embedding slot is set up
39
+ ```
40
+
41
+ `resolve_model(model_name=...)` overrides the configured model name for
42
+ same-endpoint multi-model setups. `embedding_dim()` returns the configured
43
+ vector width. All errors subclass `AiError`.
44
+
45
+ > The import package is `sm_ai`, not `ai` — the bare name belongs to another
46
+ > PyPI project and would collide in hosts that install it.
47
+
48
+ ## Settings
49
+
50
+ Two slots — chat and embeddings — each with provider (`anthropic`, `openai`,
51
+ `google`, `openai_compatible`), model, base URL and API key. The
52
+ `openai_compatible` provider covers vLLM, Ollama and LM Studio (base URL
53
+ required; a blank key is replaced by a protocol-satisfying placeholder).
54
+
55
+ Configure at `/ai/` (permission `ai.manage`) or seed via `SM_AI_*` env vars
56
+ (e.g. `SM_AI_CHAT_MODEL`); DB values win over env. API keys are encrypted at
57
+ rest with a key derived from `SM_SECRET_KEY` — rotating that secret means
58
+ re-entering provider keys (the old ciphertexts surface as unreadable). A
59
+ settings save hot-reloads the worker that handled it; other workers pick
60
+ the change up on restart.
61
+
62
+ ## Routes
63
+
64
+ | Route | Purpose |
65
+ |---|---|
66
+ | `GET /ai/` | admin settings page |
67
+ | `GET`/`PUT /api/ai/settings` | read (secrets masked) / update |
68
+ | `POST /api/ai/test` | probe the chat or embedding slot |
69
+
70
+ Writes require an `X-CSRF-Token` header bound to the session (the admin page
71
+ sends it automatically; the token is mirrored to the `sm_ai_csrf` cookie).
@@ -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,47 @@
1
+ [project]
2
+ name = "simple_module_ai"
3
+ version = "0.0.6"
4
+ description = "AI base module: provider-configurable Pydantic AI service layer, encrypted key storage, admin settings UI"
5
+ requires-python = ">=3.12"
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
9
+ keywords = ["simple-module", "ai", "llm", "pydantic-ai", "embeddings"]
10
+ # Ranges, never `==`: an exact framework pin makes a published module
11
+ # uninstallable in any host running a newer framework build.
12
+ dependencies = [
13
+ "simple_module_core>=0.0.25,<0.1",
14
+ "simple_module_db>=0.0.25,<0.1",
15
+ "simple_module_hosting>=0.0.25,<0.1",
16
+ # Floor verified: register_module_settings and apply_changes_and_reload
17
+ # both exist at 0.0.25.
18
+ "simple_module_settings>=0.0.25,<0.1",
19
+ "pydantic-ai-slim[anthropic,openai,google]>=2.0,<3",
20
+ "pydantic-settings>=2.0",
21
+ "cryptography>=42.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/antosubash/simple_module_python_modules"
26
+
27
+ [project.entry-points.simple_module]
28
+ ai = "sm_ai.module:AiModule"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "simple_module_test>=0.0.25,<0.1"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["sm_ai"]
39
+
40
+ # package.json lives at the module root so npm workspaces see it; copying it
41
+ # into the package lets the host discover JS deps after a pip install. This
42
+ # module ships no bundled frontend build, so there is no dist to include.
43
+ [tool.hatch.build.targets.wheel.force-include]
44
+ "package.json" = "sm_ai/package.json"
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
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
+ }
@@ -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
+ )