feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
96cd24f17d
commit
08265510ea
@@ -103,6 +103,13 @@ type agentChatResp struct {
|
||||
Message agentMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
// Usage lo devuelven todos los proveedores OpenAI-compatibles con el mismo
|
||||
// nombre de campo, así que no hace falta un caso por proveedor.
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
@@ -1277,7 +1284,11 @@ type anthropicReq struct {
|
||||
type anthropicResp struct {
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Error *struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
@@ -1286,7 +1297,9 @@ type anthropicResp struct {
|
||||
// ─── Llamada al AI con function calling ──────────────────────────────────────
|
||||
|
||||
// callAI despacha al provider correcto (Anthropic o OpenAI-compatible).
|
||||
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
// El segundo valor son los tokens totales que reportó el proveedor (0 si no
|
||||
// los informa) — lo usa uMind para medir el consumo facturable.
|
||||
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
if strings.ToLower(ai.Provider) == "anthropic" {
|
||||
return callAnthropicAI(ai, messages, tools)
|
||||
}
|
||||
@@ -1294,7 +1307,7 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a
|
||||
}
|
||||
|
||||
// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.)
|
||||
func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
baseURL := ai.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = ProviderDefaultURL(ai.Provider)
|
||||
@@ -1312,7 +1325,7 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
|
||||
payload, _ := json.Marshal(reqBody)
|
||||
req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -1320,27 +1333,27 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
|
||||
var chatResp agentChatResp
|
||||
if err := json.Unmarshal(raw, &chatResp); err != nil {
|
||||
return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
|
||||
return nil, 0, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
|
||||
}
|
||||
if chatResp.Error != nil {
|
||||
return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
|
||||
return nil, 0, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("el AI no devolvió respuesta")
|
||||
return nil, 0, fmt.Errorf("el AI no devolvió respuesta")
|
||||
}
|
||||
msg := chatResp.Choices[0].Message
|
||||
return &msg, nil
|
||||
return &msg, chatResp.Usage.TotalTokens, nil
|
||||
}
|
||||
|
||||
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
|
||||
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
|
||||
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
|
||||
// Convertir herramientas al formato Anthropic
|
||||
anthropicTools := make([]anthropicTool, len(tools))
|
||||
for i, t := range tools {
|
||||
@@ -1459,7 +1472,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
payload, _ := json.Marshal(reqBody)
|
||||
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("x-api-key", ai.ClaveEnClaro())
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
@@ -1468,17 +1481,17 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
|
||||
var anthropicRsp anthropicResp
|
||||
if err := json.Unmarshal(raw, &anthropicRsp); err != nil {
|
||||
return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
|
||||
return nil, 0, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
|
||||
}
|
||||
if anthropicRsp.Error != nil {
|
||||
return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
|
||||
return nil, 0, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
|
||||
}
|
||||
|
||||
// Convertir respuesta Anthropic → agentMessage (formato interno OpenAI)
|
||||
@@ -1512,7 +1525,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
|
||||
result.Content = strings.Join(textParts, "\n")
|
||||
}
|
||||
result.ToolCalls = toolCalls
|
||||
return result, nil
|
||||
return result, anthropicRsp.Usage.InputTokens + anthropicRsp.Usage.OutputTokens, nil
|
||||
}
|
||||
|
||||
func ProviderDefaultURL(provider string) string {
|
||||
@@ -1683,7 +1696,7 @@ Comandos: /reset · /instancias · /ayuda`, nil
|
||||
// Loop de function calling (máximo 6 rondas)
|
||||
var finalResponse string
|
||||
for round := 0; round < 6; round++ {
|
||||
aiMsg, err := callAI(ai, messages, tools)
|
||||
aiMsg, _, err := callAI(ai, messages, tools)
|
||||
if err != nil {
|
||||
log.Printf("[AGENT] Error llamando AI round %d: %v", round, err)
|
||||
return "Error al contactar el sistema de IA. Intenta de nuevo.", err
|
||||
|
||||
Reference in New Issue
Block a user