stapel-agent 0.1.0__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.
- stapel_agent/__init__.py +55 -0
- stapel_agent/admin.py +35 -0
- stapel_agent/apps.py +17 -0
- stapel_agent/cache.py +85 -0
- stapel_agent/checks.py +77 -0
- stapel_agent/conf.py +70 -0
- stapel_agent/conftest.py +123 -0
- stapel_agent/dto.py +55 -0
- stapel_agent/errors.py +21 -0
- stapel_agent/functions.py +93 -0
- stapel_agent/migrations/0001_initial.py +43 -0
- stapel_agent/migrations/__init__.py +0 -0
- stapel_agent/models.py +67 -0
- stapel_agent/parsing.py +86 -0
- stapel_agent/providers/__init__.py +95 -0
- stapel_agent/providers/anthropic.py +63 -0
- stapel_agent/providers/base.py +61 -0
- stapel_agent/providers/claude_cli.py +79 -0
- stapel_agent/providers/openai_compat.py +81 -0
- stapel_agent/py.typed +0 -0
- stapel_agent/schemas/functions/llm.complete.json +27 -0
- stapel_agent/schemas/functions/llm.translate.json +23 -0
- stapel_agent/serializers.py +37 -0
- stapel_agent/services.py +282 -0
- stapel_agent/tests/__init__.py +0 -0
- stapel_agent/tests/fakes.py +84 -0
- stapel_agent/tests/test_api.py +201 -0
- stapel_agent/tests/test_extension_points.py +210 -0
- stapel_agent/tests/test_functions.py +79 -0
- stapel_agent/tests/test_models_and_admin.py +93 -0
- stapel_agent/tests/test_parsing.py +83 -0
- stapel_agent/tests/test_providers.py +334 -0
- stapel_agent/tests/test_public_api.py +100 -0
- stapel_agent/tests/test_services.py +203 -0
- stapel_agent/tests/urls.py +5 -0
- stapel_agent/urls.py +19 -0
- stapel_agent/views.py +114 -0
- stapel_agent-0.1.0.dist-info/METADATA +155 -0
- stapel_agent-0.1.0.dist-info/RECORD +42 -0
- stapel_agent-0.1.0.dist-info/WHEEL +5 -0
- stapel_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- stapel_agent-0.1.0.dist-info/top_level.txt +1 -0
stapel_agent/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""stapel-agent — LLM facade: completion, translation, prompt cache/ledger.
|
|
2
|
+
|
|
3
|
+
Public API (lazily resolved, PEP 562 — importing this package pulls in
|
|
4
|
+
no Django code until an attribute is actually accessed):
|
|
5
|
+
|
|
6
|
+
agent_settings — the ``STAPEL_AGENT`` settings namespace
|
|
7
|
+
complete — raw LLM completion (cache + PromptLog ledger)
|
|
8
|
+
translate — key-value translation flow
|
|
9
|
+
LlmProvider — base class for custom LLM backends
|
|
10
|
+
ProviderResult — completion text + token accounting dataclass
|
|
11
|
+
CachePolicy — base class for custom prompt-cache policies
|
|
12
|
+
register_provider — runtime provider registration (apps.ready())
|
|
13
|
+
registered_providers — the effective provider registry mapping
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CachePolicy",
|
|
18
|
+
"LlmProvider",
|
|
19
|
+
"ProviderResult",
|
|
20
|
+
"agent_settings",
|
|
21
|
+
"complete",
|
|
22
|
+
"register_provider",
|
|
23
|
+
"registered_providers",
|
|
24
|
+
"translate",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# name -> (relative module, attribute)
|
|
28
|
+
_EXPORTS = {
|
|
29
|
+
"agent_settings": (".conf", "agent_settings"),
|
|
30
|
+
"complete": (".services", "complete"),
|
|
31
|
+
"translate": (".services", "translate"),
|
|
32
|
+
"LlmProvider": (".providers.base", "LlmProvider"),
|
|
33
|
+
"ProviderResult": (".providers.base", "ProviderResult"),
|
|
34
|
+
"CachePolicy": (".cache", "CachePolicy"),
|
|
35
|
+
"register_provider": (".providers", "register_provider"),
|
|
36
|
+
"registered_providers": (".providers", "registered_providers"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name):
|
|
41
|
+
try:
|
|
42
|
+
module_path, attr = _EXPORTS[name]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise AttributeError(
|
|
45
|
+
f"module {__name__!r} has no attribute {name!r}"
|
|
46
|
+
) from None
|
|
47
|
+
from importlib import import_module
|
|
48
|
+
|
|
49
|
+
value = getattr(import_module(module_path, __name__), attr)
|
|
50
|
+
globals()[name] = value # cache: subsequent lookups skip __getattr__
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __dir__():
|
|
55
|
+
return sorted(set(globals()) | set(__all__))
|
stapel_agent/admin.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import PromptLog
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(PromptLog)
|
|
7
|
+
class PromptLogAdmin(admin.ModelAdmin):
|
|
8
|
+
"""Read-only: PromptLog is an immutable ledger — rows are written by
|
|
9
|
+
the service layer only (editing one would corrupt token accounting
|
|
10
|
+
and could poison the prompt cache)."""
|
|
11
|
+
|
|
12
|
+
list_display = [
|
|
13
|
+
"created_at",
|
|
14
|
+
"source",
|
|
15
|
+
"model",
|
|
16
|
+
"model_size",
|
|
17
|
+
"status",
|
|
18
|
+
"input_tokens",
|
|
19
|
+
"output_tokens",
|
|
20
|
+
"duration_ms",
|
|
21
|
+
"user_id",
|
|
22
|
+
]
|
|
23
|
+
list_filter = ["source", "status", "model_size"]
|
|
24
|
+
search_fields = ["prompt", "user_id", "model"]
|
|
25
|
+
date_hierarchy = "created_at"
|
|
26
|
+
ordering = ["-created_at"]
|
|
27
|
+
|
|
28
|
+
def has_add_permission(self, request):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def has_change_permission(self, request, obj=None):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def has_delete_permission(self, request, obj=None):
|
|
35
|
+
return False
|
stapel_agent/apps.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "stapel_agent"
|
|
7
|
+
label = "agent"
|
|
8
|
+
verbose_name = "Stapel Agent"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
# comm Function providers (in-process in a monolith, transport
|
|
12
|
+
# chosen by STAPEL_COMM in microservices — same code).
|
|
13
|
+
from . import functions # noqa: F401
|
|
14
|
+
|
|
15
|
+
# Django system checks (provider registry / DEFAULT_PROVIDER
|
|
16
|
+
# misconfiguration) — registered on import.
|
|
17
|
+
from . import checks # noqa: F401
|
stapel_agent/cache.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Cache-policy seam — swap the prompt cache without forking.
|
|
2
|
+
|
|
3
|
+
``STAPEL_AGENT["CACHE_POLICY"]`` is a dotted path to a ``CachePolicy``
|
|
4
|
+
subclass (resolved via ``import_strings``, instantiated per call). The
|
|
5
|
+
default, ``PromptLogCachePolicy``, implements the stock behaviour: the
|
|
6
|
+
latest successful ``PromptLog`` row with an identical
|
|
7
|
+
prompt+system_prompt+source within ``CACHE_TTL``, gated per source by
|
|
8
|
+
``CACHE_LOOKUP``.
|
|
9
|
+
|
|
10
|
+
Hosts can point the setting at a Redis-backed policy, a no-op policy,
|
|
11
|
+
or anything else::
|
|
12
|
+
|
|
13
|
+
# myproject/llm_cache.py
|
|
14
|
+
from stapel_agent.cache import CachePolicy
|
|
15
|
+
|
|
16
|
+
class RedisCachePolicy(CachePolicy):
|
|
17
|
+
def should_cache(self, source): ...
|
|
18
|
+
def lookup(self, prompt, system_prompt, source): ...
|
|
19
|
+
def store(self, prompt, system_prompt, source, response): ...
|
|
20
|
+
|
|
21
|
+
# settings.py
|
|
22
|
+
STAPEL_AGENT = {"CACHE_POLICY": "myproject.llm_cache.RedisCachePolicy"}
|
|
23
|
+
|
|
24
|
+
The PromptLog *ledger* row is always written regardless of the policy —
|
|
25
|
+
caching is a read seam, accounting is not optional. ``store()`` exists
|
|
26
|
+
for policies with their own storage; the default is a no-op because the
|
|
27
|
+
ledger row IS the default policy's storage.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from abc import ABC, abstractmethod
|
|
32
|
+
from datetime import timedelta
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CachePolicy(ABC):
|
|
36
|
+
"""Decides when to consult the prompt cache and answers lookups."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def should_cache(self, source: str) -> bool:
|
|
40
|
+
"""Whether *source* ("llm_facade"/"translate"/...) uses the cache."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def lookup(self, prompt: str, system_prompt: str | None, source: str) -> str | None:
|
|
44
|
+
"""Return the cached raw response text, or None on a miss."""
|
|
45
|
+
|
|
46
|
+
def store(self, prompt: str, system_prompt: str | None, source: str, response: str) -> None:
|
|
47
|
+
"""Persist a successful response for future lookups.
|
|
48
|
+
|
|
49
|
+
No-op by default: the default policy reads the PromptLog ledger
|
|
50
|
+
row that ``services.complete`` writes anyway. Policies with
|
|
51
|
+
external storage (Redis, ...) override this.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PromptLogCachePolicy(CachePolicy):
|
|
56
|
+
"""Stock policy: PromptLog rows + CACHE_LOOKUP/CACHE_TTL settings."""
|
|
57
|
+
|
|
58
|
+
def should_cache(self, source: str) -> bool:
|
|
59
|
+
from .conf import agent_settings
|
|
60
|
+
|
|
61
|
+
return bool((agent_settings.CACHE_LOOKUP or {}).get(source, False))
|
|
62
|
+
|
|
63
|
+
def lookup(self, prompt: str, system_prompt: str | None, source: str) -> str | None:
|
|
64
|
+
from django.utils import timezone
|
|
65
|
+
|
|
66
|
+
from .conf import agent_settings
|
|
67
|
+
from .models import PromptLog, PromptStatus
|
|
68
|
+
|
|
69
|
+
ttl = int(agent_settings.CACHE_TTL)
|
|
70
|
+
qs = PromptLog.objects.filter(
|
|
71
|
+
prompt=prompt,
|
|
72
|
+
source=source,
|
|
73
|
+
status=PromptStatus.SUCCESS,
|
|
74
|
+
response__isnull=False,
|
|
75
|
+
created_at__gte=timezone.now() - timedelta(seconds=ttl),
|
|
76
|
+
)
|
|
77
|
+
if system_prompt:
|
|
78
|
+
qs = qs.filter(system_prompt=system_prompt)
|
|
79
|
+
else:
|
|
80
|
+
qs = qs.filter(system_prompt__isnull=True)
|
|
81
|
+
row = qs.order_by("-created_at").first()
|
|
82
|
+
return row.response if row is not None else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["CachePolicy", "PromptLogCachePolicy"]
|
stapel_agent/checks.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Django system checks — catch provider misconfiguration at startup.
|
|
2
|
+
|
|
3
|
+
Registered from ``AgentConfig.ready()``. IDs:
|
|
4
|
+
|
|
5
|
+
- ``stapel_agent.E001`` — ``DEFAULT_PROVIDER`` names a provider that is
|
|
6
|
+
not in the effective registry (built-ins ← settings merge ← runtime).
|
|
7
|
+
- ``stapel_agent.W001`` — a registry entry's dotted path fails to import
|
|
8
|
+
(typo, or an optional dependency missing in this image).
|
|
9
|
+
- ``stapel_agent.W002`` — a registry entry resolves to something that is
|
|
10
|
+
not an ``LlmProvider`` subclass.
|
|
11
|
+
|
|
12
|
+
Import/subclass problems are warnings, not errors, on purpose: providers
|
|
13
|
+
resolve lazily per request and degrade to ``status: "failure"`` — a
|
|
14
|
+
broken *unused* entry must not block deploys, but it should be visible.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
|
|
20
|
+
from django.core import checks
|
|
21
|
+
from django.utils.module_loading import import_string
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@checks.register("stapel_agent")
|
|
25
|
+
def check_providers(app_configs, **kwargs):
|
|
26
|
+
from .conf import agent_settings
|
|
27
|
+
from .providers import registered_providers
|
|
28
|
+
from .providers.base import LlmProvider
|
|
29
|
+
|
|
30
|
+
issues = []
|
|
31
|
+
effective = registered_providers()
|
|
32
|
+
|
|
33
|
+
default = agent_settings.DEFAULT_PROVIDER
|
|
34
|
+
if default not in effective:
|
|
35
|
+
issues.append(
|
|
36
|
+
checks.Error(
|
|
37
|
+
f"STAPEL_AGENT['DEFAULT_PROVIDER'] is {default!r}, which is "
|
|
38
|
+
"not in the effective provider registry "
|
|
39
|
+
f"({sorted(effective) or 'empty'}).",
|
|
40
|
+
hint=(
|
|
41
|
+
"Add it via STAPEL_AGENT['PROVIDERS'] or "
|
|
42
|
+
"stapel_agent.providers.register_provider(), or point "
|
|
43
|
+
"DEFAULT_PROVIDER at an existing name."
|
|
44
|
+
),
|
|
45
|
+
id="stapel_agent.E001",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for name, target in effective.items():
|
|
50
|
+
if isinstance(target, str):
|
|
51
|
+
try:
|
|
52
|
+
target = import_string(target)
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
issues.append(
|
|
55
|
+
checks.Warning(
|
|
56
|
+
f"LLM provider {name!r} cannot be imported: {exc}",
|
|
57
|
+
hint=(
|
|
58
|
+
"Fix the dotted path, install the missing "
|
|
59
|
+
"dependency, or remove the entry (set it to None)."
|
|
60
|
+
),
|
|
61
|
+
id="stapel_agent.W001",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
continue
|
|
65
|
+
if not (inspect.isclass(target) and issubclass(target, LlmProvider)):
|
|
66
|
+
issues.append(
|
|
67
|
+
checks.Warning(
|
|
68
|
+
f"LLM provider {name!r} resolves to {target!r}, which is "
|
|
69
|
+
"not a stapel_agent.LlmProvider subclass.",
|
|
70
|
+
hint="Implement the LlmProvider ABC (see MODULE.md).",
|
|
71
|
+
id="stapel_agent.W002",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return issues
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["check_providers"]
|
stapel_agent/conf.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Settings namespace for stapel-agent.
|
|
2
|
+
|
|
3
|
+
All configuration is read through ``agent_settings`` (lazily, at call
|
|
4
|
+
time) instead of module-level ``os.getenv`` — so tests and host projects
|
|
5
|
+
can override any key via ``settings.STAPEL_AGENT``, a flat Django setting
|
|
6
|
+
of the same name, or an environment variable::
|
|
7
|
+
|
|
8
|
+
STAPEL_AGENT = {
|
|
9
|
+
"DEFAULT_PROVIDER": "openai-compat",
|
|
10
|
+
"OPENAI_COMPAT_BASE_URL": "https://api.deepseek.com/v1",
|
|
11
|
+
"OPENAI_COMPAT_API_KEY": "sk-...",
|
|
12
|
+
"OPENAI_COMPAT_MODELS": {"small": "deepseek-chat"},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
``PROVIDERS`` entries are **merged over** the built-in registry
|
|
16
|
+
(``stapel_agent.providers.BUILTIN_PROVIDERS``) — adding one custom
|
|
17
|
+
provider does not require restating the built-ins, and setting a name to
|
|
18
|
+
``None``/``""`` removes it. Values are dotted paths to ``LlmProvider``
|
|
19
|
+
subclasses, resolved lazily per request in ``services.get_provider``
|
|
20
|
+
(not via ``import_strings`` — an unknown or broken provider must degrade
|
|
21
|
+
to a ``status: failure`` response, never an import-time crash).
|
|
22
|
+
"""
|
|
23
|
+
from stapel_core.conf import AppSettings
|
|
24
|
+
|
|
25
|
+
agent_settings = AppSettings(
|
|
26
|
+
"STAPEL_AGENT",
|
|
27
|
+
defaults={
|
|
28
|
+
# Size → model-name map used by the default (Anthropic-flavoured)
|
|
29
|
+
# providers. OpenAI-compatible hosts override per-size names via
|
|
30
|
+
# OPENAI_COMPAT_MODELS instead.
|
|
31
|
+
"MODELS": {
|
|
32
|
+
"small": "claude-haiku-4-5-20251001",
|
|
33
|
+
"medium": "claude-sonnet-5",
|
|
34
|
+
"large": "claude-opus-4-8",
|
|
35
|
+
},
|
|
36
|
+
# Overlay merged OVER providers.BUILTIN_PROVIDERS (anthropic /
|
|
37
|
+
# openai-compat / claude-code): add or override entries here,
|
|
38
|
+
# None/"" removes a name. Resolved lazily per request via
|
|
39
|
+
# import_string in services.get_provider(name).
|
|
40
|
+
"PROVIDERS": {},
|
|
41
|
+
"DEFAULT_PROVIDER": "anthropic",
|
|
42
|
+
# Anthropic SDK (read lazily at call time, never frozen at import).
|
|
43
|
+
"ANTHROPIC_API_KEY": "",
|
|
44
|
+
# Any OpenAI-compatible /chat/completions endpoint
|
|
45
|
+
# (OpenAI, DeepSeek, MiMo, GLM, Kimi, ...).
|
|
46
|
+
"OPENAI_COMPAT_BASE_URL": "",
|
|
47
|
+
"OPENAI_COMPAT_API_KEY": "",
|
|
48
|
+
# Optional size → model-name map for the openai-compat provider,
|
|
49
|
+
# e.g. {"small": "gpt-4o-mini", "medium": "gpt-4o"}. Missing sizes
|
|
50
|
+
# fall back to MODELS[size].
|
|
51
|
+
"OPENAI_COMPAT_MODELS": {},
|
|
52
|
+
# Claude Code CLI provider (opt-in only, never the default).
|
|
53
|
+
"CLI_BINARY": "claude",
|
|
54
|
+
"CLI_TIMEOUT": 120,
|
|
55
|
+
"MAX_TOKENS": 4096,
|
|
56
|
+
# Per-source cache-by-prompt toggle: a repeated identical
|
|
57
|
+
# prompt+system_prompt within CACHE_TTL returns the stored response
|
|
58
|
+
# without calling the provider.
|
|
59
|
+
"CACHE_LOOKUP": {"llm_facade": False, "translate": True},
|
|
60
|
+
# Seconds; cached rows older than this are ignored (7 days).
|
|
61
|
+
"CACHE_TTL": 604800,
|
|
62
|
+
# Dotted path to a stapel_agent.cache.CachePolicy subclass — the
|
|
63
|
+
# cache seam. The default implements the PromptLog+TTL behaviour;
|
|
64
|
+
# swap for Redis/no-op without forking.
|
|
65
|
+
"CACHE_POLICY": "stapel_agent.cache.PromptLogCachePolicy",
|
|
66
|
+
},
|
|
67
|
+
import_strings=("CACHE_POLICY",),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__all__ = ["agent_settings"]
|
stapel_agent/conftest.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
def pytest_configure(config):
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
if not settings.configured:
|
|
4
|
+
settings.configure(
|
|
5
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
6
|
+
INSTALLED_APPS=[
|
|
7
|
+
"django.contrib.contenttypes",
|
|
8
|
+
"django.contrib.auth",
|
|
9
|
+
"django.contrib.sessions",
|
|
10
|
+
"django.contrib.messages",
|
|
11
|
+
# contrib.admin so the ModelAdmin registrations in admin.py
|
|
12
|
+
# are importable (and covered) in tests.
|
|
13
|
+
"django.contrib.admin",
|
|
14
|
+
"stapel_core.django.users",
|
|
15
|
+
"rest_framework",
|
|
16
|
+
"stapel_agent",
|
|
17
|
+
],
|
|
18
|
+
AUTH_USER_MODEL="users.User",
|
|
19
|
+
DATABASES={
|
|
20
|
+
"default": {
|
|
21
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
22
|
+
"NAME": ":memory:",
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
26
|
+
USE_TZ=True,
|
|
27
|
+
ROOT_URLCONF="stapel_agent.tests.urls",
|
|
28
|
+
CACHES={
|
|
29
|
+
"default": {
|
|
30
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
# In-memory bus — no Kafka/Redis broker needed
|
|
34
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
35
|
+
# Synchronous in-process comm and schema validation ON so
|
|
36
|
+
# llm.complete / llm.translate payloads are checked against
|
|
37
|
+
# schemas/functions/*.json in tests.
|
|
38
|
+
STAPEL_COMM={
|
|
39
|
+
"OUTBOX_ENABLED": False,
|
|
40
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
41
|
+
"FUNCTION_TRANSPORT": "inprocess",
|
|
42
|
+
"VALIDATE_SCHEMAS": True,
|
|
43
|
+
},
|
|
44
|
+
MIDDLEWARE=[
|
|
45
|
+
"django.middleware.common.CommonMiddleware",
|
|
46
|
+
"stapel_core.django.jwt.middleware.ServiceAPIKeyMiddleware",
|
|
47
|
+
],
|
|
48
|
+
SERVICE_API_KEY="test-service-key",
|
|
49
|
+
# Skip migrations — create tables directly from models
|
|
50
|
+
MIGRATION_MODULES={
|
|
51
|
+
"users": None,
|
|
52
|
+
"agent": None,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
import django
|
|
56
|
+
django.setup()
|
|
57
|
+
|
|
58
|
+
# Register schemas/functions/*.json with the comm registries so
|
|
59
|
+
# call() payloads are validated against the committed contracts.
|
|
60
|
+
from stapel_core.comm.schemas import autoload_schemas
|
|
61
|
+
autoload_schemas()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
import pytest # noqa: E402
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def user(db):
|
|
69
|
+
from django.contrib.auth import get_user_model
|
|
70
|
+
User = get_user_model()
|
|
71
|
+
return User.objects.create_user(
|
|
72
|
+
username="testuser",
|
|
73
|
+
email="testuser@example.com",
|
|
74
|
+
password="testpass123",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def staff_user(db):
|
|
80
|
+
from django.contrib.auth import get_user_model
|
|
81
|
+
User = get_user_model()
|
|
82
|
+
return User.objects.create_user(
|
|
83
|
+
username="staffuser",
|
|
84
|
+
email="staff@example.com",
|
|
85
|
+
password="testpass123",
|
|
86
|
+
is_staff=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.fixture
|
|
91
|
+
def api_client():
|
|
92
|
+
from rest_framework.test import APIClient
|
|
93
|
+
return APIClient()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def staff_client(staff_user):
|
|
98
|
+
from rest_framework.test import APIClient
|
|
99
|
+
client = APIClient()
|
|
100
|
+
client.force_authenticate(user=staff_user)
|
|
101
|
+
return client
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture
|
|
105
|
+
def fake_provider(settings):
|
|
106
|
+
"""Route completions to the recording FakeProvider (default provider).
|
|
107
|
+
|
|
108
|
+
Keys are read lazily through stapel_agent.conf.agent_settings, so the
|
|
109
|
+
settings override takes effect at call time. Class-level state is
|
|
110
|
+
reset around each test — get_provider() instantiates a fresh object
|
|
111
|
+
per request, so recordings must live on the class.
|
|
112
|
+
"""
|
|
113
|
+
from stapel_agent.tests.fakes import FakeProvider
|
|
114
|
+
|
|
115
|
+
# Merge semantics: adding "fake" does not restate the built-ins —
|
|
116
|
+
# they stay resolvable alongside it.
|
|
117
|
+
settings.STAPEL_AGENT = {
|
|
118
|
+
"PROVIDERS": {"fake": "stapel_agent.tests.fakes.FakeProvider"},
|
|
119
|
+
"DEFAULT_PROVIDER": "fake",
|
|
120
|
+
}
|
|
121
|
+
FakeProvider.reset()
|
|
122
|
+
yield FakeProvider
|
|
123
|
+
FakeProvider.reset()
|
stapel_agent/dto.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""DTOs for the agent API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CompleteRequest:
|
|
9
|
+
"""JSON LLM completion request.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
prompt: The user prompt sent to the model.
|
|
13
|
+
model: Model size — small, medium or large. Example: small
|
|
14
|
+
provider: Provider name from STAPEL_AGENT["PROVIDERS"]; defaults
|
|
15
|
+
to DEFAULT_PROVIDER.
|
|
16
|
+
system_prompt: Replaces the built-in JSON-API system prompt.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
prompt: str
|
|
20
|
+
model: str
|
|
21
|
+
provider: Optional[str] = None
|
|
22
|
+
system_prompt: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TranslateRequest:
|
|
27
|
+
"""Key-value translation request.
|
|
28
|
+
|
|
29
|
+
The wire key for the source language is ``from`` (a Python keyword) —
|
|
30
|
+
the serializer maps it onto ``from_lang`` explicitly.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
from_lang: Source language code, or "auto" to auto-detect.
|
|
34
|
+
to: Target language code. Example: de
|
|
35
|
+
entries: Mapping of keys to source-language strings.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from_lang: str
|
|
39
|
+
to: str
|
|
40
|
+
entries: Dict[str, str] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TranslateResponse:
|
|
45
|
+
"""Key-value translation response (iron-agent contract).
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
status: "ok" or "failure".
|
|
49
|
+
result: Mapping of keys to translated strings (on success).
|
|
50
|
+
reason: Failure reason (on failure).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
status: str
|
|
54
|
+
result: Optional[Dict[str, str]] = None
|
|
55
|
+
reason: Optional[str] = None
|
stapel_agent/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom error keys for the agent service.
|
|
2
|
+
|
|
3
|
+
Only *request validation* problems are error-key responses (HTTP 400).
|
|
4
|
+
LLM/provider failures are NOT errors at the HTTP layer — the iron-agent
|
|
5
|
+
contract returns HTTP 200 with ``{"status": "failure", "reason": ...}``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from stapel_core.django.api.errors import ErrorKeysView, register_service_errors
|
|
9
|
+
|
|
10
|
+
ERR_400_INVALID_MODEL_SIZE = "error.400.invalid_model_size"
|
|
11
|
+
|
|
12
|
+
AGENT_ERRORS = {
|
|
13
|
+
ERR_400_INVALID_MODEL_SIZE: "Model must be one of: small, medium, large",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
register_service_errors(AGENT_ERRORS)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentErrorKeysView(ErrorKeysView):
|
|
20
|
+
def get_service_errors(self):
|
|
21
|
+
return AGENT_ERRORS
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""comm Function providers of the agent module.
|
|
2
|
+
|
|
3
|
+
Registered from ``AgentConfig.ready()`` (importing this module is enough:
|
|
4
|
+
re-imports are no-ops and re-registering the same handler object is
|
|
5
|
+
idempotent). Other modules call these by name via ``stapel_core.comm.call``
|
|
6
|
+
— no import of this package needed, and in a monolith the call is
|
|
7
|
+
in-process without HTTP:
|
|
8
|
+
|
|
9
|
+
from stapel_core.comm import call
|
|
10
|
+
|
|
11
|
+
call("llm.translate", {"from_lang": "auto", "to": "de", "entries": {...}})
|
|
12
|
+
"""
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from stapel_core.comm import function
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
COMPLETE_SCHEMA = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"prompt": {"type": "string", "description": "The user prompt."},
|
|
23
|
+
"model": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": ["small", "medium", "large"],
|
|
26
|
+
"description": "Model size, mapped via STAPEL_AGENT['MODELS'].",
|
|
27
|
+
},
|
|
28
|
+
"system_prompt": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Replaces the built-in JSON-API system prompt.",
|
|
31
|
+
},
|
|
32
|
+
"provider": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Provider name from STAPEL_AGENT['PROVIDERS'].",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"required": ["prompt", "model"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
TRANSLATE_SCHEMA = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"from_lang": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Source language code, or 'auto' to auto-detect.",
|
|
47
|
+
},
|
|
48
|
+
"to": {"type": "string", "description": "Target language code."},
|
|
49
|
+
"entries": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"additionalProperties": {"type": "string"},
|
|
52
|
+
"description": "Mapping of keys to source-language strings.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["from_lang", "to", "entries"],
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@function("llm.complete", schema=COMPLETE_SCHEMA)
|
|
61
|
+
def llm_complete(payload: dict) -> dict:
|
|
62
|
+
"""JSON LLM completion — same result dict as ``POST api/llm/complete``.
|
|
63
|
+
|
|
64
|
+
Payload: ``{"prompt": str, "model": "small"|"medium"|"large",
|
|
65
|
+
"system_prompt"?: str, "provider"?: str}``. Returns
|
|
66
|
+
``{"status": "ok"|"failure", "result"?: object, "comment"?: str,
|
|
67
|
+
"reason"?: str, "usage"?: {...}}``.
|
|
68
|
+
"""
|
|
69
|
+
from . import services
|
|
70
|
+
|
|
71
|
+
return services.complete_json(
|
|
72
|
+
payload["prompt"],
|
|
73
|
+
payload["model"],
|
|
74
|
+
system_prompt=payload.get("system_prompt"),
|
|
75
|
+
provider=payload.get("provider"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@function("llm.translate", schema=TRANSLATE_SCHEMA)
|
|
80
|
+
def llm_translate(payload: dict) -> dict:
|
|
81
|
+
"""Key-value translation — same result dict as ``POST api/llm/translate``.
|
|
82
|
+
|
|
83
|
+
Payload: ``{"from_lang": str, "to": str, "entries": {key: text}}``.
|
|
84
|
+
Returns ``{"status": "ok", "result": {key: translated}}`` or
|
|
85
|
+
``{"status": "failure", "reason": str}``.
|
|
86
|
+
"""
|
|
87
|
+
from . import services
|
|
88
|
+
|
|
89
|
+
return services.translate(
|
|
90
|
+
payload["from_lang"],
|
|
91
|
+
payload["to"],
|
|
92
|
+
payload["entries"],
|
|
93
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Generated by Django 6.0.6 on 2026-07-03 20:16
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='PromptLog',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
19
|
+
('source', models.CharField(choices=[('llm_facade', 'LLM Facade'), ('translate', 'Translate'), ('other', 'Other')], db_index=True, max_length=32)),
|
|
20
|
+
('model', models.CharField(max_length=128)),
|
|
21
|
+
('model_size', models.CharField(max_length=16)),
|
|
22
|
+
('prompt', models.TextField()),
|
|
23
|
+
('system_prompt', models.TextField(blank=True, null=True)),
|
|
24
|
+
('response', models.TextField(blank=True, null=True)),
|
|
25
|
+
('status', models.CharField(choices=[('success', 'Success'), ('failure', 'Failure'), ('timeout', 'Timeout'), ('error', 'Error')], db_index=True, max_length=16)),
|
|
26
|
+
('error_message', models.TextField(blank=True, null=True)),
|
|
27
|
+
('input_tokens', models.IntegerField(blank=True, null=True)),
|
|
28
|
+
('output_tokens', models.IntegerField(blank=True, null=True)),
|
|
29
|
+
('thinking_tokens', models.IntegerField(blank=True, null=True)),
|
|
30
|
+
('cache_read_tokens', models.IntegerField(blank=True, null=True)),
|
|
31
|
+
('cache_write_tokens', models.IntegerField(blank=True, null=True)),
|
|
32
|
+
('duration_ms', models.IntegerField(blank=True, null=True)),
|
|
33
|
+
('user_id', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
|
34
|
+
('metadata', models.JSONField(blank=True, null=True)),
|
|
35
|
+
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
36
|
+
],
|
|
37
|
+
options={
|
|
38
|
+
'db_table': 'agent_prompt_log',
|
|
39
|
+
'ordering': ['-created_at'],
|
|
40
|
+
'indexes': [models.Index(fields=['source', '-created_at'], name='agent_source_created_idx')],
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
]
|
|
File without changes
|