Skip to content
Open
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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ ATLASCLOUD_API_KEY=
ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1
ATLASCLOUD_MODEL=qwen/qwen3.5-flash

# AI/ML API (OpenAI-compatible API)
AIMLAPI_API_KEY=
AIMLAPI_BASE_URL=https://api.aimlapi.com/v1
AIMLAPI_MODEL=anthropic/claude-sonnet-4.6

# Ollama Cloud (API key required for ollama.com/v1)
OLLAMA_CLOUD_API_KEY=
OLLAMA_CLOUD_BASE_URL=https://ollama.com/v1
Expand Down Expand Up @@ -60,7 +65,7 @@ MIMO_TOKEN_PLAN_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
MIMO_TOKEN_PLAN_MODEL=mimo-v2.5-pro
MIMO_TOKEN_PLAN_ENABLED=false

# Default provider: deepseek | openai | anthropic | grok | atlascloud | ollamaCloud | ollamaLocal | openaiCompat | mimo | mimoTokenPlan
# Default provider: deepseek | openai | anthropic | grok | atlascloud | aimlapi | ollamaCloud | ollamaLocal | openaiCompat | mimo | mimoTokenPlan
DEFAULT_PROVIDER=deepseek

# ── Telegram ──
Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function maskKey(key: string): string {
}

