Skip to content

Commit 032c1cc

Browse files
authored
Merge pull request #129 from itmisx/feat/vendor-balance
✨ feat: vendor-balance
2 parents 132ed55 + 58f13df commit 032c1cc

6 files changed

Lines changed: 249 additions & 7 deletions

File tree

agent/balance.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strconv"
10+
"strings"
11+
"time"
12+
)
13+
14+
// BalanceResult 是一次账户余额查询的结果。Display 已是可直接渲染的金额串(含币种符号,如 "¥110.00")。
15+
type BalanceResult struct {
16+
Display string // 可渲染金额串;Supported=false 时为空
17+
Supported bool // 该供应商是否支持凭模型 API Key 查余额
18+
}
19+
20+
// ProbeBalance 用模型 API Key 查询账户剩余金额。
21+
//
22+
// 现状:四家预置供应商里只有 DeepSeek 和 Kimi(Moonshot)提供可凭模型 Key 直接调用的余额接口。
23+
// qwen 需阿里云 AccessKey/SecretKey 走 BSS OpenAPI、mimo 无任何余额接口 —— 这两家(及未知 custom 端点)
24+
// 返回 Supported=false,调用方据此显示 "-"。
25+
//
26+
// 返回 (result, err):
27+
// - err != nil:网络 / 5xx 等瞬时错误 → 调用方不更新,下次再探;
28+
// - err == nil 且 Supported=false:该供应商不支持查询;
29+
// - err == nil 且 Supported=true:Display 为可渲染金额串。
30+
func ProbeBalance(ctx context.Context, entry ModelEntry) (BalanceResult, error) {
31+
if entry.BaseURL == "" || entry.APIKey == "" {
32+
return BalanceResult{Supported: false}, nil
33+
}
34+
host := balanceHostOf(entry.BaseURL)
35+
switch {
36+
case strings.Contains(host, "deepseek"):
37+
return probeDeepSeekBalance(ctx, entry)
38+
case strings.Contains(host, "moonshot"):
39+
return probeKimiBalance(ctx, entry)
40+
default:
41+
// qwen / mimo / 未知 custom 端点:无可凭 Key 调用的余额接口。
42+
return BalanceResult{Supported: false}, nil
43+
}
44+
}
45+
46+
// probeDeepSeekBalance 调 GET {base_url}/user/balance(DeepSeek base_url 不含 /v1,拼出 /user/balance)。
47+
func probeDeepSeekBalance(ctx context.Context, entry ModelEntry) (BalanceResult, error) {
48+
body, err := httpGetJSON(ctx, entry.BaseURL+"/user/balance", entry.APIKey)
49+
if err != nil {
50+
return BalanceResult{}, err
51+
}
52+
var r struct {
53+
IsAvailable bool `json:"is_available"`
54+
BalanceInfos []struct {
55+
Currency string `json:"currency"`
56+
TotalBalance string `json:"total_balance"`
57+
} `json:"balance_infos"`
58+
}
59+
if err := json.Unmarshal(body, &r); err != nil {
60+
return BalanceResult{}, err
61+
}
62+
if len(r.BalanceInfos) == 0 {
63+
return BalanceResult{Supported: true, Display: "—"}, nil
64+
}
65+
// DeepSeek 会按币种返回多条(常见 USD 0.00 在前、CNY 有钱在后)。挑金额最大的那条展示,
66+
// 全为 0 则退回第一条 —— 避免误显 "$0.00" 而把真实的 ¥24.86 漏掉。
67+
best := r.BalanceInfos[0]
68+
bestAmt := -1.0
69+
for _, b := range r.BalanceInfos {
70+
amt, err := strconv.ParseFloat(b.TotalBalance, 64)
71+
if err != nil {
72+
continue
73+
}
74+
if amt > bestAmt {
75+
bestAmt, best = amt, b
76+
}
77+
}
78+
return BalanceResult{Supported: true, Display: currencySymbol(best.Currency) + best.TotalBalance}, nil
79+
}
80+
81+
// probeKimiBalance 调 GET {base_url}/users/me/balance(Kimi base_url 带 /v1,拼出 /v1/users/me/balance)。
82+
func probeKimiBalance(ctx context.Context, entry ModelEntry) (BalanceResult, error) {
83+
body, err := httpGetJSON(ctx, entry.BaseURL+"/users/me/balance", entry.APIKey)
84+
if err != nil {
85+
return BalanceResult{}, err
86+
}
87+
var r struct {
88+
Status bool `json:"status"`
89+
Data struct {
90+
AvailableBalance float64 `json:"available_balance"`
91+
} `json:"data"`
92+
}
93+
if err := json.Unmarshal(body, &r); err != nil {
94+
return BalanceResult{}, err
95+
}
96+
// Kimi 余额均为人民币,且 available_balance = 现金 + 代金券,即"还能花多少"。
97+
return BalanceResult{Supported: true, Display: fmt.Sprintf("¥%.2f", r.Data.AvailableBalance)}, nil
98+
}
99+
100+
// httpGetJSON 发一个带 Bearer 鉴权的 GET,返回响应体(非 200 视为错误,调用方按瞬时错误处理)。
101+
func httpGetJSON(ctx context.Context, url, apiKey string) ([]byte, error) {
102+
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
103+
defer cancel()
104+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
105+
if err != nil {
106+
return nil, err
107+
}
108+
req.Header.Set("Accept", "application/json")
109+
req.Header.Set("Authorization", "Bearer "+apiKey)
110+
resp, err := http.DefaultClient.Do(req)
111+
if err != nil {
112+
return nil, err
113+
}
114+
defer resp.Body.Close()
115+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
116+
if resp.StatusCode != 200 {
117+
return nil, fmt.Errorf("balance: http %d", resp.StatusCode)
118+
}
119+
return body, nil
120+
}
121+
122+
// currencySymbol 把币种代码转成符号;未知币种回退成 "CODE "(带尾空格,数字前留隔)。
123+
func currencySymbol(cur string) string {
124+
switch strings.ToUpper(cur) {
125+
case "CNY", "RMB":
126+
return "¥"
127+
case "USD":
128+
return "$"
129+
default:
130+
if cur == "" {
131+
return ""
132+
}
133+
return cur + " "
134+
}
135+
}
136+
137+
// balanceHostOf 从 base_url 抽出 host(去 scheme / path),用于判定供应商。
138+
func balanceHostOf(rawURL string) string {
139+
h := rawURL
140+
if i := strings.Index(h, "://"); i >= 0 {
141+
h = h[i+3:]
142+
}
143+
if i := strings.IndexAny(h, "/?"); i >= 0 {
144+
h = h[:i]
145+
}
146+
return h
147+
}

