Skip to content

Commit b39d44a

Browse files
author
joo
committed
feat: add automatic vendor usage sync
1 parent 1930028 commit b39d44a

20 files changed

Lines changed: 640 additions & 109 deletions

core/cmd/profiles_cmd.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/spf13/cobra"
1010

1111
"github.com/clovapi/switcher/internal/desktop"
12+
"github.com/clovapi/switcher/internal/usage"
1213
)
1314

1415
func cmdProfilesGroup() *cobra.Command {
@@ -151,11 +152,7 @@ func cmdProfilesUsage() *cobra.Command {
151152
}
152153
return fmt.Errorf("usage query failed")
153154
}
154-
if result.Usage.Success {
155-
fmt.Printf("Usage for %s: ok\n", strings.TrimSpace(result.Vendor))
156-
} else {
157-
fmt.Printf("Usage for %s: %s\n", strings.TrimSpace(result.Vendor), strings.TrimSpace(result.Usage.Error))
158-
}
155+
fmt.Printf("Usage for %s:\n%s\n", strings.TrimSpace(result.Vendor), usage.FormatResult(result.Usage))
159156
return nil
160157
},
161158
}

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.67"
7+
Version = "dev0.1.69"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/desktop/models.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,12 @@ func summarizeAuthStatus(providerID string, loggedIn bool, data map[string]any)
144144
if !isActiveClaudeSubscriptionDetail(detail) {
145145
return "Logged in · inactive subscription"
146146
}
147-
return "Logged in · " + detail
147+
return detail
148148
}
149149
return "Logged in · inactive subscription"
150150
}
151151
if providerID == provider.CodexProviderID {
152-
if mode, _ := data["auth_mode"].(string); strings.TrimSpace(mode) != "" {
153-
return "Logged in · " + mode
154-
}
152+
return "Logged in"
155153
}
156154
return "Logged in"
157155
}

core/internal/desktop/models_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ func TestAuthStatusCodexLoggedInWithoutCLIInstalled(t *testing.T) {
6161
}
6262
}
6363

