| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- // Package api wraps the ZenMux subscription-detail endpoint.
- package api
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "time"
- )
- // DefaultBaseURL is the production ZenMux API.
- const DefaultBaseURL = "https://zenmux.ai"
- // Response mirrors the top-level JSON envelope returned by
- // GET /api/v1/management/subscription/detail.
- type Response struct {
- Success bool `json:"success"`
- Data Data `json:"data"`
- Error string `json:"error,omitempty"`
- }
- // Data holds the populated fields when Success is true.
- type Data struct {
- Plan Plan `json:"plan"`
- Currency string `json:"currency"`
- BaseUSDPerFlow float64 `json:"base_usd_per_flow"`
- EffectiveUSDPerFlow float64 `json:"effective_usd_per_flow"`
- AccountStatus string `json:"account_status"`
- Quota5Hour QuotaWindow `json:"quota_5_hour"`
- Quota7Day QuotaWindow `json:"quota_7_day"`
- QuotaMonthly QuotaMonthly `json:"quota_monthly"`
- }
- // Plan describes the subscription tier.
- type Plan struct {
- Tier string `json:"tier"`
- AmountUSD float64 `json:"amount_usd"`
- Interval string `json:"interval"`
- ExpiresAt string `json:"expires_at"`
- }
- // QuotaWindow is a rolling window (5-hour or 7-day) with used/max metrics.
- // ResetsAt is a pointer so we can distinguish null from the zero time.
- type QuotaWindow struct {
- UsagePercentage float64 `json:"usage_percentage"`
- ResetsAt *string `json:"resets_at"`
- MaxFlows float64 `json:"max_flows"`
- UsedFlows float64 `json:"used_flows"`
- RemainingFlows float64 `json:"remaining_flows"`
- UsedValueUSD float64 `json:"used_value_usd"`
- MaxValueUSD float64 `json:"max_value_usd"`
- }
- // QuotaMonthly is the billing-cycle window. The API does not populate
- // used_* fields here, so we only model the max bounds.
- type QuotaMonthly struct {
- MaxFlows float64 `json:"max_flows"`
- MaxValueUSD float64 `json:"max_value_usd"`
- }
- // Sentinel errors for transport and status mapping.
- var (
- ErrUnauthorized = errors.New("authentication rejected")
- ErrRateLimited = errors.New("rate limited")
- ErrTimeout = errors.New("request timed out")
- ErrServer = errors.New("server error")
- ErrBadResponse = errors.New("malformed response")
- )
- // Client issues subscription-detail requests.
- type Client struct {
- BaseURL string
- HTTPClient *http.Client
- }
- // NewClient returns a Client with a sensible default HTTP client.
- func NewClient(timeout time.Duration) *Client {
- return &Client{
- BaseURL: DefaultBaseURL,
- HTTPClient: &http.Client{
- Timeout: timeout,
- },
- }
- }
- // FetchSubscriptionDetail issues a single GET request using apiKey as the
- // bearer token. It returns the parsed response, the raw response body
- // (useful for --json passthrough), and a classified error.
- func (c *Client) FetchSubscriptionDetail(ctx context.Context, apiKey string) (*Response, []byte, error) {
- endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/management/subscription/detail")
- if err != nil {
- return nil, nil, fmt.Errorf("build url: %w", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, nil, fmt.Errorf("build request: %w", err)
- }
- req.Header.Set("Authorization", "Bearer "+apiKey)
- req.Header.Set("Accept", "application/json")
- resp, err := c.HTTPClient.Do(req)
- if err != nil {
- if isTimeout(err) {
- return nil, nil, fmt.Errorf("%w: %v", ErrTimeout, err)
- }
- return nil, nil, err
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, nil, fmt.Errorf("read body: %w", err)
- }
- switch resp.StatusCode {
- case http.StatusUnauthorized, http.StatusForbidden:
- return nil, body, fmt.Errorf("%w: HTTP %d", ErrUnauthorized, resp.StatusCode)
- case http.StatusUnprocessableEntity, http.StatusTooManyRequests:
- return nil, body, fmt.Errorf("%w: HTTP %d", ErrRateLimited, resp.StatusCode)
- }
- if resp.StatusCode >= 500 {
- return nil, body, fmt.Errorf("%w: HTTP %d", ErrServer, resp.StatusCode)
- }
- if resp.StatusCode >= 400 {
- return nil, body, fmt.Errorf("unexpected HTTP %d: %s", resp.StatusCode, trimBody(body))
- }
- var parsed Response
- if err := json.Unmarshal(body, &parsed); err != nil {
- return nil, body, fmt.Errorf("%w: %v", ErrBadResponse, err)
- }
- if !parsed.Success {
- msg := parsed.Error
- if msg == "" {
- msg = "api returned success=false"
- }
- return &parsed, body, fmt.Errorf("%w: %s", ErrBadResponse, msg)
- }
- return &parsed, body, nil
- }
- // isTimeout unwraps net/url-style errors to check for a context or client timeout.
- func isTimeout(err error) bool {
- if err == nil {
- return false
- }
- if errors.Is(err, context.DeadlineExceeded) {
- return true
- }
- var t interface{ Timeout() bool }
- if errors.As(err, &t) {
- return t.Timeout()
- }
- return false
- }
- // trimBody keeps error messages bounded.
- func trimBody(b []byte) string {
- const max = 200
- if len(b) <= max {
- return string(b)
- }
- return string(b[:max]) + "..."
- }
|