mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11: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]>
160 lines
4.9 KiB
Go
160 lines
4.9 KiB
Go
package aimlapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
|
|
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
var body map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["name"] != "Zero CLI" {
|
|
t.Fatalf("name = %q", body["name"])
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
|
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if key.Key != "sk-issued" || key.ID != "key-id" {
|
|
t.Fatalf("key = %#v", key)
|
|
}
|
|
}
|
|
|
|
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"key-id"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
|
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
|
|
t.Fatal("CreateKey accepted a response without an API key")
|
|
}
|
|
}
|
|
|
|
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"token":""}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
|
tests := []struct {
|
|
name string
|
|
call func() error
|
|
}{
|
|
{
|
|
name: "verify sign-in code",
|
|
call: func() error {
|
|
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "create passwordless account",
|
|
call: func() error {
|
|
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if err := test.call(); err == nil {
|
|
t.Fatal("accepted a response without an auth token")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClientReturnsTypedAPIError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "invalid key", http.StatusUnauthorized)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
|
_, err := client.GetBalance(context.Background(), "bad-key")
|
|
var apiErr APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("error = %T %v, want APIError", err, err)
|
|
}
|
|
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
|
|
t.Fatalf("APIError = %#v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
|
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
|
|
t.Fatal("GetBalance accepted an empty successful response")
|
|
}
|
|
}
|
|
|
|
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
|
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
|
|
t.Fatalf("SendSignInCode() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseAmountUSD(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want int
|
|
wantErr bool
|
|
}{
|
|
{name: "default", want: DefaultAmountUSDMinor},
|
|
{name: "minimum", value: "20", want: 2000},
|
|
{name: "cents", value: "25.49", want: 2549},
|
|
{name: "below minimum", value: "19.99", wantErr: true},
|
|
{name: "above maximum", value: "10001", wantErr: true},
|
|
{name: "not a number", value: "twenty", wantErr: true},
|
|
{name: "nan", value: "NaN", wantErr: true},
|
|
{name: "infinity", value: "Inf", wantErr: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := ParseAmountUSD(test.value)
|
|
if (err != nil) != test.wantErr {
|
|
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
|
|
}
|
|
if got != test.want {
|
|
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|