simple-module-ai 0.0.7__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.
- simple_module_ai-0.0.7/.gitignore +43 -0
- simple_module_ai-0.0.7/PKG-INFO +93 -0
- simple_module_ai-0.0.7/README.md +71 -0
- simple_module_ai-0.0.7/package.json +17 -0
- simple_module_ai-0.0.7/pyproject.toml +47 -0
- simple_module_ai-0.0.7/sm_ai/__init__.py +0 -0
- simple_module_ai-0.0.7/sm_ai/components/SlotCard.tsx +172 -0
- simple_module_ai-0.0.7/sm_ai/constants.py +66 -0
- simple_module_ai-0.0.7/sm_ai/contracts/__init__.py +54 -0
- simple_module_ai-0.0.7/sm_ai/contracts/errors.py +30 -0
- simple_module_ai-0.0.7/sm_ai/contracts/schemas.py +120 -0
- simple_module_ai-0.0.7/sm_ai/crypto.py +114 -0
- simple_module_ai-0.0.7/sm_ai/deps.py +21 -0
- simple_module_ai-0.0.7/sm_ai/endpoints/__init__.py +0 -0
- simple_module_ai-0.0.7/sm_ai/endpoints/api.py +85 -0
- simple_module_ai-0.0.7/sm_ai/endpoints/views.py +28 -0
- simple_module_ai-0.0.7/sm_ai/module.py +100 -0
- simple_module_ai-0.0.7/sm_ai/pages/Settings.tsx +157 -0
- simple_module_ai-0.0.7/sm_ai/resolve.py +131 -0
- simple_module_ai-0.0.7/sm_ai/security.py +133 -0
- simple_module_ai-0.0.7/sm_ai/service.py +89 -0
- simple_module_ai-0.0.7/sm_ai/services.py +53 -0
- simple_module_ai-0.0.7/sm_ai/settings.py +41 -0
- simple_module_ai-0.0.7/sm_ai/utils/api.ts +115 -0
- simple_module_ai-0.0.7/tests/ai_test_stubs.py +25 -0
- simple_module_ai-0.0.7/tests/conftest.py +29 -0
- simple_module_ai-0.0.7/tests/test_ai_api.py +184 -0
- simple_module_ai-0.0.7/tests/test_ai_csrf.py +121 -0
- simple_module_ai-0.0.7/tests/test_ai_module.py +55 -0
- simple_module_ai-0.0.7/tests/test_crypto.py +62 -0
- simple_module_ai-0.0.7/tests/test_resolve.py +158 -0
- simple_module_ai-0.0.7/tests/test_settings.py +83 -0
- simple_module_ai-0.0.7/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.7
|
|
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.7",
|
|
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.7"
|
|
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
|
+
)
|