feat(aimlapi): passwordless onboarding and resumable card top-up (3/3) (#2032)

* feat(aimlapi): add checkout state persistence and sign-in key cache

* fix(aimlapi): cover lock recovery and complete the reset receipt

* test(aimlapi): cover CAS result, reset receipt and sign-in key permissions

* fix(aimlapi): make stale-lock recovery ownership-safe across processes

* test(aimlapi): pass a file URL specifier to the lock workers

* fix(aimlapi): use proper-lockfile and harden checkout-state persistence

* fix(aimlapi): preserve issued keys and survive stale-lock steal races

* fix(aimlapi): surface swallowed lock-retry conditions

* feat(aimlapi): resume interrupted checkouts in the top-up entry points

* fix(aimlapi): keep resumable checkouts through transient and paid states

* fix(aimlapi): surface a lost settled-receipt write to the caller

* fix(aimlapi): harden checkout resume across CLI resume, idempotency and transient errors

* fix(aimlapi): resume settling checkouts and preserve records on ambiguous reads

- Treat 'exchanging' as a paid/resumable status so a run interrupted between
  payment and receipt resumes the exchange instead of opening a second,
  chargeable checkout (matches pollUntilPaid).
- Preserve the recorded checkout on a malformed-but-successful status read
  (AimlapiApiError status 200), alongside the existing transient-error path.
- Print the full recovery key in the receipt-write-failed warning; a masked
  key is useless as a last copy.
- Re-register the real client/providerProfile modules in afterAll so the
  test stubs cannot leak into later files (mock.restore does not undo
  mock.module).
- Bound each lock worker's exit before draining its pipes, and loosen the
  held-lock timeout assertion to any errno (Windows is not always ELOCKED).

* fix(aimlapi): stop mock leak, fail closed on spent sessions, clear GUI receipt

- topup.test.ts: capture the real client/providerProfile modules through a
  cache-busting query so afterAll restores the genuine module instead of the
  corrupted (stub-mutated) reference. Without this the './client.js' stub bled
  past afterAll and failed 25 client.test.ts cases whenever it ran first.
- resolveCheckoutSession: fail closed when a resumed session is already
  'exchanged' but no settled receipt survived locally, matching pollUntilPaid,
  instead of opening a second chargeable checkout for a one-shot key that is
  already gone.
- provisionAimlapiKey: return a clearReceipt closure; ProviderManager now calls
  it only after persistDraft actually saves, so a second GUI top-up opens a
  fresh checkout instead of short-circuiting to a stale key or throwing.
- claimAimlapiTopupState: refuse to replace a stored record that still has an
  open resume token for a different intent, so a changed amount cannot strand a
  still-payable checkout and open a second one.
- Mask the issued key in the CLI receipt-write-failed warning; a paid-for
  credential should not land in scrollback.
- index.ts: note the live CLI/GUI callers and the clear-receipt obligation.
- Regression coverage for exchanged fail-closed, the claim guard, and the GUI
  receipt clear.

* fix(aimlapi): inject the topup transport instead of mocking client.js globally

The prior mock.module('./client.js') stub in topup.test.ts leaked past its
afterAll into client.test.ts (25 failures when this file ran first in CI,
bun 1.3.13). Replace it with a local injection seam:

- topup.ts exposes setAimlapiTopupTestDoubles, and both entry points create
  their client / write their profile through it (defaults unchanged, so
  production behaviour is identical).
- topup.test.ts injects a stub transport through that seam and no longer calls
  mock.module at all, so nothing can bleed OUT to client.test.ts.
- It still loads topup.js through a cache-busting ?ts= query so it stays immune
  to ProviderManager.test.tsx's mock.module('../integrations/aimlapi/index.js'),
  which mock.restore() does not undo and which would otherwise replace the
  shared provisionAimlapiKey binding (verified: without the query the barrel
  stub reaches this file and every topup test fails).

* fix(aimlapi): converge racing checkouts and preserve state on ambiguous reads

- Close a post-claim race: two runs of the same intent converge on one payment
  id, then each can open a session before either records one, and the second
  save overwrote the first's resume token (two payable checkouts). Add
  recordAimlapiCheckoutSession, a compare-and-swap that only records while the
  token is empty; resolveCheckoutSession adopts the winner's session and
  abandons the one it just opened, so pay() converges idempotently on a single
  charge.
- Resume path now preserves the record on any ambiguous getSession error
  (transient, malformed-200, auth/4xx) and retires it only on a definitive
  404/410 gone-session, instead of clearing on every non-transient error.
- pollUntilPaid retries a malformed-but-successful (status 200) body instead of
  aborting, matching the resume path.
- Regression coverage for the peer-records-first race, ambiguous-error
  preserve, 404 replace, and poll-retry-on-200.

* fix(aimlapi): re-validate an adopted peer session before paying it

When two racing runs converge and this one adopts the peer's recorded session,
route that session through the same status classification as the initial
resume: return it only while resumable, fail closed on 'exchanged', and surface
a re-run error on any other terminal status instead of calling pay() on a dead
session. Add regression coverage for an adopted session that is exchanged or
cancelled in the race window.

* test(aimlapi): make abandoned-lock recovery test deterministic

The stale-lock recovery test asserted that all racing claims return the same
payment id. Under a stale-lock steal two recoverers can briefly hold the lock
and mint distinct ids, so that assertion was flaky in CI. A diverged claim is
harmless — it is refused at its next compare-and-swap — so the invariant that
actually matters is that exactly ONE checkout gets established. Drive the
workers through the full claim->record flow and assert a single winner, which
the single-slot store plus the record CAS guarantee deterministically.

* fix(aimlapi): acquire checkout-state off the interactive thread and recover fresh orphans

The interactive top-up flow acquired the checkout-state lock synchronously,
parking the Ink event loop (UI, timers, SIGINT) on Atomics.wait for up to the
5s timeout, and that timeout was shorter than the 30s stale window so a lock
orphaned by an interrupted holder could not be recovered on an immediate resume.

- Add withStateLockAsync + async variants of the state mutators, sharing the
  same inner operations. It acquires with the timer-free lockSync (a sub-ms
  mkdir) but yields via await between retries, so the UI stays live, and its
  longer deadline (15s) covers the stale window so a fresh orphan is reclaimed
  once stale rather than timing out. Shrink the stale window to 8s (sub-ms
  sections never approach it) so recovery is quick.
- Route topup.ts (CLI + GUI) through the async variants; provisioned.clearReceipt
  is now async, and ProviderManager awaits it best-effort so a cleanup failure
  cannot surface an error that invites a retry (a duplicate provider profile).
- Regression coverage for async orphan recovery (mutation-checked: a deadline
  below the stale window fails to recover). Generous per-file test timeout since
  the now-async provisioning yields to a loaded runner's event loop.

* test(aimlapi): cover the async state mutators and speed up the orphan-recovery test

- Add thin contract tests for saveAimlapiTopupStateAsync (CAS accepted/rejected),
  recordAimlapiCheckoutSessionAsync (compare-and-swap on the empty resume token),
  and clearAimlapiTopupStateAsync (ownership-scoped clear); the CLI/GUI flow now
  routes through these and only claimAsync was exercised.
- Back-date the orphaned lock to just inside the stale window so the async
  recovery test reclaims it in ~2s instead of burning the full 8s window.

* fix(aimlapi): fail closed on corrupt state, deliver keys on receipt failure, add a reset action

- Fail closed when the checkout-state file is present but unreadable/schema-
  invalid instead of reading it as absent: a claim would otherwise overwrite an
  open/paid checkout or an exchanged key and open a second chargeable one.
- Never strand a one-shot exchanged key: a receipt-write that throws (lock
  timeout / fs / corrupt state), not just a lost CAS, is caught so the CLI still
  writes the profile and the GUI still returns the key; the post-delivery clear
  is best-effort too.
- Add an explicit discard/reset escape hatch — discardAimlapiCheckoutState, an
  "openclaude aimlapi reset" command, and a GUI "Start over" on the top-up error
  screen — so a terminal checkout (whose resume token blocks a different intent)
  or a corrupt state file can be cleared without editing internal files.
- Surface a failed GUI receipt retirement: retry a few times, then show a
  non-blocking warning instead of swallowing it silently.
- Only chmod a config directory this flow actually created (via mkdirSync's
  return); never tighten a pre-existing OPENCLAUDE_CONFIG_DIR root. The state
  file's own 0600 mode protects the credential.
- Tests for each, incl. corrupt/schema-invalid fail-closed, receipt-write-throw
  key delivery (CLI + GUI), discard semantics, retried/surfaced GUI cleanup, and
  a POSIX check that an existing config dir keeps its mode.

* fix(aimlapi): surface the CLI recovery-receipt clear failure and cover the reset handler

- The CLI finishCliTopup clear failure only went to logForDebugging (debug-only),
  invisible to the user, unlike the loud receipt-write warning and the GUI
  warning. Print a [warn] line pointing at "openclaude aimlapi reset" so a
  stranded receipt that blocks a different top-up is not hidden.
- Add tests for the aimlapiReset CLI handler (discards a stored checkout /
  reports when there is nothing to discard).
- Assert the CLI clear-failure warning is surfaced in the receipt-write-failure
  test (mutation-checked).
- Document "openclaude aimlapi reset" and the GUI Start over recovery in
  docs/aimlapi-setup.md.

* fix(aimlapi): protect a settled receipt from reset and bind method into the checkout identity

- Discard (CLI reset / GUI Start over) no longer deletes a settled receipt — the
  only copy of a paid-for, one-shot key — unless forced. discard now returns
  'discarded' | 'kept-settled' | 'none'; the CLI adds --force and the GUI refuses
  and points back at the recovering retry.
- Add the payment method to AimlapiTopupIntent so a card->crypto (or reverse)
  restart is a different intent and cannot adopt the prior checkout's reused
  idempotency id on the wrong rail; covered by a changed-method resume test.
- Narrow the sign-in-key cache: it is a persistence primitive for the follow-up
  guided passwordless flow with no in-tree consumer, so drop it from the public
  barrel and document the scope (kept in topupState.ts for that follow-up).
- Regression + mutation coverage for the settled-receipt protection and the
  method-scoped intent.

* fix(aimlapi): back off receipt-clear retries, expand the discard API, and test Start over

- Add a 150ms backoff between the GUI clearReceipt() retries so the loop can
  actually ride out a lock-contention window instead of exhausting three
  back-to-back attempts.
- Re-export AimlapiDiscardResult and add resetAimlapiCheckoutSessionAsync so
  barrel consumers can name the discard outcome and use a non-blocking reset,
  matching the other mutators.
- Cover the GUI Start over recovery: it is 'r' (settings:retry) on the top-up
  error screen — the Settings context has no confirm:yes and Enter closes the
  panel; tests drive both the discard and the kept-settled refusal path.
- Rename the test's stale-age constant (LOCK_AGE_WELL_PAST_STALE_MS) so it no
  longer reads as the source's 8s window.

* fix(aimlapi): serialize the one-shot key exchange and recover receipts before login

Address three checkout-state review findings:

- [P1] Serialize the non-idempotent key exchange behind an exchange lease so
  racing same-intent processes mint and record the credential exactly once. The
  elected lease holder exchanges and records the settled receipt; a peer that
  loses the election waits for that receipt and resumes from it instead of
  exchanging in parallel; a lease abandoned by a crashed holder goes stale (past
  the client request timeout) and is reclaimed on a later attempt. The lease is
  released on a failed exchange so a retry is not blocked for the stale window.

- [P1] Recover a settled local receipt BEFORE authenticating in both the CLI and
  guided entry points. A run interrupted after the one-shot exchange but before
  the profile write leaves the paid-for key only in that receipt; requiring a
  fresh login to reach it stranded the key whenever the password had changed or
  the auth service was down. The receipt read is side-effect free and needs no
  token, so it now runs first and only authenticates when a checkout must be
  created/resumed/exchanged.

- [P2] Fix the guided-recovery key in the setup guide: Start over is bound to r,
  not Enter (Enter closes the settings panel).

Covered by state-layer lease tests (acquire/held/stale-steal/settled/gone/
release), a two-process race asserting the exchange runs exactly once with the
loser resuming the receipt, and no-auth-on-settled tests for both entry points.

* test(aimlapi): harden the exchange-lease coverage

Address review follow-ups on the exchange-lease tests (all test-only):

- Gate the two-process race on the loser's own "waiting" status signal instead
  of a fixed sleep, and assert it fired, so the test deterministically exercises
  the held -> wait -> resume path rather than possibly reading settled directly.
- Type seedPersistedState's overrides as Partial<AimlapiPersistedTopup> so a
  misspelled/wrong-typed seed key is a typecheck failure instead of a record
  that silently reroutes the test down another branch.
- Cover re-acquiring your own lease (leaseOwner === owner) so the guard that
  keeps a caller from mistaking its own fresh lease for a live peer's — and
  self-blocking until the stale window — cannot regress unnoticed.

* fix(aimlapi): fence a superseded profile write, protect unreadable state, resume the receipt model

Address three checkout-state review findings:

- [P1] Fence an in-flight checkout before it writes its key to the provider
  profile. If a reset (or a fresh top-up) replaces the stored slot while an
  abandoned flow is awaiting client.exchange(), that flow's settled-receipt CAS
  now misses; previously it still went on to write its stale key and could
  clobber the profile the new top-up created. recordSettledReceipt now reports
  recorded | superseded | errored, and the exchange path aborts (rejecting, and
  pointing the user at rotating the orphaned key) on `superseded` while still
  delivering on a transient `errored` so a paid-for key is never stranded.

- [P1] Do not discard an unreadable/corrupt state file without --force. Such a
  file could be a damaged receipt holding the sole copy of a one-shot key, so
  discardAimlapiCheckoutState now returns `kept-unreadable` and keeps it unless
  forced — matching the safety promise (and existing settled-receipt protection)
  that reset never loses an issued key. The CLI and guided "Start over" surface
  the new outcome and point at `reset --force`; docs updated.

- [P2] Use the settled receipt's model when a peer completed the exchange. The
  settled lease branch dropped lease.state.model, so a loser resuming another
  run's receipt configured its own --model instead of the one actually
  provisioned; it now propagates the receipt's model through both callers.

Covered by: a superseded-mid-exchange fence test, corrupt-discard-needs-force
tests (state layer + CLI + guided GUI), and a two-process race asserting the
loser adopts the winner's provisioned model. All three are mutation-proven.

* test(aimlapi): drop the flaky third guided Start-over drive test

The guided "Start over on a kept-unreadable discard" test added a third
consecutive full Ink mount+drive to ProviderManager.test.tsx. On the CI-pinned
bun (1.3.13) that destabilises the Ink stdin harness (`stdin.ref is not a
function`), so the 'r' keypress never reaches the handler and the awaited
discard call never fires — a timeout unrelated to the code under test (it passes
on local bun 1.3.14). The two-drive configuration is green on CI.

The kept-unreadable behaviour stays covered where it is deterministic: the state
layer (kept-unreadable unforced, discarded on --force) and the CLI handler (the
`reset --force` guidance). The guided keybinding→discard→refusal plumbing is
covered by the identical-structure kept-settled drive test; the kept-unreadable
GUI branch is a trivial mirror of it. A comment records why the third drive is
intentionally omitted.

* wip(aimlapi): converge integration + CLI to the passwordless card-only flow (#1988)

Mid-port checkpoint. Rewrites the AI/ML API integration backend and CLI to the
canonical passwordless, card-only design (target PR #1988), in the current
code style. NOTE: the branch does not yet compile — the GUI (ProviderManager
passwordless rewire) and the aimlapi test suite still reference the removed
API and are the remaining work.

Done (typecheck-clean in these files):
- config.ts: add payBaseUrl + verificationBaseUrl endpoints, buildPartnerReturnUrl.
- topupState.ts: reduce to the #1988 shape — intent keyed on payBaseUrl/
  verificationBaseUrl (no `method`); sync lockfile-based state; drop the
  exchange-lease, discard/reset-command surface, async variants and
  fail-closed-on-corrupt (corrupt reads as absent).
- client.ts: drop the password path (signup/login) and PaymentMethod/crypto;
  pay() is card-only.
- topup.ts: rewrite to the passwordless phase-machine flow (checkAccount ->
  code sign-in / new-account -> provision or by-key top-up; resolveTopupSession;
  pollUntilPaid / pollUntilExchangeSettled / pollUntilByKeyToppedUp). Keeps the
  DI test seam (no cross-file mock.module). Profile env writes AIMLAPI_API_KEY
  mirror + CLAUDE_CODE_PROVIDER_ROUTE_ID.
- onboarding.ts, messages.ts (canonical copy), validation.ts, index.ts (lean
  barrel), providerManagerAimlapi.ts (GUI indirection layer): new.
- CLI: aimlapiCommand.ts (registerAimlapiCommand, --email/--code/--amount, no
  --method/reset), main.tsx wiring, handlers/aimlapi.ts (redacted errors).

Mandatory attribution headers wired on EVERY aimlapi request: X-AIMLAPI-Source
(agent/openclaude) + X-AIMLAPI-Partner-ID — in client.ts request() (auth/
checkout) and the config attribution path (inference/catalog).

* wip(aimlapi): port the passwordless provider-manager GUI (#1988)

Rebase ProviderManager.tsx on the #1988 passwordless aimlapi flow (email ->
6-digit code -> low-balance -> top-up / paste-existing-key -> done), replacing
the old password/method/Start-over flow and importing the aimlapi surface from
providerManagerAimlapi.js. Re-apply the current newer-main, non-aimlapi
`apiFormat: 'auto'` feature that the rebase would otherwise revert (form
metadata, toDraft default, display label, startCreateFromPreset default, the
API-format picker's Automatic option, and persistDraft's selectedApiFormat
'auto' -> undefined branch, kept alongside #1988's deferNavigation/onSaved).
Re-export resolveRouteCredentialValue from integrations/index for the GUI.

All source now typechecks; the aimlapi test suite is the remaining work.

* test(aimlapi): port integration + CLI tests to the passwordless flow

- topupState/topup/onboarding/aimlapiCommand tests: port #1988's coverage,
  adapting the transport to `globalThis.fetch` stubbing and the profile/prompt
  doubles to the module's `setAimlapiTopupTestDoubles` DI seam (no process-global
  mock.module, which leaks across files in this repo).
- client/config tests: keep the current repo's stricter versions (complete
  session contracts), pruning the removed password/crypto cases.
- Add mandatory-attribution-header coverage on all four request classes: the
  client sends X-AIMLAPI-Source + X-AIMLAPI-Partner-ID on auth/checkout, and the
  config attribution path sends both on inference/catalog and strips them for a
  non-canonical proxy endpoint.
- Remove the reset-based CLI handler test (reset no longer exists).

Full aimlapi integration + CLI suite green (67 tests). ProviderManager GUI test
is the remaining piece.

* test(aimlapi): port the passwordless provider-manager GUI tests (#1988)

Rebase ProviderManager.test.tsx on #1988's version for the passwordless aimlapi
GUI tests (email -> code -> low-balance -> top-up / paste-existing-key), which
mock ./providerManagerAimlapi.js. Re-apply HEAD's newer-main apiFormat 'auto'
test cases (API-mode picker, token field, OpenAI/GPT-5/MiniMax presets) since
the source keeps that feature, and restore the current preset list ('LongCat')
in the test's PRESET_ORDER so navigateToPreset indexes match the real presets.

ProviderManager suite green (42 tests).

* docs(aimlapi): rewrite the setup guide for the passwordless card-only flow (#1988)

Describe the passwordless /provider flow (saved-key continue, new-user email +
6-digit code, paste-existing-key, low-balance top-up) and the card-only CLI
`aimlapi topup --email/--code/--amount` (no --method, no reset). Document the
full endpoint override set and the two mandatory attribution headers
(X-AIMLAPI-Source + X-AIMLAPI-Partner-ID) sent on every request, stripped for a
non-canonical proxy endpoint.

* refactor(aimlapi): let tests inject prompt doubles into the top-up flow

* fix(aimlapi): lock the partner id and complete mandatory-header coverage

Lock the partner id to OpenClaude's own attribution id: drop the --partner-id
CLI flag and the AIMLAPI_PARTNER_ID env override so rebate/revenue-share
attribution can never be redirected. resolvePartnerId() now always returns the
built-in id; the mandatory X-AIMLAPI-Partner-ID header itself is unchanged.

Assert the mandatory X-AIMLAPI-Source header on the catalog/discovery and
inference paths (discoveryService, bootstrap, runtimeMetadata) — the header was
already sent, only the test expectations lagged. Refresh stale password-era copy
in the interactive prompt and a top-up comment left over from the removed flow.

* fix(aimlapi): address CodeRabbit review — settlement, key-safety, redaction

- Wait for a resumed sign-in top-up to settle before returning: the account
  non-exchange path now mirrors the by-key flow, so a credited balance is never
  reported while the billing operation is still in flight.
- Preserve a freshly minted sign-in key when the balance read is aborted, so an
  abort cannot orphan a paid credential and mint a second key on the next run.
- Clear the first-run env-key adoption markers when validation fails, so a retry
  re-validates instead of short-circuiting into persisting the unvalidated key.
- Never crash the top-up success screen on an amount parse edge — fall back to
  the raw entered amount after the payment has already cleared.
- Show all four API-format options (visibleOptionCount 3 -> 4).
- Document that guided provisioning requires the canonical inference endpoint.
- Add regression tests: resumed-sign-in settlement, aborted-balance key
  retention, failed-env-key re-validation, and CLI error redaction.

* test(aimlapi): wait for the masked code frame instead of a fixed delay

The AIMLAPI code-screen assertion captured output after a fixed 25ms sleep,
which is too short on a slower CI runner (Node 24) and intermittently missed the
freshly rendered mask characters. Wait for the masked frame instead so the
assertion is deterministic.

* test(aimlapi): harden the provider-manager GUI flows against CI timing

The GUI top-up flow intermittently failed on a loaded CI runner: the final
keystroke on the success screen was sent in the same tick as the render, before
Ink attached the input handler, so it was dropped and the flow stranded on the
done screen. Add the same input settle the other steps already use.

Also raise the shared waitForCondition default timeout (2s -> 5s). The predicate
is polled every 10ms and returns as soon as it is met, so this only adds patience
for a slow runner and never slows a passing wait — keeping the Ink-driven flows
deterministic under CI load.

* fix(aimlapi): gate attribution headers by trusted AI/ML API host

The client sent the mandatory source/partner headers on every request, but the
auth/app/pay/inference base URLs are all env-overridable — so a request pointed
at a user proxy (notably the balance probe against an overridden inference URL)
leaked OpenClaude's partner/source identity. Send them only when the resolved
request host is aimlapi.com (production or staging, over HTTPS), mirroring the
inference/catalog stripping contract in resolveAimlapiAttributionHeaders. Adds
an isTrustedAimlapiRequestUrl predicate plus canonical-sends / proxy-withholds
regression tests.

* test(aimlapi): wait for the done screen to settle before the final keystroke

Replace the fixed 25ms delay before the success-screen keystroke with an
observable frame-stability wait, so a slow CI runner cannot drop the keystroke
before Ink has committed the render and attached its input handler.

* fix(aimlapi): durable receipts and atomic election for concurrent top-ups

- Restore the atomic checkout-session election dropped during the passwordless
  convergence: recordAimlapiCheckoutSession is a first-writer-wins CAS, so two
  concurrent runs of the same intent settle on ONE payable checkout — a loser
  adopts the winner's token and abandons the session it just opened instead of
  leaving two chargeable checkouts. Wired through resolveTopupSession (the create
  branch elects then adopts; the resume branch notifies once) and both the CLI
  and GUI onSession callbacks.
- Persist the settled receipt (apiKey / apiKeyId / model / settled) in the GUI
  BEFORE the profile write, so an interrupted or failed write resumes with the
  paid, one-shot exchanged key instead of stranding it (mirrors the CLI).
- Clear the sign-in key cache with the just-minted key id on a sufficient-balance
  sign-in: persistDraft runs onSaved synchronously, so the aimlapiIssuedKeyId
  state setter has not applied yet — pass the id explicitly.

Adds regression tests for the election (first-writer-wins + loser adoption), the
settled-receipt ordering, and the sufficient-balance cache clear.

* fix(aimlapi): abort on a lost election and keep the receipt write best-effort

- Treat a null recordAimlapiCheckoutSession result as "the slot was cleared by a
  sibling that already completed this top-up" and abort, instead of silently
  proceeding to pay a second, unrecorded checkout (both the CLI persistSession
  and the GUI reportSession). Closes the residual double-charge race.
- Make the GUI settled-receipt write best-effort: the payment already cleared, so
  a receipt-write failure (lock contention, full/read-only disk) must not divert
  the flow into the top-up error path — the profile write is what matters.
- Align the recordAimlapiCheckoutSession test double with the real semantics:
  match on intent + payment id only and return null on a non-matching slot.

Adds a regression test that a sibling clearing the checkout mid-flow aborts
before any /pay call. The sufficient-balance sign-in test now waits for the code
screen to settle before typing (the transition dropped the first keystroke).

* test(aimlapi): settle after each awaited frame so keystrokes aren't dropped

The provider-manager GUI tests type on the line after waitForFrameOutput matches
a new screen, but Ink registers input handlers in an effect that runs after the
render commits. On a loaded CI runner the first post-transition keystroke could
be dropped, stranding the flow and timing out (seen intermittently on Node 22).
Add a short settle after every frame match — returning the same matched frame,
so no assertion changes — which lets the input handler attach before the caller
types. Fixes the class instead of patching individual call sites.

* fix(aimlapi): recover a settled GUI receipt and harden checkout/key edge cases

- Recover a settled checkout receipt in the provider-manager GUI before
  provisioning: if a prior run paid + exchanged the key and saved the receipt but
  was interrupted before the profile write, finish that write with the retained
  key instead of re-entering provisioning against the now-exchanged session
  (which fails in resolveTopupSession and strands the paid credential). Mirrors
  the CLI.
- Reject a non-HTTPS checkout payUrl at the client response boundary (the
  validator required only "openable"), so a session is never retained with an
  address the flow refuses later and then polls with no usable link.
- Do not discard a freshly minted sign-in key when its cache write fails: copy
  the key into memory before persisting and make the sign-in-cache / top-up-state
  writes best-effort, in both the GUI and the CLI, so a lock/permission/disk
  failure can't force a second key on retry.
- Narrow the setup guide: the canonical-inference requirement applies to
  new-account onboarding + key provisioning; the existing-key top-up runs against
  the configured endpoint.

Adds regression tests for the settled-receipt recovery (no re-provision) and the
non-HTTPS payUrl rejection.

* fix(aimlapi): HTTPS checkout callbacks, per-email key cache, safer edges

- Require a credential-free HTTPS base for the checkout return URLs (they embed
  the resumable session token) and for the browser return/landing URL, so a
  cleartext AIMLAPI_PAY_URL/AIMLAPI_RETURN_URL override can't leak the token or
  break the documented HTTPS return-target contract.
- Store sign-in recovery keys as an email-keyed collection instead of a single
  global record, so a concurrent/interrupted sign-in for one account no longer
  evicts another's key (which forced a duplicate mint). Old single-record files
  migrate on read; clear stays per-email ownership-aware.
- Treat post-success receipt cleanup as best-effort in both the CLI (finishProfile)
  and the GUI (resetAimlapiCheckoutIntent): the profile is already saved, so a
  lock/permission/IO failure clearing the receipt must not report failure.
- Reject scientific-notation amounts: parseAimlapiAmountUsd now requires a plain
  decimal with at most two fractional digits, closing the "20.001e0" sub-cent
  bypass that silently rounded to a wrong charge.
- Add the pay/verification/return env vars to the config-test snapshot so a set
  override can't pollute default-endpoint assertions.

Adds regression tests for each.

* fix(aimlapi): async checkout-state clear for the Ink flow + reject malformed bases

- Clear the checkout receipt through an async lock in the provider-manager GUI:
  restore withStateLockAsync + clearAimlapiTopupStateAsync and fire it
  best-effort (unawaited) from the save callback, so a contended lock no longer
  blocks Ink input/timers/SIGINT after the profile is already saved. The CLI keeps
  the sync clear (one-shot command).
- Reject a query string or fragment in the checkout/return base URLs: a base like
  https://pay.aimlapi.com/#x would swallow the appended /checkout?...sessionToken
  into the fragment, so the token never reaches the callback as a query param.
- Surface a non-fatal CLI note when receipt cleanup fails (the profile is already
  saved and the stale receipt reconciles on the next run).

Adds regression tests: async clear ownership, query/fragment rejection, and the
legacy single-record sign-in-cache migration.

* fix(aimlapi): reject bare ?/# delimiters in checkout and return base URLs

url.search / url.hash are empty for a bare delimiter (e.g. https://pay.aimlapi.com/?
or .../#), so those slipped past the query/fragment guard and still corrupted the
appended /checkout?...sessionToken=... . Reject any raw ?/# in the candidate in
both safeHttpsBaseUrl and requireHttpsBaseUrl, and cover the bare-delimiter cases.

* fix(aimlapi): harden checkout recovery — exchange lease, retry modes, payable guard

Addresses a fresh review round on the checkout state machine:

- Restore the cross-process exchange lease (dropped in the passwordless
  convergence): the one-shot key exchange is serialized so two processes resuming
  the same paid sign-up session cannot both exchange and strand the credential —
  the lease winner exchanges, peers wait for its settled receipt.
- Persist the exchange mode in the receipt so a retry that has since become
  sign-in still exchanges the paid session instead of minting an unrelated key
  and clearing the paid checkout (CLI + GUI).
- Recover the checkout URL on a pending_payment resume by re-issuing the
  idempotent pay/top-up (the stable paymentSessionId prevents a double charge)
  instead of polling a session the user can never open.
- Route settled-receipt recovery through persistExistingAimlapi for an existing
  saved profile / AIMLAPI_API_KEY top-up, so it updates the selected profile
  (preserveEnv) rather than minting a new one and copying the env key.
- Confirm before abandoning an already-open checkout: editing amount/auto-top-up
  after a checkout URL was opened now requires an explicit re-submit (the old
  browser tab stays chargeable and no endpoint can cancel it).
- Treat a credentials/query/fragment inference base as non-canonical so a
  `.../v1#x` override cannot be written as OPENAI_BASE_URL and break the shim.

Tests: exchange-lease election + failed-exchange release, retry-exchanges-the-
paid-session, idempotent URL recovery on resume, canonical-gate rejection, and
the re-edit confirmation.

* fix(aimlapi): repair exchange-lease liveness and the re-edit abandon guard

- exchange lease: a peer that finds a live foreign lease now re-attempts on each
  poll instead of only watching for a settled receipt, so it resumes the moment
  the holder settles OR frees the lease (failed/crashed) rather than hanging the
  full 20-minute poll window; folds the wait into the lease loop.
- exchange lease: treat a future-dated exchangeLeaseAt (backwards clock jump or
  an edited state file) as stale and reclaim it, instead of reading a negative
  age as perpetually fresh and deadlocking every peer.
- re-edit guard: reset the abandon acknowledgement when a new checkout opens so a
  further edit to a different amount/auto-top-up is confirmed again instead of
  silently abandoning the freshly-opened chargeable tab; clear the opened-checkout
  tracking once payment settles so a later re-edit never warns about a paid tab.
- tests: lease release is owner-scoped and preserves a settled receipt; a
  future-dated lease is reclaimed; the GUI re-edit warning re-arms after a second
  edit.

* test(aimlapi): sync re-edit test on rendered amount; guard vacuous lease seed

- re-edit GUI test: submit only once the edited amount is reflected in the
  rendered frame instead of after a fixed 25ms delay, so Enter is never processed
  against the stale amount on a slow runner.
- future-dated lease test: assert the seed compare-and-swap actually persisted the
  lease before acquiring, so the reclaim path can never pass vacuously.
- exchange lease: record a swallowed release failure via file-backed debug logging
  (safe on the Ink GUI path) so a lock/permission problem behind a slow takeover is
  diagnosable.

* test(aimlapi): match the complete edited amount in the re-edit frame wait

Prefix matching let "$250" match a stray "$2500" (and "$2500" match "$25000"),
so a wrong-amount input regression could pass unnoticed. Pin the complete value
with a negative lookahead on a trailing digit.

* fix(aimlapi): make the one-shot key exchange crash-durable and per-operation

Three checkout-recovery correctness fixes:

- Persist the exchanged key under the CAS BEFORE returning it. The lease winner
  used to hand the /exchange key to the caller, which wrote the receipt only
  afterward; a crash in between left the checkout exchanged but its only key
  unpersisted, so a retry re-ran (and was rejected by) the spent one-shot
  exchange. exchangeKeyWithLease now records the settled receipt via
  recordAimlapiSettledKeyAsync (merges over the record, clears the lease) as soon
  as the exchange succeeds.

- Use a per-operation exchange-lease owner instead of a module-global id. Two
  overlapping top-ups in the same process shared one owner, which the acquire
  treats as self and immediately reclaims, so both could POST the non-idempotent
  /exchange concurrently. A fresh owner per operation makes the second observe
  the first's lease as foreign and back off; a retry within one operation keeps
  its owner and still reclaims the lease it released.

- Never serialize an empty apiKey/apiKeyId. The existing-key top-up path reports
  apiKeyId: '', which the reader rejects, making the whole settled receipt (and
  the paid key it records) unrecoverable. The save path now coerces an empty
  key/id to absent so the receipt stays readable.

* fix(aimlapi): refuse to overwrite an unfinished checkout when the intent changes

claimAimlapiTopupState backs a single slot, so rerunning with a different amount,
auto-top-up, or endpoint used to unconditionally replace the stored record. When
the prior checkout had opened a session (a resume token — possibly already paid
but not yet exchanged) or held a settled key not yet written to a profile, that
dropped the only handle to a paid session/key and stranded it permanently.

claim now refuses a changed intent while such a record exists, with an actionable
message to finish or cancel the earlier top-up first (re-running the same intent
still resumes it). A never-advanced claim — empty resume token, unsettled, no key
— is still replaced. The CLI surfaces the message directly; the interactive flow
already clears the prior record on edit, so normal re-edits are unaffected.

* fix(aimlapi): never settle a keyless receipt; keep the paid key reaching the profile

Addresses a further review batch:

- recordAimlapiSettledKeyAsync now refuses to mark a receipt settled (and clear
  the lease) when no key resolves from the call or the stored record. A keyless
  settled receipt would make a peer resume from a spent one-shot exchange with no
  credential; the record and its lease now survive so a retry can still exchange.

- The CLI's pre-profile settled-receipt save is now best-effort (try/catch + a dim
  note), matching the earlier saves. A lock/permission/IO failure there no longer
  throws before finishProfile, so the paid, exchanged key still reaches the
  provider profile.

- startCreateFromPreset drops aimlapiPersistedIntentRef on a fresh flow entry
  (in-memory only) so a later resetAimlapiCheckoutIntent can never clear a previous
  flow's on-disk receipt against a stale payment id.

- Prompt copy: "Do you have an aimlapi.com key?" / "I already have an aimlapi.com
  key" (missing article).

Tests: keyless settle is rejected and leaves the lease intact; the CLI forwards
explicit --amount/--model; the settled-receipt recovery renders the top-up (not
"ready") done copy.

* test(aimlapi): assert the exchange lease stays held on a keyless settle attempt

Tighten the keyless-settlement guard test: a "not settled" assertion also passes
if the lease were wrongly cleared (a peer would then see 'acquired'). Assert the
peer acquisition returns 'held' so the test pins that a keyless settle preserves
the lease for a retry.

* fix(aimlapi): poll the checkout token, not the auth bearer, while waiting on a resumed exchange

pollUntilExchangeSettled was called with the passwordless-auth bearer
instead of the partner checkout-session token, so it polled the wrong
resource. A terminal error there clears the recovery receipt, stranding
a paid one-shot sign-up exchange.

* fix(aimlapi): keep the checkout receipt resumable through an unconfirmed amount/auto-top-up edit

Editing the amount or auto-top-up cleared the persisted checkout intent
and durable receipt immediately, before the abandon-ack confirmation
that gates actually starting a new payment session. A user who edits
and backs out (or completes the still-open browser checkout) before
confirming lost the only mapping to that chargeable checkout, so a
later run would open a new one instead of resuming the paid session.

The reset now happens only once the user has explicitly confirmed
abandonment: claimAimlapiTopupState takes an `abandonExisting` option
that atomically overwrites the retained record under the same lock
acquisition, instead of racing a separate async clear against a
synchronous claim.

* test(aimlapi): sync on the rendered email before submitting in the new receipt-resume test

A fixed sleep doesn't guarantee the TextInput has processed the typed
email before Enter is sent; a loaded runner can drop the submit. Wait
for the typed value to actually render, matching the amount-edit sync
already used later in this same test.

* docs(aimlapi): describe checkout retention as durable, not session-scoped

The prior wording ("retained while the provider flow remains open")
undersold what topupState.ts actually does: the payment identity and
any issued key are persisted to disk, so a restart resumes the same
checkout too, and a prior paid+exchanged run finishes the profile
write on the next run instead of re-provisioning.

* fix(aimlapi): unify error-status extraction, drop dead top-up state, tighten wrappers

- Extract aimlapiApiErrorStatus as the one place that reads an HTTP
  status off a caught error, structurally (not `instanceof
  AimlapiApiError`) since some callers surface a duck-typed error with
  a bolted-on `status` instead of the real class; use it at both call
  sites that previously duplicated (and disagreed on) this check.
- Remove aimlapiPaymentSessionId/isAimlapiTopupRunning: both were
  write-only state (declared with a blank destructure slot, never
  read), so every setter call scheduled a render for no observable
  effect.
- Switch providerManagerAimlapi.ts's wrappers to `...args` forwarding
  so an implementation gaining a parameter can't silently get it
  dropped by a wrapper that still names the old ones positionally.
- Stop exporting pollUntilPaid from the aimlapi barrel; nothing
  imports it through there (topup.test.ts imports it directly from
  topup.js), so keep it out of the public surface.
- Normalize the email key while rebuilding the sign-in key store's
  collection branch on read, matching the legacy single-record
  migration branch right above it - a hand-edited or older-build file
  with a mixed-case key would otherwise be invisible to
  loadAimlapiSignInKey and mint a duplicate key.

* test(aimlapi): cover resetAimlapiCheckoutSession, by-key top-up args, and error edges

- resetAimlapiCheckoutSession: refreshes the payment session while
  preserving a minted key, and is a no-op when there's no key to
  preserve.
- ProviderManager: a low-balance saved key that gets topped up charges
  the EXISTING key via topUpAimlapiByApiKey (apiKey, non-empty
  paymentSessionId, empty resumeSessionToken) instead of opening a new
  passwordless-account checkout - previously only exercised through
  the default test mock, with no assertion on the call.
- The three negative assertions in the top-up progress-frame check
  tested strings that don't exist anywhere in this GUI (CLI-only or
  pure invention), so they could never fail; add a check against the
  real failure copy so a regression that silently fails at that point
  is actually caught.
- CLI: pin the --no-open default (false) when the flag is absent, and
  cover the non-Error (thrown string) branch of the handler's
  credential-redaction path - both previously only exercised through
  the Error/AimlapiApiError branches.

* fix(aimlapi): close checkout-state concurrency and exchange-lease races

- saveAimlapiTopupState now merges resumeSessionToken like the other
  retained fields instead of spreading the caller's value verbatim. A
  caller saves this record at points where its in-memory copy is still
  empty (right after sign-in, before a checkout session exists); a
  concurrent peer running the same intent can have already elected and
  recorded a real token in that window, and the unconditional spread
  was overwriting it with "", stranding the peer's chargeable checkout.
- The exchange lease is sized for a single POST (EXCHANGE_LEASE_STALE_MS,
  75s) but a resumed wait-exchange holder can sit in a read-only poll
  for up to POLL_TIMEOUT_MS (20 minutes) before ever reaching that POST.
  Without refreshing, a peer would see the lease go stale mid-wait,
  reclaim it, and risk a second concurrent /exchange on the same
  one-shot session. Add refreshAimlapiExchangeLeaseAsync and call it
  every poll iteration.
- When a peer finishes /exchange and records the settled key WHILE this
  process holds the lease and is polling/exchanging, the poll seeing
  the session flip to 'exchanged' threw a hard failure instead of
  resuming from that peer's settled receipt. Re-check for a settled
  receipt before releasing the lease and rethrowing.
- claimAimlapiTopupState's abandonExisting no longer drops an
  already-minted (but not yet paid) existing-account key when
  overwriting a retained checkout for a different amount/auto-top-up -
  it now merges apiKey/apiKeyId/model in, matching
  resetAimlapiCheckoutSession's retain-key pattern. A fully settled
  (paid + exchanged) credential is refused unconditionally regardless
  of abandonExisting, since that confirms giving up an UNPAID checkout,
  never an already-paid one.

* fix(aimlapi): guard GUI checkout abandonment and receipt recovery

- The abandon-ack gate only armed once a checkout URL surfaced
  (aimlapiOpenedCheckoutRef), but resolveTopupSession can already elect
  and persist a resumeSessionToken before that point. Backing out in
  that window then editing the amount hit claimAimlapiTopupState's
  generic refusal instead of the same confirm-to-abandon flow. Extend
  the gate to also cover a persisted (not yet opened) intent.
- Persist an existing-account key minted at sign-in into the top-up
  receipt itself (mirrors the CLI), not just the separate sign-in-key
  cache, so a restart before settlement can resume from one
  self-contained record instead of depending on two files staying
  consistent.
- reportSession's terminal branch (a cancelled/expired/dead session)
  always fully wiped the receipt; mirror the CLI's persistSession,
  which retains an already-minted key (fresh payment session, dead
  token dropped) and only falls back to a full clear when there's no
  key to keep.
- Submitting the email screen unconditionally reset the whole
  onboarding identity, silently abandoning a chargeable checkout on an
  accidental Esc-back-and-resubmit of the same email. Require the same
  explicit confirmation an amount edit does when a resumable checkout
  exists.
- "Set up a new key or switch account" only cleared in-memory fields,
  leaving a durable receipt from an earlier interrupted top-up (this
  mount's refs were never populated for it, since it may be from an
  earlier process) to hit the same refusal on the next onboarding
  attempt with no way to recover short of deleting the file by hand.
  Force the next claim to override it once.
- existingAimlapiCredential() rejected saved-profile discovery whenever
  the AMBIENT AIMLAPI_INFERENCE_URL wasn't canonical, even for a
  profile that was itself saved against the canonical endpoint. Narrow
  the canonical requirement to what it's actually protecting: reading
  the ambient env key, and sending a saved key to a non-canonical
  endpoint (the existing per-profile check).
- The post-signup success screen claimed a magic link was emailed; this
  flow is passwordless email-code sign-in, no magic link is ever sent.
  Point at the dashboard instead.

* docs(aimlapi): note the interactive/CLI auto-top-up default mismatch

The guided GUI flow pre-selects auto-top-up on; the CLI's --auto-top-up
only enrolls when explicitly passed. Left both defaults as-is (auto-top-up
is a real billing behavior, not something to flip unilaterally) and
documented the asymmetry so it's not a surprise either way.

* test(aimlapi): sync on the settled frame before confirming switch-account

A fixed sleep doesn't prove the Select's focus actually moved to the
second option; on a loaded runner the following Enter could land on
"Continue with your saved API key" instead and assert against the
wrong branch. Wait for the frame to stop changing, matching the
settle-poll pattern already used elsewhere in this file.

* fix(aimlapi): stop the exchange poll when a peer reclaims the lease

The periodic lease refresh added to pollUntilExchangeSettled discarded
its result (`.catch(() => false)`), so a peer reclaiming the lease
mid-wait was silently ignored: the poll kept going, returned normally
once the session left 'exchanging', and the caller walked straight
into the non-idempotent /exchange POST with no ownership check of its
own — racing whatever the peer was doing with the same one-shot
session. The comment claiming this was safe ("resolves on this
function's next outer retry") was simply wrong: there is no outer
retry on the success path, control goes directly to the POST.

Distinguish a thrown refresh (transient lock contention — best-effort,
retry next iteration) from an explicit `false` result (the lease is
definitively no longer ours) and bail out on the latter, so the
caller's existing catch block re-checks for the peer's settled
receipt (or fails the run, requiring a re-run) instead of racing it.

* test(aimlapi): require the settle-wait frame to actually differ from before the keypress

waitForCondition polls every 10ms; on a loaded runner two consecutive
polls can both land before Ink has processed the keypress at all, so
the "stable frame" check was satisfied by the unchanged PRE-keypress
frame, sending Enter before focus ever moved to the second option.
Snapshot the frame before the keypress and require the settled frame
to differ from it, not just be internally stable.

* fix(aimlapi): elect the retained key atomically, stop blocking Ink on claim

- Two concurrent sign-ins for the same intent could each mint their own
  existing-account key before either save landed, and saveAimlapiTopupState
  (last-writer-wins) let whichever saved last silently overwrite the
  other's key on disk while both runs kept using their own in-memory
  copy. Elect apiKey/apiKeyId first-writer-wins (same as
  resumeSessionToken already is), re-check the receipt right before
  minting so a losing run adopts the winner's key instead of minting a
  second, and re-check again after a save that lost the election so the
  run's own in-memory key matches what's actually on disk. Apply the
  same first-writer-wins election to the separate GUI sign-in-key cache
  (saveAimlapiSignInKey), which had the identical last-writer-wins gap.
- The GUI called the sync claimAimlapiTopupState directly from an event
  handler; its lock retry blocks the whole event loop (Atomics.wait) for
  up to LOCK_TIMEOUT_MS on contention, freezing Ink rendering, Esc, and
  SIGINT — exactly what resetAimlapiCheckoutSessionAsync already exists
  to avoid for the same reason. Add claimAimlapiTopupStateAsync (sharing
  the same claim logic via an extracted operation function) and switch
  the GUI to it, making startAimlapiTopup async.

recordAimlapiCheckoutSession (the reportSession/onSession path) has the
same sync-lock exposure but is called from a callback whose return value
AimlapiProvisionOptions.onSession drives synchronous control flow in
several places across both the CLI and GUI provisioning paths; making it
async is a larger, riskier contract change deliberately left out of this
pass.

* fix(aimlapi): never pair a new key with a stale or unrelated apiKeyId

saveAimlapiTopupState's apiKeyId fallback still read current.apiKeyId
even when current.apiKey was empty (the id-without-a-key case) or when
state carried a genuinely new apiKey with its own empty-id sentinel,
letting a fresh key get silently tagged with an unrelated leftover id.
Gate apiKeyId on the same winner apiKey came from instead of falling
back to current independently.

* fix(aimlapi): stop cross-account key leaks, lease key-minting, keep GUI CAS async

- claimAimlapiTopupState's abandonExisting carried a retained apiKey into
  ANY differing intent, including a switch from account A to account B
  (the GUI's forceAbandonExisting path). A B-flow restart before the
  profile write could then initialize from the receipt and call the B
  checkout with A's credential — crediting A while B's flow saves A's
  key. Gate the carry-over on the intent's account/key identity
  (`email`) matching, not just abandonExisting.
- The key-choice screen (I am a new user / I already have a key) reset
  the whole onboarding identity unconditionally on either choice, even
  when Esc had backed all the way out from the amount screen past an
  already-opened, still-chargeable checkout. Apply the same
  abandon-confirmation gate startAimlapiEmailOnboarding already uses.
- POST /v1/keys (minting an existing-account key) had no cross-process
  serialization: two concurrent runs for the same intent could each
  observe no retained key and both mint, orphaning whichever key lost
  the first-writer-wins receipt race. Add a key-mint lease (mirroring
  the exchange lease's acquire/release shape) so exactly one process
  ever mints; a peer backs off and adopts the winner's recorded key.
- The interactive flow already claimed asynchronously, but still called
  the synchronous saveAimlapiTopupState and recordAimlapiCheckoutSession
  directly from an event handler and the onSession callback — either
  could block the whole event loop for up to LOCK_TIMEOUT_MS under lock
  contention, freezing rendering, Esc, and SIGINT while a payment
  session is being created. Add async CAS variants and await them; this
  needed widening AimlapiProvisionOptions.onSession to allow returning a
  promise, since its return value drives resolveTopupSession's session
  election.
- An ambient AIMLAPI_API_KEY takes the by-key route with
  aimlapiExistingUsesEnv, so the eventual profile intentionally stays
  keyless. The settled-receipt save before that write unconditionally
  copied the env value into aimlapi-topup.json regardless, expanding a
  secret's on-disk exposure surface for no recovery benefit (a restart
  re-reads the same env var). Keep an env-backed receipt credential-free.

* fix(aimlapi): preserve the key-mint lease across unrelated CAS writes

saveTopupStateOperation and recordCheckoutSessionOperation merged the
exchange lease but not the key-mint lease added in the previous commit:
AimlapiCheckoutState (what every caller spreads checkoutState from)
carries neither lease pair, so an unrelated write - persisting the
exchange flag, or electing a checkout session - silently dropped an
in-flight peer's key-mint lease. A third process would then see the
slot as free and mint its own key, reopening the exact double-mint race
the lease exists to close. Fall back to the current lease the same way
the exchange lease already does.

Also: add a future-dated key-mint lease reclaim test mirroring the
exchange lease's, and align the default saveAimlapiTopupStateAsync test
mock with the real CAS (match on intent + payment id, keep the first
writer's resumeSessionToken/apiKey) so it no longer accepts a write the
real store would reject.

* test(aimlapi): preserve the key-mint/exchange lease in the mocked GUI CAS writes

saveAimlapiTopupStateAsync's and recordAimlapiCheckoutSessionAsync's
default mocks spread { ...state } as their write's base, same gap as
the real saveTopupStateOperation/recordCheckoutSessionOperation had
before the previous commit: neither lease pair survived a write whose
state didn't carry them (which is every real caller, since
AimlapiCheckoutState exposes neither).

Fixing the merge alone wasn't enough — the same two mocks' "does this
write still belong to this slot" check also compared lease fields as
if they were part of the intent identity, so a write that seeded a
lease value failed to match the just-claimed record and silently
no-op'd instead of persisting anything. Exclude both lease pairs from
that comparison too, matching the real matchingStateOrNull (which only
ever compares INTENT_KEYS + paymentSessionId).

* fix(aimlapi): recover ambiguous key-mint/exchange outcomes before releasing leases

createKey and /exchange are both non-idempotent with no server-side retrieval
path, so a lost response after the request actually committed left three
races: a retry could exchange (or mint) a second time and orphan the first
credential, or the CLI's exchange caller would surface a generic network
error instead of the accurate "already exchanged, rotate the key" guidance.

exchangeKeyWithLease now distinguishes a genuinely ambiguous transport
failure of the /exchange POST itself from other doExchange failures (a
pre-POST bail on a reclaimed lease, or a definite rejection): only the
former re-checks the session status directly, surfaces the already-exchanged
error when confirmed, and otherwise leaves the lease held instead of
releasing it into a race. mintExistingAccountKeyWithLease applies the same
ambiguous/definite split before deciding whether to release its lease.

The GUI sign-in flow had an equivalent gap one step earlier: two concurrent
code-verification races could each see an empty key cache and both mint
before either save elected a winner, so the loser never adopted the winner's
key. completeAimlapiCodeSignIn now serializes the cache lookup and mint
behind a new email-scoped lease in topupState.ts, so a losing process waits
and adopts the winner's cached credential instead of minting its own.

* fix(aimlapi): treat caller-aborted mutations as ambiguous and dedupe the transport helpers

client.request rethrows a caller-driven abort as the raw abort error instead
of wrapping it in AimlapiApiError, so the ambiguous-outcome checks added for
createKey and /exchange missed it: cancelling client-side does not stop a
non-idempotent POST from completing server-side, but the lease was still
released as if the request definitely failed, leaving the door open to a
retry racing a second mint/exchange. All three call sites (the checkout-time
key-mint lease, the exchange lease, and the sign-in key-mint lease) now also
hold the lease when the caller's own signal fired.

Extracted the duplicated abortError/sleep/isAmbiguousTransportApiError
helpers shared between topup.ts and onboarding.ts into transport.ts so the
ambiguity rule can't drift between the CLI and GUI paths. Switched the GUI
sign-in flow's cache save to the async, lock-yielding variant and logged its
lease-release failures for parity with the other leases.

* fix(aimlapi): close six checkout-state races found across the claim, lease, and recovery paths

claimAimlapiTopupState's in-progress check only looked at
resumeSessionToken/settled/apiKey, so a receipt claimed just before its
non-idempotent POST (/v1/keys or /exchange) still looked blank and
replaceable to a different intent. A competing claim could overwrite it
mid-flight, leaving the in-flight request's eventual CAS save with no
matching record to land in and orphaning the credential it was about to
mint or exchange. The claim now also refuses (unconditionally, even under
abandonExisting) while either lease is live.

The sign-in key-mint lease's 75s stale window exactly matched createKey's
worst-case duration (60s) plus the async lock's own timeout (15s) for the
cache write that follows, with zero margin for anything else. A legitimately
still-working holder could lose the lease to a peer moments before its
result was cached. It's now refreshed right after createKey succeeds, giving
the cache-write phase its own fresh window.

ProviderManager's code-verification path called the synchronous
saveAimlapiSignInKey, whose lock retry blocks the whole event loop for up to
five seconds on contention — freezing Ink rendering, timers, Esc, and SIGINT
right after a sign-in. Switched to the async variant, exported through
providerManagerAimlapi.ts alongside the other async cache operations.

The three session polling helpers typed onSession as returning void and
never awaited it, even though ProviderManager's callback is async and starts
receipt cleanup before returning. A terminal session (cancelled/expired/
failed, or a dead session) could let the UI reach the amount screen before
the durable receipt was actually reset, so an immediate retry still saw the
stale resume token and got rejected as "not yet abandoned." Both sides now
await through to completion.

The confirmed email-switch flow cleared the in-memory checkout intent and
fired an un-awaited, error-swallowing state clear, but derived its later
claim's abandonExisting only from refs that clear had just wiped — so a
slow or failed clear left the user's explicit confirmation unenforced at the
claim itself. It now sets the same one-shot force-abandon signal the
"switch account" flow already uses for exactly this kind of on-disk,
this-mount-invisible conflict.

A cached sign-in key that the server had revoked was indistinguishable from
one that was merely unreachable: both collapsed into balanceStatus:
'unknown', which re-cached the same dead key and sent the user to manual-key
entry with no way back into the guided flow short of deleting local state.
A definite 401/403 against a cached (not freshly minted) key now invalidates
the stale cache entry and mints one replacement before falling back to the
generic unknown-balance path; every other (ambiguous) failure still leaves
the cache untouched.

Extracted a shared claim/lease-liveness helper in topupState.ts and added
regression coverage for each race — including two that hold a mocked
createKey/reset call open to prove the competing operation actually waits
instead of just asserting on the end state.

* fix(aimlapi): clear the stale force-abandon flag on a fresh preset entry

aimlapiForceAbandonExistingRef is armed when the user confirms abandoning a
checkout during an email switch, then consumed by the next claim. If that
claim never runs — the switch's own onboarding fails and the user backs all
the way out to preset selection instead of retrying — the flag stayed armed.
Re-entering the aimlapi preset with an unrelated email then passed
abandonExisting: true on its first claim with no confirmation for that flow,
silently overwriting whatever unpaid checkout was still on disk.
startCreateFromPreset now resets the flag alongside the other per-flow refs
it already clears on fresh entry.

Also swapped a fixed 20ms sleep in the cross-intent concurrency test for a
signal fired from the held-open /v1/keys handler, so the test can't flake
under CI load waiting for the run to reach the point it needs to race.

* fix(aimlapi): close the remaining confirmation, cleanup, and lease gaps in checkout state

The API-key-choice screen's own confirm-abandon gate (Enter twice to accept
"a checkout from this account is still pending") reset the onboarding
identity but never armed the force-abandon signal the email-switch and
switch-account flows already use. A contended or failed pre-clear left the
next claim to hit the CAS's unconfirmed-conflict refusal despite the user
having just confirmed abandonment through this exact screen.

reportSession('')'s terminal-session handler discarded the persisted intent
ref before its reset/clear attempt settled, and swallowed any failure as
success. A lock timeout or I/O error then left the durable receipt exactly
as it was, but with no ownership left in memory to retry cleanup or to route
a later conflicting claim through the normal confirmation gate — the CAS
just rejected it outright. Ownership now only drops once the transition
actually commits; a failure is logged and the ref stays populated so the
existing gate covers the next claim.

The sign-in key-mint lease's stale window already had zero margin for its
own refresh call's lock wait (up to 15s) on top of createKey's own worst
case (60s) and the cache save's lock wait (another 15s) — 90s with nothing
left over. Widened it to 150s and lengthened the losing side's patience to
match, and stopped silently ignoring a refresh that reports lost ownership:
it's now logged for diagnosability even though the save itself stays safe
to attempt regardless (first-writer-wins makes a losing write a no-op).

Both onSaved completion paths (persistExistingAimlapi and persistAimlapiKey)
called the synchronous clearAimlapiSignInKey from Ink's synchronous save
callback, whose lock retry blocks the event loop for up to five seconds on
contention — freezing rendering, timers, Esc, and SIGINT right at
completion, the same class of bug already fixed for the sign-in save path.
Re-exported the async variant through providerManagerAimlapi.ts and switched
both call sites to fire-and-forget it instead.

* fix(aimlapi): stop the flow instead of risking a stranded key on a receipt-write failure

/exchange (and the by-key top-up) is a one-shot operation: once it succeeds,
the issued key exists only in memory until a durable copy lands somewhere.
Both the CLI and the GUI wrote the local recovery receipt right after that,
but treated a failure there as best-effort and proceeded straight into the
provider-profile write regardless. If the receipt write failed and the
profile write then also failed — or the process was interrupted between the
two — the key was gone: nothing durable ever recorded it, and a retry can't
re-exchange an already-spent session to get it back.

Both paths now treat the receipt as a required checkpoint rather than an
optional resume aid: a failure here stops the flow with a clear error
pointing at the one real recovery path (rotating the key from the aimlapi.com
dashboard) instead of silently continuing. This shouldn't cost much in
practice — the underlying CAS write already retries substantially on lock
contention before giving up, so a failure this deep signals a real problem
rather than a transient blip a fallback write would likely have hit too.

Added failure-injection coverage for both paths: the CLI test breaks the
config directory (a file where a directory is expected) right as the
exchange response lands, so the post-exchange save fails deterministically
without relying on OS-specific permission semantics; the GUI test mocks the
save to reject directly and asserts the profile write is never reached.

* test(aimlapi): strengthen receipt-write-failure coverage and document the recovery path

Both the CLI and interactive-flow tests for the post-exchange receipt-write
failure only asserted the generic error text, which would still pass if the
issued key id — the actual recovery handle the error exists to surface — got
dropped from the message later. Both now assert the id appears too. The
interactive test also confirms the screen stays usable after the error: a
retry reaches the amount-submission path again instead of the flow being
stuck.

Documented the resulting behavior in the setup guide: since the key exchange
is one-shot, a receipt-write failure after a successful payment now stops
both flows with an error naming the issued key, rather than continuing
silently — recovery is manual, via rotating that key on the aimlapi.com
dashboard.

* test(aimlapi): move to end-of-line before clearing the email field after Esc

Rebasing onto current main picked up the input layer's DEL-coalescing fix,
which now correctly respects the cursor position for a backspace run instead
of dropping it. These three tests backspaced assuming the cursor sat at the
end of the retained email text, but cursorOffset is a single state shared
across every screen's text field and was last set for the amount screen (its
default "25" is 2 chars) — going back via Esc never resets it, so the cursor
was actually stuck mid-string. Sending an explicit end-of-line sequence
before the backspaces makes the clear correct regardless of where the stale
cursor was left.

* fix(aimlapi): close six checkout/onboarding gaps from the latest review pass

Treats a malformed-but-2xx key-mint response as ambiguous (not proof of
failure) so an unusable receipt no longer releases the mint lease and risks
an orphaned credential; fences startAimlapiTopup's cancellation to an
epoch created before the state-lock await so Esc/unmount during that wait
can no longer barge back in; makes the checkout-receipt read fail closed on
a permission/IO/parse/schema failure instead of silently claiming over it;
completes (or reconciles) a settled by-key receipt instead of stranding it
at the post-payment model picker; retires a sign-in mint lease together
with its cache entry so it can't resurface as held once the cache is later
cleared; and adds a --code-stdin path plus a deprecation warning so the
passwordless code no longer has to travel through shell history or argv.

* fix(aimlapi): reconcile env-credential receipts and stop endorsing AIMLAPI_CODE as safe

reconcileSettledAimlapiTopupStateAsync matched a stale settled receipt by
its stored apiKey, but an env-sourced credential's receipt never persists
one, so that path stayed permanently stranded; it now matches on the
absence of a stored key when the caller is reusing an env credential.
Also stops recommending AIMLAPI_CODE as an equivalently safe alternative
to the deprecated --code flag, since typing it inline still lands in
shell history, and adds a lock-release assertion to the fail-closed
receipt-read tests.

* fix(aimlapi): await stale-receipt reconciliation and stop overclaiming --code-stdin's history safety

Reconciling a stale settled receipt matches by the by-key credential's
apiKey, which a genuinely new top-up for that same credential can also
produce — firing the reconcile call without waiting for it left a window
where a fresh settlement landing in that gap could be swept up by it.
Await it before moving on so the two can no longer interleave.

Also narrows the --code-stdin messaging: it only guarantees the code stays
out of this process's argv/`ps` output, not shell history in general,
since that still depends on how the caller feeds stdin.

* fix(aimlapi): keep the configured screen locked until reconciliation finishes

Clearing isAimlapiKeyValidating before the reconcile await let the
aimlapi-configured screen's Select (and its Esc binding) become
interactive while that reconcile was still running in the background —
it carries no abort signal of its own, so aborting the surrounding
controller only stops this flow from acting on the result, not the
reconcile itself. That left a window where the user could start a
competing top-up for the same credential and have its fresh settlement
caught by the still-in-flight reconcile. Both now stay gated on
isAimlapiKeyValidating through the whole wait.

* fix(aimlapi): fail closed on the sign-in cache, bind env receipts by identity, and commit minted keys

Makes the sign-in key cache and its mint lease match the checkout
receipt's fail-closed contract: only ENOENT means no record, so a
permission/IO/parse failure can no longer be mistaken for "nothing
cached, no lease held" and authorize a second createKey call or a
concurrent lease acquisition.

Reworks reconcileSettledAimlapiTopupStateAsync to match on the by-key
checkout intent's non-secret key fingerprint (already carried in the
persisted email field) instead of the raw stored apiKey or its mere
absence — an env-backed receipt never stores its key, so absence alone
couldn't tell two different env credentials' receipts apart, letting one
credential's balance check discard another's still-unrecovered payment.

Treats persisting a freshly minted existing-account key as a commit
point in the CLI's mint-with-lease path: a write failure now stops the
flow with a recovery-oriented error and leaves the lease held, instead
of continuing with the key only in memory where an interruption before
the later checkout/profile save would orphan it once the lease goes
stale.

Adds a shared isValidAimlapiSignInCode check so both the CLI and the
interactive flow reject a malformed passwordless code (empty,
non-numeric, wrong length) before it ever reaches verifySignInCode.

* fix(aimlapi): reject an array-shaped sign-in cache/lease file instead of degrading to empty

typeof [] === 'object' and [] !== null, so readJsonObjectFile's shape
check let a JSON array through as if it were a valid store. Both readers
then found no matching entries and returned {}, exactly the "no cached
key, no live lease" outcome the fail-closed contract exists to prevent —
authorizing a second createKey call or a lease acquisition over a
possibly-live one.

* fix(aimlapi): commit the sign-in key as a checkpoint and retire a completed mint's lease

mintOrAdoptSignInKey swallowed a failed cache commit and returned the
minted key only in memory — the same non-idempotent-mutation gap already
closed for the CLI's mintExistingAccountKeyWithLease. A commit failure
now stops the flow with a recovery-oriented error and leaves the lease
held, instead of risking the key becoming unrecoverable once the lease
ages out and a retry mints a second one.

Also retires the checkout key-mint lease in the same save that elects a
freshly minted key, mirroring how the exchange lease is already cleared
on settle. Without it, backing out of an unpaid checkout and confirming
a different amount right away still hit the "minting or exchanging"
refusal for the full 75s stale window even though the mint had already
completed.

* fix(aimlapi): make key-mint lease retirement owner-checked, preserve error causes

Retiring the checkout key-mint lease on a successful mint (the previous
commit's fix) cleared it unconditionally, with no check that the save
still belonged to the owner that acquired it. createKey has no refresh
mechanism, so a slow response can let the lease go stale and be
reclaimed by a peer before the original owner's save lands — clearing
the lease then would drop that peer's still-live one and let a
differently-amounted claim proceed as though minting were done while
the peer's mint was still genuinely in flight.

Replaces the raw saveAimlapiTopupState call in
mintExistingAccountKeyWithLease with a dedicated
recordAimlapiMintedKeyAsync that takes the acquiring owner and only
retires the lease while it's still theirs — mirroring how
recordAimlapiSettledKeyAsync already handles the exchange lease.

Also adds `cause` to the three recovery-oriented errors thrown on a
receipt-write failure, so the underlying persistence error stays
diagnosable instead of being replaced by the wrapper message alone.

* test(aimlapi): assert a stale owner's minted key is still persisted

The reclaimed-peer-lease regression test only pinned the lease
bookkeeping, so a variant regression that skipped the write entirely for
a non-owning caller (e.g. an early return on lease-owner mismatch) would
still pass while silently discarding a real, non-idempotently minted
key. Asserts the receipt retains it regardless of who currently holds
the lease.

* fix(aimlapi): persist and charge the endpoint a manually-entered key was actually validated against

persistExistingAimlapi's no-existing-profile fallback saved the profile
with resolveEndpoints().inferenceBaseUrl (the current ambient endpoint)
instead of aimlapiInferenceBaseUrl (the endpoint this flow actually
validated the key and will charge against). The two diverge for a
manually-entered key: after "Set up a new key or switch account" resets
aimlapiInferenceBaseUrl to the ambient default, draft.baseUrl (what
validateAndPersistAimlapiKey actually calls the balance/top-up endpoints
with) keeps the OLD profile's endpoint if it differs (e.g. a canonical
saved profile while AIMLAPI_INFERENCE_URL currently points at a proxy).

Now captures the validated endpoint into aimlapiInferenceBaseUrl as soon
as the low-balance branch is reached, and the fallback save uses that
state instead of re-resolving the ambient endpoint — so the top-up
charge and the persisted profile both follow the endpoint that was
actually validated.

* fix(aimlapi): reject stale key-mint results, make the exchange checkpoint mandatory, validate lease pairs

recordAimlapiMintedKeyAsync previously let a stale owner's delayed
createKey result land beside a peer's reclaimed, still-live lease: since
no key was recorded yet, first-writer-wins accepted the stale result
outright, so the reclaiming peer's own (equally real, non-idempotent)
mint got silently discarded once its own save landed — turning one lost
credential into two. It now rejects a result whose ownership was already
lost when nothing is recorded yet to adopt instead, surfacing a
recovery-oriented error naming the issued key id rather than risking a
second orphan. The caller now also returns whichever credential is
actually durably recorded, not always its own.

exchangeKeyWithLease's own settled-receipt commit — the only durable
record of a one-shot /exchange result until the caller's later,
separate save runs — was only logged on failure. A crash in that
window left the paid session exchanged with its key absent from local
recovery state, so a retry could only report the session was already
exchanged with no way to recover automatically. The commit is now a
required checkpoint: its failure stops the flow with the existing
recovery guidance and leaves the exchange lease held, exactly as the
analogous key-mint checkpoint already does.

The receipt schema validated the exchange lease pair but not the newer
key-mint one, and even the exchange check only verified each field in
isolation — a one-sided pair (an owner with no timestamp, or vice versa)
passed either way. A shared validator now enforces both lease pairs are
either fully present or fully absent, so a malformed or partial lease
can no longer be silently accepted as "not currently live" and let a
claim replace it out from under an in-flight mint or exchange.

* fix(aimlapi): fail the exchange when the settled-receipt commit no-ops, not just when it throws

recordAimlapiSettledKeyAsync silently returned without writing whenever the
CAS no longer matched (checkout cleared/reset mid-flight) or no credential
could be resolved to settle with. exchangeKeyWithLease only caught thrown
errors, so both no-op paths let a successfully exchanged key return with no
durable local receipt. The function now returns a boolean, and the caller
treats false exactly like a thrown save error.

Also fixes recordAimlapiMintedKeyAsync returning the raw untrimmed apiKeyId
instead of the trimmed value it actually persisted.

---------

Co-authored-by: Lookoff123 <bataryshkinairina@gmail.com>
This commit is contained in:
Stan
2026-08-14 10:12:13 +08:00
committed by GitHub
co-authored by Lookoff123
parent 575b407275
commit fb9102c422
29 changed files with 12734 additions and 797 deletions
+29 -17
View File
@@ -4,41 +4,51 @@ OpenClaude connects to [AI/ML API](https://aimlapi.com) through its OpenAI-compa
## Overview
AI/ML API is an aggregating gateway that exposes many chat models behind a single OpenAI-compatible API. OpenClaude ships a first-class `AI/ML API` provider preset: it stores credentials under `AIMLAPI_API_KEY`, sends the OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog. It defaults to `gpt-4o`.
AI/ML API is an aggregating gateway that exposes many chat models behind a single OpenAI-compatible API. OpenClaude ships a first-class `AI/ML API` provider preset: it uses `AIMLAPI_API_KEY`, sends the OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog. It defaults to `gpt-4o`.
## Prerequisites
None. You don't need to visit <https://aimlapi.com> first the guided top-up flow below can create an AI/ML API account and issue a key for you. If you already have a key from the dashboard, you can paste it directly instead.
None. You do not need to visit <https://aimlapi.com> first - the guided top-up flow can create an account and issue a key. If you already have a dashboard key or set `AIMLAPI_API_KEY`, OpenClaude can use that credential instead.
## Option 1 Interactive (`/provider`)
## Option 1 - Interactive (`/provider`)
1. Start OpenClaude and run `/provider`.
2. Choose **aimlapi.com**, then confirm the default model (Step 1 of 2).
3. Step 2 of 2 — choose how to get an API key:
- **Top up and get API key** — enter your AI/ML API email and password (an account is created automatically if you don't have one yet), pick a top-up amount ($20$10,000) and payment method (card or crypto), complete payment in the browser, and OpenClaude saves the issued key for you.
- **Enter existing API key** — paste a key you already have from the AI/ML API dashboard.
2. Choose **aimlapi.com**.
3. If aimlapi.com is already configured, choose one of:
- **Continue with your saved API key** - validate the saved key or `AIMLAPI_API_KEY`, check its balance, optionally top up a low balance, then choose a model.
- **Set up a new key or switch account** - enter the new-user or existing-key flow.
4. Otherwise choose how to get an API key:
- **I am a new user** - enter your email. OpenClaude creates a passwordless account, lets you pick a top-up amount and automatic top-up preference (auto top-up is pre-selected **on** here; toggle it off if you don't want it), opens card checkout, then saves the issued key.
- **I already have aimlapi.com key** - paste a key from the dashboard. OpenClaude validates it, checks its balance, and offers an optional API-key top-up when the balance is low.
Either way, the base URL (`https://api.aimlapi.com/v1`) and default model (`gpt-4o`) are filled in automatically.
For an email that already has an account, AI/ML API sends a 6-digit sign-in code. OpenClaude creates a new API key for that account, checks its balance, and only offers checkout when the balance is low. You can top up or save the key and skip funding for now.
Switch models any time with `/model` — only chat-capable models from the AI/ML API catalog are listed.
When `AIMLAPI_API_KEY` supplies the credential, OpenClaude uses its runtime value for validation and balance checks but saves an empty credential in the provider profile. The literal environment value is not copied into configuration.
## Option 2 — CLI (`openclaude aimlapi topup`)
Checkout progress is durable, not just kept for the current session: the payment identity and any issued key are persisted to disk, so a restart resumes the same checkout too. Retrying an ambiguous payment or exchange failure resumes the original partner session instead of creating a second checkout, and if a prior run already paid and exchanged the key, the next run finishes the profile write instead of re-provisioning.
Run the same guided top-up flow non-interactively:
The key exchange itself is one-shot: it cannot be retried. If payment succeeds and a key is issued but the local recovery receipt for it cannot be saved (for example a locked or read-only config directory), both the CLI and the interactive flow stop rather than risk losing that key silently. The error names the issued key id — open <https://aimlapi.com/app> and rotate that key to recover access, then configure the new key manually.
The base URL (`https://api.aimlapi.com/v1`) and default model (`gpt-4o`) are filled in automatically. Switch models any time with `/model`; only chat-capable models from the AI/ML API catalog are listed.
## Option 2 - CLI (`openclaude aimlapi topup`)
Run the guided account top-up flow from the CLI:
```bash
openclaude aimlapi topup --email you@example.com --amount 25 --method card
openclaude aimlapi topup --email you@example.com --amount 25
```
- Credentials: pass `--email` (or set `AIMLAPI_EMAIL`) and set `AIMLAPI_PASSWORD`; if either is missing you're prompted interactively (password entry is hidden).
- Pass `--email` (or set `AIMLAPI_EMAIL`). Existing accounts also need the emailed 6-digit code: interactive terminals prompt for it (hidden, not echoed); for scripts, pipe it in with `--code-stdin` (reads one line from stdin) — the recommended noninteractive path, since the value never appears in this process's argv or `ps` output. Whether it also avoids shell history depends on how you feed stdin: reading from a file (`--code-stdin < code.txt`) or a secret manager avoids it, but `echo 123456 | openclaude ...` still records that whole pipeline. `AIMLAPI_CODE` has the same caveat — set it through a mechanism that itself avoids shell history (a CI secret, a sourced env file, `systemd`'s `Environment=`), not by typing `AIMLAPI_CODE=123456 openclaude ...` directly. Avoid the deprecated `--code <value>` flag entirely — a plain argument is always visible to other local users via shell history and the process list.
- `--amount`: top-up amount in USD (min 20, max 10000; defaults to 25).
- `--method`: `card` (Stripe, default) or `crypto` (NOWPayments).
- Checkout always uses card payment; there is no separate payment-method step.
- `--auto-top-up`: enroll the account in automatic top-up at checkout. Off by default here, unlike the interactive flow above, where it's pre-selected on.
- `--model`: default model id written into the provider profile (defaults to `gpt-4o`).
- `--no-open`: print the payment URL instead of auto-opening a browser.
The issued key is written into OpenClaude's provider profile automatically once payment clears.
## Option 3 Environment variables
## Option 3 - Environment variables
Setting `AIMLAPI_API_KEY` alone is enough; OpenClaude auto-detects the AI/ML API route:
@@ -59,12 +69,14 @@ export OPENAI_MODEL="gpt-4o"
## Verify
- `/status` shows **aimlapi.com** as the active provider with the `https://api.aimlapi.com/v1` base URL.
- `/status` shows **AI/ML API** as the active provider with the `https://api.aimlapi.com/v1` base URL.
- `/model` lists chat-capable models discovered from the catalog.
- Send any prompt to confirm responses come back from the selected model.
## Notes
- Model discovery uses the public, unauthenticated `GET /models` endpoint and surfaces only chat-completions models; image, audio, embeddings, and other modalities are intentionally not routed through the coding workflow.
- Requests carry `X-AIMLAPI-Integration-*` attribution headers (owner/repo/version) plus the `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude` headers that AI/ML API uses to attribute integration traffic.
- Every request to AI/ML API (inference, catalog, sign-in/account, and checkout) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity.
- `AIMLAPI_AUTH_URL`, `AIMLAPI_APP_URL`, `AIMLAPI_PAY_URL`, and `AIMLAPI_VERIFICATION_BASE_URL` can point the complete flow at another environment. Guided **new-account** onboarding and key provisioning require the canonical `api.aimlapi.com` inference endpoint, so `AIMLAPI_INFERENCE_URL` must be unset (or left at its default) to create a new account and mint its key — a non-canonical value is rejected there. The existing-key top-up path (paste an aimlapi.com key) instead runs against whatever `AIMLAPI_INFERENCE_URL` is configured. `AIMLAPI_RETURN_URL` overrides only the browser landing page.
- Checkout success is detected by polling. The browser return target is an HTTPS page; OpenClaude does not install a custom URL-scheme handler.
- Usage (`/usage`) reporting is not supported for this provider.
+166
View File
@@ -0,0 +1,166 @@
import { expect, mock, test } from 'bun:test'
import { Command as CommanderCommand } from '@commander-js/extra-typings'
import { registerAimlapiCommand } from './aimlapiCommand.js'
test('aimlapi topup forwards the passwordless CLI contract', async () => {
const handler = mock(async () => {})
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => handler)
await program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--email',
'user@example.com',
'--code',
'123456',
'--auto-top-up',
'--no-open',
])
expect(handler).toHaveBeenCalledWith({
email: 'user@example.com',
code: '123456',
amountUsd: undefined,
autoTopUp: true,
model: 'gpt-4o',
noOpen: true,
})
})
test('aimlapi topup forwards explicit amount and model', async () => {
const handler = mock(async () => {})
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => handler)
await program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--email',
'user@example.com',
'--amount',
'50',
'--model',
'gpt-5',
'--no-open',
])
expect(handler).toHaveBeenCalledWith({
email: 'user@example.com',
code: undefined,
amountUsd: '50',
autoTopUp: undefined,
model: 'gpt-5',
noOpen: true,
})
})
test('aimlapi topup defaults to opening the browser when --no-open is absent', async () => {
const handler = mock(async () => {})
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => handler)
await program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--email',
'user@example.com',
])
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ noOpen: false }),
)
})
test('aimlapi topup --code-stdin reads the code from stdin instead of argv', async () => {
const handler = mock(async () => {})
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => handler)
const { Readable } = await import('node:stream')
const originalStdin = process.stdin
const fakeStdin = Readable.from('123456\n') as unknown as typeof process.stdin
Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true })
try {
await program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--email',
'user@example.com',
'--code-stdin',
'--no-open',
])
} finally {
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true })
}
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@example.com', code: '123456' }),
)
})
test('aimlapi topup --code prints a deprecation warning steering off argv', async () => {
const handler = mock(async () => {})
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => handler)
const stderr = mock(() => true)
const originalWrite = process.stderr.write.bind(process.stderr)
process.stderr.write = stderr as unknown as typeof process.stderr.write
try {
await program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--email',
'user@example.com',
'--code',
'123456',
'--no-open',
])
} finally {
process.stderr.write = originalWrite
}
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('shell history'))
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ code: '123456' }),
)
})
test('aimlapi topup help text does not encourage the argv --code form', () => {
const program = new CommanderCommand().exitOverride()
const aimlapi = registerAimlapiCommand(program, async () => async () => {})
const topupHelp = aimlapi.commands
.find(command => command.name() === 'topup')
?.helpInformation()
expect(topupHelp).toBeTruthy()
expect(topupHelp).toContain('--code-stdin')
expect(topupHelp).toContain('Deprecated')
expect(topupHelp).not.toMatch(/--code <code>\s+6-digit code/)
})
test('aimlapi topup rejects the removed method option', async () => {
const program = new CommanderCommand().exitOverride()
registerAimlapiCommand(program, async () => async () => {})
await expect(
program.parseAsync([
'node',
'openclaude',
'aimlapi',
'topup',
'--method',
'card',
]),
).rejects.toMatchObject({ code: 'commander.unknownOption' })
})
+101
View File
@@ -0,0 +1,101 @@
import { Command as CommanderCommand } from '@commander-js/extra-typings'
import { createInterface } from 'node:readline'
import {
DEFAULT_MODEL,
MAX_AMOUNT_USD_MINOR,
MIN_AMOUNT_USD_MINOR,
} from '../integrations/aimlapi/config.js'
import type { AimlapiTopupOptions } from '../integrations/aimlapi/topup.js'
type AimlapiTopupHandler = (options: AimlapiTopupOptions) => Promise<void>
type LoadAimlapiTopupHandler = () => Promise<AimlapiTopupHandler>
const loadAimlapiTopupHandler: LoadAimlapiTopupHandler = async () => {
const { aimlapiTopup } = await import('./handlers/aimlapi.js')
return aimlapiTopup
}
/**
* Read exactly one line from stdin for --code-stdin: a noninteractive way to
* supply the passwordless sign-in code that never touches argv (visible to
* other local users via shell history and the process list, e.g. `ps` /
* `/proc/<pid>/cmdline`) or an env var (visible via `/proc/<pid>/environ`).
* Stops at the first line rather than draining to EOF, so a caller piping
* more than one value is not silently read past its intended input.
*/
async function readOneLineFromStdin(): Promise<string> {
const rl = createInterface({ input: process.stdin })
try {
for await (const line of rl) {
return line.trim()
}
return ''
} finally {
rl.close()
}
}
/**
* Register the `aimlapi` command group (`topup`). The handler is lazily imported
* so the heavy top-up flow only loads when the subcommand actually runs; tests
* inject `loadHandler` to capture the parsed options without touching the network.
*/
export function registerAimlapiCommand(
program: CommanderCommand,
loadHandler: LoadAimlapiTopupHandler = loadAimlapiTopupHandler,
): CommanderCommand {
const aimlapi = program
.command('aimlapi')
.description('AI/ML API (aimlapi.com) — top up balance and configure the provider')
aimlapi
.command('topup')
.description(
'Use passwordless sign-in, open AI/ML API top-up, then configure OpenClaude',
)
.option('--email <email>', 'AI/ML API account email (or AIMLAPI_EMAIL env)')
.option(
'--code <code>',
'Deprecated: 6-digit code for an existing account, as a plain argument. This ' +
'exposes it to shell history and the process list (`ps`, /proc/<pid>/cmdline) on ' +
'the local machine. Prefer --code-stdin for scripts, or the interactive prompt.',
)
.option(
'--code-stdin',
'Read the 6-digit code for an existing account from stdin (one line) instead of ' +
'--code, so it never appears in this process\'s argv/`ps` output. Whether it also ' +
'avoids shell history depends on how you feed stdin (e.g. a file avoids it; ' +
'`echo code | ...` does not).',
)
.option(
'--amount <usd>',
`Top-up amount in USD (min ${MIN_AMOUNT_USD_MINOR / 100}, max ${MAX_AMOUNT_USD_MINOR / 100})`,
)
.option('--auto-top-up', 'Enable automatic top-up at checkout')
.option(
'--model <model>',
'Default model id written into the provider profile',
DEFAULT_MODEL,
)
.option('--no-open', 'Do not auto-open the browser; print the payment URL instead')
.action(async opts => {
if (opts.code) {
process.stderr.write(
'Warning: --code exposes the sign-in code via shell history and the process ' +
'list. Use --code-stdin for scripts, or omit it to be prompted instead.\n',
)
}
const code = opts.codeStdin ? await readOneLineFromStdin() : opts.code
const handler = await loadHandler()
await handler({
email: opts.email,
code,
amountUsd: opts.amount,
autoTopUp: opts.autoTopUp,
model: opts.model,
noOpen: opts.open === false,
})
})
return aimlapi
}
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, expect, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js'
import {
AimlapiApiError,
type AimlapiClient,
} from '../../integrations/aimlapi/client.js'
import { setAimlapiTopupTestDoubles } from '../../integrations/aimlapi/topup.js'
import { aimlapiTopup } from './aimlapi.js'
// An OpenAI-style key that redactSensitiveInfo replaces with a marker.
const SECRET = 'sk-abcdef0123456789abcdef0123'
const originalExit = process.exit
const originalError = console.error
const originalInferenceUrl = process.env.AIMLAPI_INFERENCE_URL
const tempDirs: string[] = []
afterEach(() => {
setAimlapiTopupTestDoubles(undefined)
setClaudeConfigHomeDirForTesting(undefined)
process.exit = originalExit
console.error = originalError
if (originalInferenceUrl === undefined) delete process.env.AIMLAPI_INFERENCE_URL
else process.env.AIMLAPI_INFERENCE_URL = originalInferenceUrl
for (const dir of tempDirs.splice(0)) rmSync(dir, { force: true, recursive: true })
})
// Drive the handler until it fails on the injected client error, capturing every
// console.error line and short-circuiting the process.exit(1) it ends with.
async function runHandlerWithError(clientError: unknown): Promise<string> {
// Default (canonical) inference endpoint so guided provisioning is not refused
// before it reaches the account lookup.
delete process.env.AIMLAPI_INFERENCE_URL
const dir = mkdtempSync(join(tmpdir(), 'openclaude-aimlapi-handler-'))
tempDirs.push(dir)
setClaudeConfigHomeDirForTesting(dir)
setAimlapiTopupTestDoubles({
createClient: () =>
({
checkAccount: async () => {
throw clientError
},
}) as unknown as AimlapiClient,
writeProfile: () => 'profile.json',
promptText: async () => '',
promptHidden: async () => '',
})
const lines: string[] = []
console.error = ((...args: unknown[]) => {
lines.push(args.map(String).join(' '))
}) as typeof console.error
process.exit = ((): never => {
throw new Error('__exit__')
}) as typeof process.exit
await expect(
aimlapiTopup({ email: 'user@example.com', amountUsd: '25', noOpen: true }),
).rejects.toThrow('__exit__')
return lines.join('\n')
}
test('redacts credentials in an AimlapiApiError message and body before exit', async () => {
const output = await runHandlerWithError(
new AimlapiApiError(`auth failed ${SECRET}`, 401, `body leak ${SECRET}`),
)
expect(output).not.toContain(SECRET)
expect(output).toContain('[REDACTED_OPENAI_KEY]')
})
test('redacts credentials in a generic error before exit', async () => {
const output = await runHandlerWithError(new Error(`unexpected failure ${SECRET}`))
expect(output).not.toContain(SECRET)
expect(output).toContain('[REDACTED_OPENAI_KEY]')
})
test('redacts credentials in a thrown non-Error value before exit', async () => {
const output = await runHandlerWithError(`unexpected failure ${SECRET}`)
expect(output).not.toContain(SECRET)
expect(output).toContain('[REDACTED_OPENAI_KEY]')
})
+6 -3
View File
@@ -2,6 +2,7 @@
import chalk from 'chalk'
import { redactSensitiveInfo } from '../../utils/redaction.js'
import { AimlapiApiError } from '../../integrations/aimlapi/client.js'
import {
runAimlapiTopup,
@@ -13,12 +14,14 @@ export async function aimlapiTopup(options: AimlapiTopupOptions): Promise<void>
await runAimlapiTopup(options)
} catch (error) {
if (error instanceof AimlapiApiError) {
console.error(chalk.red(`\n ✗ ${error.message}`))
console.error(chalk.red(`\n ✗ ${redactSensitiveInfo(error.message)}`))
if (error.body) {
console.error(chalk.dim(` ${error.body}`))
console.error(chalk.dim(` ${redactSensitiveInfo(error.body)}`))
}
} else {
const message = error instanceof Error ? error.message : String(error)
const message = redactSensitiveInfo(
error instanceof Error ? error.message : String(error),
)
console.error(chalk.red(`\n ✗ ${message}`))
}
process.exit(1)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
/**
* Indirection layer between ProviderManager and the aimlapi integration. The GUI
* imports the top-up / onboarding / checkout-state functions from HERE so tests
* can swap them out by mocking this single module, instead of a process-global
* `mock.module` of the integration barrel (which leaks across test files).
*/
import {
provisionAimlapiKey as provisionAimlapiKeyImpl,
topUpAimlapiByApiKey as topUpAimlapiByApiKeyImpl,
parseAimlapiAmountUsd as parseAimlapiAmountUsdImpl,
isValidAimlapiEmail as isValidAimlapiEmailImpl,
isValidAimlapiSignInCode as isValidAimlapiSignInCodeImpl,
beginAimlapiEmailOnboarding as beginAimlapiEmailOnboardingImpl,
completeAimlapiCodeSignIn as completeAimlapiCodeSignInImpl,
validateAimlapiApiKey as validateAimlapiApiKeyImpl,
} from '../integrations/aimlapi/index.js'
import {
claimAimlapiTopupStateAsync as claimAimlapiTopupStateAsyncImpl,
clearAimlapiTopupStateAsync as clearAimlapiTopupStateAsyncImpl,
recordAimlapiCheckoutSessionAsync as recordAimlapiCheckoutSessionAsyncImpl,
resetAimlapiCheckoutSessionAsync as resetAimlapiCheckoutSessionAsyncImpl,
saveAimlapiTopupStateAsync as saveAimlapiTopupStateAsyncImpl,
reconcileSettledAimlapiTopupStateAsync as reconcileSettledAimlapiTopupStateAsyncImpl,
aimlapiByKeyIdentity as aimlapiByKeyIdentityImpl,
loadAimlapiSignInKey as loadAimlapiSignInKeyImpl,
saveAimlapiSignInKeyAsync as saveAimlapiSignInKeyAsyncImpl,
clearAimlapiSignInKeyAsync as clearAimlapiSignInKeyAsyncImpl,
} from '../integrations/aimlapi/topupState.js'
import type {
AimlapiPersistedTopup,
AimlapiTopupIntent,
} from '../integrations/aimlapi/topupState.js'
export {
AimlapiApiError,
AIMLAPI_MESSAGES,
type AimlapiTopupStatus,
} from '../integrations/aimlapi/index.js'
export type { AimlapiPersistedTopup, AimlapiTopupIntent }
export const provisionAimlapiKey: typeof provisionAimlapiKeyImpl = (...args) =>
provisionAimlapiKeyImpl(...args)
export const topUpAimlapiByApiKey: typeof topUpAimlapiByApiKeyImpl = (...args) =>
topUpAimlapiByApiKeyImpl(...args)
export const parseAimlapiAmountUsd: typeof parseAimlapiAmountUsdImpl = (...args) =>
parseAimlapiAmountUsdImpl(...args)
export const isValidAimlapiEmail: typeof isValidAimlapiEmailImpl = (...args) =>
isValidAimlapiEmailImpl(...args)
export const isValidAimlapiSignInCode: typeof isValidAimlapiSignInCodeImpl = (...args) =>
isValidAimlapiSignInCodeImpl(...args)
export const beginAimlapiEmailOnboarding: typeof beginAimlapiEmailOnboardingImpl = (
...args
) => beginAimlapiEmailOnboardingImpl(...args)
export const completeAimlapiCodeSignIn: typeof completeAimlapiCodeSignInImpl = (
...args
) => completeAimlapiCodeSignInImpl(...args)
export const validateAimlapiApiKey: typeof validateAimlapiApiKeyImpl = (...args) =>
validateAimlapiApiKeyImpl(...args)
export const claimAimlapiTopupStateAsync: typeof claimAimlapiTopupStateAsyncImpl = (
...args
) => claimAimlapiTopupStateAsyncImpl(...args)
export const clearAimlapiTopupStateAsync: typeof clearAimlapiTopupStateAsyncImpl = (
...args
) => clearAimlapiTopupStateAsyncImpl(...args)
export const recordAimlapiCheckoutSessionAsync: typeof recordAimlapiCheckoutSessionAsyncImpl = (
...args
) => recordAimlapiCheckoutSessionAsyncImpl(...args)
export const resetAimlapiCheckoutSessionAsync: typeof resetAimlapiCheckoutSessionAsyncImpl = (
...args
) => resetAimlapiCheckoutSessionAsyncImpl(...args)
export const saveAimlapiTopupStateAsync: typeof saveAimlapiTopupStateAsyncImpl = (...args) =>
saveAimlapiTopupStateAsyncImpl(...args)
export const reconcileSettledAimlapiTopupStateAsync: typeof reconcileSettledAimlapiTopupStateAsyncImpl = (
...args
) => reconcileSettledAimlapiTopupStateAsyncImpl(...args)
export const aimlapiByKeyIdentity: typeof aimlapiByKeyIdentityImpl = (...args) =>
aimlapiByKeyIdentityImpl(...args)
export const loadAimlapiSignInKey: typeof loadAimlapiSignInKeyImpl = (...args) =>
loadAimlapiSignInKeyImpl(...args)
export const saveAimlapiSignInKeyAsync: typeof saveAimlapiSignInKeyAsyncImpl = (...args) =>
saveAimlapiSignInKeyAsyncImpl(...args)
export const clearAimlapiSignInKeyAsync: typeof clearAimlapiSignInKeyAsyncImpl = (...args) =>
clearAimlapiSignInKeyAsyncImpl(...args)
+60 -68
View File
@@ -8,10 +8,23 @@ afterEach(() => {
globalThis.fetch = originalFetch
})
// The default *.example.test hosts stand in for a user-overridden (non-aimlapi)
// deployment: attribution headers must be withheld from them.
const endpoints: AimlapiEndpoints = {
authBaseUrl: 'https://auth.example.test',
appBaseUrl: 'https://app.example.test',
payBaseUrl: 'https://pay.example.test',
inferenceBaseUrl: 'https://api.example.test/v1',
verificationBaseUrl: 'https://front.example.test',
}
// Production AI/ML API hosts, which DO receive the mandatory attribution headers.
const canonicalEndpoints: AimlapiEndpoints = {
authBaseUrl: 'https://auth.aimlapi.com',
appBaseUrl: 'https://app.aimlapi.com',
payBaseUrl: 'https://pay.aimlapi.com',
inferenceBaseUrl: 'https://api.aimlapi.com/v1',
verificationBaseUrl: 'https://aimlapi.com/app',
}
function jsonResponse(value: unknown): Response {
@@ -111,9 +124,9 @@ test('pay only sends autoTopUp when it is enabled', async () => {
])
})
test('pay carries the selected method and omits an absent payment session id', async () => {
// The password flow lets the user pick crypto and has no payment session id;
// both must survive alongside the passwordless defaults.
test('pay omits an absent payment session id and always sends card', async () => {
// Card is the only rail; the field is always present and there is no payment
// session id here, so the body carries just the amount + return URL + card.
const bodies: unknown[] = []
globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
bodies.push(typeof init?.body === 'string' ? JSON.parse(init.body) : undefined)
@@ -123,11 +136,10 @@ test('pay carries the selected method and omits an absent payment session id', a
const client = new AimlapiClient(endpoints)
await client.pay('bearer', 'session', {
amountUsdMinor: 2500,
method: 'crypto',
successUrl: 'https://ok.test',
})
expect(bodies).toEqual([
{ amountUsdMinor: 2500, method: 'crypto', successUrl: 'https://ok.test' },
{ amountUsdMinor: 2500, method: 'card', successUrl: 'https://ok.test' },
])
})
@@ -209,14 +221,16 @@ test('sendSignInCode accepts a non-empty plain-text acknowledgement', async () =
)
})
test('a receipt whose payUrl cannot be opened is rejected', async () => {
// `payUrl` goes straight to openBrowser; a value it cannot open would leave the
// flow polling for 20 minutes with no usable checkout link after the charge.
test('a receipt whose payUrl is not HTTPS is rejected at the response boundary', async () => {
// `payUrl` must be HTTPS (it is opened in a browser and matches announceCheckout).
// Rejecting a cleartext/unopenable URL here stops the session being retained and
// then polled for 20 minutes with no usable checkout link after the charge.
for (const payUrl of [
'not-a-url',
'javascript:alert(1)',
'file:///tmp/checkout',
'ftp://checkout.test/pay',
'http://checkout.test/pay',
]) {
globalThis.fetch = mock(async () =>
jsonResponse(payReceipt({ checkout: { providerSessionId: 'provider', payUrl } })),
@@ -326,7 +340,7 @@ test('a JSON-escaped credential is redacted from a reflected body', async () =>
const client = new AimlapiClient(endpoints)
const error = await client
.login('user@example.com', password)
.verifySignInCode('user@example.com', password)
.then(() => null, (reason: unknown) => reason)
expect(error).toBeInstanceOf(AimlapiApiError)
@@ -402,65 +416,6 @@ test('a short session token is still redacted from transport errors', async () =
expect((error as AimlapiApiError).message).toContain('[REDACTED]')
})
test('password sign-up and sign-in keep their existing contracts', async () => {
const calls: Array<{ method?: string; url: string; body?: unknown }> = []
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
calls.push({
method: init?.method,
url: String(input),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
})
return jsonResponse({ token: 'legacy-bearer', exp: 7 })
}) as unknown as typeof fetch
const client = new AimlapiClient(endpoints)
expect(
await client.signup({
email: 'user@example.com',
password: 'secret',
inviteCode: 'invite',
}),
).toEqual({ token: 'legacy-bearer', exp: 7 })
expect(await client.login('user@example.com', 'secret')).toEqual({
token: 'legacy-bearer',
exp: 7,
})
expect(calls).toEqual([
{
method: 'POST',
url: 'https://auth.example.test/v1/auth/account',
body: { email: 'user@example.com', password: 'secret', inviteCode: 'invite' },
},
{
method: 'PUT',
url: 'https://auth.example.test/v1/auth/account',
body: { email: 'user@example.com', password: 'secret' },
},
])
})
test('password methods reject a response without a token', async () => {
globalThis.fetch = mock(async () => jsonResponse({ exp: 1 })) as unknown as typeof fetch
const client = new AimlapiClient(endpoints)
// A malformed success payload must surface the same error contract as every
// other endpoint, so a caller can branch on the type/status uniformly instead
// of special-casing the auth paths.
for (const call of [
() => client.signup({ email: 'user@example.com', password: 'secret' }),
() => client.login('user@example.com', 'secret'),
]) {
const error = await call().then(
() => null,
(reason: unknown) => reason,
)
expect(error).toBeInstanceOf(AimlapiApiError)
expect((error as AimlapiApiError).status).toBe(200)
expect((error as AimlapiApiError).message).toContain('did not return an auth token')
}
})
test('topUpByKey uses the v2 billing endpoint and API key bearer', async () => {
let seenUrl = ''
let seenHeaders = new Headers()
@@ -655,3 +610,40 @@ test('typed methods reject wrong-typed success fields without a raw TypeError',
expect(accountError).toBeInstanceOf(AimlapiApiError)
expect(accountError).toHaveProperty('status', 200)
})
test('every request to an AI/ML API host carries the mandatory attribution headers', async () => {
let headers = new Headers()
globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
headers = new Headers(init?.headers)
return jsonResponse({ action: 'sign-in' })
}) as unknown as typeof fetch
const client = new AimlapiClient(canonicalEndpoints)
await client.checkAccount('user@example.com')
// Both headers are mandatory on EVERY aimlapi request (auth / checkout /
// catalog): the integration source and the partner id.
expect(headers.get('X-AIMLAPI-Source')).toBe('agent/openclaude')
const partner = headers.get('X-AIMLAPI-Partner-ID')
expect(partner).toBeTruthy()
expect(partner?.startsWith('part_')).toBe(true)
})
test('attribution headers are withheld from an overridden (non-aimlapi) inference host', async () => {
let headers = new Headers()
globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
headers = new Headers(init?.headers)
return jsonResponse({ balance: 10, lowBalance: false, lowBalanceThreshold: 20 })
}) as unknown as typeof fetch
// inferenceBaseUrl points at api.example.test (a user proxy). The balance probe
// must not leak OpenClaude's partner/source identity to a third-party host,
// mirroring the inference/catalog stripping contract.
const client = new AimlapiClient(endpoints)
await client.getBalance('key_test')
expect(headers.get('X-AIMLAPI-Source')).toBeNull()
expect(headers.get('X-AIMLAPI-Partner-ID')).toBeNull()
// The caller's own credential still rides — only OpenClaude attribution is gated.
expect(headers.get('Authorization')).toBe('Bearer key_test')
})
+31 -54
View File
@@ -1,7 +1,14 @@
/** AI/ML API passwordless onboarding and partner-checkout HTTP client. */
import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js'
import type { AimlapiEndpoints } from './config.js'
import {
AIMLAPI_SOURCE,
isTrustedAimlapiRequestUrl,
PARTNER_HEADER_NAME,
resolvePartnerId,
SOURCE_HEADER_NAME,
type AimlapiEndpoints,
} from './config.js'
export type PartnerCheckoutSessionStatus =
| 'pending_auth'
@@ -39,11 +46,6 @@ export type TopUpByKeyResult = PayResult
export type ExchangeResult = { apiKey: string; apiKeyId: string }
export type AuthResult = { token: string; exp: number }
/**
* Payment method for the password-based checkout. The passwordless flow always
* pays by card; this is retained while the top-up flow still offers the choice.
*/
export type PaymentMethod = 'card' | 'crypto'
export type AccountCheckResult = {
action: 'sign-in' | 'sign-up'
provider?: string | null
@@ -143,11 +145,14 @@ function isNullableFiniteNumber(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isFinite(value))
}
/** Consumers hand `payUrl` straight to `openBrowser`, which only opens HTTP(S). */
function isOpenableHttpUrl(value: string): boolean {
/**
* The checkout `payUrl` is opened in a browser and must be HTTPS — reject a
* cleartext URL at the response boundary so a session is never retained with an
* address the flow will only refuse later (matching `announceCheckout`).
*/
function isHttpsUrl(value: string): boolean {
try {
const { protocol } = new URL(value)
return protocol === 'https:' || protocol === 'http:'
return new URL(value).protocol === 'https:'
} catch {
return false
}
@@ -193,7 +198,7 @@ function isPaymentSession(value: unknown): value is PaymentSession {
isRecord(value) &&
isNonEmptyString(value.providerSessionId) &&
(value.payUrl === null ||
(isNonEmptyString(value.payUrl) && isOpenableHttpUrl(value.payUrl)))
(isNonEmptyString(value.payUrl) && isHttpsUrl(value.payUrl)))
)
}
@@ -287,45 +292,6 @@ export class AimlapiApiError extends Error {
export class AimlapiClient {
constructor(private readonly endpoints: AimlapiEndpoints) {}
/** Register a password account -> access (Bearer) token. */
async signup(
input: { email: string; password: string; inviteCode?: string },
signal?: AbortSignal,
): Promise<AuthResult> {
const result = await this.request<unknown>(
`${this.endpoints.authBaseUrl}/v1/auth/account`,
{
method: 'POST',
body: {
email: input.email,
password: input.password,
...(input.inviteCode ? { inviteCode: input.inviteCode } : {}),
},
signal,
secrets: [input.password, input.inviteCode],
},
)
if (!isAuthResult(result)) {
throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '')
}
return result
}
/** Sign in with email + password -> access (Bearer) token. */
async login(
email: string,
password: string,
signal?: AbortSignal,
): Promise<AuthResult> {
const result = await this.request<unknown>(
`${this.endpoints.authBaseUrl}/v1/auth/account`,
{ method: 'PUT', body: { email, password }, signal, secrets: [password] },
)
if (!isAuthResult(result)) {
throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '')
}
return result
}
async checkAccount(email: string, signal?: AbortSignal): Promise<AccountCheckResult> {
const url = `${this.endpoints.authBaseUrl}/v1/auth/account`
@@ -454,8 +420,6 @@ export class AimlapiClient {
amountUsdMinor: number
/** Supplied by the passwordless flow to make the charge idempotent. */
paymentSessionId?: string
/** Password flow lets the user choose; the passwordless flow uses card. */
method?: PaymentMethod
successUrl?: string
cancelUrl?: string
autoTopUp?: boolean
@@ -471,7 +435,8 @@ export class AimlapiClient {
...(input.paymentSessionId
? { paymentSessionId: input.paymentSessionId }
: {}),
method: input.method ?? 'card',
// Card is the only supported rail; the backend still expects the field.
method: 'card',
...(input.successUrl ? { successUrl: input.successUrl } : {}),
...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}),
...(input.autoTopUp ? { autoTopUp: true } : {}),
@@ -567,7 +532,19 @@ export class AimlapiClient {
redacted.name = error.name
return redacted
}
const headers: Record<string, string> = { Accept: 'application/json' }
// Both mandatory attribution headers (integration source + partner id) ride
// on every request to an AI/ML API host — auth, checkout, and the balance
// probe. The auth/app/pay/inference base URLs are all env-overridable, so a
// request pointed at a user-controlled proxy must NOT carry OpenClaude's
// partner/source identity — mirroring the inference/catalog stripping
// contract in resolveAimlapiAttributionHeaders.
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (isTrustedAimlapiRequestUrl(url)) {
headers[SOURCE_HEADER_NAME] = AIMLAPI_SOURCE
headers[PARTNER_HEADER_NAME] = resolvePartnerId()
}
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
if (options.bearer) headers.Authorization = `Bearer ${options.bearer.trim()}`
+95 -3
View File
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import {
buildPartnerCheckoutReturnUrls,
buildPartnerReturnUrl,
isCanonicalAimlapiInferenceBaseUrl,
isTrustedAimlapiRequestUrl,
resolveAimlapiAttributionHeaders,
resolvePartnerId,
resolveEndpoints,
withResolvedPartnerHeader,
@@ -10,7 +14,10 @@ import {
const envNames = [
'AIMLAPI_AUTH_URL',
'AIMLAPI_APP_URL',
'AIMLAPI_PAY_URL',
'AIMLAPI_INFERENCE_URL',
'AIMLAPI_VERIFICATION_BASE_URL',
'AIMLAPI_RETURN_URL',
'AIMLAPI_PARTNER_ID',
] as const
const originalEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]]))
@@ -34,20 +41,24 @@ test('resolveEndpoints returns the production endpoints', () => {
expect(resolveEndpoints()).toEqual({
authBaseUrl: 'https://auth.aimlapi.com',
appBaseUrl: 'https://app.aimlapi.com',
payBaseUrl: 'https://pay.aimlapi.com',
inferenceBaseUrl: 'https://api.aimlapi.com/v1',
verificationBaseUrl: 'https://aimlapi.com/app',
})
})
test('partner id override is shared with the inference header', () => {
test('partner id is fixed and ignores the env override', () => {
process.env.AIMLAPI_PARTNER_ID = 'part_override'
expect(resolvePartnerId()).toBe('part_override')
// The partner id is locked to OpenClaude's attribution id; an env override is
// intentionally ignored so rebate attribution can never be redirected.
expect(resolvePartnerId()).toBe('part_62yQoGYDq4Yqnrj2R1iGrDNJ')
expect(
withResolvedPartnerHeader({
'x-aimlapi-partner-id': 'part_catalog',
'X-Title': 'OpenClaude',
}),
).toEqual({
'X-AIMLAPI-Partner-ID': 'part_override',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-Title': 'OpenClaude',
})
})
@@ -66,6 +77,87 @@ test('canonical endpoint check excludes proxies and look-alike paths', () => {
// A different protocol/host is never canonical.
expect(isCanonicalAimlapiInferenceBaseUrl('http://api.aimlapi.com/v1')).toBe(false)
expect(isCanonicalAimlapiInferenceBaseUrl('https://proxy.example.test/v1')).toBe(false)
// A query, fragment, bare delimiter, or embedded credential is non-canonical:
// written verbatim as OPENAI_BASE_URL it would break the shim's path append.
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1?x=1')).toBe(false)
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1#x')).toBe(false)
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1?')).toBe(false)
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1#')).toBe(false)
expect(isCanonicalAimlapiInferenceBaseUrl('https://user:pass@api.aimlapi.com/v1')).toBe(false)
// Garbage input fails closed.
expect(isCanonicalAimlapiInferenceBaseUrl('not-a-url')).toBe(false)
})
test('checkout return URLs require a credential-free HTTPS base', () => {
const { successUrl, cancelUrl } = buildPartnerCheckoutReturnUrls(
'https://pay.aimlapi.com',
'sess_1',
)
expect(successUrl).toContain('https://pay.aimlapi.com/checkout?')
expect(successUrl).toContain('sessionToken=sess_1')
expect(cancelUrl).toContain('checkout=cancel')
// These URLs carry the resumable session token, so a cleartext or credentialed
// base must be rejected before a session is created.
expect(() => buildPartnerCheckoutReturnUrls('http://pay.aimlapi.com', 'sess_1')).toThrow(
/https/i,
)
expect(() =>
buildPartnerCheckoutReturnUrls('https://user:pass@pay.aimlapi.com', 'sess_1'),
).toThrow(/credential/i)
// A query string or fragment would swallow the appended checkout params.
expect(() =>
buildPartnerCheckoutReturnUrls('https://pay.aimlapi.com/#resume', 'sess_1'),
).toThrow(/query string or fragment/i)
expect(() =>
buildPartnerCheckoutReturnUrls('https://pay.aimlapi.com/?x=1', 'sess_1'),
).toThrow(/query string or fragment/i)
// Bare `?`/`#` delimiters leave url.search/url.hash empty but still corrupt the
// appended checkout params, so they must be rejected too.
expect(() => buildPartnerCheckoutReturnUrls('https://pay.aimlapi.com/?', 'sess_1')).toThrow(
/query string or fragment/i,
)
expect(() => buildPartnerCheckoutReturnUrls('https://pay.aimlapi.com/#', 'sess_1')).toThrow(
/query string or fragment/i,
)
expect(() => buildPartnerCheckoutReturnUrls('not-a-url', 'sess_1')).toThrow()
})
test('the browser return URL ignores a non-HTTPS override', () => {
process.env.AIMLAPI_RETURN_URL = 'http://landing.example.test'
expect(buildPartnerReturnUrl('https://front.example.test')).toBe('https://front.example.test')
process.env.AIMLAPI_RETURN_URL = 'https://landing.example.test'
expect(buildPartnerReturnUrl('https://front.example.test')).toBe('https://landing.example.test')
// A bare `?`/`#` delimiter is ignored like any other malformed base.
process.env.AIMLAPI_RETURN_URL = 'https://landing.example.test/#'
expect(buildPartnerReturnUrl('https://front.example.test')).toBe('https://front.example.test')
delete process.env.AIMLAPI_RETURN_URL
expect(buildPartnerReturnUrl('http://front.example.test')).toBe('https://aimlapi.com/app')
})
test('trusted-host gate accepts only https aimlapi.com hosts', () => {
// Production + staging aimlapi hosts (any path) are trusted.
expect(isTrustedAimlapiRequestUrl('https://auth.aimlapi.com/v1/auth/account')).toBe(true)
expect(isTrustedAimlapiRequestUrl('https://api.aimlapi.com/v1/billing/balance')).toBe(true)
expect(isTrustedAimlapiRequestUrl('https://aimlapi.com/app')).toBe(true)
expect(isTrustedAimlapiRequestUrl('https://auth.staging.aimlapi.com/v1')).toBe(true)
// A user proxy, look-alike hosts, and cleartext are all untrusted.
expect(isTrustedAimlapiRequestUrl('https://proxy.example.test/v1')).toBe(false)
expect(isTrustedAimlapiRequestUrl('https://notaimlapi.com/v1')).toBe(false)
expect(isTrustedAimlapiRequestUrl('https://aimlapi.com.attacker.test/v1')).toBe(false)
expect(isTrustedAimlapiRequestUrl('http://api.aimlapi.com/v1')).toBe(false)
expect(isTrustedAimlapiRequestUrl('not-a-url')).toBe(false)
})
test('inference/catalog attribution sends both mandatory headers, stripped off-canonical', () => {
const canonical = resolveAimlapiAttributionHeaders({}, 'https://api.aimlapi.com/v1')
expect(canonical['X-AIMLAPI-Source']).toBe('agent/openclaude')
expect(canonical['X-AIMLAPI-Partner-ID']).toBe('part_62yQoGYDq4Yqnrj2R1iGrDNJ')
// A user proxy must never receive OpenClaude's partner identity or source.
const proxied = resolveAimlapiAttributionHeaders(
{ 'X-AIMLAPI-Source': 'agent/openclaude', 'X-AIMLAPI-Partner-ID': 'part_x' },
'https://proxy.example.test/v1',
)
expect(proxied['X-AIMLAPI-Source']).toBeUndefined()
expect(proxied['X-AIMLAPI-Partner-ID']).toBeUndefined()
})
+131 -20
View File
@@ -6,8 +6,7 @@
* OpenClaude's provider profile automatically. Usage attributes to the Gitlawb
* rebate partner (see the partner id below).
*
* Override any single URL via the `AIMLAPI_AUTH_URL`, `AIMLAPI_APP_URL`, or
* `AIMLAPI_INFERENCE_URL` env vars.
* Override any single URL via the corresponding `AIMLAPI_*_URL` env var.
*/
export type AimlapiEndpoints = {
@@ -15,14 +14,20 @@ export type AimlapiEndpoints = {
authBaseUrl: string
/** app/gateway BFF - hosts `/v3/partner-checkout/*`. */
appBaseUrl: string
/** hosted checkout frontend base URL (return URLs redirect here). */
payBaseUrl: string
/** OpenAI-compatible inference base URL written into the provider profile. */
inferenceBaseUrl: string
/** browser landing page after checkout / consent completes. */
verificationBaseUrl: string
}
const DEFAULT_ENDPOINTS: AimlapiEndpoints = {
authBaseUrl: 'https://auth.aimlapi.com',
appBaseUrl: 'https://app.aimlapi.com',
payBaseUrl: 'https://pay.aimlapi.com',
inferenceBaseUrl: 'https://api.aimlapi.com/v1',
verificationBaseUrl: 'https://aimlapi.com/app',
}
/**
@@ -35,9 +40,18 @@ const DEFAULT_ENDPOINTS: AimlapiEndpoints = {
export const DEFAULT_PARTNER_ID = 'part_62yQoGYDq4Yqnrj2R1iGrDNJ'
export const DEFAULT_PARTNER_NAME = 'Gitlawb'
export const PARTNER_HEADER_NAME = 'X-AIMLAPI-Partner-ID'
export const SOURCE_HEADER_NAME = 'X-AIMLAPI-Source'
/**
* Attribution `source` sent on EVERY aimlapi request (inference, catalog, auth,
* checkout) alongside the partner id — identifies OpenClaude as the integration
* client, matching the `agent/<client>` convention (e.g. `agent/zero`).
*/
export const AIMLAPI_SOURCE = 'agent/openclaude'
/** Default model id written into the profile - override with `--model`. */
export const DEFAULT_MODEL = 'gpt-4o'
/** Fallback browser landing page after checkout when no override applies. */
export const DEFAULT_RETURN_URL = 'https://aimlapi.com/app'
/** Top-up bounds enforced by the backend DTO (USD minor units / cents). */
export const MIN_AMOUNT_USD_MINOR = 2000 // $20
@@ -48,35 +62,38 @@ export function resolveEndpoints(): AimlapiEndpoints {
return {
authBaseUrl: process.env.AIMLAPI_AUTH_URL?.trim() || DEFAULT_ENDPOINTS.authBaseUrl,
appBaseUrl: process.env.AIMLAPI_APP_URL?.trim() || DEFAULT_ENDPOINTS.appBaseUrl,
payBaseUrl: process.env.AIMLAPI_PAY_URL?.trim() || DEFAULT_ENDPOINTS.payBaseUrl,
inferenceBaseUrl:
process.env.AIMLAPI_INFERENCE_URL?.trim() || DEFAULT_ENDPOINTS.inferenceBaseUrl,
verificationBaseUrl:
process.env.AIMLAPI_VERIFICATION_BASE_URL?.trim() ||
DEFAULT_ENDPOINTS.verificationBaseUrl,
}
}
/** Resolve checkout and inference attribution with one shared precedence. */
export function resolvePartnerId(explicit?: string): string {
return (
explicit?.trim() ||
process.env.AIMLAPI_PARTNER_ID?.trim() ||
DEFAULT_PARTNER_ID
)
}
/**
* Return a header copy with the effective partner id. Header matching is
* case-insensitive so an override replaces the catalog spelling instead of
* creating a duplicate header.
* The partner id is locked to OpenClaude's own attribution id. It is
* deliberately NOT user-overridable (no CLI flag, no env var): letting a caller
* change it would redirect rebate/revenue-share attribution away from OpenClaude.
*/
export function resolvePartnerId(): string {
return DEFAULT_PARTNER_ID
}
/**
* Return a header copy carrying the fixed partner id. Header matching is
* case-insensitive so it replaces the catalog spelling instead of creating a
* duplicate header.
*/
export function withResolvedPartnerHeader(
headers: Readonly<Record<string, string>>,
explicit?: string,
): Record<string, string> {
const resolved: Record<string, string> = {}
for (const [name, value] of Object.entries(headers)) {
if (name.trim().toLowerCase() === PARTNER_HEADER_NAME.toLowerCase()) continue
resolved[name] = value
}
resolved[PARTNER_HEADER_NAME] = resolvePartnerId(explicit)
resolved[PARTNER_HEADER_NAME] = resolvePartnerId()
return resolved
}
@@ -84,7 +101,14 @@ function parseCanonicalUrl(
value: string,
): { origin: string; pathname: string } | null {
try {
const url = new URL(value.trim())
const trimmed = value.trim()
const url = new URL(trimmed)
// Credentials, a query, or a fragment (even a bare `?`/`#`) make this
// non-canonical: it is written verbatim as OPENAI_BASE_URL and the OpenAI
// shim concatenates `/chat/completions` onto the raw string, which a trailing
// `?x`/`#x` would push into the query/fragment (server then sees only `/v1`).
if (url.username || url.password) return null
if (trimmed.includes('?') || trimmed.includes('#')) return null
// `origin` already lowercases protocol and host. Collapse only a single
// trailing slash so `/v1` and `/v1/` match, while `/v1//`, `/V1`, or
// `/v1/anything` stay distinct from the canonical `/v1` path.
@@ -111,6 +135,25 @@ export function isCanonicalAimlapiInferenceBaseUrl(value: string): boolean {
)
}
/**
* True when an outbound client request targets an AI/ML API-controlled host
* (production or staging under `aimlapi.com`, over HTTPS). The auth/app/pay/
* inference base URLs are all env-overridable, so the mandatory attribution
* headers are gated on this: a request pointed at a user proxy must not carry
* OpenClaude's partner/source identity, mirroring the inference/catalog
* stripping contract in `resolveAimlapiAttributionHeaders`.
*/
export function isTrustedAimlapiRequestUrl(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'https:') return false
const host = parsed.hostname.toLowerCase()
return host === 'aimlapi.com' || host.endsWith('.aimlapi.com')
} catch {
return false
}
}
/**
* Attribution headers AI/ML API records for canonical `api.aimlapi.com`
* traffic. They identify the partner and the referring integration, so they
@@ -118,6 +161,7 @@ export function isCanonicalAimlapiInferenceBaseUrl(value: string): boolean {
*/
const CATALOG_ATTRIBUTION_HEADER_NAMES = new Set([
PARTNER_HEADER_NAME.toLowerCase(),
SOURCE_HEADER_NAME.toLowerCase(),
'x-aimlapi-integration-repo',
'x-aimlapi-integration-version',
'http-referer',
@@ -139,7 +183,8 @@ export function resolveAimlapiAttributionHeaders(
baseUrl: string | undefined,
): Record<string, string> {
if (!baseUrl || isCanonicalAimlapiInferenceBaseUrl(baseUrl)) {
return withResolvedPartnerHeader(headers)
// Canonical endpoint: send BOTH mandatory attribution headers.
return { ...withResolvedPartnerHeader(headers), [SOURCE_HEADER_NAME]: AIMLAPI_SOURCE }
}
return Object.fromEntries(
@@ -158,10 +203,13 @@ export function resolveAimlapiAttributionHeaders(
* `/checkout?checkout=success` that is NOT co-branded.
*/
export function buildPartnerCheckoutReturnUrls(
appBaseUrl: string,
payBaseUrl: string,
sessionToken: string,
): { successUrl: string; cancelUrl: string } {
const base = appBaseUrl.replace(/\/+$/, '')
// The return URLs carry the resumable sessionToken, so the checkout base MUST
// be a credential-free HTTPS URL — a cleartext callback would hand the payment
// provider a browser link containing the checkout credential.
const base = requireHttpsBaseUrl(payBaseUrl, 'AIMLAPI_PAY_URL').replace(/\/+$/, '')
const token = encodeURIComponent(sessionToken)
const query = (status: string): string =>
`checkout=${status}&partnerCheckout=1&sessionToken=${token}`
@@ -170,3 +218,66 @@ export function buildPartnerCheckoutReturnUrls(
cancelUrl: `${base}/checkout?${query('cancel')}`,
}
}
/**
* Browser landing URL after checkout. OpenClaude learns success by polling, so
* this must be an ordinary HTTP(S) page rather than an unregistered custom
* scheme. Precedence: the `AIMLAPI_RETURN_URL` override, then the resolved
* frontend base URL, then the packaged default.
*/
export function buildPartnerReturnUrl(frontendBaseUrl: string): string {
return (
safeHttpsBaseUrl(process.env.AIMLAPI_RETURN_URL) ??
safeHttpsBaseUrl(frontendBaseUrl) ??
DEFAULT_RETURN_URL
)
}
/** A trimmed https:// base URL without embedded credentials, or null. */
function safeHttpsBaseUrl(value: string | undefined): string | null {
const candidate = value?.trim()
if (!candidate) return null
try {
const url = new URL(candidate)
// The setup guide promises an HTTPS return target; a cleartext landing page
// is not honored. Embedded credentials are never legitimate here either.
if (url.protocol !== 'https:') return null
if (url.username || url.password) return null
// Reject any raw `?`/`#` — a bare delimiter leaves url.search/url.hash empty
// yet still swallows the appended `/checkout?...sessionToken`.
if (candidate.includes('?') || candidate.includes('#')) return null
return candidate
} catch {
return null
}
}
/**
* Require a credential-free https:// base URL, throwing otherwise. Used for the
* checkout base, whose return URLs embed the resumable session token and must
* never be sent over cleartext.
*/
function requireHttpsBaseUrl(value: string, label: string): string {
const candidate = value.trim()
let url: URL
try {
url = new URL(candidate)
} catch {
throw new Error(`${label} must be a valid https:// URL.`)
}
if (url.protocol !== 'https:') {
throw new Error(
`${label} must use https:// so the checkout callback carrying the session token is not sent in cleartext.`,
)
}
if (url.username || url.password) {
throw new Error(`${label} must not embed credentials.`)
}
// Reject any raw `?`/`#` — a bare delimiter (e.g. `https://pay.aimlapi.com/?`)
// leaves url.search/url.hash empty yet still swallows the appended
// `/checkout?...sessionToken=...` into the query/fragment.
if (candidate.includes('?') || candidate.includes('#')) {
throw new Error(`${label} must not include a query string or fragment.`)
}
return candidate
}
+16 -3
View File
@@ -1,12 +1,25 @@
export {
provisionAimlapiKey,
runAimlapiTopup,
topUpAimlapiByApiKey,
type AimlapiByKeyTopupOptions,
type AimlapiProvisionOptions,
type AimlapiProvisionedKey,
type AimlapiTopupOptions,
type AimlapiTopupStatus,
} from './topup.js'
export {
isValidAimlapiEmail,
isValidAimlapiSignInCode,
parseAimlapiAmountUsd,
} from './validation.js'
export {
beginAimlapiEmailOnboarding,
completeAimlapiCodeSignIn,
validateAimlapiApiKey,
type AimlapiCodeSignInResult,
type AimlapiEmailOnboardingResult,
} from './onboarding.js'
export { AimlapiClient, AimlapiApiError } from './client.js'
export type {
AimlapiEndpoints,
} from './config.js'
export { AIMLAPI_MESSAGES } from './messages.js'
export type { AimlapiEndpoints } from './config.js'
+30
View File
@@ -0,0 +1,30 @@
/** Canonical AIMLAPI onboarding copy ported from the aimlapi.com (Zero) flow. */
export const AIMLAPI_MESSAGES = {
apiKeyInputPrompt: 'Enter your aimlapi.com key.',
apiKeyHiddenHint: 'Your API key will be hidden and verified automatically.',
apiKeyInvalid:
'API key is invalid. Please make sure you enter a valid aimlapi.com key.',
pickPathPrompt: 'Do you have an aimlapi.com key?',
pickPathHaveKey: 'I already have an aimlapi.com key',
pickPathNewUser: 'I am a new user',
enterEmail: 'Enter your email.',
emailInvalid: 'Email format is incorrect.',
codeSent: (email: string) => `Enter the 6-digit code sent to ${email}.`,
codeIncorrect: "Code you've entered is incorrect.",
lowBalance: 'Your aimlapi.com credits are running low - top up now?',
lowBalanceTopUp: "Sure, let's do that",
lowBalanceSkip: "I'll skip for now",
topUpPrompt: 'Add credits (min $20).',
amountRequired: 'Please enter a top-up amount.',
guidedNeedsCanonicalEndpoint:
'Creating a new key requires the aimlapi.com production endpoint. Unset AIMLAPI_INFERENCE_URL, or paste an existing key to use a custom endpoint.',
topUpBrowserFallback:
'If the browser did not open automatically please use this link to top up your account:',
topUpFailed: 'Top up failed. Please try again.',
everythingRuns: 'Everything is ready.',
topUpSuccess: (amountUsd: string) =>
`Top-up successful - $${amountUsd} credited to your account`,
successMagicLink: (email: string) =>
`Your aimlapi.com account is ready. Sign in at https://aimlapi.com/app with ${email} to review your usage.`,
} as const
+632
View File
@@ -0,0 +1,632 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js'
import {
beginAimlapiEmailOnboarding,
completeAimlapiCodeSignIn,
validateAimlapiApiKey,
} from './onboarding.js'
import {
acquireAimlapiSignInKeyLeaseAsync,
loadAimlapiSignInKey,
saveAimlapiSignInKey,
} from './topupState.js'
const originalFetch = globalThis.fetch
const originalEnv = {
AIMLAPI_AUTH_URL: process.env.AIMLAPI_AUTH_URL,
AIMLAPI_APP_URL: process.env.AIMLAPI_APP_URL,
AIMLAPI_INFERENCE_URL: process.env.AIMLAPI_INFERENCE_URL,
}
// completeAimlapiCodeSignIn persists the sign-in key cache/lease to disk (see
// mintOrAdoptSignInKey), so tests need an isolated config dir per test —
// otherwise they'd read/write the real ~/.openclaude and bleed into each other.
let configDirectory: string
beforeEach(() => {
configDirectory = mkdtempSync(join(tmpdir(), 'openclaude-aimlapi-onboarding-'))
setClaudeConfigHomeDirForTesting(configDirectory)
})
afterEach(() => {
globalThis.fetch = originalFetch
setClaudeConfigHomeDirForTesting(undefined)
rmSync(configDirectory, { force: true, recursive: true })
for (const [name, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
})
function response(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status })
}
// Permission bits don't restrict a root process (common in CI containers), so
// a chmod-based unreadable-file test would spuriously pass without exercising
// anything. Detect that case and skip rather than assert on it.
function readableDespiteNoPermissions(path: string): boolean {
if (process.platform === 'win32') return true
try {
readFileSync(path, 'utf8')
return true
} catch {
return false
}
}
test('existing account onboarding sends a code, creates a key, and reports low balance', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_APP_URL = 'https://app.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const calls: string[] = []
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
calls.push(`${init?.method} ${url}`)
if (url.endsWith('/v1/auth/account')) return response({ action: 'sign-in' })
if (url.endsWith('/v1/auth/sign-in/code')) return new Response('', { status: 204 })
if (url.endsWith('/code/verify')) return response({ token: 'session', exp: 1 })
if (url.endsWith('/v1/keys')) return response({ key: 'key_test', id: 'id_test' })
if (url.endsWith('/billing/balance')) {
return response({ balance: 5, lowBalance: true, lowBalanceThreshold: 20 })
}
return response({}, 404)
}) as unknown as typeof fetch
expect(await beginAimlapiEmailOnboarding('user@example.com')).toEqual({
action: 'code-sent',
})
expect(await completeAimlapiCodeSignIn('user@example.com', '123456')).toEqual({
sessionToken: 'session',
apiKey: 'key_test',
apiKeyId: 'id_test',
balanceStatus: 'confirmed',
lowBalance: true,
})
expect(calls).toEqual([
'PATCH https://auth.example.test/v1/auth/account',
'POST https://auth.example.test/v1/auth/sign-in/code',
'POST https://auth.example.test/v1/auth/sign-in/code/verify',
'POST https://app.example.test/v1/keys',
'GET https://api.example.test/v1/billing/balance',
])
})
test('balance failures preserve the issued key without marking it ready', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_APP_URL = 'https://app.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'session', exp: 1 })
if (url.endsWith('/v1/keys')) return response({ key: 'key_test', id: 'id_test' })
return response({ error: 'unavailable' }, 503)
}) as unknown as typeof fetch
const result = await completeAimlapiCodeSignIn('user@example.com', '123456')
expect(result).toEqual({
sessionToken: 'session',
apiKey: 'key_test',
apiKeyId: 'id_test',
balanceStatus: 'unknown',
balanceError: 'GET https://api.example.test -> 503',
})
expect(result).not.toHaveProperty('lowBalance')
})
test('an aborted balance read after minting still returns the issued key', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const controller = new AbortController()
const calls: string[] = []
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
calls.push(url)
if (url.endsWith('/code/verify')) return response({ token: 'session', exp: 1 })
if (url.endsWith('/v1/keys')) return response({ key: 'minted_key', id: 'minted_id' })
if (url.endsWith('/billing/balance')) {
// Abort mid-read, after the key has already been minted.
controller.abort()
throw new Error('balance aborted')
}
return response({}, 404)
}) as unknown as typeof fetch
const result = await completeAimlapiCodeSignIn(
'user@example.com',
'123456',
controller.signal,
)
// The mint is irreversible, so an aborted balance read must still surface the
// key (as unknown balance) rather than rethrow and orphan a paid credential.
expect(result.apiKey).toBe('minted_key')
expect(result.apiKeyId).toBe('minted_id')
expect(result.balanceStatus).toBe('unknown')
expect(result).not.toHaveProperty('lowBalance')
// Exactly one mint: the abort must not trigger a second key on the next run.
expect(calls.filter(url => url.endsWith('/v1/keys')).length).toBe(1)
})
test('new account onboarding returns a passwordless session', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
return url.endsWith('/passwordless')
? response({ token: 'new-session', exp: 1 })
: response({ action: 'sign-up' })
}) as unknown as typeof fetch
expect(await beginAimlapiEmailOnboarding('new@example.com')).toEqual({
action: 'new-account',
sessionToken: 'new-session',
})
})
test('existing API key validation uses the balance endpoint', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
expect(String(input)).toBe('https://api.example.test/v1/billing/balance')
expect(init?.method).toBe('GET')
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer key_test')
return response({ balance: 25, lowBalance: false, lowBalanceThreshold: 20 })
}) as unknown as typeof fetch
expect(await validateAimlapiApiKey(' key_test ')).toEqual({
balance: 25,
lowBalance: false,
lowBalanceThreshold: 20,
})
})
test('existing API key validation can pin the validated endpoint', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://override.example.test/v1'
globalThis.fetch = mock(async (input: string | URL | Request) => {
expect(String(input)).toBe('https://api.aimlapi.com/v1/billing/balance')
return response({ balance: 25, lowBalance: false, lowBalanceThreshold: 20 })
}) as unknown as typeof fetch
await validateAimlapiApiKey(
'key_test',
undefined,
'https://api.aimlapi.com/v1',
)
})
test('unknown account actions are rejected instead of signing up', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
globalThis.fetch = mock(async () => response({ action: 'migrate' })) as unknown as typeof fetch
// The client validates the account action at the boundary and fails closed on
// an unknown one, so onboarding never reaches its own unsupported-action guard.
await expect(beginAimlapiEmailOnboarding('user@example.com')).rejects.toThrow(
/invalid account response/i,
)
})
test('completeAimlapiCodeSignIn reuses a supplied key instead of minting a new one', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const calls: string[] = []
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
calls.push(url)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/billing/balance')) {
return response({ balance: 100, lowBalance: false, lowBalanceThreshold: 20 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
const result = await completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
{ apiKey: 'existing-key', apiKeyId: 'existing-id' },
)
expect(result.apiKey).toBe('existing-key')
expect(result.apiKeyId).toBe('existing-id')
// No key was minted; only verify + balance were called.
expect(calls.some(call => call.endsWith('/v1/keys'))).toBe(false)
})
test('a revoked cached key is invalidated and replaced with one freshly minted key', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
// Seed the on-disk cache the way a real caller (ProviderManager's
// loadAimlapiSignInKey) would, so this proves the entry is actually
// invalidated on disk, not just bypassed in memory.
saveAimlapiSignInKey('user@example.com', 'revoked-key', 'revoked-id')
let keyMints = 0
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) {
keyMints += 1
return response({ key: 'replacement-key', id: 'replacement-id' })
}
if (url.endsWith('/billing/balance')) {
// The cached key was revoked/deleted server-side — a definite
// rejection, not a transient/ambiguous failure.
return response({ error: 'invalid api key' }, 401)
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
const result = await completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
{ apiKey: 'revoked-key', apiKeyId: 'revoked-id' },
)
// Recovered with exactly one replacement key, not the dead one.
expect(keyMints).toBe(1)
expect(result.apiKey).toBe('replacement-key')
expect(result.apiKeyId).toBe('replacement-id')
expect(result.balanceStatus).toBe('unknown')
// The cache reflects the replacement, not the revoked key — a future
// sign-in adopts it instead of looping on the dead credential forever.
expect(loadAimlapiSignInKey('user@example.com')).toEqual({
apiKey: 'replacement-key',
apiKeyId: 'replacement-id',
})
})
test('two concurrent sign-ins for the same email never both mint a key', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
let keyMints = 0
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) {
keyMints += 1
// Hold the POST open long enough for the concurrent call to reach its
// own lease-acquire attempt and observe this one still in flight
// (status 'held'), instead of racing it to a second POST.
await new Promise(resolve => setTimeout(resolve, 200))
return response({ key: `minted-key-${keyMints}`, id: `minted-id-${keyMints}` })
}
if (url.endsWith('/billing/balance')) {
return response({ balance: 100, lowBalance: false, lowBalanceThreshold: 20 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
const runOnce = () =>
completeAimlapiCodeSignIn('user@example.com', '123456', undefined, 'https://api.example.test/v1')
const [resultA, resultB] = await Promise.all([runOnce(), runOnce()])
// The core guarantee: exactly one POST /v1/keys happened, not two — the
// loser's lease-acquire attempt found the winner's lease held and adopted
// its cached key instead of minting (and orphaning) its own.
expect(keyMints).toBe(1)
expect(resultA.apiKey).toBe('minted-key-1')
expect(resultA.apiKeyId).toBe('minted-id-1')
expect(resultB.apiKey).toBe('minted-key-1')
expect(resultB.apiKeyId).toBe('minted-id-1')
}, 10_000)
test('a 2xx createKey response with an unusable body holds the sign-in lease instead of allowing an immediate re-mint', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
// POST /v1/keys is non-idempotent: a 2xx status means the server received
// and likely committed the request, even when the body that would have
// confirmed it is unusable. Each of these must be held ambiguous, not
// treated as proof nothing was created.
const malformedResponses: Record<string, () => Response> = {
empty: () => new Response('', { status: 200 }),
'non-JSON': () => new Response('not json', { status: 200 }),
oversized: () => new Response('x'.repeat((1 << 20) + 1), { status: 200 }),
'missing id': () => new Response(JSON.stringify({ key: 'k_test' }), { status: 200 }),
'missing key': () => new Response(JSON.stringify({ id: 'id_test' }), { status: 200 }),
}
for (const [label, makeResponse] of Object.entries(malformedResponses)) {
// Lowercase: the lease file's keys are normalized (case-insensitive)
// email, so a mixed-case label here (e.g. "non-JSON") would otherwise
// look up the wrong key below and fail regardless of the real behavior.
const email = `user-${label.toLowerCase().replace(/\s+/g, '-')}@example.com`
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) return makeResponse()
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
await expect(
completeAimlapiCodeSignIn(email, '123456', undefined, 'https://api.example.test/v1'),
).rejects.toThrow()
const leasePath = join(configDirectory, 'aimlapi-signin-lease.json')
const lease = JSON.parse(readFileSync(leasePath, 'utf8')) as Record<
string,
{ owner?: string; at?: number }
>
expect(lease[email]?.owner, `lease held after a ${label} 2xx response`).toBeTruthy()
// No key was ever cached for this ambiguous outcome — a later resolution
// (once the lease goes stale, or the real outcome is confirmed some other
// way) must still be able to adopt/recover it, not find a wrong cache entry.
expect(loadAimlapiSignInKey(email)).toBeNull()
}
})
test('a slow createKey refreshes the sign-in lease before the cache save lands', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const leasePath = join(configDirectory, 'aimlapi-signin-lease.json')
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) {
// Simulate createKey having taken nearly the full 60s request timeout:
// by the time it resolves, the lease is already right up against the
// 75s stale threshold, with the cache write still to come.
const store = JSON.parse(readFileSync(leasePath, 'utf8'))
store['user@example.com'].at = Date.now() - 74_000
writeFileSync(leasePath, JSON.stringify(store))
return response({ key: 'minted-key', id: 'minted-id' })
}
if (url.endsWith('/billing/balance')) {
return response({ balance: 100, lowBalance: false, lowBalanceThreshold: 20 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
await completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
)
// The commit at the end of a successful mint retires the lease outright
// (see commitAimlapiSignInKeyAsync) rather than merely refreshing it, so a
// near-stale timestamp at commit time must not stop that retirement — the
// refresh's own effect (keeping a still-in-flight lease from going stale
// mid-wait) is covered directly by the isolated topupState lease test this
// one closes the gap for.
expect(existsSync(leasePath)).toBe(false)
})
test('a cache-commit failure right after a successful mint stops the flow instead of stranding the key', async () => {
process.env.AIMLAPI_AUTH_URL = 'https://auth.example.test'
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
// A file (not a directory) at the path the config dir is switched to right
// as createKey succeeds — forces the post-mint commit's own mkdirSync
// (ensureOwnerOnlyDir) to fail with ENOTDIR deterministically and
// portably, simulating a real lock/permission/IO-class failure without
// relying on OS-specific permission semantics.
const brokenParent = join(configDirectory, 'not-a-directory')
writeFileSync(brokenParent, '')
const brokenConfigDir = join(brokenParent, 'nested')
let keyMints = 0
globalThis.fetch = mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) {
keyMints += 1
// The key is minted server-side — genuinely successful — but the
// config dir is switched to a broken path right before returning, so
// the commit that follows fails deterministically.
setClaudeConfigHomeDirForTesting(brokenConfigDir)
return response({ key: 'minted-key', id: 'minted-id' })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
const error = await completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
).catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(Error)
const message = (error as Error).message
expect(message).toMatch(/recovery receipt could not be saved/i)
// The issued key id is the recovery handle this error exists to surface —
// without it, the dashboard-rotation guidance has nothing to point the
// user at, so the message must name it, not just describe the failure.
expect(message).toContain('minted-id')
// Exactly one createKey call: the flow stopped instead of retrying
// blindly within the same attempt.
expect(keyMints).toBe(1)
// Restore the good directory to inspect what was actually left behind —
// the lease was acquired in the ORIGINAL directory, before the switch.
setClaudeConfigHomeDirForTesting(configDirectory)
expect(loadAimlapiSignInKey('user@example.com')).toBeNull()
// The lease must still be held — releasing it (or letting a peer reclaim
// it) here would let a retry mint (and orphan) a second key while this
// one's receipt remains unresolved.
const retryLease = await acquireAimlapiSignInKeyLeaseAsync('user@example.com', 'retry-owner')
expect(retryLease.status).toBe('held')
})
function mockVerifyAndKeyMintCounter(keyMints: { count: number }): typeof fetch {
return mock(async (input: string | URL | Request) => {
const url = String(input)
if (url.endsWith('/code/verify')) return response({ token: 'bearer', exp: 1 })
if (url.endsWith('/v1/keys')) {
keyMints.count += 1
return response({ key: 'new-key', id: 'new-id' })
}
if (url.endsWith('/billing/balance')) {
return response({ balance: 100, lowBalance: false, lowBalanceThreshold: 20 })
}
throw new Error(`Unexpected request: ${url}`)
}) as unknown as typeof fetch
}
test('an unreadable sign-in key cache fails closed instead of minting a fresh key', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const cachePath = join(configDirectory, 'aimlapi-signin-key.json')
writeFileSync(
cachePath,
JSON.stringify({ 'user@example.com': { apiKey: 'cached-key', apiKeyId: 'cached-id' } }),
)
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
chmodSync(cachePath, 0o000)
try {
if (readableDespiteNoPermissions(cachePath)) return
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/Could not read the local AI\/ML API sign-in key cache/)
} finally {
// Best-effort: the fixed code path never gets far enough to touch this
// file, but tolerate it being gone regardless so cleanup itself is never
// what fails the test.
try {
chmodSync(cachePath, 0o600)
} catch {
// Nothing to restore.
}
}
// An unreadable cache must never be mistaken for "no cached key" — that
// would authorize a second, orphan-risking createKey call for an account
// that may already have one minted.
expect(keyMints.count).toBe(0)
})
test('a malformed-JSON sign-in key cache fails closed instead of minting a fresh key', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const cachePath = join(configDirectory, 'aimlapi-signin-key.json')
writeFileSync(cachePath, '{ this is not valid json')
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/is not valid JSON/)
expect(keyMints.count).toBe(0)
expect(readFileSync(cachePath, 'utf8')).toBe('{ this is not valid json')
})
test('an array-shaped sign-in key cache fails closed instead of degrading to an empty store', async () => {
// Valid JSON, but not an object: typeof [] === 'object' && [] !== null, so
// this must be rejected explicitly — otherwise it silently degrades to "no
// cached key", authorizing a second, orphan-risking createKey call.
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const cachePath = join(configDirectory, 'aimlapi-signin-key.json')
writeFileSync(cachePath, '[1,2,3]')
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/does not match the expected format/)
expect(keyMints.count).toBe(0)
expect(readFileSync(cachePath, 'utf8')).toBe('[1,2,3]')
})
test('an unreadable sign-in lease file fails closed instead of minting a fresh key', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const leasePath = join(configDirectory, 'aimlapi-signin-lease.json')
writeFileSync(
leasePath,
JSON.stringify({ 'someone-else@example.com': { owner: 'peer', at: Date.now() } }),
)
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
chmodSync(leasePath, 0o000)
try {
if (readableDespiteNoPermissions(leasePath)) return
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/Could not read the local AI\/ML API sign-in key-mint lease/)
} finally {
// Best-effort: the fixed code path never gets far enough to touch this
// file, but tolerate it being gone regardless so cleanup itself is never
// what fails the test.
try {
chmodSync(leasePath, 0o600)
} catch {
// Nothing to restore.
}
}
// An unreadable lease file must not be mistaken for "no live lease" — a
// competing process's still-live lease could be sitting in those
// unreadable bytes, and minting anyway risks a second concurrent createKey.
expect(keyMints.count).toBe(0)
})
test('a malformed-JSON sign-in lease file fails closed instead of minting a fresh key', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const leasePath = join(configDirectory, 'aimlapi-signin-lease.json')
writeFileSync(leasePath, '{ this is not valid json')
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/is not valid JSON/)
expect(keyMints.count).toBe(0)
expect(readFileSync(leasePath, 'utf8')).toBe('{ this is not valid json')
})
test('an array-shaped sign-in lease file fails closed instead of degrading to an empty store', async () => {
process.env.AIMLAPI_INFERENCE_URL = 'https://api.example.test/v1'
const leasePath = join(configDirectory, 'aimlapi-signin-lease.json')
writeFileSync(leasePath, '[1,2,3]')
const keyMints = { count: 0 }
globalThis.fetch = mockVerifyAndKeyMintCounter(keyMints)
await expect(
completeAimlapiCodeSignIn(
'user@example.com',
'123456',
undefined,
'https://api.example.test/v1',
),
).rejects.toThrow(/does not match the expected format/)
expect(keyMints.count).toBe(0)
expect(readFileSync(leasePath, 'utf8')).toBe('[1,2,3]')
})
+276
View File
@@ -0,0 +1,276 @@
/**
* Passwordless email onboarding helpers for the guided provider-manager flow:
* discover whether an email signs in or signs up, complete a 6-digit code
* sign-in (minting or reusing an existing-account key), and validate a pasted
* key's balance. The CLI top-up flow (topup.ts) inlines the same steps.
*/
import { randomUUID } from 'node:crypto'
import { logForDebugging } from '../../utils/debug.js'
import { AimlapiApiError, AimlapiClient, type BalanceResult } from './client.js'
import { resolveEndpoints } from './config.js'
import {
acquireAimlapiSignInKeyLeaseAsync,
clearAimlapiSignInKeyAsync,
commitAimlapiSignInKeyAsync,
refreshAimlapiSignInKeyLeaseAsync,
releaseAimlapiSignInKeyLeaseAsync,
} from './topupState.js'
import { abortError, isAmbiguousTransportApiError, sleep } from './transport.js'
// A 401/403 from getBalance for a CACHED (not freshly minted) key is a
// definite rejection: the key was revoked or deleted server-side, not
// merely unreachable. Distinct from isAmbiguousTransportApiError's set
// (network/timeout/rate-limit/5xx), which says nothing about the key's
// validity and must not trigger cache invalidation or a replacement mint.
function isDefiniteCredentialRejection(error: unknown): boolean {
return error instanceof AimlapiApiError && (error.status === 401 || error.status === 403)
}
function clientForInferenceBaseUrl(inferenceBaseUrl?: string): AimlapiClient {
const endpoints = resolveEndpoints()
if (inferenceBaseUrl?.trim()) endpoints.inferenceBaseUrl = inferenceBaseUrl.trim()
return new AimlapiClient(endpoints)
}
const SIGN_IN_KEY_LEASE_POLL_INTERVAL_MS = 3000
// A losing process's own patience while a peer holds the lease. Kept at
// least as long as SIGN_IN_KEY_LEASE_STALE_MS's worst case, so a loser never
// gives up on (and reports an error for) a winner that is still legitimately
// within its own generous stale window.
const SIGN_IN_KEY_LEASE_POLL_TIMEOUT_MS = 160_000
/**
* Mint the sign-in key for this email under a cross-process lease so racing
* ProviderManager instances never both call POST /v1/keys for the same
* account before either caches its result — the loser adopts the winner's
* cached key instead of minting (and orphaning) its own. See
* `AimlapiSignInKeyLease`.
*/
async function mintOrAdoptSignInKey(
client: AimlapiClient,
email: string,
accessToken: string,
signal?: AbortSignal,
): Promise<{ apiKey: string; apiKeyId: string }> {
const owner = randomUUID()
const deadline = Date.now() + SIGN_IN_KEY_LEASE_POLL_TIMEOUT_MS
for (;;) {
if (signal?.aborted) throw abortError(signal)
const lease = await acquireAimlapiSignInKeyLeaseAsync(email, owner)
if (lease.status === 'cached') {
return { apiKey: lease.apiKey, apiKeyId: lease.apiKeyId }
}
if (lease.status === 'acquired') {
let created: { key: string; id: string }
try {
created = await client.createKey(accessToken, 'OpenClaude CLI', signal)
} catch (error) {
// createKey is non-idempotent and exposes no retrieval-by-id
// endpoint, so a transport-level failure is ambiguous: the POST may
// have minted a key server-side before its response was lost, with
// no way to recover that credential here. Releasing the lease in
// that case would let a retry (or a racing peer) mint a second,
// orphaned key. A caller-driven abort mid-flight is ambiguous too —
// cancelling client-side does not stop the server from completing an
// already-sent POST. A definite rejection means nothing was created,
// so release immediately to let a retry proceed without waiting out
// the stale window.
if (!isAmbiguousTransportApiError(error) && !signal?.aborted) {
await releaseAimlapiSignInKeyLeaseAsync(email, owner).catch((releaseError: unknown) => {
logForDebugging(
`Failed to release the AI/ML API sign-in key-mint lease: ${String(releaseError)}`,
{ level: 'warn' },
)
})
}
throw error
}
// createKey succeeded — the key exists server-side regardless of what
// happens below, so nothing from here on may release the lease: this
// commit is the ONLY durable record of that key, and losing it while
// the lease is reclaimable would let a peer mint (and orphan) a
// second one before this credential is ever recovered.
//
// Best-effort: give the cache-write phase its own fresh stale window
// (see refreshAimlapiSignInKeyLeaseAsync) instead of sharing the
// mint's budget — SIGN_IN_KEY_LEASE_STALE_MS already carries generous
// margin over this refresh's own worst-case lock wait, so losing
// ownership here should be exceedingly rare. A failed refresh call,
// or one that reports ownership already lost, is still non-fatal: the
// key was already minted, and the commit below is safe to attempt
// regardless (its cache write is first-writer-wins, so a losing write
// here is a harmless no-op, and it only retires a lease it still
// owns) — but log the lost-ownership case, since it signals the
// stale window was cut closer than expected and a peer may now also
// be minting concurrently.
const refreshed = await refreshAimlapiSignInKeyLeaseAsync(email, owner).catch(
(refreshError: unknown): false => {
logForDebugging(
`Failed to refresh the AI/ML API sign-in key-mint lease: ${String(refreshError)}`,
{ level: 'warn' },
)
return false
},
)
if (!refreshed) {
logForDebugging(
'AI/ML API sign-in key-mint lease ownership was lost before the cache write; ' +
'a concurrent mint may be in flight.',
{ level: 'warn' },
)
}
try {
// Commits the cache entry and retires this lease together: leaving
// the lease behind after a successful mint would let it resurface
// as "held" for a fresh acquire as soon as something later clears
// just the cache entry (see clearAimlapiSignInKeyAsync), even
// though no mint is still running.
await commitAimlapiSignInKeyAsync(email, created.key, created.id, owner)
} catch (error) {
// A required checkpoint, not a best-effort resume aid: returning the
// key only in memory here risks losing it outright — if the caller
// is then interrupted (e.g. the user presses Esc while verification
// is completing) before it reaches a durable save of its own, the
// lease is all that is left, and it eventually goes stale and lets a
// retry mint (and orphan) a second key. Stop instead, and leave the
// lease exactly as it is: still held, so a retry waits for it rather
// than reclaiming it out from under this still-unresolved commit.
throw new Error(
`A key was issued (id ${created.id}), but the local recovery receipt could not ` +
`be saved (${error instanceof Error ? error.message : String(error)}). Open ` +
`https://aimlapi.com/app and rotate the issued key to recover access.`,
{ cause: error },
)
}
return { apiKey: created.key, apiKeyId: created.id }
}
// held: a live peer is minting; back off and re-attempt rather than
// minting in parallel and orphaning a credential.
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for a concurrent sign-in key mint. Retry to resume.')
}
await sleep(SIGN_IN_KEY_LEASE_POLL_INTERVAL_MS, signal)
}
}
export type AimlapiEmailOnboardingResult =
| { action: 'code-sent' }
| { action: 'new-account'; sessionToken: string }
export type AimlapiCodeSignInResult = {
sessionToken: string
apiKey: string
apiKeyId: string
} & (
| { balanceStatus: 'confirmed'; lowBalance: boolean }
| { balanceStatus: 'unknown'; balanceError: string }
)
export async function validateAimlapiApiKey(
apiKey: string,
signal?: AbortSignal,
inferenceBaseUrl?: string,
): Promise<BalanceResult> {
return clientForInferenceBaseUrl(inferenceBaseUrl).getBalance(apiKey.trim(), signal)
}
export async function beginAimlapiEmailOnboarding(
email: string,
signal?: AbortSignal,
): Promise<AimlapiEmailOnboardingResult> {
const client = new AimlapiClient(resolveEndpoints())
const account = await client.checkAccount(email, signal)
switch (account.action) {
case 'sign-in':
await client.sendSignInCode(email, signal)
return { action: 'code-sent' }
case 'sign-up': {
const auth = await client.createPasswordlessAccount(email, signal)
return { action: 'new-account', sessionToken: auth.token }
}
default:
throw new Error('AI/ML API returned an unsupported account action.')
}
}
export async function completeAimlapiCodeSignIn(
email: string,
code: string,
signal?: AbortSignal,
inferenceBaseUrl?: string,
existingKey?: { apiKey: string; apiKeyId: string },
): Promise<AimlapiCodeSignInResult> {
const client = clientForInferenceBaseUrl(inferenceBaseUrl)
const auth = await client.verifySignInCode(email, code, signal)
let apiKey = existingKey?.apiKey?.trim() ?? ''
let apiKeyId = existingKey?.apiKeyId ?? ''
let mintedKey = false
if (!apiKey) {
// Reuse a previously issued key when one is supplied so a restart does not
// mint a second key for the same account.
const minted = await mintOrAdoptSignInKey(client, email, auth.token, signal)
apiKey = minted.apiKey
apiKeyId = minted.apiKeyId
mintedKey = true
}
try {
const balance = await client.getBalance(apiKey, signal)
return {
sessionToken: auth.token,
apiKey,
apiKeyId,
balanceStatus: 'confirmed',
lowBalance: balance.lowBalance,
}
} catch (error) {
// A balance read failure must not discard an already-issued key. When THIS
// call minted the key, even an aborted read returns it so the caller can
// cache it — otherwise the next sign-in mints a second key for the account.
// When the caller supplied the key it already holds it, so an abort can
// propagate as usual.
if (signal?.aborted && !mintedKey) throw error
// A cached (not freshly minted) key the server now definitively rejects
// is revoked or deleted, not merely unreachable. The cache is only ever
// cleared on a successful profile save, so returning that dead key here
// would have the caller re-cache it (first-writer-wins) and loop forever
// on every future sign-in with no way out short of deleting local state.
// Invalidate it and mint a replacement instead. This still preserves the
// duplicate-mint protection for every OTHER (ambiguous) failure below,
// which must not touch the cache or mint a second key alongside a
// possibly-still-valid one.
if (!mintedKey && isDefiniteCredentialRejection(error)) {
await clearAimlapiSignInKeyAsync(email, apiKeyId).catch(() => {})
const minted = await mintOrAdoptSignInKey(client, email, auth.token, signal)
apiKey = minted.apiKey
apiKeyId = minted.apiKeyId
mintedKey = true
try {
const balance = await client.getBalance(apiKey, signal)
return {
sessionToken: auth.token,
apiKey,
apiKeyId,
balanceStatus: 'confirmed',
lowBalance: balance.lowBalance,
}
} catch (retryError) {
return {
sessionToken: auth.token,
apiKey,
apiKeyId,
balanceStatus: 'unknown',
balanceError: retryError instanceof Error ? retryError.message : String(retryError),
}
}
}
return {
sessionToken: auth.token,
apiKey,
apiKeyId,
balanceStatus: 'unknown',
balanceError: error instanceof Error ? error.message : String(error),
}
}
}
+2 -1
View File
@@ -9,7 +9,8 @@ import { createInterface, type Interface } from 'node:readline'
function assertInteractive(): void {
if (!process.stdin.isTTY) {
throw new Error(
'No interactive terminal available. Provide credentials via --email (or AIMLAPI_EMAIL) and the AIMLAPI_PASSWORD env var.',
'No interactive terminal available. Provide --email (or AIMLAPI_EMAIL) and, for an ' +
'existing account, the sign-in code via --code-stdin.',
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { AimlapiApiError } from './client.js'
import { isAmbiguousTransportApiError } from './transport.js'
test('isAmbiguousTransportApiError treats every 2xx status as ambiguous, not a definite failure', () => {
// A 2xx proves the server received and processed the request — client.request
// throwing anyway (empty/non-JSON/oversized/malformed body) means the
// confirmation was lost, not that a non-idempotent mutation never happened.
for (const status of [200, 201, 204, 299]) {
expect(isAmbiguousTransportApiError(new AimlapiApiError('msg', status, ''))).toBe(true)
}
})
test('isAmbiguousTransportApiError still treats network/timeout/rate-limit/5xx as ambiguous', () => {
for (const status of [0, 408, 429, 500, 503]) {
expect(isAmbiguousTransportApiError(new AimlapiApiError('msg', status, ''))).toBe(true)
}
})
test('isAmbiguousTransportApiError treats a definite 4xx rejection (other than 408/429) as not ambiguous', () => {
for (const status of [400, 401, 403, 404, 422]) {
expect(isAmbiguousTransportApiError(new AimlapiApiError('msg', status, ''))).toBe(false)
}
})
test('isAmbiguousTransportApiError is false for a non-AimlapiApiError', () => {
expect(isAmbiguousTransportApiError(new Error('plain error'))).toBe(false)
expect(isAmbiguousTransportApiError(undefined)).toBe(false)
})
+66
View File
@@ -0,0 +1,66 @@
/**
* Shared transport-failure helpers for the AI/ML API integration: an
* abortable sleep used by every lease-poll loop, and the predicate that
* distinguishes a genuinely ambiguous request outcome from a definite server
* rejection. Used by both topup.ts (CLI) and onboarding.ts (GUI sign-in) so
* the ambiguity rule — which decides whether a non-idempotent mutation's
* lease is safe to release — cannot drift between the two callers.
*/
import { AimlapiApiError } from './client.js'
export function abortError(signal?: AbortSignal): unknown {
return signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortError(signal))
return new Promise<void>((resolve, reject) => {
const cleanup = (): void => signal?.removeEventListener('abort', onAbort)
const timer = setTimeout(() => {
cleanup()
resolve()
}, ms)
const onAbort = (): void => {
clearTimeout(timer)
cleanup()
reject(abortError(signal))
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
// Transient transport failures (network error, timeout, rate-limit, 5xx) say
// nothing about whether a request landed server-side: polling retries them
// for a session's fate instead of aborting, and a non-idempotent mutation
// (createKey, exchange) must not treat them as proof it never happened. A
// genuine 4xx (other than 408/429) is a definitive server rejection instead.
//
// A 2xx status belongs in this set too, not the definite-failure side: it
// proves the server received and processed the request, so client.request
// throwing anyway (empty body, non-JSON body, oversized body, or a body that
// parsed but failed an endpoint's own shape check, e.g. createKey's
// isCreatedKey) means the response confirming a non-idempotent mutation was
// lost, not that the mutation never happened — if anything a 2xx is stronger
// evidence the mutation committed than a 5xx or a network error is. Treating
// it as a definite failure would release a mint/exchange lease and let a
// retry orphan the credential the lost response could no longer name.
//
// This predicate alone is NOT the complete ambiguity test for a mutation's
// in-flight request: client.request rethrows a caller-driven abort as the
// raw (unwrapped) abort error rather than an AimlapiApiError, so it fails the
// `instanceof` check here on its own. A caller aborting an in-flight POST
// does not stop the server from completing it, so callers guarding a
// non-idempotent mutation must also treat `signal?.aborted` as ambiguous
// alongside this predicate — see exchangeKeyWithLease, doMint's catch in
// mintExistingAccountKeyWithLease, and mintOrAdoptSignInKey.
export function isAmbiguousTransportApiError(error: unknown): boolean {
return (
error instanceof AimlapiApiError &&
(error.status === 0 ||
error.status === 408 ||
error.status === 429 ||
error.status >= 500 ||
(error.status >= 200 && error.status < 300))
)
}
+62
View File
@@ -0,0 +1,62 @@
import {
DEFAULT_AMOUNT_USD_MINOR,
MAX_AMOUNT_USD_MINOR,
MIN_AMOUNT_USD_MINOR,
} from './config.js'
/**
* Parse a whole-USD top-up amount into backend minor units (cents), enforcing
* the same bounds the backend DTO does. An empty value falls back to the default
* so the guided flow can offer a sensible amount without prompting.
*/
export function parseAimlapiAmountUsd(amountUsd: string | undefined): number {
if (!amountUsd?.trim()) return DEFAULT_AMOUNT_USD_MINOR
const normalized = amountUsd.trim()
const dollars = Number(normalized)
if (!Number.isFinite(dollars) || dollars <= 0) {
throw new Error(`Invalid amount: "${amountUsd}". Pass a positive number of USD.`)
}
// Only accept a plain decimal with at most two fractional digits. This rejects
// scientific notation (e.g. "20.001e0"), signs, and sub-cent precision that
// Number() would otherwise silently round into a wrong charge.
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
throw new Error(`Invalid amount: "${amountUsd}". Pass a valid USD amount.`)
}
const minor = Math.round(dollars * 100)
if (minor < MIN_AMOUNT_USD_MINOR) {
throw new Error(`Minimum top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
}
if (minor > MAX_AMOUNT_USD_MINOR) {
throw new Error(`Maximum top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
}
return minor
}
/**
* Lightweight email shape check for the passwordless flow: one `@`, a dotted
* domain with a sane alphabetic TLD. The backend is the source of truth; this
* only avoids an obvious round-trip on a malformed address.
*/
export function isValidAimlapiEmail(value: string): boolean {
const email = value.trim()
const match = /^[^\s@]+@([^\s@]+)$/.exec(email)
if (!match) return false
const domain = match[1]
if (domain.startsWith('.') || domain.endsWith('.') || domain.includes('..')) {
return false
}
const labels = domain.split('.')
const tld = labels.at(-1) ?? ''
return labels.length >= 2 && /^[A-Za-z]{2,}$/.test(tld)
}
/**
* The passwordless sign-in code is documented and issued as a 6-digit
* numeric string. Rejecting anything else locally — empty, non-numeric,
* short, long — avoids an obvious round trip to verifySignInCode for input
* that can never succeed, and keeps that contract in one place instead of
* being reimplemented (and able to drift) at each entry point.
*/
export function isValidAimlapiSignInCode(value: string): boolean {
return /^\d{6}$/.test(value.trim())
}
@@ -512,6 +512,7 @@ describe('discoverModelsForRoute', () => {
expect(result?.source).toBe('network')
expect(capturedHeaders).toEqual({
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
'X-AIMLAPI-Integration-Version': publicBuildVersion,
+1
View File
@@ -144,6 +144,7 @@ export {
isLongcatBaseUrl,
normalizeXiaomiMimoBaseUrl,
resolveActiveRouteIdFromEnv,
resolveRouteCredentialValue,
resolveRouteIdFromBaseUrl,
routeSupportsApiFormatSelection,
routeSupportsAuthHeaders,
+13 -11
View File
@@ -213,8 +213,10 @@ describe('resolveModelRuntimeLimits', () => {
})
describe('AIMLAPI runtime attribution', () => {
it('uses the partner override only on the canonical endpoint', () => {
it('sends the fixed partner id on the canonical endpoint only', () => {
const previous = process.env.AIMLAPI_PARTNER_ID
// The partner id is locked; an ambient env override must be ignored, never
// forwarded to the backend.
process.env.AIMLAPI_PARTNER_ID = 'part_runtime_override'
try {
const canonical = resolveOpenAIShimRuntimeContext({
@@ -223,7 +225,11 @@ describe('AIMLAPI runtime attribution', () => {
model: 'gpt-4o',
})
expect(canonical.openaiShimConfig.headers?.['X-AIMLAPI-Partner-ID']).toBe(
'part_runtime_override',
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
)
// The mandatory source header rides on every canonical inference request.
expect(canonical.openaiShimConfig.headers?.['X-AIMLAPI-Source']).toBe(
'agent/openclaude',
)
const proxy = resolveOpenAIShimRuntimeContext({
@@ -233,6 +239,7 @@ describe('AIMLAPI runtime attribution', () => {
})
// Every catalog attribution header must be stripped on a proxy endpoint,
// not just the partner id.
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Source']).toBeUndefined()
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Partner-ID']).toBeUndefined()
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Integration-Repo']).toBeUndefined()
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Integration-Version']).toBeUndefined()
@@ -253,6 +260,7 @@ describe('AIMLAPI runtime attribution', () => {
baseUrl: 'https://proxy.example.test/v1',
})
for (const name of [
'X-AIMLAPI-Source',
'X-AIMLAPI-Partner-ID',
'X-AIMLAPI-Integration-Repo',
'X-AIMLAPI-Integration-Version',
@@ -262,27 +270,21 @@ describe('AIMLAPI runtime attribution', () => {
expect(proxy?.[name]).toBeUndefined()
}
// The canonical assertions below compare against the built-in partner id,
// so an ambient AIMLAPI_PARTNER_ID in the invoking shell would fail them.
const previous = process.env.AIMLAPI_PARTNER_ID
delete process.env.AIMLAPI_PARTNER_ID
try {
// The partner id is locked to the built-in attribution id, so the canonical
// assertions hold regardless of any ambient AIMLAPI_PARTNER_ID.
const canonical = getRouteDiscoveryHeaders('aimlapi', {
baseUrl: 'https://api.aimlapi.com/v1',
})
expect(canonical?.['X-AIMLAPI-Partner-ID']).toBe(
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
)
expect(canonical?.['X-AIMLAPI-Source']).toBe('agent/openclaude')
expect(canonical?.['HTTP-Referer']).toBe('OpenClaude')
// A missing base URL falls back to the route default, which is canonical.
expect(getRouteDiscoveryHeaders('aimlapi')?.['X-AIMLAPI-Partner-ID']).toBe(
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
)
} finally {
if (previous === undefined) delete process.env.AIMLAPI_PARTNER_ID
else process.env.AIMLAPI_PARTNER_ID = previous
}
})
})
+4 -30
View File
@@ -36,7 +36,6 @@ import { launchRepl } from './replLauncher.js';
import { refreshGrowthBookAfterAuthChange } from './services/analytics/growthbook.js';
import { fetchBootstrapData } from './services/api/bootstrap.js';
import { refreshStartupDiscoveryForActiveRoute } from './integrations/discoveryService.js';
import { MAX_AMOUNT_USD_MINOR, MIN_AMOUNT_USD_MINOR } from './integrations/aimlapi/config.js';
import { prefetchOllamaModels } from './utils/model/ollamaModels.js';
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
import { prefetchPassesEligibility } from './services/api/referral.js';
@@ -47,6 +46,7 @@ import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './servi
import type { ToolInputJSONSchema } from './Tool.js';
import { createSyntheticOutputTool, isSyntheticOutputToolEnabled } from './tools/SyntheticOutputTool/SyntheticOutputTool.js';
import { registerTaskReportCommand } from './cli/commands/taskReport.js';
import { registerAimlapiCommand } from './cli/aimlapiCommand.js';
import { getTools } from './tools.js';
import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js';
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
@@ -4024,35 +4024,9 @@ async function run(): Promise<CommanderCommand> {
await xaiStatus();
});
// AI/ML API (aimlapi.com) — log in, open the co-branded top-up page, and
// auto-configure the provider with the issued key.
const aimlapi = program.command('aimlapi').description('AI/ML API (aimlapi.com) — top up balance and configure the provider').configureHelp(createSortedHelpConfig());
aimlapi.command('topup')
.description("Log in, open AI/ML API top-up, then set the issued key as OpenClaude's provider")
.option('--email <email>', 'AI/ML API account email (or AIMLAPI_EMAIL env)')
.option('--amount <usd>', `Top-up amount in USD (min ${MIN_AMOUNT_USD_MINOR / 100}, max ${MAX_AMOUNT_USD_MINOR / 100})`)
.addOption(new Option('--method <method>', 'Payment method: card (Stripe) or crypto (NOWPayments)').choices(['card', 'crypto']).default('card'))
.option('--model <model>', 'Default model id written into the provider profile', 'gpt-4o')
.option('--partner-id <id>', 'Partner id for rebate attribution (part_...)')
.option('--no-open', 'Do not auto-open the browser; print the payment URL instead')
.action(async (opts: {
email?: string;
amount?: string;
method?: string;
model?: string;
partnerId?: string;
open?: boolean;
}) => {
const { aimlapiTopup } = await import('./cli/handlers/aimlapi.js');
await aimlapiTopup({
email: opts.email,
amountUsd: opts.amount,
method: opts.method === 'crypto' ? 'crypto' : 'card',
model: opts.model,
partnerId: opts.partnerId,
noOpen: opts.open === false,
});
});
// AI/ML API (aimlapi.com) — passwordless sign-in, open the co-branded top-up
// page, and auto-configure the provider with the issued key.
registerAimlapiCommand(program).configureHelp(createSortedHelpConfig());
/**
* Helper function to handle marketplace command errors consistently.
+1
View File
@@ -217,6 +217,7 @@ test('AIMLAPI discovery omits credentials on the public /models route', async ()
expect(discoveryOptions?.headers).toBeUndefined()
expect(fallbackOptions?.apiKey).toBeUndefined()
expect(fallbackOptions?.headers).toEqual({
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
'X-AIMLAPI-Integration-Version': publicBuildVersion,