mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+24
-4
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')}`,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user