django-agentic 0.3.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.
- django_agentic/__init__.py +44 -0
- django_agentic/admin.py +163 -0
- django_agentic/agent.py +101 -0
- django_agentic/apps.py +13 -0
- django_agentic/context.py +5 -0
- django_agentic/credits.py +89 -0
- django_agentic/management/__init__.py +1 -0
- django_agentic/management/commands/__init__.py +1 -0
- django_agentic/management/commands/reset_free_credits.py +16 -0
- django_agentic/migrations/0001_initial.py +116 -0
- django_agentic/migrations/0002_add_model_type.py +18 -0
- django_agentic/migrations/0003_remove_db_table_overrides.py +44 -0
- django_agentic/migrations/0004_seed_default_models.py +57 -0
- django_agentic/migrations/__init__.py +1 -0
- django_agentic/models.py +204 -0
- django_agentic/py.typed +0 -0
- django_agentic/serializers.py +28 -0
- django_agentic/service.py +677 -0
- django_agentic/tests/__init__.py +1 -0
- django_agentic/tests/test_credits.py +124 -0
- django_agentic/tests/test_models.py +133 -0
- django_agentic/tests/test_service.py +149 -0
- django_agentic/tests/test_views.py +149 -0
- django_agentic/tests/urls.py +6 -0
- django_agentic/urls.py +13 -0
- django_agentic/views.py +237 -0
- django_agentic-0.3.0.dist-info/METADATA +441 -0
- django_agentic-0.3.0.dist-info/RECORD +31 -0
- django_agentic-0.3.0.dist-info/WHEEL +5 -0
- django_agentic-0.3.0.dist-info/licenses/LICENSE +21 -0
- django_agentic-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""django-agentic: AI framework for Django — LangGraph + LangChain.
|
|
2
|
+
|
|
3
|
+
Add 'django_agentic' to INSTALLED_APPS, run migrations, configure models in admin.
|
|
4
|
+
|
|
5
|
+
Checkpointer Configuration
|
|
6
|
+
--------------------------
|
|
7
|
+
Chat history is stored via LangGraph checkpointers. Configure in settings:
|
|
8
|
+
|
|
9
|
+
DJANGO_AGENTIC = {
|
|
10
|
+
"CHECKPOINTER": <checkpointer_instance>,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Default: InMemorySaver (works out of the box, loses history on server restart).
|
|
14
|
+
|
|
15
|
+
For production with PostgreSQL:
|
|
16
|
+
|
|
17
|
+
# pip install langgraph-checkpoint-postgres
|
|
18
|
+
from langgraph.checkpoint.postgres import PostgresSaver
|
|
19
|
+
DJANGO_AGENTIC = {
|
|
20
|
+
"CHECKPOINTER": PostgresSaver.from_conn_string(
|
|
21
|
+
"postgresql://user:pass@host:5432/dbname"
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
# Call DJANGO_AGENTIC["CHECKPOINTER"].setup() once (e.g. in a management command
|
|
25
|
+
# or AppConfig.ready()) to create the checkpoint tables.
|
|
26
|
+
|
|
27
|
+
For production with Redis:
|
|
28
|
+
|
|
29
|
+
# pip install langgraph-checkpoint-redis
|
|
30
|
+
from langgraph.checkpoint.redis import RedisSaver
|
|
31
|
+
DJANGO_AGENTIC = {
|
|
32
|
+
"CHECKPOINTER": RedisSaver(redis_url="redis://localhost:6379"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
For SQLite (local dev with persistence across restarts):
|
|
36
|
+
|
|
37
|
+
# pip install langgraph-checkpoint-sqlite
|
|
38
|
+
import sqlite3
|
|
39
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
40
|
+
DJANGO_AGENTIC = {
|
|
41
|
+
"CHECKPOINTER": SqliteSaver(sqlite3.connect("checkpoints.db", check_same_thread=False)),
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
__version__ = "0.3.0"
|
django_agentic/admin.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import datetime
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
from django.db.models import Sum, Count
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from django.utils.html import escape
|
|
7
|
+
from django.utils.safestring import mark_safe
|
|
8
|
+
from .models import AIModel, AIUsageLog, SiteAIConfig, UserAIProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _daily_usage_chart(qs, title="Usage (Last 30 Days)", days=30):
|
|
12
|
+
"""Generate Chart.js HTML for daily usage trends."""
|
|
13
|
+
from django.db.models.functions import TruncDate
|
|
14
|
+
cutoff = timezone.now() - datetime.timedelta(days=days)
|
|
15
|
+
daily = (
|
|
16
|
+
qs.filter(created_at__gte=cutoff)
|
|
17
|
+
.annotate(date=TruncDate("created_at"))
|
|
18
|
+
.values("date")
|
|
19
|
+
.annotate(
|
|
20
|
+
requests=Count("id"), cost=Sum("cost_usd"),
|
|
21
|
+
input_tokens=Sum("prompt_tokens"), output_tokens=Sum("completion_tokens"),
|
|
22
|
+
cache_read=Sum("cache_read_tokens"), cache_write=Sum("cache_creation_tokens"),
|
|
23
|
+
)
|
|
24
|
+
.order_by("date")
|
|
25
|
+
)
|
|
26
|
+
daily_map = {r["date"]: r for r in daily}
|
|
27
|
+
today = timezone.now().date()
|
|
28
|
+
|
|
29
|
+
dates, costs, inputs, outputs, cache_r, cache_w, reqs = [], [], [], [], [], [], []
|
|
30
|
+
for i in range(days - 1, -1, -1):
|
|
31
|
+
d = today - datetime.timedelta(days=i)
|
|
32
|
+
r = daily_map.get(d)
|
|
33
|
+
dates.append(d.strftime("%b %d"))
|
|
34
|
+
costs.append(float(r["cost"] or 0) if r else 0)
|
|
35
|
+
inputs.append(r["input_tokens"] or 0 if r else 0)
|
|
36
|
+
outputs.append(r["output_tokens"] or 0 if r else 0)
|
|
37
|
+
cache_r.append(r["cache_read"] or 0 if r else 0)
|
|
38
|
+
cache_w.append(r["cache_write"] or 0 if r else 0)
|
|
39
|
+
reqs.append(r["requests"] or 0 if r else 0)
|
|
40
|
+
|
|
41
|
+
total_cost = f"${sum(costs):.4f}"
|
|
42
|
+
total_reqs = sum(reqs)
|
|
43
|
+
total_tokens = f"{sum(inputs)+sum(outputs)+sum(cache_r)+sum(cache_w):,}"
|
|
44
|
+
cid = f"aichart_{id(qs) % 99999}"
|
|
45
|
+
data = json.dumps({"d": dates, "c": costs, "i": inputs,
|
|
46
|
+
"o": outputs, "cr": cache_r, "cw": cache_w})
|
|
47
|
+
|
|
48
|
+
safe_title = escape(title)
|
|
49
|
+
js = (
|
|
50
|
+
'(function(){'
|
|
51
|
+
'var d=' + data + ';'
|
|
52
|
+
'var ctx=document.getElementById("' + cid + '");'
|
|
53
|
+
'if(ctx&&d.d.length>0){new Chart(ctx,{type:"line",data:{labels:d.d,datasets:['
|
|
54
|
+
'{label:"Cost ($)",data:d.c,borderColor:"rgb(75,192,192)",backgroundColor:"rgba(75,192,192,0.1)",yAxisID:"y",tension:0.3,fill:true},'
|
|
55
|
+
'{label:"Input",data:d.i,borderColor:"rgb(54,162,235)",backgroundColor:"rgba(54,162,235,0.1)",yAxisID:"y1",tension:0.3,fill:true},'
|
|
56
|
+
'{label:"Output",data:d.o,borderColor:"rgb(255,159,64)",backgroundColor:"rgba(255,159,64,0.1)",yAxisID:"y1",tension:0.3,fill:true},'
|
|
57
|
+
'{label:"Cache Read",data:d.cr,borderColor:"rgb(153,102,255)",backgroundColor:"rgba(153,102,255,0.1)",yAxisID:"y1",tension:0.3,fill:true},'
|
|
58
|
+
'{label:"Cache Write",data:d.cw,borderColor:"rgb(255,99,132)",backgroundColor:"rgba(255,99,132,0.1)",yAxisID:"y1",tension:0.3,fill:true}'
|
|
59
|
+
']},options:{responsive:true,interaction:{mode:"index",intersect:false},'
|
|
60
|
+
'scales:{y:{type:"linear",position:"left",title:{display:true,text:"Cost ($)"},ticks:{callback:function(v){return "$"+v.toFixed(3)}}},'
|
|
61
|
+
'y1:{type:"linear",position:"right",grid:{drawOnChartArea:false},title:{display:true,text:"Tokens"},'
|
|
62
|
+
'ticks:{callback:function(v){return v>=1e6?(v/1e6).toFixed(1)+"M":v>=1e3?(v/1e3).toFixed(0)+"K":v}}}'
|
|
63
|
+
'}}})}})();'
|
|
64
|
+
)
|
|
65
|
+
return mark_safe(
|
|
66
|
+
'<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>'
|
|
67
|
+
'<div style="background:#fff;padding:20px;border:1px solid #ddd;border-radius:4px;margin:10px 0">'
|
|
68
|
+
'<h3 style="margin-top:0">' + str(safe_title) + '</h3>'
|
|
69
|
+
'<div style="display:flex;gap:40px;margin-bottom:15px">'
|
|
70
|
+
'<div><strong>Requests:</strong> ' + str(total_reqs) + '</div>'
|
|
71
|
+
'<div><strong>Cost:</strong> ' + str(total_cost) + '</div>'
|
|
72
|
+
'<div><strong>Tokens:</strong> ' + str(total_tokens) + '</div></div>'
|
|
73
|
+
'<canvas id="' + cid + '" style="max-height:300px"></canvas></div>'
|
|
74
|
+
'<script>' + js + '</script>'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@admin.register(AIModel)
|
|
79
|
+
class AIModelAdmin(admin.ModelAdmin):
|
|
80
|
+
list_display = ("display_name", "name", "model_type", "provider", "input_cost_per_1m", "output_cost_per_1m",
|
|
81
|
+
"allowed_for_free", "allowed_for_paid", "active", "total_cost_30d")
|
|
82
|
+
list_filter = ("model_type", "provider", "active", "allowed_for_free", "allowed_for_paid")
|
|
83
|
+
list_editable = ("allowed_for_free", "allowed_for_paid", "active")
|
|
84
|
+
readonly_fields = ("usage_charts",)
|
|
85
|
+
|
|
86
|
+
def total_cost_30d(self, obj):
|
|
87
|
+
cutoff = timezone.now() - datetime.timedelta(days=30)
|
|
88
|
+
total = obj.usage_logs.filter(created_at__gte=cutoff).aggregate(total=Sum("cost_usd"))["total"]
|
|
89
|
+
return f"${total:.4f}" if total else "$0"
|
|
90
|
+
total_cost_30d.short_description = "Cost (30d)"
|
|
91
|
+
|
|
92
|
+
def usage_charts(self, obj):
|
|
93
|
+
if not obj.pk:
|
|
94
|
+
return "Save the model first to see usage charts."
|
|
95
|
+
return _daily_usage_chart(obj.usage_logs.all(), f"{obj.display_name or obj.name} — Usage")
|
|
96
|
+
usage_charts.short_description = "Usage Statistics"
|
|
97
|
+
|
|
98
|
+
def get_fieldsets(self, request, obj=None):
|
|
99
|
+
base = [(None, {"fields": ("name", "display_name", "model_type", "provider",
|
|
100
|
+
"input_cost_per_1m", "output_cost_per_1m",
|
|
101
|
+
"cache_write_cost_per_1m", "cache_read_cost_per_1m",
|
|
102
|
+
"active", "allowed_for_free", "allowed_for_paid",
|
|
103
|
+
"context_window", "max_output_tokens")})]
|
|
104
|
+
if obj and obj.pk:
|
|
105
|
+
base.append(("Usage Statistics", {"fields": ("usage_charts",)}))
|
|
106
|
+
return base
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@admin.register(SiteAIConfig)
|
|
110
|
+
class SiteAIConfigAdmin(admin.ModelAdmin):
|
|
111
|
+
list_display = ("__str__", "default_free_model", "default_paid_model", "monthly_free_credits")
|
|
112
|
+
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
|
113
|
+
if db_field.name in ("default_free_model", "default_paid_model"):
|
|
114
|
+
kwargs["queryset"] = AIModel.objects.filter(active=True, model_type="chat")
|
|
115
|
+
if db_field.name == "default_free_model":
|
|
116
|
+
kwargs["queryset"] = kwargs["queryset"].filter(allowed_for_free=True)
|
|
117
|
+
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
|
118
|
+
def has_add_permission(self, request):
|
|
119
|
+
return not SiteAIConfig.objects.exists()
|
|
120
|
+
def has_delete_permission(self, request, obj=None):
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@admin.register(UserAIProfile)
|
|
125
|
+
class UserAIProfileAdmin(admin.ModelAdmin):
|
|
126
|
+
list_display = ("user", "free_monthly_credits", "purchased_credits", "total_credits",
|
|
127
|
+
"model_override", "credits_reset_at")
|
|
128
|
+
list_filter = ("model_override",)
|
|
129
|
+
search_fields = ("user__email", "user__username")
|
|
130
|
+
readonly_fields = ("created_at", "updated_at", "usage_charts")
|
|
131
|
+
|
|
132
|
+
def total_credits(self, obj):
|
|
133
|
+
return f"${obj.total_credits:.6f}"
|
|
134
|
+
total_credits.short_description = "Total"
|
|
135
|
+
|
|
136
|
+
def usage_charts(self, obj):
|
|
137
|
+
if not obj.pk:
|
|
138
|
+
return "Save the profile first to see usage charts."
|
|
139
|
+
return _daily_usage_chart(AIUsageLog.objects.filter(user=obj.user), f"{obj.user} — Usage")
|
|
140
|
+
usage_charts.short_description = "Usage Statistics"
|
|
141
|
+
|
|
142
|
+
def get_fieldsets(self, request, obj=None):
|
|
143
|
+
base = [(None, {"fields": ("user", "free_monthly_credits", "purchased_credits",
|
|
144
|
+
"model_override", "credits_reset_at", "created_at", "updated_at")})]
|
|
145
|
+
if obj and obj.pk:
|
|
146
|
+
base.append(("Usage Statistics", {"fields": ("usage_charts",)}))
|
|
147
|
+
return base
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@admin.register(AIUsageLog)
|
|
151
|
+
class AIUsageLogAdmin(admin.ModelAdmin):
|
|
152
|
+
list_display = ("created_at", "workflow", "node", "model_name", "user",
|
|
153
|
+
"prompt_tokens", "completion_tokens",
|
|
154
|
+
"cache_read_tokens", "cache_creation_tokens",
|
|
155
|
+
"cost_usd", "used_free_credits", "used_paid_credits",
|
|
156
|
+
"error_type", "duration_ms", "success")
|
|
157
|
+
list_filter = ("workflow", "model_name", "success", "error_type", "created_at")
|
|
158
|
+
readonly_fields = ("id", "idempotency_key", "created_at", "request_time", "response_time",
|
|
159
|
+
"provider_request_id",
|
|
160
|
+
"input_cost_per_1m_at_time", "output_cost_per_1m_at_time",
|
|
161
|
+
"cache_write_cost_per_1m_at_time", "cache_read_cost_per_1m_at_time")
|
|
162
|
+
search_fields = ("workflow", "node", "input_summary", "user__email", "provider_request_id")
|
|
163
|
+
date_hierarchy = "created_at"
|
django_agentic/agent.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""ModelAgent — abstract base for entity-aware AI agents.
|
|
2
|
+
|
|
3
|
+
Mirrors silverstripe-ai's DataObjectAgent. Consuming app subclasses this
|
|
4
|
+
and implements get_static_instructions(), get_dynamic_context(), get_tools().
|
|
5
|
+
django_ai handles provider, prompt caching, chat history, logging, credits, HITL.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from django_agentic.agent import ModelAgent
|
|
9
|
+
|
|
10
|
+
class MyModelAgent(ModelAgent):
|
|
11
|
+
def get_static_instructions(self) -> str: ...
|
|
12
|
+
def get_dynamic_context(self) -> str: ...
|
|
13
|
+
def get_tools(self) -> list: ... # optional
|
|
14
|
+
|
|
15
|
+
Register in settings:
|
|
16
|
+
DJANGO_AGENTIC = {
|
|
17
|
+
"AGENT_MAPPINGS": {
|
|
18
|
+
"myapp.MyModel": "myapp.agents.MyModelAgent",
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import abc
|
|
24
|
+
|
|
25
|
+
from django.db.models import Model
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModelAgent(abc.ABC):
|
|
29
|
+
|
|
30
|
+
def __init__(self, user, model_config: "AIModel", entity: Model | None, thread_id: str):
|
|
31
|
+
self.user = user
|
|
32
|
+
self.model_config = model_config
|
|
33
|
+
self.entity = entity
|
|
34
|
+
self.thread_id = thread_id
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_collection_mode(self) -> bool:
|
|
38
|
+
return self.entity is None or not getattr(self.entity, "pk", None)
|
|
39
|
+
|
|
40
|
+
@abc.abstractmethod
|
|
41
|
+
def get_static_instructions(self) -> str:
|
|
42
|
+
"""Cacheable system prompt. Cached by Anthropic for 5 min."""
|
|
43
|
+
|
|
44
|
+
@abc.abstractmethod
|
|
45
|
+
def get_dynamic_context(self) -> str:
|
|
46
|
+
"""Ephemeral context — entity state, date. Rebuilt per request."""
|
|
47
|
+
|
|
48
|
+
def get_tools(self) -> list:
|
|
49
|
+
"""LangChain tools the agent can call. Default: no tools (chat-only).
|
|
50
|
+
|
|
51
|
+
Return a list of LangChain tool instances (functions decorated with
|
|
52
|
+
@tool, or BaseTool subclasses). Tools are passed to create_agent
|
|
53
|
+
which binds them to the LLM automatically.
|
|
54
|
+
"""
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
def get_tools_requiring_approval(self) -> list[str]:
|
|
58
|
+
"""Tool names requiring HITL approval before execution.
|
|
59
|
+
|
|
60
|
+
Return tool names (strings) that should trigger an interrupt before
|
|
61
|
+
the tool node runs. The frontend receives the pending tool calls and
|
|
62
|
+
must resume with an approval/rejection decision.
|
|
63
|
+
|
|
64
|
+
Maps to silverstripe-ai's ToolApproval middleware concept.
|
|
65
|
+
"""
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
def summarise_action(self, tool_name: str, tool_input: dict) -> str:
|
|
69
|
+
"""Human-readable summary for a pending tool action (HITL card).
|
|
70
|
+
|
|
71
|
+
Override in subclasses for domain-specific summaries.
|
|
72
|
+
"""
|
|
73
|
+
return f"Execute: {tool_name}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AgentRegistry:
|
|
77
|
+
"""Maps Django model → agent class via DJANGO_AGENTIC['AGENT_MAPPINGS']."""
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def get_agent_class(cls, entity_class_path: str) -> type[ModelAgent]:
|
|
81
|
+
from django.conf import settings
|
|
82
|
+
from django.utils.module_loading import import_string
|
|
83
|
+
mappings = getattr(settings, "DJANGO_AGENTIC", {}).get("AGENT_MAPPINGS", {})
|
|
84
|
+
if entity_class_path in mappings:
|
|
85
|
+
return import_string(mappings[entity_class_path])
|
|
86
|
+
try:
|
|
87
|
+
entity_class = import_string(entity_class_path)
|
|
88
|
+
for parent in entity_class.__mro__:
|
|
89
|
+
if hasattr(parent, "_meta"):
|
|
90
|
+
short = f"{parent._meta.app_label}.{parent.__name__}"
|
|
91
|
+
if short in mappings:
|
|
92
|
+
return import_string(mappings[short])
|
|
93
|
+
except (ImportError, AttributeError):
|
|
94
|
+
pass
|
|
95
|
+
raise ValueError(f"No agent mapping for '{entity_class_path}'.")
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def create_for_entity(cls, user, model_config, entity, thread_id: str) -> ModelAgent:
|
|
99
|
+
path = f"{entity._meta.app_label}.{entity.__class__.__name__}"
|
|
100
|
+
agent_class = cls.get_agent_class(path)
|
|
101
|
+
return agent_class(user=user, model_config=model_config, entity=entity, thread_id=thread_id)
|
django_agentic/apps.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoAIConfig(AppConfig):
|
|
5
|
+
"""Django app configuration for django-agentic.
|
|
6
|
+
|
|
7
|
+
Add ``'django_agentic'`` to ``INSTALLED_APPS`` to enable AI model management,
|
|
8
|
+
credit tracking, usage logging, and agent chat endpoints.
|
|
9
|
+
"""
|
|
10
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
11
|
+
name = "django_agentic"
|
|
12
|
+
label = "django_ai"
|
|
13
|
+
verbose_name = "AI"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Credit system — model selection and atomic deduction."""
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from django.db import transaction
|
|
6
|
+
from .models import AIModel, SiteAIConfig, UserAIProfile
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CreditLimitExceeded(Exception):
|
|
12
|
+
def __init__(self, available: Decimal, required: Decimal):
|
|
13
|
+
self.available = available
|
|
14
|
+
self.required = required
|
|
15
|
+
super().__init__(f"Insufficient credits. Required: ${required}, Available: ${available}.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AIServiceUnavailable(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModelSelection:
|
|
24
|
+
model: AIModel
|
|
25
|
+
model_name: str
|
|
26
|
+
estimated_cost: Decimal
|
|
27
|
+
purchased_deduct: Decimal
|
|
28
|
+
free_deduct: Decimal
|
|
29
|
+
is_free_tier: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_or_create_profile(user) -> UserAIProfile:
|
|
33
|
+
profile, _ = UserAIProfile.objects.get_or_create(
|
|
34
|
+
user=user, defaults={"free_monthly_credits": SiteAIConfig.load().monthly_free_credits},
|
|
35
|
+
)
|
|
36
|
+
return profile
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_model_for_user(user, input_tokens: int = 5000, output_tokens: int = 2000) -> ModelSelection:
|
|
40
|
+
config = SiteAIConfig.load()
|
|
41
|
+
profile = get_or_create_profile(user)
|
|
42
|
+
paid_model = profile.model_override or config.default_paid_model
|
|
43
|
+
free_model = config.default_free_model
|
|
44
|
+
if not paid_model:
|
|
45
|
+
raise AIServiceUnavailable("Paid AI model not configured. Contact admin.")
|
|
46
|
+
if not free_model:
|
|
47
|
+
raise AIServiceUnavailable("Free AI model not configured. Contact admin.")
|
|
48
|
+
if not free_model.allowed_for_free:
|
|
49
|
+
raise AIServiceUnavailable(f"Default free model '{free_model.display_name}' not allowed for free credits.")
|
|
50
|
+
|
|
51
|
+
paid_cost = paid_model.estimate_cost(input_tokens, output_tokens)
|
|
52
|
+
free_cost = free_model.estimate_cost(input_tokens, output_tokens)
|
|
53
|
+
|
|
54
|
+
if user.is_staff:
|
|
55
|
+
return ModelSelection(paid_model, paid_model.name, paid_cost, Decimal(0), Decimal(0), False)
|
|
56
|
+
|
|
57
|
+
if profile.purchased_credits >= paid_cost:
|
|
58
|
+
return ModelSelection(paid_model, paid_model.name, paid_cost, paid_cost, Decimal(0), False)
|
|
59
|
+
|
|
60
|
+
total = profile.purchased_credits + profile.free_monthly_credits
|
|
61
|
+
if total >= free_cost:
|
|
62
|
+
used_p = min(profile.purchased_credits, free_cost)
|
|
63
|
+
return ModelSelection(free_model, free_model.name, free_cost, used_p, free_cost - used_p, True)
|
|
64
|
+
|
|
65
|
+
raise CreditLimitExceeded(available=total, required=free_cost)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def deduct_credits(user, cost: Decimal, model: AIModel, idempotency_key: str | None = None) -> dict:
|
|
69
|
+
if user.is_staff:
|
|
70
|
+
return {"purchased_deduct": Decimal(0), "free_deduct": Decimal(0)}
|
|
71
|
+
if idempotency_key:
|
|
72
|
+
from .models import AIUsageLog
|
|
73
|
+
if AIUsageLog.objects.filter(idempotency_key=idempotency_key).exists():
|
|
74
|
+
return {"purchased_deduct": Decimal(0), "free_deduct": Decimal(0)}
|
|
75
|
+
|
|
76
|
+
profile = get_or_create_profile(user)
|
|
77
|
+
with transaction.atomic():
|
|
78
|
+
profile = UserAIProfile.objects.select_for_update().get(pk=profile.pk)
|
|
79
|
+
can_use_free = getattr(model, "allowed_for_free", True)
|
|
80
|
+
if can_use_free:
|
|
81
|
+
used_p = min(profile.purchased_credits, cost)
|
|
82
|
+
used_f = min(profile.free_monthly_credits, cost - used_p)
|
|
83
|
+
else:
|
|
84
|
+
used_p = min(profile.purchased_credits, cost)
|
|
85
|
+
used_f = Decimal(0)
|
|
86
|
+
profile.purchased_credits = max(Decimal(0), profile.purchased_credits - used_p)
|
|
87
|
+
profile.free_monthly_credits = max(Decimal(0), profile.free_monthly_credits - used_f)
|
|
88
|
+
profile.save(update_fields=["purchased_credits", "free_monthly_credits", "updated_at"])
|
|
89
|
+
return {"purchased_deduct": used_p, "free_deduct": used_f}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# management package
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# commands package
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Monthly free credit reset. Run via cron on the 1st of each month."""
|
|
2
|
+
from django.core.management.base import BaseCommand
|
|
3
|
+
from django.utils import timezone
|
|
4
|
+
from django_agentic.models import SiteAIConfig, UserAIProfile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = "Reset free monthly AI credits for all users"
|
|
9
|
+
|
|
10
|
+
def handle(self, *args, **options):
|
|
11
|
+
config = SiteAIConfig.load()
|
|
12
|
+
amount = config.monthly_free_credits
|
|
13
|
+
updated = UserAIProfile.objects.all().update(
|
|
14
|
+
free_monthly_credits=amount, credits_reset_at=timezone.now(),
|
|
15
|
+
)
|
|
16
|
+
self.stdout.write(self.style.SUCCESS(f"Reset {updated} profiles to ${amount} free credits."))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Generated by Django 6.0.3 on 2026-04-11 22:33
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
import uuid
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import migrations, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Migration(migrations.Migration):
|
|
11
|
+
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
('contenttypes', '0002_remove_content_type_name'),
|
|
16
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
operations = [
|
|
20
|
+
migrations.CreateModel(
|
|
21
|
+
name='AIModel',
|
|
22
|
+
fields=[
|
|
23
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
24
|
+
('name', models.CharField(max_length=100, unique=True)),
|
|
25
|
+
('display_name', models.CharField(blank=True, default='', max_length=200)),
|
|
26
|
+
('provider', models.CharField(choices=[('anthropic', 'Anthropic'), ('openai', 'OpenAI'), ('google', 'Google'), ('bedrock', 'AWS Bedrock')], default='anthropic', max_length=50)),
|
|
27
|
+
('input_cost_per_1m', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
|
28
|
+
('output_cost_per_1m', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
|
29
|
+
('cache_write_cost_per_1m', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
|
|
30
|
+
('cache_read_cost_per_1m', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
|
|
31
|
+
('active', models.BooleanField(default=True)),
|
|
32
|
+
('allowed_for_free', models.BooleanField(default=True)),
|
|
33
|
+
('allowed_for_paid', models.BooleanField(default=True)),
|
|
34
|
+
('context_window', models.IntegerField(default=200000)),
|
|
35
|
+
('max_output_tokens', models.IntegerField(default=8192)),
|
|
36
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
37
|
+
],
|
|
38
|
+
options={
|
|
39
|
+
'db_table': 'ailogs_aimodel',
|
|
40
|
+
'ordering': ['input_cost_per_1m'],
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
migrations.CreateModel(
|
|
44
|
+
name='SiteAIConfig',
|
|
45
|
+
fields=[
|
|
46
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
47
|
+
('monthly_free_credits', models.DecimalField(decimal_places=2, default=Decimal('2.00'), max_digits=10)),
|
|
48
|
+
('step_model_config', models.JSONField(blank=True, default=dict)),
|
|
49
|
+
('default_free_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='django_ai.aimodel')),
|
|
50
|
+
('default_paid_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='django_ai.aimodel')),
|
|
51
|
+
],
|
|
52
|
+
options={
|
|
53
|
+
'verbose_name': 'AI Configuration',
|
|
54
|
+
'verbose_name_plural': 'AI Configuration',
|
|
55
|
+
'db_table': 'ailogs_siteaiconfig',
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
migrations.CreateModel(
|
|
59
|
+
name='UserAIProfile',
|
|
60
|
+
fields=[
|
|
61
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
62
|
+
('free_monthly_credits', models.DecimalField(decimal_places=6, default=Decimal('2.000000'), max_digits=10)),
|
|
63
|
+
('purchased_credits', models.DecimalField(decimal_places=6, default=Decimal('0.000000'), max_digits=10)),
|
|
64
|
+
('credits_reset_at', models.DateTimeField(blank=True, null=True)),
|
|
65
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
66
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
67
|
+
('model_override', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='django_ai.aimodel')),
|
|
68
|
+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_profile', to=settings.AUTH_USER_MODEL)),
|
|
69
|
+
],
|
|
70
|
+
options={
|
|
71
|
+
'verbose_name': 'User AI Profile',
|
|
72
|
+
'db_table': 'ailogs_useraiprofile',
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
migrations.CreateModel(
|
|
76
|
+
name='AIUsageLog',
|
|
77
|
+
fields=[
|
|
78
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
79
|
+
('idempotency_key', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
|
80
|
+
('model_name', models.CharField(db_index=True, max_length=100)),
|
|
81
|
+
('workflow', models.CharField(db_index=True, max_length=100)),
|
|
82
|
+
('node', models.CharField(max_length=100)),
|
|
83
|
+
('entity_id', models.CharField(blank=True, default='', max_length=255)),
|
|
84
|
+
('input_summary', models.TextField(blank=True, default='')),
|
|
85
|
+
('output_json', models.JSONField(default=dict)),
|
|
86
|
+
('prompt_tokens', models.IntegerField(blank=True, null=True)),
|
|
87
|
+
('completion_tokens', models.IntegerField(blank=True, null=True)),
|
|
88
|
+
('total_tokens', models.IntegerField(blank=True, null=True)),
|
|
89
|
+
('cache_read_tokens', models.IntegerField(blank=True, null=True)),
|
|
90
|
+
('cache_creation_tokens', models.IntegerField(blank=True, null=True)),
|
|
91
|
+
('cost_usd', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
|
|
92
|
+
('input_cost_per_1m_at_time', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
|
93
|
+
('output_cost_per_1m_at_time', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
|
94
|
+
('cache_write_cost_per_1m_at_time', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
|
|
95
|
+
('cache_read_cost_per_1m_at_time', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
|
|
96
|
+
('used_free_credits', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
|
|
97
|
+
('used_paid_credits', models.DecimalField(decimal_places=6, default=0, max_digits=10)),
|
|
98
|
+
('provider_request_id', models.CharField(blank=True, default='', max_length=255)),
|
|
99
|
+
('error_type', models.CharField(blank=True, choices=[('', 'None'), ('credit_limit', 'Credit Limit'), ('api_error', 'API Error'), ('validation', 'Validation'), ('context_overflow', 'Context Overflow'), ('unknown', 'Unknown')], default='', max_length=20)),
|
|
100
|
+
('request_time', models.DateTimeField(blank=True, null=True)),
|
|
101
|
+
('response_time', models.DateTimeField(blank=True, null=True)),
|
|
102
|
+
('duration_ms', models.IntegerField(blank=True, null=True)),
|
|
103
|
+
('success', models.BooleanField(default=True)),
|
|
104
|
+
('error', models.TextField(blank=True, default='')),
|
|
105
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
106
|
+
('ai_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='django_ai.aimodel')),
|
|
107
|
+
('entity_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')),
|
|
108
|
+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_usage_logs', to=settings.AUTH_USER_MODEL)),
|
|
109
|
+
],
|
|
110
|
+
options={
|
|
111
|
+
'db_table': 'ailogs_aiusagelog',
|
|
112
|
+
'ordering': ['-created_at'],
|
|
113
|
+
'indexes': [models.Index(fields=['model_name', 'created_at'], name='ailogs_aius_model_n_434025_idx'), models.Index(fields=['workflow', 'created_at'], name='ailogs_aius_workflo_7e02c2_idx'), models.Index(fields=['user', 'created_at'], name='ailogs_aius_user_id_cd756c_idx')],
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.0.3 on 2026-04-11 22:56
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('django_ai', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='aimodel',
|
|
15
|
+
name='model_type',
|
|
16
|
+
field=models.CharField(choices=[('chat', 'Chat / Generation'), ('embedding', 'Embedding'), ('transcription', 'Transcription'), ('image', 'Image Generation')], db_index=True, default='chat', max_length=20),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Generated by Django 6.0.3 on 2026-04-13 00:36
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('django_ai', '0002_add_model_type'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.RenameIndex(
|
|
14
|
+
model_name='aiusagelog',
|
|
15
|
+
new_name='django_ai_a_model_n_053a01_idx',
|
|
16
|
+
old_name='ailogs_aius_model_n_434025_idx',
|
|
17
|
+
),
|
|
18
|
+
migrations.RenameIndex(
|
|
19
|
+
model_name='aiusagelog',
|
|
20
|
+
new_name='django_ai_a_workflo_9b3be0_idx',
|
|
21
|
+
old_name='ailogs_aius_workflo_7e02c2_idx',
|
|
22
|
+
),
|
|
23
|
+
migrations.RenameIndex(
|
|
24
|
+
model_name='aiusagelog',
|
|
25
|
+
new_name='django_ai_a_user_id_6d10e0_idx',
|
|
26
|
+
old_name='ailogs_aius_user_id_cd756c_idx',
|
|
27
|
+
),
|
|
28
|
+
migrations.AlterModelTable(
|
|
29
|
+
name='aimodel',
|
|
30
|
+
table=None,
|
|
31
|
+
),
|
|
32
|
+
migrations.AlterModelTable(
|
|
33
|
+
name='aiusagelog',
|
|
34
|
+
table=None,
|
|
35
|
+
),
|
|
36
|
+
migrations.AlterModelTable(
|
|
37
|
+
name='siteaiconfig',
|
|
38
|
+
table=None,
|
|
39
|
+
),
|
|
40
|
+
migrations.AlterModelTable(
|
|
41
|
+
name='useraiprofile',
|
|
42
|
+
table=None,
|
|
43
|
+
),
|
|
44
|
+
]
|