Skip to content
28 changes: 24 additions & 4 deletions docs/aimlapi-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
26 changes: 26 additions & 0 deletions src/cli/handlers/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
135 changes: 132 additions & 3 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Comment on lines +926 to +1005

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and locate the AI/ML API top-up path.
git ls-files 'src/components/ProviderManager.test.tsx' 'src/components/ProviderManager.tsx' 'src/**/aimlapi*' 'src/**/ProviderManager*' | sort

echo '--- outline ProviderManager.test.tsx ---'
ast-grep outline src/components/ProviderManager.test.tsx --view expanded || true

echo '--- outline ProviderManager.tsx ---'
ast-grep outline src/components/ProviderManager.tsx --view expanded || true

echo '--- search for AIMLAPI env usage ---'
rg -n "AIMLAPI_EMAIL|AIMLAPI_PASSWORD|renderAimlapiApiKeyChoice|topup" src/components src/services src/utils src/commands src/integrations | sed -n '1,240p'

Repository: Gitlawb/openclaude

Length of output: 7084


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the implementation around the top-up branch.
file=src/components/ProviderManager.tsx
line=$(rg -n "renderAimlapiApiKeyChoice|AIMLAPI_EMAIL|AIMLAPI_PASSWORD" "$file" | head -n 1 | cut -d: -f1 || true)
if [ -n "${line:-}" ]; then
  start=$((line-80)); [ "$start" -lt 1 ] && start=1
  end=$((line+180))
  sed -n "${start},${end}p" "$file" | cat -n
fi

echo '--- relevant test section ---'
sed -n '924,1000p' src/components/ProviderManager.test.tsx | cat -n

echo '--- existing env isolation patterns in tests ---'
rg -n "delete process\.env\.AIMLAPI_|originalAimlapi|process\.env\.[A-Z_]+ =|delete process\.env\." src/components/ProviderManager.test.tsx src/**/*.test.tsx src/**/*.test.ts | sed -n '1,240p'

Repository: Gitlawb/openclaude

Length of output: 34052


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the test suite or nearby tests rely on these vars being present.
rg -n "AIMLAPI_EMAIL|AIMLAPI_PASSWORD" . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,240p'

Repository: Gitlawb/openclaude

Length of output: 1244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe the exact branch logic with a read-only script snippet extracted from the file.
python3 - <<'PY'
from pathlib import Path
import re
text = Path('src/components/ProviderManager.tsx').read_text()
for pat in ['AIMLAPI_EMAIL', 'AIMLAPI_PASSWORD', 'topup', 'renderAimlapiApiKeyChoice']:
    print(f'--- {pat} ---')
    for m in re.finditer(pat, text):
        s = max(0, m.start()-260)
        e = min(len(text), m.end()+520)
        print(text[s:e])
        print('====')
        break
PY

Repository: Gitlawb/openclaude

Length of output: 3454


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ProviderManager env-branch implementation ---'
rg -n -A40 -B40 "AIMLAPI_EMAIL|AIMLAPI_PASSWORD|renderAimlapiApiKeyChoice|topup" src/components/ProviderManager.tsx | sed -n '1,240p'

echo '--- targeted test snippet ---'
sed -n '924,1000p' src/components/ProviderManager.test.tsx | cat -n

echo '--- all repo env references ---'
rg -n "AIMLAPI_EMAIL|AIMLAPI_PASSWORD" src tests . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,240p'

Repository: Gitlawb/openclaude

Length of output: 13508


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
text = Path('src/components/ProviderManager.tsx').read_text()
needles = ['AIMLAPI_EMAIL', 'AIMLAPI_PASSWORD', 'renderAimlapiApiKeyChoice', 'topup']
for needle in needles:
    idx = text.find(needle)
    print(f'=== {needle} @ {idx} ===')
    if idx != -1:
        start = max(0, idx - 900)
        end = min(len(text), idx + 1600)
        print(text[start:end])
        print()
PY

Repository: Gitlawb/openclaude

Length of output: 10290


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Try a narrow search around the topup branch in the component file.
file='src/components/ProviderManager.tsx'
match_line=$(rg -n "AIMLAPI_EMAIL|AIMLAPI_PASSWORD|topup" "$file" | head -n 1 | cut -d: -f1)
echo "match_line=${match_line:-none}"
if [ -n "${match_line:-}" ]; then
  start=$((match_line-120)); [ "$start" -lt 1 ] && start=1
  end=$((match_line+240))
  sed -n "${start},${end}p" "$file" | cat -n
fi

Repository: Gitlawb/openclaude

Length of output: 13544


Clear AIMLAPI_EMAIL/AIMLAPI_PASSWORD in this test.
renderAimlapiApiKeyChoice skips the email/password prompts when both are set, so this case can hang waiting for a screen that never renders. Save and restore both env vars around the test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ProviderManager.test.tsx` around lines 924 - 1000, This
ProviderManager top-up test is relying on the email/password prompts, but
renderAimlapiApiKeyChoice will bypass them if AIMLAPI_EMAIL and AIMLAPI_PASSWORD
are already set. Update the test to save the current env values, clear both
before mounting ProviderManager and running the flow, then restore them
afterward so the prompts are rendered consistently and the test cannot hang.

Source: Path instructions


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',
Expand Down
Loading