tui/balance.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
6+
"deepx/agent"
7+
8+
tea "charm.land/bubbletea/v2"
9+
)
10+
11+
// 账户余额查询的接入层(配合右栏「模型厂商」段展示):
12+
//
13+
// 仅 DeepSeek / Kimi 提供可凭模型 Key 调用的余额接口(见 agent.ProbeBalance);其它供应商
14+
// 回 Supported=false → 右栏显示 "-"。每次启动、每次 /config 改配置、每次 /provider 切换都重探一次,
15+
// 结果经 balanceMsg 回灌当前会话(异步,拿不到不阻塞 UI)。
16+
17+
// balanceMsg 是一次余额查询的回执。
18+
type balanceMsg struct {
19+
display string // 可渲染金额串(supported 时有效)
20+
supported bool
21+
}
22+
23+
// balanceProbeCmd 用当前 flash(无 key 则退 pro)的配置查余额。无 key/base_url 直接返回 nil 不发命令。
24+
func balanceProbeCmd(models agent.ModelConfig) tea.Cmd {
25+
entry := models.Flash
26+
if entry.APIKey == "" {
27+
entry = models.Pro
28+
}
29+
if entry.APIKey == "" || entry.BaseURL == "" {
30+
return nil
31+
}
32+
return func() tea.Msg {
33+
res, err := agent.ProbeBalance(context.Background(), entry)
34+
if err != nil {
35+
return nil // 瞬时错误 → 不更新,保留上次值
36+
}
37+
return balanceMsg{display: res.Display, supported: res.Supported}
38+
}
39+
}
40+
41+
// applyBalance 把回执落到当前会话:支持则存金额串,不支持存 "-"。
42+
func (m *model) applyBalance(msg balanceMsg) {
43+
if msg.supported {
44+
m.balance = msg.display
45+
return
46+
}
47+
m.balance = "-"
48+
}

