Files
ttstd-infinite-canvas/backend/internal/app/analytics_enrich.go
T

276 lines
8.7 KiB
Go

// 调用日志的费用估算与上游响应补全(请求 ID、视频 token、失败摘要)。
package app
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"strings"
"gorm.io/gorm"
"yingce/backend/internal/model"
)
func (s *Service) estimateCallCost(log *model.ApiCallLog) {
if log.Status == model.ApiCallStatusFailed && !log.UsageAvailable {
return
}
pricing, err := s.repo.ModelPricing(log.ChannelID, log.Model, log.Capability)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return
}
return
}
cost := int64(0)
if log.Billable {
cost = pricing.PerRequestMicros
}
cost += log.InputTokens * pricing.InputPerMillionMicros / 1_000_000
cost += log.OutputTokens * pricing.OutputPerMillionMicros / 1_000_000
cost += log.CachedTokens * pricing.CachedPerMillionMicros / 1_000_000
cost += int64(log.MediaCount) * pricing.PerMediaMicros
cost += int64(log.VideoSeconds) * pricing.PerVideoSecondMicros
log.EstimatedCostMicros = cost
log.CostAvailable = true
log.Currency = pricing.Currency
}
func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) {
if log == nil {
return
}
if log.ProviderRequestID == "" {
log.ProviderRequestID = providerRequestIDFromPath(log.Path)
}
payloads := providerResponsePayloads(responseBody)
for _, payload := range payloads {
s.enrichAPICallLogPayload(log, payload)
}
s.enrichAPICallLogFailureSummary(log, responseBody)
}
func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) {
if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 {
return
}
userMessage := providerUserFacingErrorMessage(providerHTTPError{
StatusCode: log.StatusCode,
Body: string(responseBody),
})
detail := strings.TrimSpace(log.Error)
if detail == "" || detail == userMessage || strings.Contains(userMessage, ";上游:"+detail) {
log.Error = userMessage
return
}
if strings.Contains(detail, userMessage) {
return
}
log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000)
}
func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) {
nestedTaskID := ""
if data, ok := payload["data"].(map[string]any); ok {
if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") {
if extracted, err := firstJSONString(data, "task_id", "taskId"); err == nil {
nestedTaskID = extracted
}
}
for key, value := range data {
if _, exists := payload[key]; !exists {
payload[key] = value
}
}
}
// Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。
if response, ok := payload["response"].(map[string]any); ok {
for key, value := range response {
if _, exists := payload[key]; !exists {
payload[key] = value
}
}
}
if log.Status == model.ApiCallStatusFailed {
errorCode, errorMessage := providerFailureDetails(payload)
log.ErrorCode = errorCode
if errorMessage != "" {
log.Error = errorMessage
}
}
arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks")
usage, _ := payload["usage"].(map[string]any)
if log.Capability == "video" {
log.InputTokens, log.CachedTokens = 0, 0
log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo)
} else if usage != nil {
inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens")
outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens")
if inputAvailable {
log.InputTokens = inputTokens
}
if outputAvailable {
log.OutputTokens = outputTokens
}
if inputAvailable || outputAvailable {
log.UsageAvailable = true
}
if details, ok := usage["input_tokens_details"].(map[string]any); ok {
log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens")
}
if details, ok := usage["prompt_tokens_details"].(map[string]any); ok && log.CachedTokens == 0 {
log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens")
}
if log.CachedTokens == 0 {
log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens")
}
}
if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" {
inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount")
outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount")
if inputAvailable {
log.InputTokens = inputTokens
}
if outputAvailable {
log.OutputTokens = outputTokens
}
if inputAvailable || outputAvailable {
log.UsageAvailable = true
}
log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount")
}
if extracted, err := firstJSONString(payload, "task_id", "id", "request_id", "name"); err == nil {
log.ProviderRequestID = firstNonEmpty(nestedTaskID, extracted, log.ProviderRequestID)
} else {
log.ProviderRequestID = firstNonEmpty(nestedTaskID, log.ProviderRequestID)
}
log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus))
if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" {
log.Status = model.ApiCallStatusFailed
errorCode, errorMessage := providerFailureDetails(payload)
log.ErrorCode = firstNonEmpty(errorCode, log.ErrorCode)
log.Error = firstNonEmpty(errorMessage, log.Error)
}
if log.Capability == "image" {
if data, ok := payload["data"].([]any); ok {
log.MediaCount = len(data)
} else if images, ok := payload["images"].([]any); ok {
log.MediaCount = len(images)
}
}
}
func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) {
if status, exists := payload["status"]; exists {
text, ok := status.(string)
status := strings.ToLower(strings.TrimSpace(text))
if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) {
return 0, false
}
}
usage, _ := payload["usage"].(map[string]any)
value, exists := usage["completion_tokens"]
if !exists && !arkProtocol {
value, exists = usage["output_tokens"]
}
if !exists {
value = usage["total_tokens"]
}
// completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。
// JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。
number, ok := value.(json.Number)
if !ok {
return 0, false
}
tokens, err := number.Int64()
if err != nil || tokens <= 0 {
return 0, false
}
return tokens, true
}
func providerResponsePayloads(responseBody []byte) []map[string]any {
if len(responseBody) == 0 {
return nil
}
if payload, ok := decodeProviderResponsePayload(responseBody); ok {
return []map[string]any{payload}
}
// 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。
result := make([]map[string]any, 0)
scanner := bufio.NewScanner(bytes.NewReader(responseBody))
scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10))
dataLines := make([]string, 0, 1)
flush := func() {
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = dataLines[:0]
if raw == "" || raw == "[DONE]" {
return
}
if event, ok := decodeProviderResponsePayload([]byte(raw)); ok {
result = append(result, event)
}
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
flush()
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " "))
}
}
flush()
return result
}
func decodeProviderResponsePayload(data []byte) (map[string]any, bool) {
if !json.Valid(data) {
return nil, false
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var payload map[string]any
err := decoder.Decode(&payload)
return payload, err == nil && payload != nil
}
func providerRequestIDFromPath(path string) string {
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
for index := len(parts) - 1; index >= 0; index-- {
part := strings.TrimSpace(parts[index])
if part == "" || part == "content" || part == "download" {
continue
}
if index > 0 && (parts[index-1] == "videos" || parts[index-1] == "tasks") {
return part
}
break
}
return ""
}
func firstInt64(values map[string]any, keys ...string) int64 {
value, _ := firstInt64Value(values, keys...)
return value
}
func firstInt64Value(values map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
switch value := values[key].(type) {
case float64:
return int64(value), true
case int64:
return value, true
case json.Number:
parsed, err := value.Int64()
return parsed, err == nil
}
}
return 0, false
}