177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
package provider
|
|
|
|
// What a Cline API key has left: ClinePass's 5-hour, weekly and monthly
|
|
// limits and the account's credit balance, read with the key as the Cline
|
|
// plugin reads them signed in (zRain on Discord: AxonHub reads them with a
|
|
// key). GET /users/me/plan/usage-limits answers a key as it answers an
|
|
// account; an account without ClinePass is a 404 there and has its credits
|
|
// alone.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// clineUsageAPI is where the Cline API's account endpoints are, a var so
|
|
// tests point it elsewhere.
|
|
var clineUsageAPI = "https://api.cline.bot/api/v1"
|
|
|
|
// clineLimits are ClinePass's limits as usage-limits names them, in the
|
|
// order the card shows them, with the plugin's names.
|
|
var clineLimits = []struct {
|
|
typ, name string
|
|
span time.Duration
|
|
}{
|
|
{"five_hour", "5 hours", 5 * time.Hour},
|
|
{"weekly", "Weekly", 7 * 24 * time.Hour},
|
|
{"monthly", "Month", 30 * 24 * time.Hour},
|
|
}
|
|
|
|
// clineKeyUsage is the ClinePass limits and the credit balance of the
|
|
// provider's key in use.
|
|
func clineKeyUsage(ctx context.Context, p Provider) (windows []QuotaWindow, balance string, err error) {
|
|
ctx = p.Via(ctx)
|
|
var lim struct {
|
|
Limits []struct {
|
|
Type string `json:"type"`
|
|
PercentUsed *float64 `json:"percentUsed"`
|
|
ResetsAt string `json:"resetsAt"`
|
|
} `json:"limits"`
|
|
}
|
|
limErr := clineGet(ctx, p.Key, "/users/me/plan/usage-limits", &lim)
|
|
for _, l := range clineLimits {
|
|
for _, x := range lim.Limits {
|
|
if x.Type != l.typ {
|
|
continue
|
|
}
|
|
w := QuotaWindow{Name: l.name, Span: l.span}
|
|
if x.PercentUsed != nil && !math.IsNaN(*x.PercentUsed) {
|
|
w.Used = min(100, max(0, *x.PercentUsed))
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(x.ResetsAt)); err == nil {
|
|
w.ResetsAt = &t
|
|
}
|
|
windows = append(windows, w)
|
|
break
|
|
}
|
|
}
|
|
balance, balErr := clineCredits(ctx, p.Key)
|
|
var nf clineStatus
|
|
if errors.As(limErr, &nf) && nf == http.StatusNotFound {
|
|
limErr = nil // no ClinePass on the account: its credits alone
|
|
}
|
|
if len(windows) == 0 && balance == "" {
|
|
// neither read: the limits' refusal says most (a key Cline
|
|
// doesn't take), else the balance's
|
|
if limErr == nil {
|
|
limErr = balErr
|
|
}
|
|
if limErr == nil {
|
|
limErr = errors.New("Cline told neither limits nor a balance")
|
|
}
|
|
return nil, "", limErr
|
|
}
|
|
return windows, balance, nil
|
|
}
|
|
|
|
// clineCredits is the account's credit balance, {balance} in millionths of
|
|
// a dollar, at /users/{id}/balance with the id /users/me tells.
|
|
func clineCredits(ctx context.Context, key string) (string, error) {
|
|
var me struct {
|
|
ID string `json:"id"`
|
|
ClineUserID string `json:"clineUserId"`
|
|
Subject string `json:"subject"`
|
|
}
|
|
if err := clineGet(ctx, key, "/users/me", &me); err != nil {
|
|
return "", err
|
|
}
|
|
var last error = errors.New("Cline told no user id")
|
|
for _, id := range []string{me.ClineUserID, me.Subject, me.ID} {
|
|
if strings.TrimSpace(id) == "" {
|
|
continue
|
|
}
|
|
var b struct {
|
|
Balance json.Number `json:"balance"`
|
|
}
|
|
if last = clineGet(ctx, key, "/users/"+url.PathEscape(id)+"/balance", &b); last != nil {
|
|
continue
|
|
}
|
|
n, err := strconv.ParseFloat(b.Balance.String(), 64)
|
|
if err != nil || n < 0 {
|
|
return "", errors.New("Cline told no balance")
|
|
}
|
|
return clineUSD(n / 1e6), nil
|
|
}
|
|
return "", last
|
|
}
|
|
|
|
// clineUSD is dollars as the plugin writes them: cents, or four decimals
|
|
// under a cent.
|
|
func clineUSD(n float64) string {
|
|
if n == 0 || n >= 0.01 {
|
|
return fmt.Sprintf("$%.2f", n)
|
|
}
|
|
return "$" + strconv.FormatFloat(math.Round(n*1e4)/1e4, 'f', -1, 64)
|
|
}
|
|
|
|
// clineStatus is an HTTP status the Cline API answered with.
|
|
type clineStatus int
|
|
|
|
func (s clineStatus) Error() string {
|
|
return fmt.Sprintf("Cline answered %d %s", int(s), http.StatusText(int(s)))
|
|
}
|
|
|
|
// clineGet asks the Cline API at path with the key and reads its
|
|
// {success, data} reply's data into out.
|
|
func clineGet(ctx context.Context, key, path string, out any) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clineUsageAPI+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ClineClient(req.Header)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
var env struct {
|
|
Success *bool `json:"success"`
|
|
Error string `json:"error"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(b, &env)
|
|
if res.StatusCode >= 300 {
|
|
if env.Error != "" {
|
|
return fmt.Errorf("%w: %s", clineStatus(res.StatusCode), env.Error)
|
|
}
|
|
return clineStatus(res.StatusCode)
|
|
}
|
|
if env.Success != nil && !*env.Success {
|
|
return fmt.Errorf("Cline refused: %s", env.Error)
|
|
}
|
|
if env.Success != nil || len(env.Data) > 0 {
|
|
b = env.Data
|
|
}
|
|
return json.Unmarshal(b, out)
|
|
}
|
|
|
|
// clineKeyCard says the provider's key card is read as Cline's: a Cline
|
|
// API key with no Balance URL of the user's own.
|
|
func clineKeyCard(p Provider) bool {
|
|
return p.IsCline() && p.Account == nil && p.Key != "" && strings.TrimSpace(p.BalanceURL) == ""
|
|
}
|