Files
vikingowl ba4dce1f76 fix(ai): per-model cost calc + thinking toggle and token tracking
estimateCost ignored the model name and billed every Gemini call at
hardcoded flash-lite rates ($0.10 / $0.40 per 1M), under-counting Pro
calls by ~12-25x. Switch to priceFor(model) and prefer resp.ModelVersion
so aliases like gemini-pro-latest resolve to their concrete family.

Capture ThoughtsTokenCount as a separate ThinkingTokens column on
ai_usage (migration 000030) and bill it at the output rate.

Add a global thinking on/off toggle that mirrors the grounding pattern:
provider holds an in-memory cache (read at startup from settings.Store),
handler keeps it in sync, Chat() applies ThinkingConfig.ThinkingBudget=0
only when disabled. Default true preserves SDK behavior. Grounding+
thinking get/set helpers folded into shared getBool/setBool to keep
goconst happy.

Web admin settings: new "Modell-Reasoning" toggle card; usage panel sums
include thinking tokens. Types are optional with `?? 0` defaults so a
brief web-before-backend rollout window cannot render NaN.
2026-04-28 12:56:04 +02:00

203 lines
5.8 KiB
Go

package settings
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/ai"
)
// AIStatus is the response payload for GET /admin/settings/ai.
type AIStatus struct {
Provider string `json:"provider"`
Connected bool `json:"connected"`
Model string `json:"model"`
Models []ai.ModelInfo `json:"models"`
APIKeyFingerprint string `json:"api_key_fingerprint,omitempty"`
GroundingEnabled bool `json:"grounding_enabled"`
GroundingQuota int `json:"grounding_quota"`
ThinkingEnabled bool `json:"thinking_enabled"`
Usage UsageSummary `json:"usage"`
}
type UsageSummary struct {
Today UsageStats `json:"today"`
Month UsageStats `json:"month"`
GroundingUsedToday int `json:"grounding_used_today"`
}
// Handler serves AI settings endpoints.
type Handler struct {
provider *ai.GeminiProvider
store *Store
usageRepo *UsageRepo
}
func NewHandler(provider *ai.GeminiProvider, store *Store, usageRepo *UsageRepo) *Handler {
return &Handler{provider: provider, store: store, usageRepo: usageRepo}
}
func (h *Handler) GetAI(c *gin.Context) {
ctx := c.Request.Context()
models, err := h.provider.ListModels(ctx)
connected := err == nil
if models == nil {
models = []ai.ModelInfo{}
}
// Fingerprint: last 4 chars of stored key (if any)
fingerprint := ""
if key, kerr := h.store.GetGeminiAPIKey(ctx); kerr == nil && len(key) >= 4 {
fingerprint = "•••" + key[len(key)-4:]
}
grounding, _ := h.store.GetGroundingEnabled(ctx)
thinking, _ := h.store.GetThinkingEnabled(ctx)
today, _ := h.usageRepo.Today(ctx)
month, _ := h.usageRepo.Month(ctx)
groundingToday, _ := h.usageRepo.GroundingToday(ctx)
c.JSON(http.StatusOK, gin.H{"data": AIStatus{
Provider: "gemini",
Connected: connected,
Model: h.provider.Model(),
Models: models,
APIKeyFingerprint: fingerprint,
GroundingEnabled: grounding,
GroundingQuota: 1500,
ThinkingEnabled: thinking,
Usage: UsageSummary{
Today: today,
Month: month,
GroundingUsedToday: groundingToday,
},
}})
}
func (h *Handler) SetModel(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Model string `json:"model" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
return
}
// Validate against allowed list; degrade open if the list call fails (e.g. network blip).
if allowed, err := h.provider.ListModels(ctx); err == nil {
found := false
for _, m := range allowed {
if m.Name == req.Model {
found = true
break
}
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "model not in allowed list"})
return
}
}
userID := callerID(c)
if err := h.store.SetModel(ctx, req.Model, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model"})
return
}
h.provider.SetModel(req.Model)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"model": req.Model}})
}
func (h *Handler) SetAPIKey(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
APIKey string `json:"api_key" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key is required"})
return
}
userID := callerID(c)
if err := h.store.SetGeminiAPIKey(ctx, req.APIKey, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save api key"})
return
}
if err := h.provider.Reinitialize(ctx, req.APIKey); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "key saved but provider init failed: " + err.Error()})
return
}
fingerprint := ""
if len(req.APIKey) >= 4 {
fingerprint = "•••" + req.APIKey[len(req.APIKey)-4:]
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"api_key_fingerprint": fingerprint}})
}
func (h *Handler) SetGrounding(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"})
return
}
userID := callerID(c)
if err := h.store.SetGroundingEnabled(ctx, req.Enabled, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save grounding setting"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"grounding_enabled": req.Enabled}})
}
func (h *Handler) SetThinking(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"})
return
}
userID := callerID(c)
if err := h.store.SetThinkingEnabled(ctx, req.Enabled, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save thinking setting"})
return
}
h.provider.SetThinkingEnabled(req.Enabled)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"thinking_enabled": req.Enabled}})
}
func (h *Handler) GetUsage(c *gin.Context) {
ctx := c.Request.Context()
limit := 50
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
limit = n
}
}
events, err := h.usageRepo.Recent(ctx, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
return
}
if events == nil {
events = []UsageEvent{}
}
c.JSON(http.StatusOK, gin.H{"data": events})
}
// callerID extracts the authenticated user's UUID from gin context.
// Returns uuid.Nil if not set (shouldn't happen behind requireAuth).
func callerID(c *gin.Context) uuid.UUID {
if v, ok := c.Get("user_id"); ok {
if id, ok := v.(uuid.UUID); ok {
return id
}
}
return uuid.Nil
}