mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -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]>
187 lines
6.4 KiB
Go
187 lines
6.4 KiB
Go
package aimlapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
|
|
type Status string
|
|
|
|
const (
|
|
StatusCreatingSession Status = "creating-session"
|
|
StatusOpeningCheckout Status = "opening-checkout"
|
|
StatusWaitingPayment Status = "waiting-payment"
|
|
StatusProvisioningKey Status = "provisioning-key"
|
|
)
|
|
|
|
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
|
|
// the inference base URL and model to write into the provider profile.
|
|
type ProvisionedKey struct {
|
|
APIKey string
|
|
APIKeyID string
|
|
BaseURL string
|
|
Model string
|
|
}
|
|
|
|
const (
|
|
pollInterval = 3 * time.Second
|
|
pollTimeout = 20 * time.Minute
|
|
)
|
|
|
|
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
|
|
// min/max top-up bounds. An empty value yields the default amount.
|
|
func ParseAmountUSD(value string) (int, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return DefaultAmountUSDMinor, nil
|
|
}
|
|
dollars, err := strconv.ParseFloat(value, 64)
|
|
// NaN/±Inf parse without error and slip past the min/max comparisons below
|
|
// (every ordered comparison with NaN is false), so reject them explicitly
|
|
// before the cents conversion turns them into a garbage minor-unit amount.
|
|
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
|
|
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
|
|
}
|
|
minor := int(dollars*100 + 0.5)
|
|
if minor < MinAmountUSDMinor {
|
|
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
|
|
}
|
|
if minor > MaxAmountUSDMinor {
|
|
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
|
|
}
|
|
return minor, nil
|
|
}
|
|
|
|
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
|
|
// The caller generates it once per top-up intent and reuses it on retry so the
|
|
// backend (and Stripe) coalesce retries onto the same checkout instead of a
|
|
// second charge.
|
|
func NewPaymentSessionID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
|
}
|
|
|
|
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
|
|
deadline := time.Now().Add(pollTimeout)
|
|
for time.Now().Before(deadline) {
|
|
session, err := client.GetSession(ctx, sessionToken)
|
|
if err != nil {
|
|
// Context cancellation/deadline is terminal (also covers an in-flight
|
|
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return PartnerCheckoutSession{}, err
|
|
}
|
|
// A 4xx API response is terminal. Everything else — a 5xx or a transient
|
|
// transport error (non-APIError, e.g. a dropped connection during the
|
|
// wait) — is retried until the poll deadline.
|
|
if isTerminalSessionAPIError(err) {
|
|
reportSession(onSession, "")
|
|
return PartnerCheckoutSession{}, err
|
|
}
|
|
if err := sleepContext(ctx, pollInterval); err != nil {
|
|
return PartnerCheckoutSession{}, err
|
|
}
|
|
continue
|
|
}
|
|
switch session.Status {
|
|
case SessionStatusPaid, SessionStatusExchanging:
|
|
return session, nil
|
|
case SessionStatusExchanged:
|
|
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
|
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
|
// The checkout cannot recover from these states. Drop the retained token
|
|
// (and the TUI's paired by-key idempotency ID) now so the very next retry
|
|
// creates a fresh payment intent.
|
|
reportSession(onSession, "")
|
|
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
|
default:
|
|
if err := sleepContext(ctx, pollInterval); err != nil {
|
|
return PartnerCheckoutSession{}, err
|
|
}
|
|
}
|
|
}
|
|
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
|
|
}
|
|
|
|
func isTerminalSessionAPIError(err error) bool {
|
|
var apiErr APIError
|
|
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
|
|
}
|
|
|
|
// pollUntilExchangeSettled follows an exchange claim that was already in flight
|
|
// when a retry resumed the session. Exchange is single-flight and the raw key is
|
|
// returned only to the winning request, so issuing Exchange again is never safe.
|
|
// Once the claim completes, direct the user to the dashboard recovery path.
|
|
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
|
|
deadline := time.Now().Add(pollTimeout)
|
|
for time.Now().Before(deadline) {
|
|
session, err := client.GetSession(ctx, sessionToken)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return err
|
|
}
|
|
if isTerminalSessionAPIError(err) {
|
|
reportSession(onSession, "")
|
|
return err
|
|
}
|
|
if err := sleepContext(ctx, pollInterval); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
switch session.Status {
|
|
case SessionStatusExchanged:
|
|
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
|
case SessionStatusExchanging:
|
|
if err := sleepContext(ctx, pollInterval); err != nil {
|
|
return err
|
|
}
|
|
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
|
reportSession(onSession, "")
|
|
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
|
|
default:
|
|
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
|
|
}
|
|
}
|
|
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
|
|
}
|
|
|
|
func sleepContext(ctx context.Context, delay time.Duration) error {
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func status(callback func(Status, string), value Status, detail string) {
|
|
if callback != nil {
|
|
callback(value, detail)
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|