diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go
index bb2e7f9..b7d7742 100644
--- a/internal/logic/admin/dashboard.go
+++ b/internal/logic/admin/dashboard.go
@@ -798,7 +798,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
}, nil
}
-// GetWalletInfo 获取租户钱包信息(管理后台)
+// GetWalletInfo 获取租户钱包信息(管理后台)。
+// balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见),
+// DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。
func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) {
type walletRow struct {
ID int64 `json:"id"`
@@ -815,11 +817,19 @@ func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq)
return nil, common.NewNotFoundError("钱包")
}
- return &v1.AdminWalletInfoRes{
+ res := &v1.AdminWalletInfoRes{
Balance: w.Balance,
FrozenBalance: w.FrozenBalance,
WarningThreshold: w.WarningThreshold,
- }, nil
+ }
+
+ // 覆盖为 Redis 实时余额(钱包 hash 缺失时 GetWallet 内部触发灾难恢复重建)
+ if wallet, err := billing.GetWallet(ctx, req.TenantID); err == nil {
+ res.Balance = wallet.Balance
+ res.FrozenBalance = wallet.FrozenBalance
+ }
+
+ return res, nil
}
// GetWalletTransactions 获取租户钱包交易流水(管理后台)
@@ -905,6 +915,9 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet
return nil, err
}
+ // 清除钱包静态字段缓存,让 GetWallet 读到新阈值(对齐租户端 UpdateWarningThreshold)
+ billing.InvalidateWalletStaticCache(ctx, req.TenantID)
+
// 阈值变更后重置预警标记,使新阈值能触发新的预警
billing.ResetLowBalanceNotified(ctx, req.TenantID)
diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go
index d265f8b..38308f8 100644
--- a/internal/logic/admin/tenant.go
+++ b/internal/logic/admin/tenant.go
@@ -3,6 +3,7 @@ package admin
import (
"context"
"encoding/json"
+ "strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
@@ -424,16 +425,20 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
memberCount, _ := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.Id).Count()
- // Get wallet balance
- var wallet *struct {
- Balance string `json:"balance"`
- }
- _ = dao.BilWallets.Ctx(ctx).
- Where("tenant_id", req.Id).Scan(&wallet)
-
+ // Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
walletBalance := "0"
- if wallet != nil && wallet.Balance != "" {
- walletBalance = wallet.Balance
+ if w, err := billing.GetWallet(ctx, req.Id); err == nil {
+ walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
+ } else {
+ var wallet *struct {
+ Balance string `json:"balance"`
+ }
+ _ = dao.BilWallets.Ctx(ctx).
+ Where("tenant_id", req.Id).Scan(&wallet)
+
+ if wallet != nil && wallet.Balance != "" {
+ walletBalance = wallet.Balance
+ }
}
ownerName := ""
if owner != nil {
diff --git a/relay/channel/claude/response.go b/relay/channel/claude/response.go
index 649d2cd..ebf4b24 100644
--- a/relay/channel/claude/response.go
+++ b/relay/channel/claude/response.go
@@ -93,8 +93,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
}
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go
index cd0c447..d49b005 100644
--- a/relay/channel/coze/adaptor.go
+++ b/relay/channel/coze/adaptor.go
@@ -133,8 +133,12 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com
// handleStreamResponse 流式模式:逐事件读取 Coze SSE,转换为 OpenAI SSE 格式输出
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
@@ -164,7 +168,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
interruptedUsage := &common.Usage{}
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
- return interruptedUsage, nil
+ return interruptedUsage, common.ErrStreamInterrupted
default:
}
@@ -207,7 +211,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
interruptedUsage := &common.Usage{}
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
- return interruptedUsage, nil
+ return interruptedUsage, common.ErrStreamInterrupted
}
case "conversation.message.completed":
diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go
index 105511a..c5501f4 100644
--- a/relay/channel/dify/adaptor.go
+++ b/relay/channel/dify/adaptor.go
@@ -199,8 +199,12 @@ func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Respon
// handleStreamResponse 处理 Dify streaming 模式 SSE 响应
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
@@ -228,7 +232,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
- return &usage, nil
+ return &usage, common.ErrStreamInterrupted
default:
}
@@ -267,7 +271,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
- return &usage, nil
+ return &usage, common.ErrStreamInterrupted
}
case "message_end":
diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go
index 28ecad0..61d6ab4 100644
--- a/relay/channel/gemini/response.go
+++ b/relay/channel/gemini/response.go
@@ -240,8 +240,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
}
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index ab6bfc0..7e7919e 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -240,6 +240,10 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
// relaykit 流式转换(仅 chat;generate/embedding 不迁移,走旧路径)
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go
index 705ae24..645959c 100644
--- a/relay/channel/openai/converter.go
+++ b/relay/channel/openai/converter.go
@@ -1573,6 +1573,10 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info
helper.WriteSSEData(writer, "[DONE]")
g.Log().Infof(ctx, "[HandleResponsesStreamToChat] completed: usage=%+v, endReason=%s", totalUsage, info.StreamStatus.GetEndReason())
+ // 流中断(客户端断开/写失败/上游 error 主动终止):透传中断信号,上层按已传输部分结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return &totalUsage, common.ErrStreamInterrupted
+ }
return &totalUsage, nil
}
diff --git a/relay/common/stream_status.go b/relay/common/stream_status.go
index e6443d2..ada6279 100644
--- a/relay/common/stream_status.go
+++ b/relay/common/stream_status.go
@@ -13,7 +13,7 @@ const (
StreamEndReasonClientGone StreamEndReason = "client_gone"
StreamEndReasonError StreamEndReason = "error"
StreamEndReasonPanic StreamEndReason = "panic"
- StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()
+ StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()(写客户端失败/上游 error 主动终止,按中断处理)
StreamEndReasonPingFail StreamEndReason = "ping_fail" // ping 写入失败
StreamEndReasonScannerErr StreamEndReason = "scanner_err" // bufio.Scanner 错误
StreamEndReasonEOF StreamEndReason = "eof" // scanner EOF 未收到 [DONE]
@@ -71,17 +71,23 @@ func (s *StreamStatus) ErrorCount() int {
}
// IsPartialStreamEnd 流已开始传输但未正常结束,应按已消费 usage 部分结算。
+// handler_stop(dataHandler 调用 Stop)纳入中断判定:正常结束由 sr.Done()/scanner 的
+// done 标记,实际走到 Stop 的只有「写客户端失败」(客户端不可达)与「上游 error 事件
+// 主动终止」两种,均属已向客户端写出部分字节后的非正常结束,应按中断部分结算。
+// 此前 handler_stop 不算中断,客户端断开时写失败若先于 ctx 取消被观察到(first-writer-wins
+// 竞态),流会被误判为正常结束、跳过中断计费兜底,导致 0 token / 0 费用结算。
func (s *StreamStatus) IsPartialStreamEnd() bool {
r := s.GetEndReason()
return r == StreamEndReasonClientGone || r == StreamEndReasonScannerErr ||
- r == StreamEndReasonTimeout || r == StreamEndReasonPingFail
+ r == StreamEndReasonTimeout || r == StreamEndReasonPingFail ||
+ r == StreamEndReasonHandlerStop
}
-// IsNormalEnd 是否正常结束
+// IsNormalEnd 是否正常结束(handler_stop 属异常终止,不算正常结束)
func (s *StreamStatus) IsNormalEnd() bool {
s.mu.RLock()
defer s.mu.RUnlock()
- return s.reason == StreamEndReasonDone || s.reason == StreamEndReasonHandlerStop || s.reason == ""
+ return s.reason == StreamEndReasonDone || s.reason == ""
}
// HasErrors 是否有错误
diff --git a/relay/common/stream_status_test.go b/relay/common/stream_status_test.go
new file mode 100644
index 0000000..1dcc1be
--- /dev/null
+++ b/relay/common/stream_status_test.go
@@ -0,0 +1,40 @@
+package common
+
+import (
+ "errors"
+ "testing"
+)
+
+// TestIsPartialStreamEnd_ReasonCoverage 流式结束原因的中断判定覆盖。
+// handler_stop(写客户端失败/上游 error 主动终止)必须算部分流中断:
+// 客户端断开时写失败常先于 ctx 取消被观察到(SetEndReason 为 first-writer-wins),
+// 漏判会跳过中断计费兜底,按 0 token 成功结算。
+func TestIsPartialStreamEnd_ReasonCoverage(t *testing.T) {
+ partialReasons := []StreamEndReason{
+ StreamEndReasonClientGone,
+ StreamEndReasonScannerErr,
+ StreamEndReasonTimeout,
+ StreamEndReasonPingFail,
+ StreamEndReasonHandlerStop,
+ }
+ for _, reason := range partialReasons {
+ s := NewStreamStatus()
+ s.SetEndReason(reason, errors.New("boom"))
+ if !s.IsPartialStreamEnd() {
+ t.Errorf("IsPartialStreamEnd(%s) = false, want true", reason)
+ }
+ if s.IsNormalEnd() {
+ t.Errorf("IsNormalEnd(%s) = true, want false", reason)
+ }
+ }
+
+ // 正常结束不算中断
+ s := NewStreamStatus()
+ s.SetEndReason(StreamEndReasonDone, nil)
+ if s.IsPartialStreamEnd() {
+ t.Error("IsPartialStreamEnd(done) = true, want false")
+ }
+ if !s.IsNormalEnd() {
+ t.Error("IsNormalEnd(done) = false, want true")
+ }
+}
diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go
index 9cd9362..cdccf07 100644
--- a/relay/handler/relay_handler.go
+++ b/relay/handler/relay_handler.go
@@ -718,6 +718,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
if streamUsage == nil {
streamUsage = &common.Usage{}
}
+ var interruptSettle *common.SettlementResult
if billing != nil && preDeductAmount > 0 {
settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
@@ -734,8 +735,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
billing.IncrMemberQuotaUsed(settleCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
}
+ interruptSettle = settleResult
}
- recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage)
+ recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle)
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
return usage, v.billingResult, err
@@ -1056,7 +1058,7 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
// recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。
// 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。
-func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage) {
+func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult) {
record := &common.UsageRecord{
TenantID: rc.TenantID,
UserID: rc.UserID,
@@ -1082,6 +1084,17 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext,
record.CompletionTokens = usage.CompletionTokens
record.TotalTokens = usage.TotalTokens
}
+ // 流中断结算成功时补充费用字段:中断已按已传输部分实际扣费,用量记录须与账本一致,
+ // 否则后台看到的中断请求永远是 0 费用(结算与用量记录是两条独立写入链路)
+ if settleResult != nil {
+ record.TotalCost = settleResult.BaseCost
+ record.ActualCost = settleResult.ActualCost
+ record.PreDeductAmount = settleResult.PreDeductAmount
+ record.RefundAmount = settleResult.RefundAmount
+ record.SupplementAmount = settleResult.SupplementAmount
+ record.BillingMode = settleResult.BillingMode
+ record.BillingSource = settleResult.BillingSource
+ }
provider.RecordUsage(context.Background(), record)
}
diff --git a/relay/relaykit_bridge/stream.go b/relay/relaykit_bridge/stream.go
index c5da8ff..78d8d7e 100644
--- a/relay/relaykit_bridge/stream.go
+++ b/relay/relaykit_bridge/stream.go
@@ -69,7 +69,8 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
var (
gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk
firstChunk bool
- transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
+ writeFailed bool // 写客户端已失败(连接不可达),客户端断开的可靠信号
+ transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
)
// chunkWriter:将转换器产出的 *dto.ChatCompletionStreamResponse 序列化为 SSE 写出,
@@ -106,7 +107,11 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
if err != nil {
return err
}
- return helper.WriteSSEData(safeWriter, string(data))
+ if err := helper.WriteSSEData(safeWriter, string(data)); err != nil {
+ writeFailed = true
+ return err
+ }
+ return nil
}
// writeTerminal 在转换器未产出结束 chunk 时补发一个终止 chunk,保证客户端正常收尾。
@@ -134,9 +139,17 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
monitor.TrackConverterCall(converterID, string(upstream), string(clientFormat), duration, err)
if err != nil {
- if ctx.Err() != nil {
- // 客户端断开 / 上下文取消:客户端已不可达,不写 [DONE]
- setEndReason(common.StreamEndReasonClientGone, ctx.Err())
+ // 客户端断开判定:ctx 已取消,或已出现过写客户端失败。后者兜住「写失败先于
+ // ctx 取消被观察到」的竞态与断开信号经代理链路延迟传导的场景——此前仅凭
+ // ctx.Err() 区分,这类情况会被误判为转换失败:跳过中断计费兜底、向死连接
+ // 补写 [DONE],最终按 0 token 成功结算。
+ if writeFailed || ctx.Err() != nil {
+ endErr := ctx.Err()
+ if endErr == nil {
+ endErr = err
+ }
+ // 客户端已不可达,不写 terminal/[DONE]
+ setEndReason(common.StreamEndReasonClientGone, endErr)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, capturedUsage, transferredTextLen)
return capturedUsage, true
diff --git a/relay/relaykit_bridge/stream_test.go b/relay/relaykit_bridge/stream_test.go
index 4367b39..c5f1ced 100644
--- a/relay/relaykit_bridge/stream_test.go
+++ b/relay/relaykit_bridge/stream_test.go
@@ -2,6 +2,8 @@ package relaykit_bridge
import (
"context"
+ "errors"
+ "net/http"
"net/http/httptest"
"strings"
"testing"
@@ -192,3 +194,74 @@ func tail(s string, n int) string {
}
return s[len(s)-n:]
}
+
+// failOnSubstringWriter 写入载荷包含 marker 时返回错误(模拟客户端已断开后的写失败),
+// 其余写入透传到内层 recorder。
+type failOnSubstringWriter struct {
+ rec *httptest.ResponseRecorder
+ marker string
+}
+
+func (w *failOnSubstringWriter) Header() http.Header { return w.rec.Header() }
+func (w *failOnSubstringWriter) WriteHeader(code int) { w.rec.WriteHeader(code) }
+func (w *failOnSubstringWriter) Write(p []byte) (int, error) {
+ if strings.Contains(string(p), w.marker) {
+ return 0, errors.New("write failed: connection reset by peer")
+ }
+ return w.rec.Body.Write(p)
+}
+
+// TestConvertStreamViaRelaykit_ClientWriteFailure 复现中断误判竞态:客户端断开由「写失败」
+// 首先暴露而 ctx 尚未取消(断开信号经代理传导延迟/first-writer-wins 场景)。
+// 此时必须按流中断处理:end reason=client_gone、usage 走中断兜底(已转发文本 2 字符/token
+// 估算),且不向死连接补写 [DONE]——而非误判为转换失败导致 0 token / 0 费用结算。
+func TestConvertStreamViaRelaykit_ClientWriteFailure(t *testing.T) {
+ claudeStream := `data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-opus-20240229","usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":4,"cache_creation_input_tokens":3}}}
+
+data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}
+
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
+
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", how can I help you?"}}
+
+data: {"type":"content_block_stop","index":0}
+
+data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}
+
+data: {"type":"message_stop"}
+
+`
+ info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI)
+ // 与生产一致(relay_handler buildRelayInfo 设置):流中断时上游 usage 缺失,
+ // 输入 token 按请求侧估算值补齐计费
+ info.SetEstimatePromptTokens(42)
+ // ctx 用 Background(永不取消),精确模拟「写失败先于 ctx 取消被观察到」的竞态
+ w := &failOnSubstringWriter{rec: httptest.NewRecorder(), marker: "how can I help you"}
+
+ usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), w)
+ if !ok {
+ t.Fatal("expected ok=true (handled), got false")
+ }
+ if usage == nil {
+ t.Fatal("expected non-nil usage")
+ }
+ if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonClientGone {
+ t.Fatalf("end reason = %q, want %q (client write failure misclassified)", reason, common.StreamEndReasonClientGone)
+ }
+ if !info.StreamStatus.IsPartialStreamEnd() {
+ t.Fatal("IsPartialStreamEnd = false, want true")
+ }
+ // 转换器的 usage chunk 只在流末尾产出,中断时尚未捕获——prompt 按请求侧估算值(42)补齐;
+ // 两个 delta 文本("Hello"+", how can I help you?" = 5+21 = 26 字节)均在写失败前计入
+ // transferredTextLen(累计发生在写之前),中断估算 completion = (26+1)/2 = 13
+ if usage.PromptTokens != 42 {
+ t.Errorf("PromptTokens = %d, want 42 (request-side estimate fallback)", usage.PromptTokens)
+ }
+ if usage.CompletionTokens != 13 {
+ t.Errorf("CompletionTokens = %d, want 13 (interrupted estimate, got 0 => fallback skipped)", usage.CompletionTokens)
+ }
+ // 中断后不得向死连接补写 [DONE]
+ if strings.Contains(w.rec.Body.String(), "[DONE]") {
+ t.Error("output should not contain [DONE] after client disconnect")
+ }
+}
diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue
index fe25de8..bea081e 100644
--- a/web/admin/src/components/ModelPricingModal.vue
+++ b/web/admin/src/components/ModelPricingModal.vue
@@ -3,7 +3,7 @@ import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
-import { displayCurrency, formatBilling } from '@/composables/useCurrency'
+import { displayCurrency, formatBilling, cnyToUsd } from '@/composables/useCurrency'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
@@ -80,6 +80,27 @@ function getOfficialSourceMeta(source: string) {
return officialSourceMeta[source] || { label: source, color: 'gray' }
}
+// 官方数据源价格恒为美元;本位币=CNY 时按汇率倒数折算展示与填入(汇率恒为 CNY→USD 单向配置,USD→CNY 取倒数)
+const usdToBaseRatio = computed(() => {
+ if (displayCurrency.value !== 'CNY') return 1
+ return 1 / cnyToUsd.value
+})
+
+// USD 原值 → 本位币值(4 位小数,与定价输入控件精度对齐)
+function toBasePrice(v: number | null | undefined): number {
+ const usd = Number(v ?? 0)
+ if (usd <= 0) return 0
+ return Math.round(usd * usdToBaseRatio.value * 10000) / 10000
+}
+
+// 官方参考价展示:折算为本位币直显;本位币=CNY 时括注美元原值便于核对
+function formatOfficialPrice(v: number | null | undefined): string {
+ const converted = formatBilling(toBasePrice(v), 2)
+ if (displayCurrency.value !== 'CNY') return converted
+ const usd = Number(Number(v ?? 0).toFixed(2))
+ return usd > 0 ? `${converted} ($${usd})` : converted
+}
+
function resetOfficialData() {
officialData.value = null
officialError.value = ''
@@ -111,10 +132,11 @@ async function fetchOfficialPricing() {
function applyOfficialPricing(pricing: any) {
if (!pricing) return
if (editorItems.length > 0) {
- editorItems[0].input_price = pricing.input_price
- editorItems[0].output_price = pricing.output_price
- editorItems[0].cache_read_price = pricing.cache_read_price
- editorItems[0].cache_creation_price = pricing.cache_creation_price
+ // 编辑表单按本位币原值输入,官方 USD 价格须先折算为本位币
+ editorItems[0].input_price = toBasePrice(pricing.input_price)
+ editorItems[0].output_price = toBasePrice(pricing.output_price)
+ editorItems[0].cache_read_price = toBasePrice(pricing.cache_read_price)
+ editorItems[0].cache_creation_price = toBasePrice(pricing.cache_creation_price)
}
if (pricing.billing_mode && pricing.billing_mode !== editorBillingMode.value) {
editorBillingMode.value = pricing.billing_mode
@@ -485,47 +507,52 @@ watch(() => props.visible, (val) => {