Files

187 lines
6.4 KiB
Go

// AI writing-assistant generation endpoint. Flow:
// 1. feature-gate the org (paid or in free trial) via CanUseWritingAssistant
// 2. atomically consume one credit (DB-enforced: no negative balance, no
// double-charge on Idempotency-Key replay)
// 3. call the configured provider (Anthropic, falling back to OpenAI)
// 4. return {text, credits_remaining, model}
//
// On insufficient credits the consume step short-circuits with 402 BEFORE any
// provider call, so a depleted org never burns a paid completion. Because the
// debit happens before the provider call, a provider failure refunds the
// credit so the customer is not charged for a generation they never received.
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/credits"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/generation"
)
// creditsPerWrite is the credit cost of one writing-assistant call. Kept as a
// constant so pricing is in one place; tokens consumed are recorded separately
// on the ledger transaction for later cost analysis.
const creditsPerWrite = 1
// writeMaxPromptLen bounds the inbound prompt so a single request can't be used
// to drive a very large (and expensive) completion.
const writeMaxPromptLen = 8000
type generationWriteRequest struct {
Prompt string `json:"prompt"`
Tone string `json:"tone"`
}
// paymentRequiredJSON emits the standard error envelope with a 402 status.
// errx has no PaymentRequired code, so this endpoint writes the 402 directly
// while keeping the same {error, message, code, request_id} shape.
func paymentRequiredJSON(c *gin.Context, message string) {
c.JSON(http.StatusPaymentRequired, gin.H{
"error": "Payment Required",
"message": message,
"code": "insufficient_credits",
"request_id": c.GetString("request_id"),
})
}
// GenerateWriting — POST /generation/write
func (h *Handler) GenerateWriting(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
var req generationWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.ErrInvalid)
return
}
req.Prompt = strings.TrimSpace(req.Prompt)
if req.Prompt == "" {
errx.JSON(c, errx.New(errx.BadRequest, "prompt is required"))
return
}
if len(req.Prompt) > writeMaxPromptLen {
errx.JSON(c, errx.New(errx.BadRequest, "prompt is too long"))
return
}
// Feature gate: paid orgs and free-trial orgs may use the assistant.
allowed, xerr := h.FeatureGateService.CanUseWritingAssistant(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
if !allowed {
errx.JSON(c, errx.New(errx.Forbidden, "The AI writing assistant requires an active plan or trial."))
return
}
// Provider must be configured.
if h.WritingGenerator == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
return
}
// Model routing by tier. Paid orgs get the stronger model; the active
// provider (Anthropic or OpenAI fallback) decides the concrete model ID.
paid, xerr := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
model := h.WritingGenerator.ModelForTier(paid)
// Consume one credit up front, unless this is a free/local model, which runs
// un-metered (AI_FREE). On the metered path the DB enforces the
// no-negative / no-replay invariants and returns 402 on a depleted balance.
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
local := h.WritingGenerator.IsLocal()
// Attribute the charge to the teammate who asked for the draft.
reqCtx := c.Request.Context()
if actor, aerr := middleware.GetUserUUID(c); aerr == nil {
reqCtx = models.WithCreditMeta(reqCtx, models.CreditMeta{ActorID: actor})
}
var remaining int
if local {
if bal, berr := h.CreditService.GetBalance(reqCtx, *orgID); berr == nil {
remaining = bal
}
} else {
var err error
remaining, err = h.CreditService.Consume(
reqCtx, *orgID, creditsPerWrite,
"writing_assistant", model, 0, idemKey,
)
if err != nil {
switch {
case errors.Is(err, credits.ErrInsufficientCredits):
paymentRequiredJSON(c, "You're out of AI credits. Upgrade or purchase more to keep using the writing assistant.")
case errors.Is(err, credits.ErrCapExceeded):
errx.JSON(c, errx.New(errx.TooManyRequests, "AI writing assistant usage limit reached, please try again later."))
default:
errx.JSON(c, errx.InternalError())
}
return
}
}
// Generate. On provider failure, refund the credit so the customer is not
// charged for a completion they never received (nothing to refund on the
// free/local path). The refund is best-effort; a failed refund is logged via
// the audit trail rather than surfaced.
voice := h.orgVoice(c.Request.Context(), *orgID, req.Tone)
result, gerr := h.WritingGenerator.GenerateWriting(c.Request.Context(), model, req.Prompt, voice)
if gerr != nil {
if !local {
if bal, rerr := h.CreditService.Grant(reqCtx, *orgID, creditsPerWrite, "writing_assistant_refund"); rerr == nil {
remaining = bal
}
}
if errors.Is(gerr, generation.ErrNotConfigured) {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured."))
return
}
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged."))
return
}
// Usage-based settle: price the actual tokens and charge any overage
// beyond the flat minimum (best-effort; never fails the delivered text).
charged := 0
if !local {
charged = creditsPerWrite
if extra, serr := h.CreditService.SettleUsage(reqCtx, *orgID, creditsPerWrite, result.Model, result.TokensUsed, "writing_assistant", settleKey(idemKey)); serr == nil && extra > 0 {
remaining -= extra
charged += extra
}
}
c.JSON(http.StatusOK, gin.H{
"text": result.Text,
"credits_remaining": remaining,
"credits_charged": charged,
"tokens_used": result.TokensUsed,
"model": result.Model,
})
}
// settleKey derives the usage-settle idempotency key from the call's key. An
// empty key stays empty (non-idempotent call, non-idempotent settle).
func settleKey(idemKey string) string {
if idemKey == "" {
return ""
}
return idemKey + ":usage"
}