Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion docs/aimlapi-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,53 @@ for the request and response format.
- `/model` lists chat-capable models discovered from the catalog.
- Send any prompt to confirm responses come back from the selected model.

## AI/ML API MCP server (Toolbox and media generation)

AI/ML API also runs a remote MCP server at `https://mcp.aimlapi.com/mcp`. It adds 19 tools to a session: the Toolbox (`tools_search`, `tools_inspect`, `tools_run`, `tools_run_status`), `image_generate`, `video_generate`, `audio_generate`, `llm_chat`, `embeddings_create`, `job_status`, `job_result`, model lookup (`models_list`, `models_search`, `models_find_best`, `models_compare`, `model_get`), `account_balance`, `account_usage`, and `ping`.

> **Billing:** Toolbox runs (`tools_run`), generations, `llm_chat` and embeddings are charged to your AI/ML API balance. `ping`, `tools_search` and `tools_inspect` are free. The server is never connected unless you turn it on.

### One step: the built-in `aimlapi` plugin

If AI/ML API is already your provider, turn the server on with:

```bash
openclaude plugin enable aimlapi@builtin
```

You can also open `/plugin` in a session, find the built-in **aimlapi** plugin and enable it there. To turn it off again:

```bash
openclaude plugin disable aimlapi@builtin
```

- The plugin signs in with the AI/ML API key you already configured. It reads the key from `AIMLAPI_API_KEY`, which an active aimlapi.com provider profile sets for the session. You can also export the variable yourself. The settings file only stores `"aimlapi@builtin": true`. The key is never written there.
- The plugin is off by default, so having a key does not connect the server. It is also hidden when no `AIMLAPI_API_KEY` is set. It stays hidden when your aimlapi profile points at a proxy base URL (`OPENAI_BASE_URL` or `OPENAI_API_BASE`), so a proxy credential is never sent to aimlapi.com.
- If `AIMLAPI_API_KEY` holds a comma-separated pool, only its first usable key is sent to the MCP server, with no rotation across keys.
- The server appears as `plugin:aimlapi:aimlapi`, and its tools appear as `mcp__plugin_aimlapi_aimlapi__<tool>`, for example `mcp__plugin_aimlapi_aimlapi__tools_search`.
- To check it, run `openclaude mcp list`. You should see `plugin:aimlapi:aimlapi: https://mcp.aimlapi.com/mcp (HTTP) - ✓ Connected`. In a session, `/mcp` shows the same server.

### Without the plugin: `openclaude mcp add`

The standard MCP commands work too. To use your API key, keep the single quotes so that your shell does not expand the variable. OpenClaude expands `${AIMLAPI_API_KEY}` when it connects, so the config file stores the reference and not the key:

```bash
openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp \
--header 'Authorization: Bearer ${AIMLAPI_API_KEY}'
```

To sign in with your aimlapi.com account (OAuth) instead, leave out the header. `openclaude mcp list` reports **Needs authentication**. In a session, open `/mcp`, select the server and authenticate in the browser:

```bash
openclaude mcp add --transport http -s user aimlapi https://mcp.aimlapi.com/mcp
```

If you configure the server both ways, the hand-added entry wins and the plugin copy is skipped as a duplicate. Note that `openclaude mcp get aimlapi` prints header values after expansion, which includes your key.

## Notes