const PROVIDER_OPTIONS: Array<{ key: ProviderName; label: string }> = [
{ key: 'aimlapi', label: 'AI/ML API (recommended — 350+ models on one key)' },
{ key: 'mercuryCloud', label: 'Mercury Cloud (hosted — no API keys needed)' },
{ key: 'deepseek', label: 'DeepSeek' },
{ key: 'openai', label: 'OpenAI' },
Expand Down Expand Up @@ -1190,6 +1191,23 @@ async function configure(existingConfig?: MercuryConfig): Promise<void> {
}

for (const provider of selectedProviders) {
if (provider === 'aimlapi') {
const mask = isReconfig && config.providers.aimlapi.apiKey ? ` [${maskKey(config.providers.aimlapi.apiKey)}]` : '';
const result = await promptApiKeyWithModelSelection(
config,
'aimlapi',
'AI/ML API',
chalk.white(` AI/ML API key${mask}${isReconfig ? '' : ' (Enter to skip)'}: `),
isReconfig,
);
if (!result.skipped && result.apiKey && result.model) {
config.providers.aimlapi.apiKey = result.apiKey;
config.providers.aimlapi.model = result.model;
config.providers.aimlapi.enabled = true;
}
continue;
}

if (provider === 'deepseek') {
const mask = isReconfig && config.providers.deepseek.apiKey ? ` [${maskKey(config.providers.deepseek.apiKey)}]` : '';
const result = await promptApiKeyWithModelSelection(
Expand Down
51 changes: 51 additions & 0 deletions src/providers/aimlapi-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { aimlapiHeaders, isAimlapiBaseUrl, AIMLAPI_BASE_URL } from './aimlapi-attribution.js';

describe('AI/ML API attribution', () => {
it('sends the four headers for the default base URL', () => {
const headers = aimlapiHeaders(AIMLAPI_BASE_URL);
expect(headers).toEqual({
'X-AIMLAPI-Partner-ID': expect.stringMatching(/^part_[A-Za-z0-9]{1,64}$/),
'X-AIMLAPI-Source': 'agent/mercury-agent',
'HTTP-Referer': 'https://github.com/cosmicstack-labs/mercury-agent',
'X-Title': 'Mercury Agent',
});
});

it('keeps sending them when the user trims or extends the path', () => {
for (const url of [
'https://api.aimlapi.com',
'https://api.aimlapi.com/',
'https://api.aimlapi.com/v1/',
'https://API.AIMLAPI.COM/v1',
]) {
expect(aimlapiHeaders(url), url).toBeDefined();
}
});

it('sends nothing to a lookalike host', () => {
// The reason this is a test and not a comment: AI/ML API serves a request
// with someone else's partner id normally, so leaking one is silent.
for (const url of [
'https://api.aimlapi.com.example.test/v1',
'https://not-api.aimlapi.com/v1',
'https://example.test/?next=https://api.aimlapi.com/v1',
'https://example.test/api.aimlapi.com/v1',
]) {
expect(aimlapiHeaders(url), url).toBeUndefined();
expect(isAimlapiBaseUrl(url), url).toBe(false);
}
});

it('sends nothing to the other providers this class serves', () => {
for (const url of [
'https://api.openai.com/v1',
'https://api.deepseek.com/v1',
'http://localhost:11434/v1',
'',
'not a url',
]) {
expect(aimlapiHeaders(url), url).toBeUndefined();
}
});
});
47 changes: 47 additions & 0 deletions src/providers/aimlapi-attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* AI/ML API attributes traffic by header. A request without them is served
* normally and simply counts for nobody, so a missing or misspelled value
* fails silently — which is why the host check below is exact and why the
* tests cover the near-misses rather than only the hit.
*
* `X-AIMLAPI-Partner-ID` must match `^part_[A-Za-z0-9]{1,64}$`: alphanumerics
* only after the prefix. `HTTP-Referer` and `X-Title` are the OpenRouter
* convention that most gateways already understand.
*/
export const AIMLAPI_HOST = 'api.aimlapi.com';
export const AIMLAPI_BASE_URL = `https://${AIMLAPI_HOST}/v1`;

const PARTNER_ID = 'part_nl32eeP4aNn6xzoF09Cn2SOQ';
const SOURCE = 'agent/mercury-agent';
const REFERER = 'https://github.com/cosmicstack-labs/mercury-agent';
const TITLE = 'Mercury Agent';

/**
* Parse the URL and compare the host, rather than matching a prefix.
*
* The failure a substring check invites is one-directional and expensive:
* `https://api.aimlapi.com.example.test/v1` is a different origin, and sending
* a partner id there hands our attribution to whoever owns it. A user is free
* to point `openaiCompat` anywhere, so this runs on untrusted input.
*/
export function isAimlapiBaseUrl(baseUrl: string): boolean {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
return url.hostname.toLowerCase() === AIMLAPI_HOST;
}

/** Attribution headers for AI/ML API, or `undefined` for every other host. */
export function aimlapiHeaders(baseUrl: string): Record<string, string> | undefined {
if (!isAimlapiBaseUrl(baseUrl)) return undefined;
return {
'X-AIMLAPI-Partner-ID': PARTNER_ID,
'X-AIMLAPI-Source': SOURCE,
'HTTP-Referer': REFERER,
'X-Title': TITLE,
};
}
44 changes: 44 additions & 0 deletions src/providers/openai-compat.aimlapi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

type ClientOptions = { baseURL?: string; headers?: Record<string, string> };

const createOpenAI = vi.fn((_options: ClientOptions) => {
const client: any = vi.fn(() => ({ id: 'model' }));
client.chat = vi.fn(() => ({ id: 'model' }));
return client;
});

vi.mock('@ai-sdk/openai', () => ({ createOpenAI: (options: ClientOptions) => createOpenAI(options) }));

import { OpenAICompatProvider } from './openai-compat.js';
import type { ProviderConfig } from '../utils/config.js';

const config = (name: string, baseUrl: string): ProviderConfig => ({
name,
apiKey: 'test-key',
baseUrl,
model: 'some-model',
enabled: true,
});

describe('OpenAICompatProvider header wiring', () => {
beforeEach(() => createOpenAI.mockClear());

it('hands the attribution headers to the client for AI/ML API', () => {
// The unit tests next door prove which headers are chosen; this proves
// they actually reach the SDK, which is the half a refactor would drop.
new OpenAICompatProvider(config('aimlapi', 'https://api.aimlapi.com/v1'), { useChatApi: true });

const options = createOpenAI.mock.calls[0]![0];
expect(options.baseURL).toBe('https://api.aimlapi.com/v1');
expect(options.headers?.['X-AIMLAPI-Partner-ID']).toMatch(/^part_[A-Za-z0-9]{1,64}$/);
expect(options.headers?.['X-AIMLAPI-Source']).toBe('agent/mercury-agent');
});

it('hands no headers to any other provider served by the same class', () => {
new OpenAICompatProvider(config('openaiCompat', 'https://api.openai.com/v1'), { useChatApi: true });

const options = createOpenAI.mock.calls[0]![0];
expect(options.headers).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions src/providers/openai-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BaseProvider } from './base.js';
import type { ProviderConfig } from '../utils/config.js';
import type { LLMResponse, LLMStreamChunk } from './base.js';
import { logger } from '../utils/logger.js';
import { aimlapiHeaders } from './aimlapi-attribution.js';

export class OpenAICompatProvider extends BaseProvider {
readonly name: string;
Expand All @@ -19,6 +20,9 @@ export class OpenAICompatProvider extends BaseProvider {
this.client = createOpenAI({
apiKey: config.apiKey || 'no-key',
baseURL: config.baseUrl,
// `undefined` for every host but AI/ML API's, so no other provider
// reached through this class sends anything extra.
headers: aimlapiHeaders(config.baseUrl),
});
this.modelInstance = useChatApi
? this.client.chat(config.model)
Expand Down
9 changes: 9 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export async function createProvider(pc: ProviderConfig, tokenStore?: import('..
// this entirely.
const { OpenAICompatProvider } = await import('./openai-compat.js');
return new OpenAICompatProvider(pc, { useChatApi: true });
} else if (pc.name === 'aimlapi') {
// OpenAI-compatible gateway. Chat Completions rather than Responses: the
// catalogue is mostly third-party models reached through that surface.
const { OpenAICompatProvider } = await import('./openai-compat.js');
return new OpenAICompatProvider(pc, { useChatApi: true });
} else if (pc.name === 'atlascloud' || pc.name === 'ollamaCloud' || pc.name === 'openaiCompat') {
const { OpenAICompatProvider } = await import('./openai-compat.js');
return new OpenAICompatProvider(pc, { useChatApi: true });
Expand Down Expand Up @@ -57,6 +62,10 @@ export class ProviderRegistry {
apiKey: config.providers.mercuryCloud.apiKey || config.cloud.jwt,
baseUrl: config.cloud.apiUrl || config.providers.mercuryCloud.baseUrl,
},
// Registered after mercuryCloud on purpose. This array is the fallback
// order, and moving a first-party hosted provider off the front of it is
// a behaviour change for existing installs rather than a listing choice.
config.providers.aimlapi,
config.providers.deepseek,
config.providers.openai,
config.providers.anthropic,
Expand Down
9 changes: 9 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface TelegramPendingRequest {

export type ProviderName =
| 'mercuryCloud'
| 'aimlapi'
| 'openai'
| 'anthropic'
| 'deepseek'
Expand Down Expand Up @@ -89,6 +90,7 @@ export interface MercuryConfig {
cloud: CloudConfig;
providers: {
default: ProviderName;
aimlapi: ProviderConfig;
mercuryCloud: ProviderConfig;
openai: ProviderConfig;
anthropic: ProviderConfig;
Expand Down Expand Up @@ -248,6 +250,13 @@ export function getDefaultConfig(): MercuryConfig {
},
providers: {
default: getEnv('DEFAULT_PROVIDER', 'deepseek') as ProviderName,
aimlapi: {
name: 'aimlapi',
apiKey: getEnv('AIMLAPI_API_KEY', ''),
baseUrl: getEnv('AIMLAPI_BASE_URL', 'https://api.aimlapi.com/v1'),
model: getEnv('AIMLAPI_MODEL', 'anthropic/claude-sonnet-4.6'),
enabled: getEnvBool('AIMLAPI_ENABLED', true),
},
mercuryCloud: {
name: 'mercuryCloud',
apiKey: '',
Expand Down
56 changes: 56 additions & 0 deletions src/utils/provider-models.aimlapi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fetchProviderModelCatalog } from './provider-models.js';
import type { ProviderConfig } from './config.js';

const config: ProviderConfig = {
name: 'aimlapi',
apiKey: 'k',
baseUrl: 'https://api.aimlapi.com/v1',
model: 'anthropic/claude-sonnet-4.6',
enabled: true,
};

// Shaped like the real listing: one row per surface, ids repeated across them,
// and not a single id that looks like an OpenAI model name.
const payload = {
data: [
{ id: 'anthropic/claude-sonnet-4.6', type: 'openai/chat-completions' },
{ id: 'anthropic/claude-sonnet-4.6', type: 'anthropic/batches' },
{ id: 'anthropic/claude-sonnet-4.6', type: 'anthropic/messages' },
{ id: 'deepseek/deepseek-chat', type: 'openai/chat-completions' },
{ id: 'google/veo-3', type: 'internal/video-generations/submit' },
{ id: 'openai/dall-e-3', type: 'openai/image-generations' },
{ id: 'elevenlabs/tts', type: 'internal/text-to-speech' },
],
};

afterEach(() => vi.unstubAllGlobals());

describe('AI/ML API model discovery', () => {
it('keeps the chat surface, collapses the repeats, drops the rest', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), {
status: 200, headers: { 'content-type': 'application/json' },
})));

const catalog = await fetchProviderModelCatalog('aimlapi', config);
const all = [catalog.recommendedModel, ...catalog.models];

expect(all).toContain('anthropic/claude-sonnet-4.6');
expect(all).toContain('deepseek/deepseek-chat');
expect(all).toHaveLength(2);
expect(all).not.toContain('google/veo-3');
expect(all).not.toContain('openai/dall-e-3');
});

it('does not fall back to the id-text rule, which empties this catalogue', async () => {
// The bug this replaces: the default filter accepts `gpt-*` and `o<digit>`
// only, so every namespaced id was dropped and the UI reported "could not
// find any supported chat models" against a listing that had 353 of them.
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
data: [{ id: 'alibaba/qwen3.8-max', type: 'openai/chat-completions' }],
}), { status: 200, headers: { 'content-type': 'application/json' } })));

const catalog = await fetchProviderModelCatalog('aimlapi', config);
expect([catalog.recommendedModel, ...catalog.models]).toContain('alibaba/qwen3.8-max');
});
});
Loading