Files
zero/internal/tui/aimlapi_onboard.go
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* 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]>
2026-07-17 12:49:38 +08:00

1180 lines
40 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/redaction"
)
// aimlapiOnboardState is the shared, event-driven aimlapi.com onboarding sub-flow
// (the spec's Paths A/B: existing key or email) embedded by BOTH TUI surfaces: the first-run setup
// (onboarding.go) and the /provider wizard (provider_wizard.go). It owns all of
// its own state and transitions; the host surface only routes key + async
// messages into it and, on a terminal outcome, reads result{APIKey, Model}.
//
// It is always held behind a pointer so async command results mutate the same
// object regardless of whether the host model is copied by value (setup) or
// pointer (providerWizard).
type aimlapiOnboardState struct {
step aimlapiOnboardStep
gen int // bumped on each user-initiated async op; stale results are dropped
pathCursor int
lowCursor int
apiKey string // Path A input, and the key that ends up saved
email string
code string
amount string
model string
baseURL string // resolved inference endpoint written into the saved profile
autoTopUp bool // amount screen "auto top up" toggle; defaults to on
amountField int // amount screen focus: 0 = amount input, 1 = auto top-up toggle
sessionToken string // acquired via email code / passwordless; enables top-up
newAccount bool // Path B sign-up: exchange the paid session into a key
busy bool
detail string // checkout / verification URL
errText string
successLines []string // shown on the terminal "done" screen
topupCh chan aimlapiTopupEvent
topupCancel context.CancelFunc
// resumeSessionToken is the partner-checkout session token of the last top-up
// attempt. It is retained across a failure so a retry resumes that session
// (recovering a paid-but-unexchanged key, never re-charging) instead of opening
// a second checkout; cleared once the session is dead or the top-up succeeds.
resumeSessionToken string
// byKey routes the top-up through the API-key-bound flow (Path A pasted key /
// AIMLAPI_API_KEY) instead of the email session: the top-up funds the account
// that owns apiKey, with no exchange. paymentSessionID is that flow's idempotency
// handle, generated once and reused on retry so a re-issued pay never double-charges.
byKey bool
paymentSessionID string
// payment intent attributes bind resumable checkout state to the normalized
// amount and auto-top-up selection that created it. Changing either starts a
// fresh intent instead of silently reusing the old checkout.
paymentAmountUSDMinor int
paymentAutoTopUp bool
// opCancel cancels the single in-flight account/key request (balance, check,
// code, passwordless, key-mint). Only one runs at a time (input is gated while
// busy); it is cancelled when the request completes or the flow is abandoned.
opCancel context.CancelFunc
noOpen bool
openBrowser func(string) error
}
type aimlapiOnboardStep int
const (
aimlapiStepPickPath aimlapiOnboardStep = iota
aimlapiStepKeyInput
aimlapiStepEmailInput
aimlapiStepCodeInput
aimlapiStepLowBalance
aimlapiStepAmountInput
aimlapiStepProgress
aimlapiStepDone
)
// aimlapiOutcome tells the host surface what to do after handling a key/message.
type aimlapiOutcome int
const (
aimlapiContinue aimlapiOutcome = iota // stay in the sub-flow
aimlapiDone // key acquired: read result and finalize
aimlapiCancel // back out to the host's provider picker
)
func newAimlapiOnboard(openBrowser func(string) error) *aimlapiOnboardState {
return &aimlapiOnboardState{
step: aimlapiStepPickPath,
amount: "25",
autoTopUp: true, // default the "auto top up" toggle to on (matches the mockup)
// Seed the base URL from the resolved endpoints so a staging / custom
// AIMLAPI_INFERENCE_URL override is carried into the saved profile for every
// path (pasted key or top-up), not silently replaced by the catalog default.
baseURL: aimlapi.ResolveEndpoints().InferenceBaseURL,
openBrowser: openBrowser,
}
}
// ---- async messages -------------------------------------------------------
type aimlapiMsgKind int
const (
aimlapiMsgKeyValidation aimlapiMsgKind = iota // Path A: validate pasted key via balance endpoint
aimlapiMsgCheck // Path B: does the account exist?
aimlapiMsgToken // Path B: session from code / passwordless
aimlapiMsgKey // Path B existing: minted key
aimlapiMsgKeyBalance // Path B existing: balance of the minted key
aimlapiMsgTopup // shared: one streamed top-up event
)
// aimlapiOnboardMsg carries one async result back to the owning sub-model. The
// state pointer lets the host route it to the right surface; gen drops results
// from an attempt the user has since abandoned.
type aimlapiOnboardMsg struct {
state *aimlapiOnboardState
gen int
kind aimlapiMsgKind
balance aimlapi.BalanceResult
check aimlapi.CheckResult
token string
key aimlapi.CreatedKey
topup aimlapiTopupEvent
topupOK bool
err error
}
func aimlapiOnboardClient() *aimlapi.Client {
return aimlapi.NewClient(aimlapi.ResolveEndpoints(), nil)
}
// applyAimlapiOnboard routes an async aimlapi.com onboarding result to whichever
// surface (the /provider wizard or the first-run setup) owns the sub-flow. The
// state pointer identifies the owner; a result whose sub-flow was replaced is
// silently dropped.
func (m model) applyAimlapiOnboard(msg aimlapiOnboardMsg) (tea.Model, tea.Cmd) {
if msg.state == nil {
return m, nil
}
if m.providerWizard != nil && m.providerWizard.aimlapi == msg.state {
return m.applyProviderWizardAimlapiOnboard(msg)
}
if m.setup.aimlapi == msg.state {
return m.applySetupAimlapiOnboard(msg)
}
return m, nil
}
// aimlapiOnboardAnimating reports whether an aimlapi.com onboarding sub-flow (in
// first-run setup or the /provider wizard) is in a state whose spinner needs the
// shared tick loop to keep advancing: a pending async round-trip or the streamed
// top-up progress screen. It lets the tick stay alive during onboarding even
// though no agent run is in flight.
func (m model) aimlapiOnboardAnimating() bool {
if aimlapiStateAnimating(m.setup.aimlapi) {
return true
}
return m.providerWizard != nil && (m.providerWizard.aimlapiExistingBusy || aimlapiStateAnimating(m.providerWizard.aimlapi))
}
func aimlapiStateAnimating(s *aimlapiOnboardState) bool {
if s == nil {
return false
}
return s.busy || s.step == aimlapiStepProgress
}
func (s *aimlapiOnboardState) msg(kind aimlapiMsgKind) aimlapiOnboardMsg {
return aimlapiOnboardMsg{state: s, gen: s.gen, kind: kind}
}
func (s *aimlapiOnboardState) keyBalanceCmd(key string) tea.Cmd {
m := s.msg(aimlapiMsgKeyBalance)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().GetBalance(ctx, key)
m.balance, m.err = res, err
return m
}
}
// keyValidationCmd validates a pasted/env key (Path A) via GetBalance, capturing
// both the error and the balance. A low balance can now be topped up safely with
// the key itself — the by-key checkout binds the top-up to this key's own account —
// so applyKeyValidation offers the optional top-up chooser instead of discarding it.
func (s *aimlapiOnboardState) keyValidationCmd(key string) tea.Cmd {
m := s.msg(aimlapiMsgKeyValidation)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().GetBalance(ctx, key)
m.balance, m.err = res, err
return m
}
}
func (s *aimlapiOnboardState) checkAccountCmd(email string) tea.Cmd {
m := s.msg(aimlapiMsgCheck)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().CheckAccount(ctx, email)
m.check, m.err = res, err
return m
}
}
// sendCodeThenVerifyCmd sends the sign-in code (Path B existing). The verify step
// happens later once the user types the code; this only requests it.
func (s *aimlapiOnboardState) sendCodeCmd(email string) tea.Cmd {
m := s.msg(aimlapiMsgToken) // token kind reused: no session yet, err-only signal
ctx := s.opContext()
return func() tea.Msg {
err := aimlapiOnboardClient().SendSignInCode(ctx, email)
m.err = err
m.token = "" // sentinel: code sent, still need verify
return m
}
}
func (s *aimlapiOnboardState) verifyCodeCmd(email, code string) tea.Cmd {
m := s.msg(aimlapiMsgToken)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().VerifySignInCode(ctx, email, code)
m.token, m.err = res.Token, err
return m
}
}
func (s *aimlapiOnboardState) passwordlessCmd(email string) tea.Cmd {
m := s.msg(aimlapiMsgToken)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().CreatePasswordlessAccount(ctx, email)
m.token, m.err = res.Token, err
return m
}
}
func (s *aimlapiOnboardState) createKeyCmd(token string) tea.Cmd {
m := s.msg(aimlapiMsgKey)
ctx := s.opContext()
return func() tea.Msg {
res, err := aimlapiOnboardClient().CreateKey(ctx, token, "zero CLI")
m.key, m.err = res, err
return m
}
}
func startAimlapiStreamTopUp(ctx context.Context, options aimlapi.StreamTopUpOptions) chan aimlapiTopupEvent {
ch := make(chan aimlapiTopupEvent, 16)
go func() {
opts := options
opts.OnStatus = func(_ aimlapi.Status, detail string) {
ch <- aimlapiTopupEvent{detail: detail}
}
opts.OnSession = func(token string) {
ch <- aimlapiTopupEvent{session: token, hasSession: true}
}
result, err := aimlapi.StreamTopUp(ctx, opts)
ch <- aimlapiTopupEvent{done: true, result: result, err: err}
}()
return ch
}
// startAimlapiStreamTopUpByKey runs the API-key-bound top-up (Path A / env key)
// and streams the same progress events, so applyTopup handles both flows uniformly.
func startAimlapiStreamTopUpByKey(ctx context.Context, options aimlapi.StreamTopUpByKeyOptions) chan aimlapiTopupEvent {
ch := make(chan aimlapiTopupEvent, 16)
go func() {
opts := options
opts.OnStatus = func(_ aimlapi.Status, detail string) {
ch <- aimlapiTopupEvent{detail: detail}
}
opts.OnSession = func(token string) {
ch <- aimlapiTopupEvent{session: token, hasSession: true}
}
result, err := aimlapi.StreamTopUpByKey(ctx, opts)
ch <- aimlapiTopupEvent{done: true, result: result, err: err}
}()
return ch
}
func waitForAimlapiTopupEvent(s *aimlapiOnboardState, gen int, ch chan aimlapiTopupEvent) tea.Cmd {
return func() tea.Msg {
event, ok := <-ch
return aimlapiOnboardMsg{state: s, gen: gen, kind: aimlapiMsgTopup, topup: event, topupOK: ok}
}
}
// ---- key handling ---------------------------------------------------------
func (s *aimlapiOnboardState) handleKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
// Esc always abandons the sub-flow; the streamed top-up is cancelled first.
if keyIs(msg, tea.KeyEsc) {
s.cancelStream()
return nil, aimlapiCancel
}
// While an async round-trip or a streamed top-up is in flight, swallow input.
if s.busy {
return nil, aimlapiContinue
}
if s.step == aimlapiStepProgress {
return nil, aimlapiContinue
}
switch s.step {
case aimlapiStepPickPath:
return s.handlePickPathKey(msg)
case aimlapiStepKeyInput:
return s.handleKeyInputKey(msg)
case aimlapiStepEmailInput:
return s.handleEmailInputKey(msg)
case aimlapiStepCodeInput:
return s.handleCodeInputKey(msg)
case aimlapiStepLowBalance:
return s.handleLowBalanceKey(msg)
case aimlapiStepAmountInput:
return s.handleAmountInputKey(msg)
case aimlapiStepDone:
if keyIs(msg, tea.KeyLeft) {
return nil, aimlapiCancel
}
if keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight) {
return nil, aimlapiDone
}
}
return nil, aimlapiContinue
}
// handlePickPathKey drives new configuration: create an account or paste a key.
// Reusing an already configured/env credential is a /provider preflight, not a
// third onboarding identity path.
func (s *aimlapiOnboardState) handlePickPathKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
s.pathCursor = (s.pathCursor + 1) % 2
case keyIs(msg, tea.KeyLeft):
return nil, aimlapiCancel
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
s.errText = ""
if s.pathCursor == 0 {
s.step = aimlapiStepEmailInput
} else {
s.step = aimlapiStepKeyInput
}
}
return nil, aimlapiContinue
}
func (s *aimlapiOnboardState) handleKeyInputKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyLeft):
s.step = aimlapiStepPickPath
s.errText = ""
case keyBackspace(msg):
s.apiKey = trimLastRune(s.apiKey)
case keyCtrl(msg, 'u'):
s.apiKey = ""
s.errText = ""
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
if strings.TrimSpace(s.apiKey) == "" {
return nil, aimlapiContinue
}
s.startBusy()
return s.keyValidationCmd(strings.TrimSpace(s.apiKey)), aimlapiContinue
case keyText(msg) != "":
s.apiKey += keyText(msg)
s.errText = ""
}
return nil, aimlapiContinue
}
func (s *aimlapiOnboardState) handleEmailInputKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyLeft):
s.step = aimlapiStepPickPath
s.errText = ""
case keyBackspace(msg):
s.email = trimLastRune(s.email)
case keyCtrl(msg, 'u'):
s.email = ""
s.errText = ""
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
email := strings.TrimSpace(s.email)
if !aimlapiValidEmail(email) {
s.errText = aimlapi.MsgEmailInvalid
return nil, aimlapiContinue
}
s.email = email
s.startBusy()
return s.checkAccountCmd(email), aimlapiContinue
case keyText(msg) != "":
s.email += keyText(msg)
s.errText = ""
}
return nil, aimlapiContinue
}
func (s *aimlapiOnboardState) handleCodeInputKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyLeft):
s.step = aimlapiStepEmailInput
s.errText = ""
case keyBackspace(msg):
s.code = trimLastRune(s.code)
case keyCtrl(msg, 'u'):
s.code = ""
s.errText = ""
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
if strings.TrimSpace(s.code) == "" {
return nil, aimlapiContinue
}
s.startBusy()
return s.verifyCodeCmd(s.email, strings.TrimSpace(s.code)), aimlapiContinue
case keyText(msg) != "":
s.code += keyText(msg)
s.errText = ""
}
return nil, aimlapiContinue
}
func (s *aimlapiOnboardState) handleLowBalanceKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
s.lowCursor = (s.lowCursor + 1) % 2
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
if s.lowCursor == 1 { // skip topping up
return s.finishEverythingRuns(), aimlapiContinue
}
// Top up now, against the account that owns the key: Path B existing (email
// session) or Path A (byKey, bound to the pasted/env key). startTopUp routes
// to the right flow.
s.step = aimlapiStepAmountInput
s.errText = ""
}
return nil, aimlapiContinue
}
// handleAmountInputKey drives the "Add credits" screen, which has two focusable
// fields: the dollar amount and the "auto top up" yes/no toggle. Tab/↑/↓ move
// focus between them; Enter continues from either. On the amount field ← is
// ignored so the top-up selection cannot retreat into an invalid previous screen;
// digits edit the value. On the toggle field ←/→/Space flip it.
func (s *aimlapiOnboardState) handleAmountInputKey(msg tea.KeyMsg) (tea.Cmd, aimlapiOutcome) {
switch {
case keyIs(msg, tea.KeyLeft) && s.amountField == 0:
return nil, aimlapiContinue
case keyIs(msg, tea.KeyTab) || keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown):
s.amountField = (s.amountField + 1) % 2
return nil, aimlapiContinue
case keyIs(msg, tea.KeyEnter):
// A valid amount goes straight to the Stripe (card) checkout. startTopUp
// re-validates and re-prompts on a bad amount.
return s.startTopUp()
}
if s.amountField == 1 {
// Auto top-up toggle focused: either horizontal arrow or Space flips yes↔no.
if keyIs(msg, tea.KeyLeft) || keyIs(msg, tea.KeyRight) || keyText(msg) == " " {
s.autoTopUp = !s.autoTopUp
s.errText = ""
}
return nil, aimlapiContinue
}
// Amount field focused.
switch {
case keyIs(msg, tea.KeyRight):
return s.startTopUp()
case keyBackspace(msg):
s.amount = trimLastRune(s.amount)
case keyCtrl(msg, 'u'):
s.amount = ""
s.errText = ""
case keyText(msg) != "":
s.amount += keyText(msg)
s.errText = ""
}
return nil, aimlapiContinue
}
// appendInput folds pasted text into the sub-flow's currently focused field.
func (s *aimlapiOnboardState) appendInput(content string) {
if s.busy {
return
}
content = strings.TrimRight(content, "\r\n")
switch s.step {
case aimlapiStepKeyInput:
s.apiKey += content
case aimlapiStepEmailInput:
s.email += content
case aimlapiStepCodeInput:
s.code += content
case aimlapiStepAmountInput:
s.amount += content
}
}
// ---- async application ----------------------------------------------------
func (s *aimlapiOnboardState) apply(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
if msg.gen != s.gen {
return nil, aimlapiContinue // stale: user moved on
}
switch msg.kind {
case aimlapiMsgKeyValidation:
return s.applyKeyValidation(msg)
case aimlapiMsgKeyBalance:
return s.applyKeyBalance(msg)
case aimlapiMsgCheck:
return s.applyCheck(msg)
case aimlapiMsgToken:
return s.applyToken(msg)
case aimlapiMsgKey:
return s.applyKey(msg)
case aimlapiMsgTopup:
return s.applyTopup(msg)
}
return nil, aimlapiContinue
}
func (s *aimlapiOnboardState) applyKeyValidation(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
s.stopBusy()
if msg.err != nil {
if aimlapiIsUnauthorized(msg.err) {
s.apiKey = ""
s.errText = aimlapi.MsgAPIKeyInvalid
s.step = aimlapiStepKeyInput
return nil, aimlapiContinue
}
s.errText = s.safeErrorMessage(msg.err)
s.step = aimlapiStepKeyInput
return nil, aimlapiContinue
}
s.apiKey = strings.TrimSpace(s.apiKey)
// A valid key with a low balance can fund its own account via the by-key
// checkout (bound to this key), so offer the optional top-up chooser.
if msg.balance.LowBalance {
s.byKey = true
s.lowCursor = 0
s.step = aimlapiStepLowBalance
return nil, aimlapiContinue
}
return s.finishEverythingRuns(), aimlapiContinue
}
func (s *aimlapiOnboardState) applyKeyBalance(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
s.stopBusy()
if msg.err != nil {
// The key is already minted; a balance read failure shouldn't block it.
return s.finishEverythingRuns(), aimlapiContinue
}
if msg.balance.LowBalance {
s.lowCursor = 0
s.step = aimlapiStepLowBalance
return nil, aimlapiContinue
}
return s.finishEverythingRuns(), aimlapiContinue
}
func (s *aimlapiOnboardState) applyCheck(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
s.stopBusy()
if msg.err != nil {
s.errText = s.safeErrorMessage(msg.err)
s.step = aimlapiStepEmailInput
return nil, aimlapiContinue
}
switch msg.check.Action {
case "sign-in":
s.newAccount = false
s.startBusy()
return s.sendCodeCmd(s.email), aimlapiContinue
case "sign-up":
// No account yet: passwordless sign-up, then fund via top-up (min $20).
s.newAccount = true
s.startBusy()
return s.passwordlessCmd(s.email), aimlapiContinue
default:
s.newAccount = false
s.errText = aimlapi.MsgAccountActionInvalid
s.step = aimlapiStepEmailInput
return nil, aimlapiContinue
}
}
func (s *aimlapiOnboardState) applyToken(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
wasVerifying := s.step == aimlapiStepCodeInput
s.stopBusy()
if msg.err != nil {
if wasVerifying {
// Stay on the code screen so the user can retry, but only claim the
// code was wrong for the backend's actual rejection. A timeout, 429,
// 5xx, or dropped connection keeps the code they typed valid, so surface
// the real error instead of sending them to re-enter a good code.
if aimlapiIsInvalidCode(msg.err) {
s.errText = aimlapi.MsgCodeIncorrect
} else {
s.errText = s.safeErrorMessage(msg.err)
}
s.step = aimlapiStepCodeInput
return nil, aimlapiContinue
}
// send-code or passwordless sign-up failed — back to the email prompt.
s.errText = s.safeErrorMessage(msg.err)
s.step = aimlapiStepEmailInput
return nil, aimlapiContinue
}
if msg.token == "" {
// Sentinel from sendCodeCmd: the code was sent, collect it now.
s.step = aimlapiStepCodeInput
s.code = ""
s.errText = ""
return nil, aimlapiContinue
}
// We hold a session now.
s.sessionToken = msg.token
if s.newAccount {
// New account: fund it (top-up will exchange for the key).
s.step = aimlapiStepAmountInput
s.errText = ""
return nil, aimlapiContinue
}
// Path B existing: mint a persistable key from the session.
s.startBusy()
return s.createKeyCmd(s.sessionToken), aimlapiContinue
}
func (s *aimlapiOnboardState) applyKey(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
s.stopBusy()
if msg.err != nil {
s.errText = s.safeErrorMessage(msg.err)
s.step = aimlapiStepEmailInput
return nil, aimlapiContinue
}
s.apiKey = msg.key.Key
s.startBusy()
return s.keyBalanceCmd(s.apiKey), aimlapiContinue
}
func (s *aimlapiOnboardState) applyTopup(msg aimlapiOnboardMsg) (tea.Cmd, aimlapiOutcome) {
if !msg.topupOK {
return nil, aimlapiContinue
}
event := msg.topup
if event.hasSession {
// Retain (or, on "", drop) the live partner-checkout token so a retry
// resumes this session rather than opening a second checkout.
s.resumeSessionToken = event.session
if strings.TrimSpace(event.session) == "" {
// An empty token marks a terminal checkout. Its by-key idempotency ID
// belongs to that dead session too; reusing it would return or reject
// the old checkout instead of allowing a fresh payment attempt.
s.paymentSessionID = ""
}
}
if !event.done {
if strings.TrimSpace(event.detail) != "" {
s.detail = event.detail
}
return waitForAimlapiTopupEvent(s, s.gen, s.topupCh), aimlapiContinue
}
s.cancelStream()
if event.err != nil {
// Keep resumeSessionToken: the next startTopUp resumes this session (poll a
// pending payment / recover a paid-but-unexchanged key) instead of charging
// again.
s.errText = aimlapi.MsgTopUpFailed
s.step = aimlapiStepAmountInput
s.amountField = 0
return nil, aimlapiContinue
}
// Top-up completed; nothing left to resume or re-fund idempotently.
s.resumeSessionToken = ""
s.paymentSessionID = ""
if s.newAccount {
s.apiKey = event.result.APIKey
}
if strings.TrimSpace(event.result.Model) != "" {
s.model = event.result.Model
}
if strings.TrimSpace(event.result.BaseURL) != "" {
s.baseURL = event.result.BaseURL
}
s.successLines = s.topUpSuccessLines()
s.step = aimlapiStepDone
return nil, aimlapiContinue
}
// ---- transitions & helpers ------------------------------------------------
func (s *aimlapiOnboardState) startTopUp() (tea.Cmd, aimlapiOutcome) {
if strings.TrimSpace(s.amount) == "" {
// An empty field would otherwise fall through to the $25 default inside
// ParseAmountUSD and silently start a checkout the user never typed.
s.errText = aimlapi.MsgAmountRequired
s.step = aimlapiStepAmountInput
s.amountField = 0
return nil, aimlapiContinue
}
amountUSDMinor, err := aimlapi.ParseAmountUSD(s.amount)
if err != nil {
// Surface the actual validation error (below-min / above-max / non-numeric),
// the same way this sub-flow renders its other errors, instead of a single
// catch-all "too low".
s.errText = redaction.ErrorMessage(err, redaction.Options{})
s.step = aimlapiStepAmountInput
s.amountField = 0
return nil, aimlapiContinue
}
s.prepareTopUpIntent(amountUSDMinor)
normalizedAmount := formatUSDMinor(amountUSDMinor)
s.gen++
gen := s.gen
s.detail = ""
s.errText = ""
s.step = aimlapiStepProgress
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute)
s.topupCancel = cancel
// Both checkout paths use the same intent id. Retain it across ambiguous
// failures, and replace it only when the amount/auto-top-up intent changes or
// the backend reports that the prior session is dead.
if strings.TrimSpace(s.paymentSessionID) == "" {
id, err := aimlapi.NewPaymentSessionID()
if err != nil {
cancel()
s.errText = aimlapi.MsgTopUpFailed
s.step = aimlapiStepAmountInput
s.amountField = 0
return nil, aimlapiContinue
}
s.paymentSessionID = id
}
if s.byKey {
// Path A / env key: fund the account bound to apiKey.
s.topupCh = startAimlapiStreamTopUpByKey(ctx, aimlapi.StreamTopUpByKeyOptions{
APIKey: s.apiKey,
AmountUSD: normalizedAmount,
InferenceBaseURL: s.baseURL,
AutoTopUp: s.autoTopUp,
ResumeSessionToken: s.resumeSessionToken,
PaymentSessionID: s.paymentSessionID,
OpenBrowser: s.openBrowser,
NoOpen: s.noOpen,
})
} else {
s.topupCh = startAimlapiStreamTopUp(ctx, aimlapi.StreamTopUpOptions{
SessionToken: s.sessionToken,
ResumeSessionToken: s.resumeSessionToken,
PaymentSessionID: s.paymentSessionID,
AmountUSD: normalizedAmount,
InferenceBaseURL: s.baseURL,
Method: aimlapi.PaymentMethodCard,
AutoTopUp: s.autoTopUp,
Exchange: s.newAccount,
OpenBrowser: s.openBrowser,
NoOpen: s.noOpen,
})
}
return waitForAimlapiTopupEvent(s, gen, s.topupCh), aimlapiContinue
}
func (s *aimlapiOnboardState) prepareTopUpIntent(amountUSDMinor int) {
if s.paymentAmountUSDMinor != 0 &&
(s.paymentAmountUSDMinor != amountUSDMinor || s.paymentAutoTopUp != s.autoTopUp) {
s.resumeSessionToken = ""
s.paymentSessionID = ""
}
s.paymentAmountUSDMinor = amountUSDMinor
s.paymentAutoTopUp = s.autoTopUp
}
func formatUSDMinor(amount int) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}
// finishEverythingRuns lands on the terminal "Everything is ready" screen with the
// key already in hand (Path A ok/skip, or Path B existing after balance/top-up).
func (s *aimlapiOnboardState) finishEverythingRuns() tea.Cmd {
s.successLines = []string{aimlapi.MsgEverythingRuns}
s.step = aimlapiStepDone
return nil
}
// safeErrorMessage strips server-controlled API response bodies before display
// and redacts every credential that can be active in the onboarding state. This
// keeps echoed keys, verification codes, and session bearers out of terminal
// scrollback while retaining the operation and HTTP status for diagnosis.
func (s *aimlapiOnboardState) safeErrorMessage(err error) string {
var apiErr aimlapi.APIError
if errors.As(err, &apiErr) {
err = aimlapi.APIError{Message: apiErr.Message, Status: apiErr.Status}
}
return redaction.ErrorMessage(err, redaction.Options{ExtraSecretValues: []string{
s.apiKey,
s.email,
s.code,
s.sessionToken,
}})
}
// topUpSuccessLines builds the terminal "done" body. For a new account the
// magic-link note follows the balance line after a blank spacer row; an existing
// key just gets the top-up confirmation.
func (s *aimlapiOnboardState) topUpSuccessLines() []string {
amount := fmt.Sprintf(aimlapi.MsgTopUpAddedFmt, formatUSDMinor(s.paymentAmountUSDMinor))
if s.newAccount {
return []string{
aimlapi.MsgTopUpSuccess,
amount,
"",
fmt.Sprintf(aimlapi.MsgSuccessMagicLink, s.email),
}
}
return []string{
aimlapi.MsgTopUpSuccess,
amount,
}
}
func (s *aimlapiOnboardState) startBusy() {
s.gen++
s.busy = true
s.errText = ""
}
func (s *aimlapiOnboardState) stopBusy() {
s.busy = false
// The result has arrived; release the request's context.
s.cancelPendingOp()
}
// opContext returns a cancellable context for the next account/key request and
// records its cancel so an abandoned flow (Esc) or the next request can stop the
// in-flight one. Only one request runs at a time, so replacing the prior cancel
// is safe.
func (s *aimlapiOnboardState) opContext() context.Context {
s.cancelPendingOp()
ctx, cancel := context.WithCancel(context.Background())
s.opCancel = cancel
return ctx
}
func (s *aimlapiOnboardState) cancelPendingOp() {
if s.opCancel != nil {
s.opCancel()
s.opCancel = nil
}
}
func (s *aimlapiOnboardState) cancelStream() {
if s.topupCancel != nil {
s.topupCancel()
s.topupCancel = nil
}
s.topupCh = nil
// Also stop any pending account/key request the user is abandoning.
s.cancelPendingOp()
s.gen++ // drop any late stream/poll events
}
func aimlapiValidEmail(value string) bool {
return aimlapi.ValidEmail(value)
}
func aimlapiIsUnauthorized(err error) bool {
var apiErr aimlapi.APIError
return errors.As(err, &apiErr) &&
(apiErr.Status == http.StatusUnauthorized || apiErr.Status == http.StatusForbidden)
}
// aimlapiIsInvalidCode reports whether err is the backend rejecting the sign-in
// code itself. The backend contract uses 400 for "Invalid or expired code";
// every other status (including auth, throttling and service failures) must be
// surfaced as itself instead of being mislabeled as user input.
func aimlapiIsInvalidCode(err error) bool {
var apiErr aimlapi.APIError
return errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest
}
// ---- rendering ------------------------------------------------------------
// aimlapiBlockWidth is the centered-block width for the aimlapi.com onboarding
// screens. It mirrors the other first-run setup stages (setupMethodBlockWidth et
// al.) so the sub-flow renders as one centered column in the same visual system
// instead of a left-aligned outlier — never wider than the surface it draws on.
func aimlapiBlockWidth(width int) int {
if width <= 0 {
width = 64
}
return maxInt(24, minInt(width, 64))
}
// view renders the current sub-flow screen. spinner is the host model's live
// liveness glyph (m.spinnerGlyph()); it drives the animated spinner that stands
// in for the streamed top-up's status lines and for every in-flight async wait.
func (s *aimlapiOnboardState) view(width int, spinner string) []string {
rowWidth := aimlapiBlockWidth(width)
var lines []string
switch s.step {
case aimlapiStepPickPath:
lines = s.viewPickPath(width)
case aimlapiStepKeyInput:
lines = aimlapiInputScreen(rowWidth, aimlapi.MsgAPIKeyInputPrompt,
"api key > ", maskedProviderWizardKey(s.apiKey), "paste your key",
"Your API key will be hidden and verified automatically.")
case aimlapiStepEmailInput:
lines = aimlapiInputScreen(rowWidth, aimlapi.MsgEnterEmail,
"email > ", strings.TrimSpace(s.email), "[email protected]", "")
case aimlapiStepCodeInput:
lines = aimlapiInputScreen(rowWidth, fmt.Sprintf(aimlapi.MsgCodeSent, s.email),
"code > ", strings.TrimSpace(s.code), "6-digit code", "")
case aimlapiStepLowBalance:
lines = s.viewLowBalance(width)
case aimlapiStepAmountInput:
lines = s.viewAmount(width)
case aimlapiStepProgress:
lines = s.viewProgress(width, spinner)
case aimlapiStepDone:
lines = s.viewDone(width)
}
// A pending async round-trip shows a spinner (not a status line) beneath the
// current screen, so the wait always reads as live motion.
if s.busy {
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiSpinnerLine(spinner, rowWidth))
}
if s.errText != "" {
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiBlockText(s.errText, zeroTheme.red.Render, rowWidth)...)
}
return lines
}
// aimlapiSpinnerLine renders the animated liveness glyph as one block row at the
// shared content indent, in accent colour. It replaces the sub-flow's textual
// status lines: the glyph advances on every spinner tick (the host keeps the tick
// loop alive while the sub-flow is busy or on the progress screen), so it never
// reads as a frozen screen. Under reduced motion the host passes a steady dot.
func aimlapiSpinnerLine(spinner string, width int) string {
glyph := strings.TrimSpace(spinner)
if glyph == "" {
glyph = "•"
}
return padSetupLine(" "+zeroTheme.accent.Render(glyph), width)
}
// aimlapiBlockText renders a plain-text message as one or more block rows: the
// text is word-wrapped to the block width (so long copy is never cut off with an
// ellipsis), given the shared two-space content indent, styled, and padded to
// width so every row shares the block's left edge as one centered column.
func aimlapiBlockText(text string, render func(...string) string, width int) []string {
segments := wrapPlainText(text, maxInt(1, width-2))
if len(segments) == 0 {
segments = []string{""}
}
lines := make([]string, 0, len(segments))
for _, segment := range segments {
lines = append(lines, padSetupLine(" "+render(segment), width))
}
return lines
}
// aimlapiPromptLines renders the first line as the primary instruction and any
// following lines as faint supporting copy.
func aimlapiPromptLines(prompt string, width int) []string {
lines := []string{}
for index, promptLine := range strings.Split(prompt, "\n") {
render := zeroTheme.ink.Render
if index > 0 {
render = zeroTheme.faint.Render
}
lines = append(lines, aimlapiBlockText(promptLine, render, width)...)
}
return lines
}
// aimlapiInputScreen renders one of the sub-flow's single-field prompts as a
// centered block with an optional faint footnote.
func aimlapiInputScreen(width int, prompt, inputPrompt, value, placeholder, footnote string) []string {
lines := []string{}
// Input prompts use their first line as the instruction and any following
// lines as supporting copy. Render that supporting copy faint so messages such
// as "To access aimlapi.com dashboard" read as subtitles, not another heading.
lines = append(lines, aimlapiPromptLines(prompt, width)...)
lines = append(lines, blankSetupBlockLine(width))
lines = append(lines, padSetupLine(" "+providerWizardInputLine(inputPrompt, value, placeholder, maxInt(1, width-2)), width))
if footnote != "" {
lines = append(lines, aimlapiBlockText(footnote, zeroTheme.faint.Render, width)...)
}
return lines
}
// aimlapiOptionRows renders one selectable option as a marker+label line with an
// optional faint subtitle beneath it, block-padded to width so the picker centers
// as one column. The selected row is marked with ❯ and drawn in bold accent — the
// same treatment the other setup stages use (setupMethodLines).
func aimlapiOptionRows(label, hint string, selected bool, width int) []string {
marker := " "
style := zeroTheme.ink
if selected {
marker = "❯ "
style = zeroTheme.accent.Bold(true)
}
rows := []string{padSetupLine(marker+style.Render(label), width)}
if hint != "" {
rows = append(rows, padSetupLine(" "+zeroTheme.faint.Render(hint), width))
}
return rows
}
func (s *aimlapiOnboardState) viewPickPath(width int) []string {
rowWidth := aimlapiBlockWidth(width)
options := []struct{ label, hint string }{
{aimlapi.MsgPickPathNewUser, aimlapi.MsgPickPathNewHint},
{aimlapi.MsgPickPathHaveKey, aimlapi.MsgPickPathHaveHint},
}
lines := []string{}
lines = append(lines, aimlapiBlockText(aimlapi.MsgPickPathPrompt, zeroTheme.ink.Render, rowWidth)...)
lines = append(lines, blankSetupBlockLine(rowWidth))
for index, option := range options {
lines = append(lines, aimlapiOptionRows(option.label, option.hint, index == s.pathCursor, rowWidth)...)
}
return lines
}
func (s *aimlapiOnboardState) viewLowBalance(width int) []string {
rowWidth := aimlapiBlockWidth(width)
lines := []string{}
lines = append(lines, aimlapiPromptLines(aimlapi.MsgLowBalance, rowWidth)...)
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiOptionRows(aimlapi.MsgLowBalanceTopUp, "", s.lowCursor == 0, rowWidth)...)
lines = append(lines, aimlapiOptionRows(aimlapi.MsgLowBalanceSkip, "", s.lowCursor == 1, rowWidth)...)
return lines
}
// viewAmount renders the "Add credits" screen: the dollar amount input and the
// "auto top up" on/off toggle beneath it. The focused field carries the ❯ marker
// (the same idiom as the pick-path / low-balance pickers).
func (s *aimlapiOnboardState) viewAmount(width int) []string {
rowWidth := aimlapiBlockWidth(width)
fieldWidth := maxInt(1, rowWidth-4)
lines := []string{}
lines = append(lines, aimlapiPromptLines(aimlapi.MsgTopUpPrompt, rowWidth)...)
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines,
padSetupLine(" "+aimlapiFieldMarker(s.amountField == 0)+
aimlapiAmountInputLine(strings.TrimSpace(s.amount), "25", fieldWidth, s.amountField == 0), rowWidth),
blankSetupBlockLine(rowWidth),
padSetupLine(" "+aimlapiFieldMarker(s.amountField == 1)+
aimlapiToggleLine("auto top up > ", s.autoTopUp, s.amountField == 1), rowWidth),
)
return lines
}
// aimlapiFieldMarker returns the fixed-width focus marker for a form field: a bold
// accent ❯ when selected, two spaces otherwise, so switching focus never shifts
// the surrounding columns.
func aimlapiFieldMarker(selected bool) string {
if selected {
return zeroTheme.accent.Bold(true).Render("❯") + " "
}
return " "
}
// aimlapiAmountInputLine highlights only the prompt and cursor while this field
// has focus. The dollar sign and entered amount remain white in either state.
func aimlapiAmountInputLine(value string, placeholder string, width int, focused bool) string {
promptStyle := zeroTheme.ink
if focused {
promptStyle = zeroTheme.accent
}
prompt := promptStyle.Render("amount > ") + zeroTheme.ink.Render("$")
cursor := promptStyle.Render("▌")
if value == "" {
return fitStyledLine(prompt+cursor+zeroTheme.faint.Render(placeholder), width)
}
return fitStyledLine(prompt+zeroTheme.ink.Render(value)+cursor, width)
}
// aimlapiToggleLine highlights only the prompt on focus. The selected choice is
// white; the other uses the same faint style as supporting copy.
func aimlapiToggleLine(prompt string, on bool, focused bool) string {
promptStyle := zeroTheme.ink
if focused {
promptStyle = zeroTheme.accent
}
onText, offText := zeroTheme.faint.Render("on"), zeroTheme.faint.Render("off")
if on {
onText = zeroTheme.ink.Render("on")
} else {
offText = zeroTheme.ink.Render("off")
}
return promptStyle.Render(prompt) + onText + zeroTheme.ink.Render("/") + offText
}
// viewProgress is the streamed top-up screen. Per the design it is a spinner only
// — the per-step status lines (registering / signing in / creating session /
// waiting for payment / issuing key) are gone. The direct checkout link is still
// shown once it arrives, since the user must open it to pay.
func (s *aimlapiOnboardState) viewProgress(width int, spinner string) []string {
rowWidth := aimlapiBlockWidth(width)
lines := []string{}
if link := strings.TrimSpace(s.detail); link != "" {
lines = append(lines, aimlapiBlockText("Opening checkout in browser...", zeroTheme.ink.Render, rowWidth)...)
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiBlockText(fmt.Sprintf(aimlapi.MsgTopUpBrowserFallback, ""), zeroTheme.faint.Render, rowWidth)...)
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiLinkLines(link, rowWidth)...)
return lines
}
lines = append(lines, aimlapiSpinnerLine(spinner, rowWidth))
return lines
}
// aimlapiLinkLines wraps a checkout / verification URL inside the centered block
// instead of truncating it, so fallback links stay copyable even when long.
func aimlapiLinkLines(link string, width int) []string {
remaining := strings.TrimSpace(link)
measure := maxInt(1, width-2)
if remaining == "" {
return nil
}
lines := []string{}
// Keep the scheme and its slashes on separate terminal rows. Otherwise the
// terminal auto-detects the visible https:// URL and adds its own hover
// underline even though the TUI emits no hyperlink escape sequences.
if schemeEnd := strings.Index(remaining, "://"); schemeEnd > 0 {
head := remaining[:schemeEnd+1]
lines = append(lines, padSetupLine(" "+zeroTheme.accent.Render(head), width))
remaining = remaining[schemeEnd+1:]
}
for remaining != "" {
head, tail := splitPlainAtDisplayWidth(remaining, measure)
if head == "" {
head, tail = string([]rune(remaining)[0]), string([]rune(remaining)[1:])
}
lines = append(lines, padSetupLine(" "+zeroTheme.accent.Render(head), width))
remaining = tail
}
return lines
}
func (s *aimlapiOnboardState) viewDone(width int) []string {
rowWidth := aimlapiBlockWidth(width)
lines := []string{}
for _, line := range s.successLines {
if line == "" {
lines = append(lines, blankSetupBlockLine(rowWidth))
continue
}
render := zeroTheme.ink.Render
if line == aimlapi.MsgEverythingRuns || line == aimlapi.MsgTopUpSuccess ||
line == fmt.Sprintf(aimlapi.MsgTopUpAddedFmt, strings.TrimSpace(s.amount)) {
render = zeroTheme.accent.Render
}
lines = append(lines, aimlapiBlockText(line, render, rowWidth)...)
}
lines = append(lines, blankSetupBlockLine(rowWidth))
lines = append(lines, aimlapiBlockText("Press Enter to continue.", zeroTheme.faint.Render, rowWidth)...)
return lines
}