- Model discovery uses the public, unauthenticated `GET /models` endpoint and surfaces only chat-completions models; image, audio, embeddings, and other modalities are intentionally not routed through the coding workflow.
- Every request to AI/ML API (inference, catalog, sign-in/account, checkout, and Toolbox web search) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity.
- Every request to AI/ML API (inference, catalog, sign-in/account, checkout, and Toolbox web search) carries the two mandatory attribution headers `X-AIMLAPI-Source: agent/openclaude` and `X-AIMLAPI-Partner-ID`. On the canonical `api.aimlapi.com` endpoint the inference/catalog requests additionally send `X-AIMLAPI-Integration-*` (owner/repo/version) plus `HTTP-Referer: OpenClaude` and `X-Title: OpenClaude`; all attribution headers are stripped when a non-canonical (proxy) base URL is configured, so a third-party host never receives OpenClaude's partner identity. MCP requests to `mcp.aimlapi.com` (matched by exact hostname, from the plugin or a hand-added server) carry `X-AIMLAPI-Source` and `X-AIMLAPI-Partner-ID`. Other MCP servers never receive them.
- `AIMLAPI_AUTH_URL`, `AIMLAPI_APP_URL`, `AIMLAPI_PAY_URL`, and `AIMLAPI_VERIFICATION_BASE_URL` can point the complete flow at another environment. Guided **new-account** onboarding and key provisioning require the canonical `api.aimlapi.com` inference endpoint, so `AIMLAPI_INFERENCE_URL` must be unset (or left at its default) to create a new account and mint its key — a non-canonical value is rejected there. The existing-key top-up path (paste an aimlapi.com key) instead runs against whatever `AIMLAPI_INFERENCE_URL` is configured. `AIMLAPI_RETURN_URL` overrides only the browser landing page.
- Checkout success is detected by polling. The browser return target is an HTTPS page; OpenClaude does not install a custom URL-scheme handler.
- Usage (`/usage`) reporting is not supported for this provider.
7 changes: 7 additions & 0 deletions src/cli/handlers/mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,13 @@ export async function mcpRemoveHandler(name: string, options: {
// mcp list (lines 4641–4688)
export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {});
// The mcp subcommands skip the session startup that registers built-in
// plugins; register them so an enabled built-in plugin's MCP server (e.g.
// aimlapi@builtin) is listed and health-checked the way a session sees it.
const {
initBuiltinPlugins
} = await import('../../plugins/bundled/index.js');
initBuiltinPlugins();
const {
servers: configs
} = await getAllMcpConfigs();
Expand Down
83 changes: 83 additions & 0 deletions src/integrations/aimlapi/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'

import {
AIMLAPI_MANAGED_ATTRIBUTION_HEADER_NAMES,
AIMLAPI_MCP_URL,
DEFAULT_PARTNER_ID,
buildPartnerCheckoutReturnUrls,
buildPartnerReturnUrl,
isAimlapiMcpUrl,
isCanonicalAimlapiInferenceBaseUrl,
isTrustedAimlapiRequestUrl,
resolveAimlapiAttributionHeaders,
resolveAimlapiMcpHeaders,
resolvePartnerId,
resolveEndpoints,
withResolvedPartnerHeader,
Expand Down Expand Up @@ -264,3 +267,83 @@ test('proxy attribution strips every managed name spelling and keeps X-Tenant',
expect(headers.get('x-tenant')).toBe('acme')
expect(Object.keys(resolved)).toEqual(['X-Tenant'])
})

test('MCP host gate is an exact https hostname match, never a suffix', () => {
expect(isAimlapiMcpUrl(AIMLAPI_MCP_URL)).toBe(true)
expect(isAimlapiMcpUrl('https://MCP.aimlapi.com/mcp')).toBe(true)
expect(isAimlapiMcpUrl('https://mcp.aimlapi.com:443/other')).toBe(true)
// Sibling aimlapi hosts are not the MCP server.
expect(isAimlapiMcpUrl('https://api.aimlapi.com/v1')).toBe(false)
expect(isAimlapiMcpUrl('https://staging.mcp.aimlapi.com/mcp')).toBe(false)
// Look-alikes, cleartext and garbage are rejected.
expect(isAimlapiMcpUrl('https://mcp.aimlapi.com.attacker.test/mcp')).toBe(false)
expect(isAimlapiMcpUrl('https://evilmcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('https://attacker.test/mcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('http://mcp.aimlapi.com/mcp')).toBe(false)
expect(isAimlapiMcpUrl('not-a-url')).toBe(false)
})

test('MCP headers gain both mandatory attribution headers on the MCP host only', () => {
const resolved = resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer test-key' },
AIMLAPI_MCP_URL,
)
expectSingleManagedAttribution(resolved, {
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
})
expect(resolved.Authorization).toBe('Bearer test-key')

// Any other MCP server keeps exactly the headers it was configured with.
for (const url of [
'https://mcp.example.test/mcp',
'https://mcp.aimlapi.com.attacker.test/mcp',
'http://mcp.aimlapi.com/mcp',
]) {
expect(resolveAimlapiMcpHeaders({ 'X-Tenant': 'acme' }, url)).toEqual({
'X-Tenant': 'acme',
})
}
})

test('MCP attribution restamps a configured partner id instead of combining it', () => {
const resolved = resolveAimlapiMcpHeaders(
{
'x-aimlapi-partner-id': 'part_attacker',
'X-AIMLAPI-SOURCE': 'agent/attacker',
'X-Tenant': 'acme',
},
AIMLAPI_MCP_URL,
)
expectSingleManagedAttribution(resolved, {
'X-AIMLAPI-Source': 'agent/openclaude',
'X-AIMLAPI-Partner-ID': DEFAULT_PARTNER_ID,
})
expect(new Headers(resolved).get('x-aimlapi-partner-id')).not.toContain(',')
expect(resolved['X-Tenant']).toBe('acme')
})

test('MCP Authorization keeps only the first key of a pooled credential', () => {
expect(
resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer aiml-first-key, aiml-second-key' },
AIMLAPI_MCP_URL,
).Authorization,
).toBe('Bearer aiml-first-key')
// A single key and a non-bearer value pass through unchanged.
expect(
resolveAimlapiMcpHeaders({ Authorization: 'Bearer aiml-only-key' }, AIMLAPI_MCP_URL)
.Authorization,
).toBe('Bearer aiml-only-key')
expect(
resolveAimlapiMcpHeaders({ authorization: 'Basic abc,def' }, AIMLAPI_MCP_URL)
.authorization,
).toBe('Basic abc,def')
// Other MCP servers keep exactly what they were configured with.
expect(
resolveAimlapiMcpHeaders(
{ Authorization: 'Bearer first, second' },
'https://mcp.example.test/mcp',
),
).toEqual({ Authorization: 'Bearer first, second' })
})
63 changes: 63 additions & 0 deletions src/integrations/aimlapi/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* Override any single URL via the corresponding `AIMLAPI_*_URL` env var.
*/

import { firstUsableCredential } from '../../services/api/credentialPool.js'

export type AimlapiEndpoints = {
/** app/auth service - mints the user access (Bearer) token. */
authBaseUrl: string
Expand Down Expand Up @@ -251,6 +253,67 @@ export function resolveAimlapiAttributionHeaders(
return rest
}

/**
* AI/ML API MCP server (streamable HTTP). It accepts the same
* `Authorization: Bearer <AIMLAPI key>` as the inference API and falls back to
* OAuth when no Authorization header is sent.
*/
export const AIMLAPI_MCP_URL = 'https://mcp.aimlapi.com/mcp'
const AIMLAPI_MCP_HOSTNAME = new URL(AIMLAPI_MCP_URL).hostname

/**
* True only for an https URL whose hostname is exactly the AI/ML API MCP host.
* Deliberately not a suffix match: attribution must never reach a look-alike
* (`mcp.aimlapi.com.evil.test`) or a sibling host the user pointed elsewhere.
*/
export function isAimlapiMcpUrl(url: string): boolean {
try {
const parsed = new URL(url)
return (
parsed.protocol === 'https:' &&
parsed.hostname.toLowerCase() === AIMLAPI_MCP_HOSTNAME
)
} catch {
return false
}
}

/**
* An aimlapi provider profile can export a comma-separated credential pool as
* AIMLAPI_API_KEY, so `Bearer ${AIMLAPI_API_KEY}` can expand to several keys.
* The MCP server takes one key: keep the first usable one, as the web search
* backend does. Any other Authorization value is returned unchanged.
*/
function singleBearerCredential(value: string): string {
const match = /^Bearer\s+(.+)$/i.exec(value.trim())
if (!match?.[1]?.includes(',')) return value
const key = firstUsableCredential(match[1])
return key ? `Bearer ${key}` : value
}

/**
* Stamp the mandatory attribution headers onto an MCP server's request headers
* when (and only when) the server is the AI/ML API MCP host. This covers both
* the built-in `aimlapi` plugin server and a server the user added by hand
* with `openclaude mcp add`. Any other MCP server's headers are returned
* untouched. Managed names are restamped through the same helper the
* inference path uses, so a configured header cannot replace the partner id.
*/
export function resolveAimlapiMcpHeaders(
headers: Readonly<Record<string, string>>,
url: string,
): Record<string, string> {
if (!isAimlapiMcpUrl(url)) return { ...headers }
const single: Record<string, string> = {}
for (const [name, value] of Object.entries(headers)) {
single[name] = name.trim().toLowerCase() === 'authorization'
? singleBearerCredential(value)
: value
}
// An undefined base URL selects the canonical (attributed) branch.
return resolveAimlapiAttributionHeaders(single, undefined)
}

/**
* Build the co-branded checkout return URLs the hosted payment page redirects
* to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1`
Expand Down
Loading
Loading