From 9a532905886fc2fe1684a283d435bca0f361bbe2 Mon Sep 17 00:00:00 2001 From: StanAIML Date: Wed, 8 Jul 2026 06:17:17 +0500 Subject: [PATCH] feat(aimlapi): add guided top-up and key provisioning (#1886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/aimlapi-setup.md | 28 +- src/cli/handlers/aimlapi.ts | 26 + src/components/ProviderManager.test.tsx | 135 ++++- src/components/ProviderManager.tsx | 474 +++++++++++++++++- src/integrations/aimlapi/client.ts | 229 +++++++++ src/integrations/aimlapi/config.ts | 75 +++ src/integrations/aimlapi/index.ts | 12 + src/integrations/aimlapi/prompt.ts | 63 +++ src/integrations/aimlapi/topup.ts | 359 +++++++++++++ src/integrations/discoveryService.test.ts | 2 +- src/integrations/gateways/aimlapi.ts | 5 +- .../gateways/gitlawb-opengateway.ts | 5 +- .../integrationManifest.generated.ts | 8 +- src/main.tsx | 31 ++ src/services/api/bootstrap.test.ts | 2 +- src/services/api/client.test.ts | 2 +- 16 files changed, 1438 insertions(+), 18 deletions(-) create mode 100644 src/cli/handlers/aimlapi.ts create mode 100644 src/integrations/aimlapi/client.ts create mode 100644 src/integrations/aimlapi/config.ts create mode 100644 src/integrations/aimlapi/index.ts create mode 100644 src/integrations/aimlapi/prompt.ts create mode 100644 src/integrations/aimlapi/topup.ts diff --git a/docs/aimlapi-setup.md b/docs/aimlapi-setup.md index 7484c566d..06af0de0f 100644 --- a/docs/aimlapi-setup.md +++ b/docs/aimlapi-setup.md @@ -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 (Dashboard → API Keys). +None. You don't need to visit 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: diff --git a/src/cli/handlers/aimlapi.ts b/src/cli/handlers/aimlapi.ts new file mode 100644 index 000000000..ac8e5f7c6 --- /dev/null +++ b/src/cli/handlers/aimlapi.ts @@ -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 { + 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) + } +} diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 7a6371b0f..6b079ca41 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -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 updateProviderProfile?: (...args: any[]) => unknown setActiveProviderProfile?: (...args: any[]) => unknown + provisionAimlapiKey?: (...args: any[]) => Promise 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', diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index ef6b315cc..7150d08e0 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -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() const [errorMessage, setErrorMessage] = React.useState() + const [aimlapiTopupEmail, setAimlapiTopupEmail] = React.useState('') + const [aimlapiTopupAmountUsd, setAimlapiTopupAmountUsd] = React.useState( + String(DEFAULT_AMOUNT_USD_MINOR / 100), + ) + const [aimlapiTopupMethod, setAimlapiTopupMethod] = + React.useState('card') + const [aimlapiTopupPassword, setAimlapiTopupPassword] = React.useState('') + const [aimlapiTopupStatus, setAimlapiTopupStatus] = + React.useState() + const [aimlapiTopupDetail, setAimlapiTopupDetail] = React.useState() + const [isAimlapiTopupRunning, setIsAimlapiTopupRunning] = React.useState(false) const [menuFocusValue, setMenuFocusValue] = React.useState() 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 ( + + + Create provider profile + + + Choose how to configure AI/ML API. Endpoint and model are already + configured. + + + Provider type:{' '} + {getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)} + + Step 2 of 2: API key + { + const method: PaymentMethod = value === 'crypto' ? 'crypto' : 'card' + setAimlapiTopupMethod(method) + startAimlapiTopup(aimlapiTopupEmail, aimlapiTopupPassword, method) + }} + onCancel={handleBackFromAimlapiTopupMethod} + visibleOptionCount={2} + /> + {errorMessage && {errorMessage}} + + Press Enter to open checkout. Press Esc to go back. + + + ) + } + + function renderAimlapiTopupProgress(): React.ReactNode { + const labels: Record = { + 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 ( + + + AI/ML API top-up + + {status} + {aimlapiTopupDetail ? {aimlapiTopupDetail} : null} + {errorMessage ? {errorMessage} : null} + + {isAimlapiTopupRunning + ? 'Complete checkout in the browser. This screen will continue automatically.' + : 'Press Esc to go back.'} + + + ) + } + 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 diff --git a/src/integrations/aimlapi/client.ts b/src/integrations/aimlapi/client.ts new file mode 100644 index 000000000..201754dc1 --- /dev/null +++ b/src/integrations/aimlapi/client.ts @@ -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 { + return this.request( + `${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 { + return this.request( + `${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 { + return this.request( + `${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 { + return this.request( + `${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 { + return this.request( + `${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 { + return this.request( + `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange`, + { method: 'POST', bearer }, + ) + } + + private async request( + url: string, + options: { + method: 'GET' | 'POST' | 'PUT' + body?: unknown + bearer?: string + }, + ): Promise { + const headers: Record = { 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, + ) + } + } +} diff --git a/src/integrations/aimlapi/config.ts b/src/integrations/aimlapi/config.ts new file mode 100644 index 000000000..985efc348 --- /dev/null +++ b/src/integrations/aimlapi/config.ts @@ -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')}`, + } +} diff --git a/src/integrations/aimlapi/index.ts b/src/integrations/aimlapi/index.ts new file mode 100644 index 000000000..b25d5bb52 --- /dev/null +++ b/src/integrations/aimlapi/index.ts @@ -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' diff --git a/src/integrations/aimlapi/prompt.ts b/src/integrations/aimlapi/prompt.ts new file mode 100644 index 000000000..348aff48f --- /dev/null +++ b/src/integrations/aimlapi/prompt.ts @@ -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 { + assertInteractive() + const suffix = opts.defaultValue ? ` [${opts.defaultValue}]` : '' + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await new Promise((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 { + 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((resolve) => { + rl.question(`${question}: `, resolve) + muted = true + }) + process.stdout.write('\n') + return answer + } finally { + rl.close() + } +} diff --git a/src/integrations/aimlapi/topup.ts b/src/integrations/aimlapi/topup.ts new file mode 100644 index 000000000..a4348259d --- /dev/null +++ b/src/integrations/aimlapi/topup.ts @@ -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 => + 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 { + 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 { + 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 { + 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 { + 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.') +} diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 1cac18553..bb56f6c9d 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -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', diff --git a/src/integrations/gateways/aimlapi.ts b/src/integrations/gateways/aimlapi.ts index 1bcad9f23..106b103ed 100644 --- a/src/integrations/gateways/aimlapi.ts +++ b/src/integrations/gateways/aimlapi.ts @@ -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', diff --git a/src/integrations/gateways/gitlawb-opengateway.ts b/src/integrations/gateways/gitlawb-opengateway.ts index 19d8162d0..4814226bf 100644 --- a/src/integrations/gateways/gitlawb-opengateway.ts +++ b/src/integrations/gateways/gitlawb-opengateway.ts @@ -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', diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index fc9d42be5..318d8ca21 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -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", diff --git a/src/main.tsx b/src/main.tsx index 7d0d3cf94..ac07178e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 { 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 ', 'AI/ML API account email (or AIMLAPI_EMAIL env)') + .option('--amount ', `Top-up amount in USD (min ${MIN_AMOUNT_USD_MINOR / 100}, max ${MAX_AMOUNT_USD_MINOR / 100})`) + .addOption(new Option('--method ', 'Payment method: card (Stripe) or crypto (NOWPayments)').choices(['card', 'crypto']).default('card')) + .option('--model ', 'Default model id written into the provider profile', 'gpt-4o') + .option('--partner-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. diff --git a/src/services/api/bootstrap.test.ts b/src/services/api/bootstrap.test.ts index b09a7ce9a..308a926ba 100644 --- a/src/services/api/bootstrap.test.ts +++ b/src/services/api/bootstrap.test.ts @@ -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', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index e7817f0f6..54273b6ee 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -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', )