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.
203 lines
5.8 KiB
Go
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
|
|
}
|