64+
func TestSummarizeAuthStatusOmitsLoggedInPrefixForPlans(t *testing.T) {
65+
claudeData := map[string]any{
66+
"claudeAiOauth": map[string]any{"subscriptionType": "Pro"},
67+
}
68+
if got := summarizeAuthStatus(provider.ClaudeCodeProviderID, true, claudeData); got != "Pro" {
69+
t.Fatalf("claude summary = %q want Pro", got)
70+
}
71+
72+
codexData := map[string]any{"auth_mode": "chatgpt"}
73+
if got := summarizeAuthStatus(provider.CodexProviderID, true, codexData); got != "Logged in" {
74+
t.Fatalf("codex summary = %q want Logged in", got)
75+
}
76+
}
77+
6478
func TestParseOpenAIModelsUsesDisplayName(t *testing.T) {
6579
models, err := parseOpenAIModels([]byte(`{
6680
"data": [

core/internal/desktop/usage.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type VendorUsageResult struct {
1818
OK bool `json:"ok"`
1919
Vendor string `json:"vendor,omitempty"`
2020
Template string `json:"templateType,omitempty"`
21+
Text string `json:"text,omitempty"`
2122
Usage usage.Result `json:"usage,omitempty"`
2223
Error string `json:"error,omitempty"`
2324
}
@@ -50,6 +51,17 @@ func resolveVendorCredentials(vendor profile.Profile) (baseURL, apiKey, template
5051
}
5152
baseURL = strings.TrimSpace(vendor.BaseURL)
5253
apiKey = strings.TrimSpace(vendor.APIKey)
54+
if baseURL == "" || apiKey == "" {
55+
for _, model := range vendor.Models {
56+
modelBaseURL := strings.TrimSpace(model.BaseURL)
57+
modelAPIKey := strings.TrimSpace(model.APIKey)
58+
if modelBaseURL != "" && modelAPIKey != "" {
59+
baseURL = modelBaseURL
60+
apiKey = modelAPIKey
61+
break
62+
}
63+
}
64+
}
5365
if baseURL == "" || apiKey == "" {
5466
return "", "", "", fmt.Errorf("vendor base URL and API key are required")
5567
}
@@ -65,7 +77,13 @@ func resolveVendorCredentials(vendor profile.Profile) (baseURL, apiKey, template
6577
return baseURL, apiKey, templateType, nil
6678
}
6779

68-
// QueryVendorUsage queries upstream quota/balance for one persisted API vendor.
80+
func querySubscriptionVendorUsage(vendor profile.Profile) usage.Result {
81+
flat := vendor
82+
profile.HydrateSubscriptionCredentials(&flat)
83+
return usage.QuerySubscriptionUsage(flat.SubscriptionProviderID, flat.APIKey, flat.AccountID)
84+
}
85+
86+
// QueryVendorUsage queries upstream quota/balance for one persisted API or subscription vendor.
6987
func QueryVendorUsage(vendorName string) VendorUsageResult {
7088
name := strings.TrimSpace(vendorName)
7189
if name == "" {
@@ -79,6 +97,17 @@ func QueryVendorUsage(vendorName string) VendorUsageResult {
7997
if !ok {
8098
return VendorUsageResult{OK: false, Error: fmt.Sprintf("vendor not found: %s", name)}
8199
}
100+
if strings.EqualFold(strings.TrimSpace(vendor.Kind), "subscription") || strings.TrimSpace(vendor.SubscriptionProviderID) != "" {
101+
result := querySubscriptionVendorUsage(vendor)
102+
return VendorUsageResult{
103+
OK: result.Success,
104+
Vendor: name,
105+
Template: "subscription",
106+
Text: usage.FormatResult(result),
107+
Usage: result,
108+
Error: result.Error,
109+
}
110+
}
82111
baseURL, apiKey, templateType, err := resolveVendorCredentials(vendor)
83112
if err != nil {
84113
return VendorUsageResult{OK: false, Vendor: name, Error: err.Error()}
@@ -88,6 +117,7 @@ func QueryVendorUsage(vendorName string) VendorUsageResult {
88117
OK: result.Success,
89118
Vendor: name,
90119
Template: templateType,
120+
Text: usage.FormatResult(result),
91121
Usage: result,
92122
Error: result.Error,
93123
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package desktop
2+
3+
import (
4+
"testing"
5+
6+
"github.com/clovapi/switcher/internal/profile"
7+
"github.com/clovapi/switcher/internal/usage"
8+
)
9+
10+
func TestResolveVendorCredentialsFallsBackToModelConnection(t *testing.T) {
11+
baseURL, apiKey, template, err := resolveVendorCredentials(profile.Profile{
12+
Kind: "api",
13+
Models: []profile.Model{{
14+
BaseURL: "https://api.kimi.com/coding/v1",
15+
APIKey: "sk-model",
16+
}},
17+
})
18+
if err != nil {
19+
t.Fatalf("resolveVendorCredentials() err = %v", err)
20+
}
21+
if baseURL != "https://api.kimi.com/coding/v1" || apiKey != "sk-model" {
22+
t.Fatalf("credentials = %q %q", baseURL, apiKey)
23+
}
24+
if template != usage.TemplateAuto {
25+
t.Fatalf("template = %q want %q", template, usage.TemplateAuto)
26+
}
27+
}

core/internal/usage/coding_plan.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ func queryKimiCodingPlan(apiKey string) Result {
201201

202202
func queryZhipuCodingPlan(apiKey string) Result {
203203
body, status, err := httpGetJSON("https://api.z.ai/api/monitor/usage/quota/limit", map[string]string{
204-
"Authorization": strings.TrimSpace(apiKey),
205-
"Content-Type": "application/json",
204+
"Authorization": strings.TrimSpace(apiKey),
205+
"Content-Type": "application/json",
206206
"Accept-Language": "en-US,en",
207207
})
208208
if err != nil {
@@ -330,6 +330,12 @@ func TiersToUsageData(tiers []Tier) []Data {
330330
for _, tier := range tiers {
331331
total := 100.0
332332
used := tier.Utilization
333+
if used < 0 {
334+
used = 0
335+
}
336+
if used > total {
337+
used = total
338+
}
333339
remaining := total - used
334340
valid := true
335341
out = append(out, Data{

core/internal/usage/format.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package usage
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"strings"
7+
)
8+
9+
// TierDisplayName returns a compact human label for token-plan buckets.
10+
func TierDisplayName(name string) string {
11+
switch strings.ToLower(strings.TrimSpace(name)) {
12+
case tierFiveHour:
13+
return "5小时"
14+
case tierWeeklyLimit:
15+
return "一周"
16+
case tierSevenDay:
17+
return "7天"
18+
case tierSevenDayOpus:
19+
return "7天 Opus"
20+
case tierSevenDaySonnet:
21+
return "7天 Sonnet"
22+
default:
23+
return strings.TrimSpace(name)
24+
}
25+
}
26+
27+
func formatNumber(v float64) string {
28+
if math.Abs(v-math.Round(v)) < 0.000001 {
29+
return fmt.Sprintf("%.0f", v)
30+
}
31+
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", v), "0"), ".")
32+
}
33+
34+
// FormatDataRow renders one usage row as plain text for CLI and desktop summaries.
35+
func FormatDataRow(row Data) string {
36+
name := strings.TrimSpace(row.PlanName)
37+
if name == tierFiveHour || name == tierWeeklyLimit || name == tierSevenDay || name == tierSevenDayOpus || name == tierSevenDaySonnet {
38+
name = TierDisplayName(name)
39+
}
40+
unit := strings.TrimSpace(row.Unit)
41+
if row.Used != nil && row.Total != nil {
42+
if unit == "%" {
43+
pct := *row.Used
44+
if *row.Total > 0 {
45+
pct = *row.Used / *row.Total * 100
46+
}
47+
if name == "" {
48+
return formatNumber(pct) + "%"
49+
}
50+
return name + " " + formatNumber(pct) + "%"
51+
}
52+
if row.Remaining != nil {
53+
body := formatNumber(*row.Remaining)
54+
if unit != "" {
55+
body += " " + unit
56+
}
57+
if name == "" {
58+
return body
59+
}
60+
return name + " " + body
61+
}
62+
body := formatNumber(*row.Used) + "/" + formatNumber(*row.Total)
63+
if unit != "" {
64+
body += " " + unit
65+
}
66+
if name == "" {
67+
return body
68+
}
69+
return name + " " + body
70+
} else if row.Remaining != nil {
71+
body := formatNumber(*row.Remaining)
72+
if unit != "" {
73+
body += " " + unit
74+
}
75+
if name == "" {
76+
return body
77+
}
78+
return name + " " + body
79+
}
80+
if msg := strings.TrimSpace(row.InvalidMessage); msg != "" {
81+
return msg
82+
}
83+
if name == "" {
84+
return "no details"
85+
}
86+
return name
87+
}
88+
89+
func formatTierRow(tier Tier) string {
90+
name := TierDisplayName(tier.Name)
91+
body := formatNumber(tier.Utilization) + "%"
92+
if name == "" {
93+
return body
94+
}
95+
return name + " " + body
96+
}
97+
98+
// FormatResult renders a complete usage query result as plain text.
99+
func FormatResult(result Result) string {
100+
if !result.Success {
101+
if err := strings.TrimSpace(result.Error); err != "" {
102+
return err
103+
}
104+
return "usage query failed"
105+
}
106+
rows := result.Data
107+
if len(rows) == 0 && len(result.Tiers) > 0 {
108+
parts := make([]string, 0, len(result.Tiers))
109+
for _, tier := range result.Tiers {
110+
parts = append(parts, formatTierRow(tier))
111+
}
112+
return strings.Join(parts, " · ")
113+
}
114+
if len(rows) == 0 {
115+
return "no usage details"
116+
}
117+
parts := make([]string, 0, len(rows))
118+
for _, row := range rows {
119+
parts = append(parts, FormatDataRow(row))
120+
}
121+
return strings.Join(parts, " · ")
122+
}

core/internal/usage/query_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,47 @@ func TestQueryVendorUsageAutoUnsupported(t *testing.T) {
4545
t.Fatalf("kind = %q want unsupported", res.Kind)
4646
}
4747
}
48+
49+
func TestFormatResultTokenPlan(t *testing.T) {
50+
res := Result{
51+
Success: true,
52+
Kind: "token_plan",
53+
Tiers: []Tier{
54+
{Name: tierFiveHour, Utilization: 12.5, ResetsAt: "2026-06-02T10:00:00Z"},
55+
{Name: tierWeeklyLimit, Utilization: 40},
56+
},
57+
}
58+
got := FormatResult(res)
59+
want := "5小时 12.5% · 一周 40%"
60+
if got != want {
61+
t.Fatalf("FormatResult() = %q want %q", got, want)
62+
}
63+
}
64+
65+
func TestFormatResultSubscriptionTiers(t *testing.T) {
66+
res := Result{
67+
Success: true,
68+
Kind: "subscription",
69+
Tiers: []Tier{
70+
{Name: tierFiveHour, Utilization: 12},
71+
{Name: tierSevenDay, Utilization: 34},
72+
},
73+
}
74+
got := FormatResult(res)
75+
want := "5小时 12% · 7天 34%"
76+
if got != want {
77+
t.Fatalf("FormatResult() = %q want %q", got, want)
78+
}
79+
}
80+
81+
func TestWindowSecondsToTierName(t *testing.T) {
82+
if got := windowSecondsToTierName(18000); got != tierFiveHour {
83+
t.Fatalf("5h tier = %q", got)
84+
}
85+
if got := windowSecondsToTierName(604800); got != tierSevenDay {
86+
t.Fatalf("7d tier = %q", got)
87+
}
88+
if got := windowSecondsToTierName(86400); got != "1_day" {
89+
t.Fatalf("1d tier = %q", got)
90+
}
91+
}

0 commit comments

Comments
 (0)