mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance) Add the AI/ML API provider integration to the zero CLI: a guided TUI onboarding sub-flow that takes an existing key or an email top-up and writes the issued key into the provider profile, attributed to the Gitlawb rebate partner. - internal/aimlapi: OpenAI-compatible client, partner-checkout top-up, key validation, streaming, and endpoint config (Paths A/B/C). - providercatalog / providermodelcatalog: register the aimlapi provider with default headers and models. - tui: aimlapi onboarding screen wired into the provider wizard and onboarding flow. Default endpoints point at production (auth/app/api/pay/aimlapi.com); each is overridable via AIMLAPI_*_URL env vars. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(aimlapi): address review findings in onboarding flow - topup: use errors.As for the 5xx retry check so a wrapped APIError still retries instead of aborting a paid checkout. - onboarding: a Path-A top-up on an email with no account now lands on a single coherent "key saved, balance not topped up" screen instead of a green "ready" plus a red error. - onboarding: an empty amount field is rejected instead of silently falling through to the $25 default. - collapse the two identical top-up prompt constants into MsgTopUpPrompt and drop the dead amountPrompt() branch. - reuse stdlib maps.Clone instead of a third bespoke header-copy helper. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(aimlapi): retry transient poll errors and sharpen amount validation * fix(aimlapi): retry transient poll errors and surface real amount errors * docs(aimlapi): document exported client API; drop out-of-scope gitignore entry Addresses the two CodeRabbit PR checks on #655: - Add doc comments to the exported aimlapi client API (Client, APIError, the session/checkout/onboarding types and their methods) and ParseAmountUSD, to satisfy the docstring-coverage check. - Remove the unrelated /zero.exe~ .gitignore entry that an earlier review-fix commit added; it is out of scope for the provider work, so the PR no longer touches .gitignore. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(aimlapi): render aimlapi row like other recommended providers * fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation) * fix(aimlapi): validate pasted API keys * test(aimlapi): clarify pasted key validation * test(aimlapi): cover key-balance low-balance transition * fix(aimlapi): reject non-finite top-up amounts * fix(cli): scope and canonicalize provider headers * fix(tui): distinguish invalid verification codes * feat(aimlapi): resume account-bound top-ups safely * feat(tui): offer top-up for existing AIMLAPI keys * feat(tui): reuse AIMLAPI env key on first run * feat(tui): reuse configured AIMLAPI profiles * test(aimlapi): trim redundant flow coverage * fix(tui): preserve existing AIMLAPI profile metadata * fix(tui): clone AIMLAPI headers in env fallback * fix(aimlapi): address remaining review findings * fix(aimlapi): harden onboarding lifecycle * fix(aimlapi): preserve validated checkout context * fix(aimlapi): harden checkout retry state * fix(tui): clarify AIMLAPI configured choices * fix(aimlapi): make checkout retries idempotent * fix(aimlapi): validate checkout responses * fix(providers): reconcile aimlapi catalog after rebase --------- Co-authored-by: Lookoff123 <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]>
260 lines
9.5 KiB
Go
260 lines
9.5 KiB
Go
package aimlapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PaymentMethod selects how a partner-checkout top-up is paid for.
|
|
type PaymentMethod string
|
|
|
|
const (
|
|
PaymentMethodCard PaymentMethod = "card"
|
|
PaymentMethodCrypto PaymentMethod = "crypto"
|
|
)
|
|
|
|
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
|
|
// by the aimlapi.com backend while polling for payment.
|
|
type SessionStatus string
|
|
|
|
const (
|
|
SessionStatusPendingAuth SessionStatus = "pending_auth"
|
|
SessionStatusPendingPayment SessionStatus = "pending_payment"
|
|
SessionStatusPaid SessionStatus = "paid"
|
|
SessionStatusExchanging SessionStatus = "exchanging"
|
|
SessionStatusExchanged SessionStatus = "exchanged"
|
|
SessionStatusCancelled SessionStatus = "cancelled"
|
|
SessionStatusExpired SessionStatus = "expired"
|
|
SessionStatusFailed SessionStatus = "failed"
|
|
)
|
|
|
|
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
|
|
type AuthResult struct {
|
|
Token string `json:"token"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
|
|
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
|
|
// addresses it on later pay/exchange/poll calls.
|
|
type PartnerCheckoutSession struct {
|
|
ID string `json:"id"`
|
|
SessionToken string `json:"sessionToken"`
|
|
PartnerID string `json:"partnerId"`
|
|
PartnerName *string `json:"partnerName"`
|
|
UserID *int64 `json:"userId"`
|
|
AmountUSDMinor *int64 `json:"amountUsdMinor"`
|
|
Status SessionStatus `json:"status"`
|
|
IssuedKeyID *string `json:"issuedKeyId"`
|
|
ReturnURL *string `json:"returnUrl"`
|
|
}
|
|
|
|
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
|
|
// user opens to pay.
|
|
type PaymentSession struct {
|
|
ProviderSessionID string `json:"providerSessionId"`
|
|
PayURL string `json:"payUrl"`
|
|
}
|
|
|
|
// PayResult pairs the hosted checkout with the updated partner-checkout session
|
|
// returned when a top-up payment is initiated.
|
|
type PayResult struct {
|
|
Checkout PaymentSession `json:"checkout"`
|
|
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
|
|
}
|
|
|
|
// ExchangeResult holds the API key minted when a paid session is exchanged.
|
|
type ExchangeResult struct {
|
|
APIKey string `json:"apiKey"`
|
|
APIKeyID string `json:"apiKeyId"`
|
|
}
|
|
|
|
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
|
|
// the account (Path A / env key), instead of an email session.
|
|
type TopUpByKeyRequest struct {
|
|
SessionToken string
|
|
AmountUSDMinor int
|
|
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
|
|
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
|
|
SuccessURL string
|
|
CancelURL string
|
|
}
|
|
|
|
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
|
|
type TopUpByKeyResult struct {
|
|
Checkout PaymentSession `json:"checkout"`
|
|
}
|
|
|
|
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
|
|
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
|
|
type APIError struct {
|
|
Message string
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
// Error renders the method, endpoint, status, and (when present) the response body.
|
|
func (e APIError) Error() string {
|
|
if strings.TrimSpace(e.Body) != "" {
|
|
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
|
|
}
|
|
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
|
|
}
|
|
|
|
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
|
|
// and partner-checkout top-up flows.
|
|
type Client struct {
|
|
endpoints Endpoints
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: 60 * time.Second}
|
|
}
|
|
return &Client{endpoints: endpoints, httpClient: httpClient}
|
|
}
|
|
|
|
// CreateSession opens a partner-checkout session attributed to partnerID; the
|
|
// returned SessionToken addresses it on the pay/exchange/poll calls.
|
|
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
|
|
body := map[string]any{"partnerId": partnerID}
|
|
if strings.TrimSpace(partnerName) != "" {
|
|
body["partnerName"] = strings.TrimSpace(partnerName)
|
|
}
|
|
if strings.TrimSpace(returnURL) != "" {
|
|
body["returnUrl"] = strings.TrimSpace(returnURL)
|
|
}
|
|
var result PartnerCheckoutSession
|
|
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
|
|
return result, err
|
|
}
|
|
|
|
// GetSession fetches the current state of a partner-checkout session; used to poll
|
|
// for payment completion.
|
|
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
|
|
var result PartnerCheckoutSession
|
|
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
|
|
return result, err
|
|
}
|
|
|
|
// Pay initiates payment for a session and returns the hosted checkout URL.
|
|
// autoTopUp enrolls the account in automatic top-up at checkout time.
|
|
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
|
|
body := map[string]any{
|
|
"amountUsdMinor": amountUSDMinor,
|
|
"paymentSessionId": paymentSessionID,
|
|
"method": method,
|
|
}
|
|
if strings.TrimSpace(successURL) != "" {
|
|
body["successUrl"] = strings.TrimSpace(successURL)
|
|
}
|
|
if strings.TrimSpace(cancelURL) != "" {
|
|
body["cancelUrl"] = strings.TrimSpace(cancelURL)
|
|
}
|
|
// Only sent when enabled: enrolls the account in automatic top-up. The backend
|
|
// honours this field at checkout time.
|
|
if autoTopUp {
|
|
body["autoTopUp"] = true
|
|
}
|
|
var result PayResult
|
|
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
|
|
return result, err
|
|
}
|
|
|
|
// Exchange converts a paid session into a freshly minted API key (new-account flow).
|
|
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
|
|
var result ExchangeResult
|
|
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
|
|
return result, err
|
|
}
|
|
|
|
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
|
|
// account (Path A / env key), returning the hosted checkout. It binds the session
|
|
// to the key's account server-side, so no email session is needed and no key is
|
|
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
|
|
// checkout, never a second charge).
|
|
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
|
|
body := map[string]any{
|
|
"sessionToken": req.SessionToken,
|
|
"amountUsdMinor": req.AmountUSDMinor,
|
|
"paymentSessionId": req.PaymentSessionID,
|
|
}
|
|
if req.AutoTopUp {
|
|
body["autoTopUp"] = true
|
|
}
|
|
if strings.TrimSpace(req.SuccessURL) != "" {
|
|
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
|
|
}
|
|
if strings.TrimSpace(req.CancelURL) != "" {
|
|
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
|
|
}
|
|
var result TopUpByKeyResult
|
|
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
|
|
return result, err
|
|
}
|
|
|
|
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
|
|
// endpoint lives next to the balance endpoint on the API gateway, one version up
|
|
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
|
|
// host/prefix an override supplied.
|
|
func (c *Client) billingV2URL(path string) string {
|
|
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
|
|
base = strings.TrimSuffix(base, "/v1")
|
|
return strings.TrimRight(base, "/") + "/v2" + path
|
|
}
|
|
|
|
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(data)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if strings.TrimSpace(bearer) != "" {
|
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
|
|
}
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
|
|
}
|
|
defer response.Body.Close()
|
|
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if len(bytes.TrimSpace(text)) == 0 {
|
|
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
|
|
}
|
|
if err := json.Unmarshal(text, out); err != nil {
|
|
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func urlPathEscape(value string) string {
|
|
return url.PathEscape(value)
|
|
}
|