tui/i18n.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,8 +462,10 @@ var translations = map[string]map[Lang]string{
462462
"panel.label.used": {LangZH: "占用", LangEN: "Used"},
463463
"panel.label.output": {LangZH: "输出", LangEN: "Output"},
464464
"panel.label.cache": {LangZH: "缓存", LangEN: "Cache"},
465-
"panel.label.sbmode": {LangZH: "隔离", LangEN: "Isolation"},
466-
"panel.label.wmode": {LangZH: "方法", LangEN: "Method"},
465+
"panel.label.sbmode": {LangZH: "隔离", LangEN: "Isolation"},
466+
"panel.label.wmode": {LangZH: "方法", LangEN: "Method"},
467+
"panel.label.endpoint": {LangZH: "接口", LangEN: "Endpoint"},
468+
"panel.label.balance": {LangZH: "余额", LangEN: "Balance"},
467469

468470
// === Status values ===
469471
"status.idle": {LangZH: "idle", LangEN: "idle"},

tui/model.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ type model struct {
8181
// 取值缺省 false → 发图走 OCR;true → 发图渲染成 base64 内联,不走 OCR。
8282
visionByModel map[string]bool
8383

84+
// balance 是右栏「模型厂商」段展示的账户剩余金额串(已含币种符号,如 "¥110.00")。
85+
// "" = 尚未探到(不显示);"-" = 该供应商不支持查询(见 balance.go / agent.ProbeBalance)。
86+
// 每次启动、改配置、切供应商时经 balanceMsg 回灌。
87+
balance string
88+
8489
mode agent.AgentMode
8590
workingMode agent.WorkingMode // 工作模式 kp/openspec/sp(默认 kp);每轮注入对应 skill 引导;按 session 保存/恢复
8691
history []agent.ChatMessage
@@ -1050,6 +1055,10 @@ func (m model) Init() tea.Cmd {
10501055
}
10511056
// 视觉能力探测:每次启动对各模型重探一次(见 vision.go),结果经 visionCapMsg 回灌。
10521057
cmds = append(cmds, visionProbeCmds(m.models)...)
1058+
// 账户余额:仅 DeepSeek/Kimi 可凭 Key 查,其它回 "-"(见 balance.go)。
1059+
if cmd := balanceProbeCmd(m.models); cmd != nil {
1060+
cmds = append(cmds, cmd)
1061+
}
10531062
if cmd := ForceGraphemeCmd(); cmd != nil {
10541063
cmds = append(cmds, cmd)
10551064
}
@@ -2250,6 +2259,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
22502259
m.applyVisionCap(msg)
22512260
return m, nil
22522261

2262+
case balanceMsg:
2263+
// 余额查询回执:更新右栏「模型厂商」段展示的剩余金额。
2264+
m.applyBalance(msg)
2265+
return m, nil
2266+
22532267
case agent.VisionUnsupportedMsg:
22542268
// 运行时自愈:某模型实际拒绝图片(agent 已自动改 OCR 重发)→ 把它标记为无视觉、纠正缓存,
22552269
// 下次发图不再对它用 base64。
@@ -2618,11 +2632,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26182632
}
26192633
}
26202634

