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

276 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 调用日志的费用估算与上游响应补全(请求 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
}