276 lines
8.7 KiB
Go
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
|
||
}
|