Skip to content
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)
}
}
137 changes: 133 additions & 4 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,16 +110,17 @@ 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
// AI/ML API pinned first, Gitlawb Opengateway 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
// canUseCodexOAuth === true (default in mocked tests).
const PRESET_ORDER = [
'AI/ML API',
'Gitlawb Opengateway',
'Anthropic',
'AI/ML API',
'Alibaba Coding Plan (China)',
'Alibaba Coding Plan',
'Atlas Cloud',
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