2635+
// 本轮花了 token,余额变了:后台重探一次(仅 DeepSeek/Kimi 实际打接口,其它是 no-op)。
2636+
balCmd := balanceProbeCmd(m.models)
2637+
26212638
// 没触发实时压缩:有排队输入就发下一条;影子 Cmd(若有)并行后台跑,不阻塞用户。
26222639
if next, qcmd, ok := m.popQueuedInput(); ok {
2623-
return next, tea.Batch(shadowCmd, qcmd)
2640+
return next, tea.Batch(shadowCmd, qcmd, balCmd)
26242641
}
2625-
return m, shadowCmd
2642+
return m, tea.Batch(shadowCmd, balCmd)
26262643

26272644
case agent.StreamErrMsg:
26282645
if m.streamCh == nil {

tui/setup_modal.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,13 @@ func (m *model) submitSetup() tea.Cmd {
338338
// 反斜杠转义已在 renderMarkdown 渲染层统一处理(见 backslashSentinel),这里不必再包反引号。
339339
m.appendChat("System", T("setup.saved_to")+path)
340340
// 对新配置的模型重探视觉能力(结果经 visionCapMsg 回灌当前会话 + 覆盖缓存)。
341-
return tea.Batch(visionProbeCmds(m.models)...)
341+
// 余额也重探:换了供应商/Key,旧值已无意义,先清空待新值回灌。
342+
m.balance = ""
343+
cmds := visionProbeCmds(m.models)
344+
if cmd := balanceProbeCmd(m.models); cmd != nil {
345+
cmds = append(cmds, cmd)
346+
}
347+
return tea.Batch(cmds...)
342348
}
343349

344350
// openSetupModal 给 /config 命令用:把当前面板切到 modal(从选供应商那步开始),允许 Esc 取消。
@@ -419,5 +425,11 @@ func (m *model) applyProvider(name string) tea.Cmd {
419425
m.visionByModel = loadVisionCaps(m.models)
420426
m.appendChat("System", fmt.Sprintf(T("provider.switched"), name, m.models.Flash.Model, m.models.Pro.Model))
421427
m.refreshViewport()
422-
return tea.Batch(visionProbeCmds(m.models)...)
428+
// 换供应商 → 余额变了,先清空待重探回灌。
429+
m.balance = ""
430+
cmds := visionProbeCmds(m.models)
431+
if cmd := balanceProbeCmd(m.models); cmd != nil {
432+
cmds = append(cmds, cmd)
433+
}
434+
return tea.Batch(cmds...)
423435
}

tui/view.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,23 @@ func (m model) rightPanelView() string {
861861
if idx := strings.IndexAny(host, "/?"); idx >= 0 {
862862
host = host[:idx]
863863
}
864-
rows = append(rows, section(T("panel.vendor"), []string{subtle(host)})...)
864+
// 厂商段拆两个子标签:接口(host)+ 余额。余额:支持的供应商(DeepSeek/Kimi)显示金额(高亮),
865+
// 不支持显示 "-"(暗色),尚未探到(m.balance=="")显示 "…"(暗色)。
866+
var balanceVal string
867+
switch {
868+
case m.balance == "" || m.balance == "-":
869+
val := m.balance
870+
if val == "" {
871+
val = "…"
872+
}
873+
balanceVal = subtle(val)
874+
default:
875+
balanceVal = lipgloss.NewStyle().Foreground(highlightColor).Render(m.balance)
876+
}
877+
rows = append(rows, section(T("panel.vendor"), []string{
878+
label(T("panel.label.endpoint")) + " " + subtle(host),
879+
label(T("panel.label.balance")) + " " + balanceVal,
880+
})...)
865881

866882
// 注:web dashboard 地址不在右栏显示 —— 启动时已在 chat 区给出可点击 / 已复制的提示,
867883
// 这里再放一份既重复又因面板窄被迫折行,反而没法点。

0 commit comments

Comments
 (0)