feat(aimlapi): add guided top-up and key provisioning (#1886)

* fix(aimlapi): send valid rebate partner id (part_62yQ…) instead of literal 'Gitlawb'

* feat(aimlapi): add guided top-up and key provisioning

* fix: restore accidentally removed OpenGateway badge

* fix(aimlapi): restore preset order and harden topup polling/logging

* fix(aimlapi): validate --method choices instead of silently defaulting to card

* feat(aimlapi): guided top-up and API key provisioning

* fix(aimlapi): point non-interactive credential error at existing flags

* docs(aimlapi): document guided top-up alongside the existing-key path

---------

Co-authored-by: Lookoff123 <bataryshkinairina@gmail.com>
This commit is contained in:
StanAIML
2026-07-08 09:17:17 +08:00
committed by GitHub
co-authored by Lookoff123
parent e204d5ad36
commit 9a53290588
16 changed files with 1438 additions and 18 deletions
+24 -4
View File
@@ -8,17 +8,37 @@ AI/ML API is an aggregating gateway that exposes many chat models behind a singl
## Prerequisites
- An AI/ML API account and API key from <https://aimlapi.com> (Dashboard → API Keys).
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.
## Option 1 — Interactive (`/provider`)
1. Start OpenClaude and run `/provider`.
2. Choose **AI/ML API**.
3. Paste your API key when prompted. The base URL (`https://api.aimlapi.com/v1`) and default model (`gpt-4o`) are filled in automatically.
2. Choose **AI/ML API**, 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.
Either way, 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 — Environment variables
## Option 2 — CLI (`openclaude aimlapi topup`)
Run the same guided top-up flow non-interactively:
```bash
openclaude aimlapi topup --email you@example.com --amount 25 --method card
```
- Credentials: pass `--email` (or set `AIMLAPI_EMAIL`) and set `AIMLAPI_PASSWORD`; if either is missing you're prompted interactively (password entry is hidden).
- `--amount`: top-up amount in USD (min 20, max 10000; defaults to 25).
- `--method`: `card` (Stripe, default) or `crypto` (NOWPayments).
- `--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
Setting `AIMLAPI_API_KEY` alone is enough; OpenClaude auto-detects the AI/ML API route:
+26
View File
@@ -0,0 +1,26 @@
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handler intentionally exits */
import chalk from 'chalk'
import { AimlapiApiError } from '../../integrations/aimlapi/client.js'
import {
runAimlapiTopup,
type AimlapiTopupOptions,
} from '../../integrations/aimlapi/index.js'
export async function aimlapiTopup(options: AimlapiTopupOptions): Promise<void> {
try {
await runAimlapiTopup(options)
} catch (error) {
if (error instanceof AimlapiApiError) {
console.error(chalk.red(`\n ✗ ${error.message}`))
if (error.body) {
console.error(chalk.dim(` ${error.body}`))
}
} else {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red(`\n ✗ ${message}`))
}
process.exit(1)
}
}
+132 -3
View File
@@ -26,6 +26,8 @@ const ORIGINAL_ENV = {
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN,
AIMLAPI_EMAIL: process.env.AIMLAPI_EMAIL,
AIMLAPI_PASSWORD: process.env.AIMLAPI_PASSWORD,
}
function extractLastFrame(output: string): string {
@@ -108,8 +110,9 @@ async function waitForCondition(
}
// Provider list is sorted from generated preset metadata by description, with
// Gitlawb Opengateway pinned first, Codex OAuth injected after DeepSeek, and
// Custom always pinned last. Keep the target-by-label indirection here so
// Gitlawb Opengateway pinned first, Anthropic second, Codex OAuth injected
// after DeepSeek, and Custom always pinned last. Keep the target-by-label
// indirection here so
// these tests survive future list edits without hardcoding raw key counts.
//
// Order matches ProviderManager.renderPresetSelection() when
@@ -324,6 +327,7 @@ function mockProviderManagerDependencies(
codexAsyncRead?: () => Promise<unknown>
updateProviderProfile?: (...args: any[]) => unknown
setActiveProviderProfile?: (...args: any[]) => unknown
provisionAimlapiKey?: (...args: any[]) => Promise<unknown>
useCodexOAuthFlow?: (options: {
onAuthenticated: (
tokens: {
@@ -438,6 +442,14 @@ function mockProviderManagerDependencies(
updateSettingsForSource: () => ({ error: null }),
}))
mock.module('../integrations/aimlapi/index.js', () => ({
provisionAimlapiKey:
options?.provisionAimlapiKey ??
(async () => {
throw new Error('Unexpected AI/ML API top-up in test')
}),
}))
mock.module('./useCodexOAuthFlow.js', () => ({
useCodexOAuthFlow:
options?.useCodexOAuthFlow ??
@@ -877,9 +889,18 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
expect(modelOutput).not.toContain('Base URL')
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
const choiceOutput = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Step 2 of 2: API key'),
)
expect(choiceOutput).toContain('Top up and get API key')
expect(choiceOutput).toContain('Enter existing API key')
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Enter the API key for AI/ML API'),
)
mounted.stdin.write('aimlapi-test-key')
await Bun.sleep(25)
@@ -902,6 +923,114 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
}
})
test('ProviderManager can top up AI/ML API and save the issued key', async () => {
delete process.env.AIMLAPI_EMAIL
delete process.env.AIMLAPI_PASSWORD
const addProviderProfile = mock((payload: any) => ({
id: 'aimlapi_profile',
...payload,
}))
const provisionAimlapiKey = mock(async (options: any) => {
options.onStatus?.('creating-session')
options.onStatus?.('opening-checkout', 'https://app.aimlapi.com/checkout/test')
options.onStatus?.('waiting-payment')
options.onStatus?.('provisioning-key')
return {
apiKey: 'aimlapi-issued-key',
apiKeyId: 'key_test',
baseUrl: 'https://api.aimlapi.com/v1',
model: 'gpt-4o',
}
})
mockProviderManagerDependencies(() => undefined, async () => undefined, {
addProviderProfile,
provisionAimlapiKey,
})
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
try {
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Provider manager'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Choose provider preset'),
)
await navigateToPreset(mounted.stdin, 'AI/ML API')
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Step 1 of 2: Default model'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Top up and get API key'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Enter your AI/ML API account email'),
)
mounted.stdin.write('user@example.com')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Enter your AI/ML API password'),
)
mounted.stdin.write('secret-password')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Choose a top-up amount in USD') &&
frame.includes('25'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Payment method') &&
frame.includes('Card') &&
frame.includes('Crypto'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => addProviderProfile.mock.calls.length > 0)
expect(provisionAimlapiKey).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
password: 'secret-password',
amountUsd: '25',
method: 'crypto',
model: 'gpt-4o',
onStatus: expect.any(Function),
}),
)
expect(addProviderProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'aimlapi',
name: 'AI/ML API',
baseUrl: 'https://api.aimlapi.com/v1',
model: 'gpt-4o',
apiKey: 'aimlapi-issued-key',
apiFormat: 'chat_completions',
}),
expect.objectContaining({ makeActive: true }),
)
} finally {
await mounted.dispose()
}
})
test('ProviderManager saves MiniMax preset with Anthropic-compatible endpoint and type', async () => {
const addProviderProfile = mock((payload: any) => ({
id: 'minimax_profile',
+471 -3
View File
@@ -45,6 +45,16 @@ import {
resolveProfileRoute,
resolveRouteIdFromBaseUrl,
} from '../integrations/index.js'
import {
provisionAimlapiKey,
type AimlapiTopupStatus,
} from '../integrations/aimlapi/index.js'
import {
DEFAULT_AMOUNT_USD_MINOR,
MAX_AMOUNT_USD_MINOR,
MIN_AMOUNT_USD_MINOR,
} from '../integrations/aimlapi/config.js'
import type { PaymentMethod } from '../integrations/aimlapi/client.js'
import { openAIShimSupportsApiFormatForModel } from '../integrations/runtimeMetadata.js'
import { probeRouteReadiness } from '../integrations/discoveryService.js'
import {
@@ -112,6 +122,12 @@ type Screen =
| 'xai-oauth'
| 'form'
| 'preset-model'
| 'aimlapi-api-key-choice'
| 'aimlapi-topup-email'
| 'aimlapi-topup-password'
| 'aimlapi-topup-amount'
| 'aimlapi-topup-method'
| 'aimlapi-topup-progress'
| 'preset-api-key'
| 'select-active'
| 'select-edit'
@@ -226,7 +242,6 @@ const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
const XAI_OAUTH_PROVIDER_NAME = 'xAI OAuth'
const XAI_OAUTH_PROVIDER_MODEL = 'grok-4.3'
const XAI_OAUTH_PROVIDER_BASE_URL = 'https://api.x.ai/v1'
type GithubCredentialSource = 'stored' | 'env' | 'none'
function toDraft(profile: ProviderProfile): ProviderDraft {
@@ -809,6 +824,17 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const [aimlapiTopupEmail, setAimlapiTopupEmail] = React.useState('')
const [aimlapiTopupAmountUsd, setAimlapiTopupAmountUsd] = React.useState(
String(DEFAULT_AMOUNT_USD_MINOR / 100),
)
const [aimlapiTopupMethod, setAimlapiTopupMethod] =
React.useState<PaymentMethod>('card')
const [aimlapiTopupPassword, setAimlapiTopupPassword] = React.useState('')
const [aimlapiTopupStatus, setAimlapiTopupStatus] =
React.useState<AimlapiTopupStatus | undefined>()
const [aimlapiTopupDetail, setAimlapiTopupDetail] = React.useState<string | undefined>()
const [isAimlapiTopupRunning, setIsAimlapiTopupRunning] = React.useState(false)
const [menuFocusValue, setMenuFocusValue] = React.useState<string | undefined>()
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
React.useState(false)
@@ -1549,6 +1575,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setDraftProvider(provider)
setDraft(nextDraft)
setPresetRequiresApiKey(defaults.requiresApiKey)
setAimlapiTopupEmail('')
setAimlapiTopupAmountUsd(String(DEFAULT_AMOUNT_USD_MINOR / 100))
setAimlapiTopupMethod('card')
setAimlapiTopupPassword('')
setAimlapiTopupStatus(undefined)
setAimlapiTopupDetail(undefined)
setIsAimlapiTopupRunning(false)
setFormStepIndex(0)
setCursorOffset(nextDraft.name.length)
setErrorMessage(undefined)
@@ -1936,7 +1969,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
function handleBackFromPresetApiKey(): void {
setErrorMessage(undefined)
setCursorOffset(draft.model.length)
setScreen('preset-model')
setScreen(draftProvider === 'aimlapi' ? 'aimlapi-api-key-choice' : 'preset-model')
}
useKeybinding('confirm:no', handleBackFromPresetApiKey, {
@@ -1944,6 +1977,75 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
isActive: screen === 'preset-api-key',
})
function handleBackFromAimlapiKeyChoice(): void {
setErrorMessage(undefined)
setCursorOffset(draft.model.length)
setScreen('preset-model')
}
useKeybinding('confirm:no', handleBackFromAimlapiKeyChoice, {
context: 'Settings',
isActive: screen === 'aimlapi-api-key-choice',
})
function handleBackFromAimlapiTopupEmail(): void {
setErrorMessage(undefined)
setCursorOffset(0)
setScreen('aimlapi-api-key-choice')
}
useKeybinding('confirm:no', handleBackFromAimlapiTopupEmail, {
context: 'Settings',
isActive: screen === 'aimlapi-topup-email',
})
function handleBackFromAimlapiTopupAmount(): void {
setErrorMessage(undefined)
setCursorOffset(aimlapiTopupPassword.length)
setScreen('aimlapi-topup-password')
}
useKeybinding('confirm:no', handleBackFromAimlapiTopupAmount, {
context: 'Settings',
isActive: screen === 'aimlapi-topup-amount',
})
function handleBackFromAimlapiTopupPassword(): void {
setErrorMessage(undefined)
setAimlapiTopupPassword('')
setCursorOffset(aimlapiTopupEmail.length)
setScreen('aimlapi-topup-email')
}
useKeybinding('confirm:no', handleBackFromAimlapiTopupPassword, {
context: 'Settings',
isActive: screen === 'aimlapi-topup-password',
})
function handleBackFromAimlapiTopupMethod(): void {
setErrorMessage(undefined)
setCursorOffset(aimlapiTopupAmountUsd.length)
setScreen('aimlapi-topup-amount')
}
useKeybinding('confirm:no', handleBackFromAimlapiTopupMethod, {
context: 'Settings',
isActive: screen === 'aimlapi-topup-method',
})
function handleCancelAimlapiTopupProgress(): void {
if (isAimlapiTopupRunning) {
return
}
setErrorMessage(undefined)
setScreen('aimlapi-api-key-choice')
}
useKeybinding('confirm:no', handleCancelAimlapiTopupProgress, {
context: 'Settings',
isActive: screen === 'aimlapi-topup-progress',
})
// xAI OAuth setup renders a TextInput for the manual-code recovery
// path, which registers its own useInput listener. The child-component
// useKeybinding inside XaiOAuthSetup ends up racing the input handler
@@ -2183,7 +2285,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
if (needsApiKey) {
setCursorOffset(0)
setScreen('preset-api-key')
setScreen(
draftProvider === 'aimlapi'
? 'aimlapi-api-key-choice'
: 'preset-api-key',
)
return
}
@@ -2265,6 +2371,350 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
)
}
function startAimlapiTopup(
email: string,
password: string,
method: PaymentMethod = aimlapiTopupMethod,
): void {
const trimmedEmail = email.trim()
const amountUsd = aimlapiTopupAmountUsd.trim()
const parsedAmountUsd = Number(amountUsd)
if (!trimmedEmail) {
setErrorMessage('AI/ML API email is required.')
setScreen('aimlapi-topup-email')
return
}
if (!Number.isFinite(parsedAmountUsd) || parsedAmountUsd <= 0) {
setErrorMessage('Enter a valid top-up amount in USD.')
setScreen('aimlapi-topup-amount')
return
}
if (Math.round(parsedAmountUsd * 100) < MIN_AMOUNT_USD_MINOR) {
setErrorMessage(`Minimum AI/ML API top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
setScreen('aimlapi-topup-amount')
return
}
if (Math.round(parsedAmountUsd * 100) > MAX_AMOUNT_USD_MINOR) {
setErrorMessage(`Maximum AI/ML API top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
setScreen('aimlapi-topup-amount')
return
}
if (!password) {
setErrorMessage('AI/ML API password is required.')
setScreen('aimlapi-topup-password')
return
}
setScreen('aimlapi-topup-progress')
setErrorMessage(undefined)
setAimlapiTopupStatus('signing-in')
setAimlapiTopupDetail(undefined)
setIsAimlapiTopupRunning(true)
void (async () => {
try {
const provisioned = await provisionAimlapiKey({
email: trimmedEmail,
password,
amountUsd,
method,
model: draft.model,
onStatus: (status, detail) => {
setAimlapiTopupStatus(status)
setAimlapiTopupDetail(detail)
},
})
const nextDraft = applyPresetApiFormat(
{
...draft,
apiKey: provisioned.apiKey,
baseUrl: provisioned.baseUrl,
model: provisioned.model,
},
draftProvider,
)
setDraft(nextDraft)
setAimlapiTopupPassword('')
setIsAimlapiTopupRunning(false)
persistDraft(nextDraft, draftProvider, null)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
setIsAimlapiTopupRunning(false)
setErrorMessage(`Could not finish AI/ML API top-up: ${detail}`)
}
})()
}
function renderAimlapiApiKeyChoice(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Create provider profile
</Text>
<Text dimColor>
Choose how to configure AI/ML API. Endpoint and model are already
configured.
</Text>
<Text dimColor>
Provider type:{' '}
{getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)}
</Text>
<Text dimColor>Step 2 of 2: API key</Text>
<Select
options={[
{
value: 'topup',
label: 'Top up and get API key',
description: 'Open checkout, wait for payment, then save the issued key',
},
{
value: 'manual',
label: 'Enter existing API key',
description: 'Paste a key you already have from AI/ML API',
},
]}
onChange={(value: string) => {
setErrorMessage(undefined)
if (value === 'manual') {
setCursorOffset(draft.apiKey.length)
setScreen('preset-api-key')
return
}
const envEmail = process.env.AIMLAPI_EMAIL?.trim() ?? ''
const envPassword = process.env.AIMLAPI_PASSWORD ?? ''
if (envEmail && envPassword) {
setAimlapiTopupEmail(envEmail)
setAimlapiTopupPassword(envPassword)
setCursorOffset(aimlapiTopupAmountUsd.length)
setScreen('aimlapi-topup-amount')
return
}
setCursorOffset(envEmail.length)
setAimlapiTopupEmail(envEmail)
setScreen('aimlapi-topup-email')
}}
onCancel={handleBackFromAimlapiKeyChoice}
visibleOptionCount={2}
/>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to continue. Press Esc to go back.
</Text>
</Box>
)
}
function renderAimlapiTopupEmail(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
AI/ML API top-up
</Text>
<Text dimColor>
Enter your AI/ML API account email. The checkout flow will use it to
register or sign in.
</Text>
<Text dimColor>Step 2 of 2: Top up account</Text>
<Box flexDirection="row" gap={1}>
<Text>{figures.pointer}</Text>
<TextInput
value={aimlapiTopupEmail}
onChange={setAimlapiTopupEmail}
onSubmit={value => {
const email = value.trim()
if (!email) {
setErrorMessage('AI/ML API email is required.')
return
}
setAimlapiTopupEmail(email)
setErrorMessage(undefined)
setCursorOffset(0)
setScreen('aimlapi-topup-password')
}}
focus={true}
showCursor={true}
placeholder={`Enter email${figures.ellipsis}`}
columns={inputColumns}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to continue. Press Esc to go back.
</Text>
</Box>
)
}
function renderAimlapiTopupAmount(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
AI/ML API top-up
</Text>
<Text dimColor>
Choose a top-up amount in USD. Minimum is ${MIN_AMOUNT_USD_MINOR / 100}.
</Text>
<Text dimColor>Step 2 of 2: Top up account</Text>
<Box flexDirection="row" gap={1}>
<Text>{figures.pointer}</Text>
<TextInput
value={aimlapiTopupAmountUsd}
onChange={setAimlapiTopupAmountUsd}
onSubmit={value => {
const amountUsd = value.trim()
const parsedAmountUsd = Number(amountUsd)
if (!Number.isFinite(parsedAmountUsd) || parsedAmountUsd <= 0) {
setErrorMessage('Enter a valid top-up amount in USD.')
return
}
if (Math.round(parsedAmountUsd * 100) < MIN_AMOUNT_USD_MINOR) {
setErrorMessage(`Minimum AI/ML API top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
return
}
if (Math.round(parsedAmountUsd * 100) > MAX_AMOUNT_USD_MINOR) {
setErrorMessage(`Maximum AI/ML API top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
return
}
setAimlapiTopupAmountUsd(amountUsd)
setErrorMessage(undefined)
setScreen('aimlapi-topup-method')
}}
focus={true}
showCursor={true}
placeholder={`Enter amount${figures.ellipsis}`}
columns={inputColumns}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to continue. Press Esc to go back.
</Text>
</Box>
)
}
function renderAimlapiTopupPassword(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
AI/ML API top-up
</Text>
<Text dimColor>
Enter your AI/ML API password. The CLI will open checkout and save the
issued API key after payment.
</Text>
<Text dimColor>Step 2 of 2: Top up account</Text>
<Box flexDirection="row" gap={1}>
<Text>{figures.pointer}</Text>
<TextInput
value={aimlapiTopupPassword}
onChange={setAimlapiTopupPassword}
onSubmit={value => {
if (!value) {
setErrorMessage('AI/ML API password is required.')
return
}
setAimlapiTopupPassword(value)
setErrorMessage(undefined)
setCursorOffset(aimlapiTopupAmountUsd.length)
setScreen('aimlapi-topup-amount')
}}
focus={true}
showCursor={true}
placeholder={`Enter password${figures.ellipsis}`}
mask="*"
columns={inputColumns}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to continue. Press Esc to go back.
</Text>
</Box>
)
}
function renderAimlapiTopupMethod(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
AI/ML API top-up
</Text>
<Text dimColor>
Choose how to pay. The selected method decides which checkout invoice
AI/ML API opens.
</Text>
<Text dimColor>Step 2 of 2: Payment method</Text>
<Select
options={[
{
value: 'card',
label: 'Card',
description: 'Open a Stripe card checkout invoice',
},
{
value: 'crypto',
label: 'Crypto',
description: 'Open a crypto checkout invoice',
},
]}
defaultValue={aimlapiTopupMethod}
defaultFocusValue={aimlapiTopupMethod}
onChange={(value: string) => {
const method: PaymentMethod = value === 'crypto' ? 'crypto' : 'card'
setAimlapiTopupMethod(method)
startAimlapiTopup(aimlapiTopupEmail, aimlapiTopupPassword, method)
}}
onCancel={handleBackFromAimlapiTopupMethod}
visibleOptionCount={2}
/>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to open checkout. Press Esc to go back.
</Text>
</Box>
)
}
function renderAimlapiTopupProgress(): React.ReactNode {
const labels: Record<AimlapiTopupStatus, string> = {
registering: 'Registering AI/ML API account...',
registered: 'Account registered.',
'signing-in': 'Signing in to AI/ML API...',
'signed-in': 'Signed in.',
'creating-session': 'Creating checkout session...',
'opening-checkout': 'Opening checkout...',
'waiting-payment': 'Waiting for payment...',
'provisioning-key': 'Issuing API key...',
}
const status = aimlapiTopupStatus
? labels[aimlapiTopupStatus]
: 'Preparing AI/ML API top-up...'
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
AI/ML API top-up
</Text>
<Text dimColor>{status}</Text>
{aimlapiTopupDetail ? <Text>{aimlapiTopupDetail}</Text> : null}
{errorMessage ? <Text color="error">{errorMessage}</Text> : null}
<Text dimColor>
{isAimlapiTopupRunning
? 'Complete checkout in the browser. This screen will continue automatically.'
: 'Press Esc to go back.'}
</Text>
</Box>
)
}
function renderMenu(): React.ReactNode {
// Use memoized menuOptions from component scope
const hasProfiles = profiles.length > 0
@@ -2692,6 +3142,24 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'preset-model':
content = renderPresetModel()
break
case 'aimlapi-api-key-choice':
content = renderAimlapiApiKeyChoice()
break
case 'aimlapi-topup-email':
content = renderAimlapiTopupEmail()
break
case 'aimlapi-topup-amount':
content = renderAimlapiTopupAmount()
break
case 'aimlapi-topup-password':
content = renderAimlapiTopupPassword()
break
case 'aimlapi-topup-method':
content = renderAimlapiTopupMethod()
break
case 'aimlapi-topup-progress':
content = renderAimlapiTopupProgress()
break
case 'preset-api-key':
content = renderPresetApiKey()
break
+229
View File
@@ -0,0 +1,229 @@
/**
* AI/ML API partner-checkout HTTP client.
*
* Talks to two services:
* - app/auth (`authBaseUrl`) - `POST /v1/auth/account` (signup) /
* `PUT /v1/auth/account` (login) -> Bearer token
* - app/gateway(`appBaseUrl`) - `/v3/partner-checkout/*`
*
* Uses the global `fetch` (Node >= 22). All error bodies are surfaced verbatim
* so failures are debuggable.
*/
import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js'
import type { AimlapiEndpoints } from './config.js'
export type PartnerCheckoutSessionStatus =
| 'pending_auth'
| 'pending_payment'
| 'paid'
| 'exchanging'
| 'exchanged'
| 'cancelled'
| 'expired'
| 'failed'
export type PartnerCheckoutSession = {
id: string
sessionToken: string
partnerId: string
partnerName: string | null
userId: number | null
amountUsdMinor: number | null
status: PartnerCheckoutSessionStatus
issuedKeyId: string | null
returnUrl: string | null
}
export type PaymentSession = {
providerSessionId: string
payUrl: string | null
}
export type PayResult = {
checkout: PaymentSession
partnerCheckout: PartnerCheckoutSession
}
export type ExchangeResult = {
apiKey: string
apiKeyId: string
}
export type PaymentMethod = 'card' | 'crypto'
export type AuthResult = { token: string; exp: number }
const REQUEST_TIMEOUT_MS = 30_000
export class AimlapiApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly body: string,
) {
super(message)
this.name = 'AimlapiApiError'
}
}
export class AimlapiClient {
constructor(private readonly endpoints: AimlapiEndpoints) {}
/** Register a new AI/ML API account -> access (Bearer) token. */
async signup(input: {
email: string
password: string
inviteCode?: string
}): Promise<AuthResult> {
return this.request<AuthResult>(
`${this.endpoints.authBaseUrl}/v1/auth/account`,
{
method: 'POST',
body: {
email: input.email,
password: input.password,
...(input.inviteCode ? { inviteCode: input.inviteCode } : {}),
},
},
)
}
/** Sign in with email + password -> access (Bearer) token. */
async login(email: string, password: string): Promise<AuthResult> {
return this.request<AuthResult>(
`${this.endpoints.authBaseUrl}/v1/auth/account`,
{ method: 'PUT', body: { email, password } },
)
}
/** Create a partner-checkout session (public - no auth). */
async createSession(input: {
partnerId: string
partnerName?: string | null
returnUrl?: string | null
}): Promise<PartnerCheckoutSession> {
return this.request<PartnerCheckoutSession>(
`${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions`,
{
method: 'POST',
body: {
partnerId: input.partnerId,
...(input.partnerName ? { partnerName: input.partnerName } : {}),
...(input.returnUrl ? { returnUrl: input.returnUrl } : {}),
},
},
)
}
/** Poll a session by its one-time token (public - no auth). */
async getSession(sessionToken: string): Promise<PartnerCheckoutSession> {
return this.request<PartnerCheckoutSession>(
`${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}`,
{ method: 'GET' },
)
}
/**
* Bind the session to the logged-in user and open a hosted payment page.
* Requires the Bearer token. Returns `checkout.payUrl` to open in a browser.
*
* `successUrl`/`cancelUrl` are the co-branded `/checkout` return URLs the
* payment provider redirects the browser to after pay/cancel (see
* `buildPartnerCheckoutReturnUrls`). When omitted the backend falls back to a
* bare, non-co-branded `/checkout?checkout=success`.
*/
async pay(
bearer: string,
sessionToken: string,
input: {
amountUsdMinor: number
method: PaymentMethod
successUrl?: string
cancelUrl?: string
},
): Promise<PayResult> {
return this.request<PayResult>(
`${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/pay`,
{
method: 'POST',
bearer,
body: {
amountUsdMinor: input.amountUsdMinor,
method: input.method,
...(input.successUrl ? { successUrl: input.successUrl } : {}),
...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}),
},
},
)
}
/**
* Exchange a PAID session for the raw CLI key. One-shot: a second call after
* a successful exchange loses the claim and returns no key. Requires Bearer.
*/
async exchange(bearer: string, sessionToken: string): Promise<ExchangeResult> {
return this.request<ExchangeResult>(
`${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange`,
{ method: 'POST', bearer },
)
}
private async request<T>(
url: string,
options: {
method: 'GET' | 'POST' | 'PUT'
body?: unknown
bearer?: string
},
): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' }
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (options.bearer) {
headers.Authorization = `Bearer ${options.bearer}`
}
const { signal, cleanup } = createCombinedAbortSignal(undefined, {
timeoutMs: REQUEST_TIMEOUT_MS,
})
let response: Response
let text: string
try {
response = await fetch(url, {
method: options.method,
headers,
signal,
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
})
text = await response.text()
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new AimlapiApiError(`Network request to ${url} failed: ${reason}`, 0, '')
} finally {
cleanup()
}
if (!response.ok) {
throw new AimlapiApiError(
`${options.method} ${url} -> ${response.status}`,
response.status,
text,
)
}
if (!text) {
return undefined as T
}
try {
return JSON.parse(text) as T
} catch {
throw new AimlapiApiError(
`${options.method} ${url} returned non-JSON body`,
response.status,
text,
)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* AI/ML API (aimlapi.com) integration - endpoint configuration.
*
* Wires OpenClaude to the AI/ML API "partner checkout" flow so a user can log
* in, top up their balance, and have the issued key written back into
* 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.
*/
export type AimlapiEndpoints = {
/** app/auth service - mints the user access (Bearer) token. */
authBaseUrl: string
/** app/gateway BFF - hosts `/v3/partner-checkout/*`. */
appBaseUrl: string
/** OpenAI-compatible inference base URL written into the provider profile. */
inferenceBaseUrl: string
}
const DEFAULT_ENDPOINTS: AimlapiEndpoints = {
authBaseUrl: 'https://auth.aimlapi.com',
appBaseUrl: 'https://app.aimlapi.com',
inferenceBaseUrl: 'https://api.aimlapi.com/v1',
}
/**
* Partner id (`^part_[A-Za-z0-9]{1,64}$`) - rebate attribution. Must EXACTLY
* match an active row in the backend `rebate_partners` table. This is the
* Gitlawb partner that all OpenClaude AI/ML API usage is credited to; it is the
* same value sent as the `X-AIMLAPI-Partner-ID` inference header (see
* `integrations/gateways/aimlapi.ts`).
*/
export const DEFAULT_PARTNER_ID = 'part_62yQoGYDq4Yqnrj2R1iGrDNJ'
export const DEFAULT_PARTNER_NAME = 'Gitlawb'
/** Default model id written into the profile - override with `--model`. */
export const DEFAULT_MODEL = 'gpt-4o'
/** Top-up bounds enforced by the backend DTO (USD minor units / cents). */
export const MIN_AMOUNT_USD_MINOR = 2000 // $20
export const MAX_AMOUNT_USD_MINOR = 1_000_000 // $10,000
export const DEFAULT_AMOUNT_USD_MINOR = 2500 // $25
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,
inferenceBaseUrl:
process.env.AIMLAPI_INFERENCE_URL?.trim() || DEFAULT_ENDPOINTS.inferenceBaseUrl,
}
}
/**
* Build the co-branded checkout return URLs the hosted payment page redirects
* to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1`
* makes the AI/ML API `/checkout` page resolve the partner (name + logo + amount)
* and render the co-branded success / failure screen instead of the
* generic top-up result. Without these params the backend falls back to a bare
* `/checkout?checkout=success` that is NOT co-branded.
*/
export function buildPartnerCheckoutReturnUrls(
appBaseUrl: string,
sessionToken: string,
): { successUrl: string; cancelUrl: string } {
const base = appBaseUrl.replace(/\/+$/, '')
const token = encodeURIComponent(sessionToken)
const query = (status: string): string =>
`checkout=${status}&partnerCheckout=1&sessionToken=${token}`
return {
successUrl: `${base}/checkout?${query('success')}`,
cancelUrl: `${base}/checkout?${query('cancel')}`,
}
}
+12
View File
@@ -0,0 +1,12 @@
export {
provisionAimlapiKey,
runAimlapiTopup,
type AimlapiProvisionOptions,
type AimlapiProvisionedKey,
type AimlapiTopupOptions,
type AimlapiTopupStatus,
} from './topup.js'
export { AimlapiClient, AimlapiApiError } from './client.js'
export type {
AimlapiEndpoints,
} from './config.js'
+63
View File
@@ -0,0 +1,63 @@
/**
* Minimal interactive prompts backed by Node's built-in readline - no extra
* dependency. Used by the AI/ML API top-up flow to collect credentials when
* they are not supplied via flags/env.
*/
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.',
)
}
}
export async function promptText(
question: string,
opts: { defaultValue?: string } = {},
): Promise<string> {
assertInteractive()
const suffix = opts.defaultValue ? ` [${opts.defaultValue}]` : ''
const rl = createInterface({ input: process.stdin, output: process.stdout })
try {
const answer = await new Promise<string>((resolve) => {
rl.question(`${question}${suffix}: `, resolve)
})
const trimmed = answer.trim()
return trimmed || opts.defaultValue || ''
} finally {
rl.close()
}
}
/** Prompt for a secret without echoing keystrokes to the terminal. */
export async function promptHidden(question: string): Promise<string> {
assertInteractive()
const rl = createInterface({
input: process.stdin,
output: process.stdout,
}) as Interface & { _writeToOutput?: (chunk: string) => void }
// Mask everything except the prompt itself.
let muted = false
rl._writeToOutput = (chunk: string): void => {
if (muted) {
process.stdout.write('*')
} else {
process.stdout.write(chunk)
}
}
try {
const answer = await new Promise<string>((resolve) => {
rl.question(`${question}: `, resolve)
muted = true
})
process.stdout.write('\n')
return answer
} finally {
rl.close()
}
}
+359
View File
@@ -0,0 +1,359 @@
/**
* AI/ML API seamless top-up flow.
*
* End to end:
* 1. Log in with AI/ML API credentials -> Bearer token (held by the CLI)
* 2. Create a partner-checkout session -> one-time sessionToken
* 3. `pay` binds the session + opens a hosted payment page (Stripe / crypto)
* 4. Open the browser for the user to pay -> no second login ("auto-login":
* the hosted page needs no AI/ML API account, the CLI already holds auth)
* 5. Poll the session until it is `paid`
* 6. Exchange the paid session for a raw key (once)
* 7. Write the key into OpenClaude's provider profile -> the agent now runs
* on AI/ML API's OpenAI-compatible endpoint
*
* After pay/cancel the provider redirects the browser to the co-branded AI/ML
* API `/checkout` success / failure screen - see
* `buildPartnerCheckoutReturnUrls`.
*
* Uses the AI/ML API endpoints from config.ts.
*/
import chalk from 'chalk'
import { openBrowser } from '../../utils/browser.js'
import { saveProfileFile } from '../../utils/providerProfile.js'
import {
AimlapiApiError,
AimlapiClient,
type PartnerCheckoutSession,
type PaymentMethod,
} from './client.js'
import {
buildPartnerCheckoutReturnUrls,
DEFAULT_AMOUNT_USD_MINOR,
DEFAULT_MODEL,
DEFAULT_PARTNER_ID,
DEFAULT_PARTNER_NAME,
MAX_AMOUNT_USD_MINOR,
MIN_AMOUNT_USD_MINOR,
resolveEndpoints,
} from './config.js'
import { promptHidden, promptText } from './prompt.js'
export type AimlapiTopupOptions = {
email?: string
password?: string
/** Top-up amount in whole USD (e.g. "25"). */
amountUsd?: string
method?: PaymentMethod
model?: string
partnerId?: string
partnerName?: string
inviteCode?: string
/** Skip opening the browser (print the URL instead). */
noOpen?: boolean
}
export type AimlapiProvisionedKey = {
apiKey: string
apiKeyId: string
baseUrl: string
model: string
}
export type AimlapiTopupStatus =
| 'registering'
| 'registered'
| 'signing-in'
| 'signed-in'
| 'creating-session'
| 'opening-checkout'
| 'waiting-payment'
| 'provisioning-key'
export type AimlapiProvisionOptions = AimlapiTopupOptions & {
onStatus?: (status: AimlapiTopupStatus, detail?: string) => void
}
const POLL_INTERVAL_MS = 3000
const POLL_TIMEOUT_MS = 20 * 60 * 1000 // 20 minutes
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
function maskKey(key: string): string {
if (key.length <= 10) {
return '****'
}
return `${key.slice(0, 6)}...${key.slice(-4)}`
}
function parseAmount(amountUsd: string | undefined): number {
if (!amountUsd) {
return DEFAULT_AMOUNT_USD_MINOR
}
const dollars = Number(amountUsd)
if (!Number.isFinite(dollars) || dollars <= 0) {
throw new Error(`Invalid amount: "${amountUsd}". Pass a positive number of USD.`)
}
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
}
function describeAimlapiAuthError(error: unknown): string {
if (error instanceof AimlapiApiError) {
const body = error.body.trim()
return body
? `HTTP ${error.status}: ${body}`
: `HTTP ${error.status}: ${error.message}`
}
return error instanceof Error ? error.message : String(error)
}
async function authenticateAimlapiAccount(
client: AimlapiClient,
options: {
email: string
password: string
inviteCode?: string
onStatus?: (status: AimlapiTopupStatus, detail?: string) => void
},
): Promise<string> {
let signupError: unknown
try {
options.onStatus?.('registering')
const { token } = await client.signup({
email: options.email,
password: options.password,
inviteCode: options.inviteCode,
})
options.onStatus?.('registered')
return token
} catch (error) {
signupError = error
}
try {
options.onStatus?.('signing-in')
const { token } = await client.login(options.email, options.password)
options.onStatus?.('signed-in')
return token
} catch (loginError) {
throw new Error(
`Could not register or log in to AI/ML API. Registration: ${describeAimlapiAuthError(signupError)}. Login: ${describeAimlapiAuthError(loginError)}.`,
)
}
}
export async function runAimlapiTopup(options: AimlapiTopupOptions): Promise<void> {
const endpoints = resolveEndpoints()
const client = new AimlapiClient(endpoints)
const partnerId = options.partnerId?.trim() || process.env.AIMLAPI_PARTNER_ID?.trim() || DEFAULT_PARTNER_ID
const partnerName = options.partnerName?.trim() || DEFAULT_PARTNER_NAME
const method: PaymentMethod = options.method === 'crypto' ? 'crypto' : 'card'
const model = options.model?.trim() || DEFAULT_MODEL
const amountUsdMinor = parseAmount(options.amountUsd)
console.log(
chalk.bold(`\n AI/ML API top-up`) +
chalk.dim(` - ${endpoints.appBaseUrl}\n`),
)
// 1. Credentials -> Bearer token.
const email = options.email?.trim() || process.env.AIMLAPI_EMAIL?.trim() || (await promptText('AI/ML API email'))
const password = options.password || process.env.AIMLAPI_PASSWORD || (await promptHidden('AI/ML API password'))
if (!email || !password) {
throw new Error('Email and password are required.')
}
console.log(chalk.dim(' -> Signing in...'))
const token = await authenticateAimlapiAccount(client, {
email,
password,
inviteCode: options.inviteCode || process.env.AIMLAPI_INVITE_CODE,
})
console.log(chalk.green(' [OK] Signed in'))
// 2. Partner-checkout session.
const session = await client.createSession({ partnerId, partnerName })
console.log(chalk.dim(` -> Session ${session.id}`))
// 3. Bind + open hosted payment page. The co-branded return URLs make the
// post-payment browser redirect land on the AI/ML API success / failure
// screen for this partner.
const { successUrl, cancelUrl } = buildPartnerCheckoutReturnUrls(
endpoints.appBaseUrl,
session.sessionToken,
)
const { checkout } = await client.pay(token, session.sessionToken, {
amountUsdMinor,
method,
successUrl,
cancelUrl,
})
if (!checkout.payUrl) {
throw new Error('Payment provider did not return a checkout URL.')
}
console.log(
chalk.bold(`\n Pay $${(amountUsdMinor / 100).toFixed(2)} (${method}) to top up:\n`) +
` ${chalk.cyan(checkout.payUrl)}\n`,
)
if (options.noOpen) {
console.log(chalk.dim(' (open the link above to complete payment)'))
} else {
const opened = await openBrowser(checkout.payUrl)
if (!opened) {
console.log(chalk.dim(' (could not auto-open a browser - open the link above manually)'))
}
}
// 4./5. Poll until paid.
console.log(chalk.dim('\n Waiting for payment...'))
const paid = await pollUntilPaid(client, session.sessionToken)
// 6. Exchange the paid session for the raw key (once).
console.log(chalk.dim(' -> Provisioning API key...'))
const { apiKey, apiKeyId } = await client.exchange(token, paid.sessionToken)
// 7. Persist into OpenClaude's provider profile.
const profilePath = saveProfileFile({
profile: 'openai',
env: {
OPENAI_BASE_URL: endpoints.inferenceBaseUrl,
OPENAI_API_KEY: apiKey,
OPENAI_MODEL: model,
},
createdAt: new Date().toISOString(),
})
console.log(chalk.green(`\n [OK] Balance topped up and provider configured.`))
console.log(` key ${chalk.dim(maskKey(apiKey))} (id ${apiKeyId})`)
console.log(` base URL ${chalk.dim(endpoints.inferenceBaseUrl)}`)
console.log(` model ${chalk.dim(model)}`)
console.log(` profile ${chalk.dim(profilePath)}`)
console.log(chalk.dim(`\n Run ${chalk.bold('openclaude')} to start coding on AI/ML API.\n`))
}
export async function provisionAimlapiKey(
options: AimlapiProvisionOptions,
): Promise<AimlapiProvisionedKey> {
const endpoints = resolveEndpoints()
const client = new AimlapiClient(endpoints)
const partnerId =
options.partnerId?.trim() ||
process.env.AIMLAPI_PARTNER_ID?.trim() ||
DEFAULT_PARTNER_ID
const partnerName = options.partnerName?.trim() || DEFAULT_PARTNER_NAME
const method: PaymentMethod = options.method === 'crypto' ? 'crypto' : 'card'
const model = options.model?.trim() || DEFAULT_MODEL
const amountUsdMinor = parseAmount(options.amountUsd)
const email =
options.email?.trim() ||
process.env.AIMLAPI_EMAIL?.trim() ||
(await promptText('AI/ML API email'))
const password =
options.password ||
process.env.AIMLAPI_PASSWORD ||
(await promptHidden('AI/ML API password'))
if (!email || !password) {
throw new Error('Email and password are required.')
}
const token = await authenticateAimlapiAccount(client, {
email,
password,
inviteCode: options.inviteCode || process.env.AIMLAPI_INVITE_CODE,
onStatus: options.onStatus,
})
options.onStatus?.('creating-session')
const session = await client.createSession({ partnerId, partnerName })
options.onStatus?.('opening-checkout')
const { successUrl, cancelUrl } = buildPartnerCheckoutReturnUrls(
endpoints.appBaseUrl,
session.sessionToken,
)
const { checkout } = await client.pay(token, session.sessionToken, {
amountUsdMinor,
method,
successUrl,
cancelUrl,
})
if (!checkout.payUrl) {
throw new Error('Payment provider did not return a checkout URL.')
}
if (options.noOpen) {
options.onStatus?.('opening-checkout', checkout.payUrl)
} else {
const opened = await openBrowser(checkout.payUrl)
options.onStatus?.(
'opening-checkout',
opened ? checkout.payUrl : `Open manually: ${checkout.payUrl}`,
)
}
options.onStatus?.('waiting-payment')
const paid = await pollUntilPaid(client, session.sessionToken)
options.onStatus?.('provisioning-key')
const { apiKey, apiKeyId } = await client.exchange(token, paid.sessionToken)
return {
apiKey,
apiKeyId,
baseUrl: endpoints.inferenceBaseUrl,
model,
}
}
async function pollUntilPaid(
client: AimlapiClient,
sessionToken: string,
): Promise<PartnerCheckoutSession> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
let session: PartnerCheckoutSession
try {
session = await client.getSession(sessionToken)
} catch (error) {
// Transient poll failures shouldn't abort a payment in progress.
// status 0 is a network-level failure (see client.ts), not a real HTTP response.
if (error instanceof AimlapiApiError && (error.status === 0 || error.status >= 500)) {
await sleep(POLL_INTERVAL_MS)
continue
}
throw error
}
switch (session.status) {
case 'paid':
case 'exchanging':
return session
case 'exchanged':
throw new Error(
'Session was already exchanged. The key can only be issued once - rotate it from the AI/ML API dashboard.',
)
case 'cancelled':
case 'expired':
case 'failed':
throw new Error(`Payment ${session.status}. Re-run the top-up to try again.`)
default:
// pending_auth / pending_payment -> keep waiting.
await sleep(POLL_INTERVAL_MS)
}
}
throw new Error('Timed out waiting for payment. Re-run once the payment clears.')
}
+1 -1
View File
@@ -461,7 +461,7 @@ describe('discoverModelsForRoute', () => {
expect(result?.source).toBe('network')
expect(capturedHeaders).toEqual({
'X-AIMLAPI-Partner-ID': 'Gitlawb',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
'X-AIMLAPI-Integration-Version': publicBuildVersion,
'HTTP-Referer': 'OpenClaude',
+3 -2
View File
@@ -74,7 +74,7 @@ export default defineGateway({
kind: 'openai-compatible',
openaiShim: {
headers: {
'X-AIMLAPI-Partner-ID': 'Gitlawb',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
'X-AIMLAPI-Integration-Version': publicBuildVersion,
// Attribution headers AI/ML API records for api.aimlapi.com requests
@@ -87,7 +87,8 @@ export default defineGateway({
},
preset: {
id: 'aimlapi',
description: 'AI/ML API OpenAI-compatible endpoint',
description: '1,000+ models OpenAI compatible endpoint',
badge: { text: 'Recommended', color: 'success' },
apiKeyEnvVars: ['AIMLAPI_API_KEY'],
modelEnvVars: ['OPENAI_MODEL'],
vendorId: 'openai',
@@ -51,12 +51,15 @@ export default defineGateway({
apiKeyEnvVars: ['OPENGATEWAY_API_KEY'],
label: 'Gitlawb Opengateway',
name: 'Gitlawb Opengateway',
badge: {
text: 'Recommended',
color: 'success',
},
vendorId: 'openai',
modelEnvVars: ['OPENAI_MODEL'],
baseUrlEnvVars: ['OPENGATEWAY_BASE_URL', 'OPENAI_BASE_URL'],
fallbackBaseUrl: 'https://opengateway.gitlawb.com/v1',
fallbackModel: 'mimo-v2.5-pro',
badge: { text: 'Recommended', color: 'success' },
},
catalog: {
source: 'static',
@@ -56,13 +56,17 @@ export const PROVIDER_PRESET_MANIFEST = [
"routeId": "aimlapi",
"vendorId": "openai",
"gatewayId": "aimlapi",
"description": "AI/ML API OpenAI-compatible endpoint",
"description": "1,000+ models OpenAI compatible endpoint",
"apiKeyEnvVars": [
"AIMLAPI_API_KEY"
],
"modelEnvVars": [
"OPENAI_MODEL"
]
],
"badge": {
"text": "Recommended",
"color": "success"
}
},
{
"preset": "dashscope-cn",
+31
View File
@@ -36,6 +36,7 @@ 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';
@@ -4009,6 +4010,36 @@ 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,
});
});
/**
* Helper function to handle marketplace command errors consistently.
* Logs the error and exits the process with status 1.
+1 -1
View File
@@ -217,7 +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-Partner-ID': 'Gitlawb',
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
'X-AIMLAPI-Integration-Version': publicBuildVersion,
'HTTP-Referer': 'OpenClaude',
+1 -1
View File
@@ -639,7 +639,7 @@ test('routes env-only AI/ML API requests through the OpenAI-compatible shim desp
expect(capturedHeaders?.get('authorization')).toBe(
'Bearer aimlapi-test-key',
)
expect(capturedHeaders?.get('x-aimlapi-partner-id')).toBe('Gitlawb')
expect(capturedHeaders?.get('x-aimlapi-partner-id')).toBe('part_62yQoGYDq4Yqnrj2R1iGrDNJ')
expect(capturedHeaders?.get('x-aimlapi-integration-repo')).toBe(
'Gitlawb/openclaude',
)