// 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]) + "..." }