diff --git a/.env.sample b/.env.sample index fd1c42d71..dc8e650b9 100644 --- a/.env.sample +++ b/.env.sample @@ -18,6 +18,9 @@ LEON_ZAI_API_KEY= # MiniMax API key LEON_MINIMAX_API_KEY= +# AI/ML API (aimlapi.com) API key +LEON_AIMLAPI_API_KEY= + # OpenAI API key LEON_OPENAI_API_KEY= diff --git a/config.sample.yml b/config.sample.yml index ebc4bbace..fab55746b 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -56,6 +56,12 @@ llm: api_key: # The value is read from LEON_MINIMAX_API_KEY in your profile .env file. env: LEON_MINIMAX_API_KEY + aimlapi: + # aimlapi.com OpenAI-compatible Chat Completions endpoint. + base_url: https://api.aimlapi.com/v1 + api_key: + # The value is read from LEON_AIMLAPI_API_KEY in your profile .env file. + env: LEON_AIMLAPI_API_KEY openai: api_key: # The value is read from LEON_OPENAI_API_KEY in your profile .env file. diff --git a/scripts/commit-msg.js b/scripts/commit-msg.js index bd78c2f54..2963b62bf 100644 --- a/scripts/commit-msg.js +++ b/scripts/commit-msg.js @@ -20,7 +20,7 @@ import { LogHelper } from '@/helpers/log-helper' 'utf8' ) const regex = - '(build|BREAKING|chore|ci|docs|feat|fix|perf|refactor|style|test)(\\((web app|scripts|server|agent mode|controlled mode|aurora|messaging app|built-in command|hotword|python tcp server|bridge\\/(python|nodejs)|tool\\/([\\w-]+)|skill\\/([\\w-]+)|provider\\/(llamacpp|sglang|openrouter|zai|minimax|openai|anthropic|moonshotai|huggingface|cerebras|groq|celeris)))?\\)?: .{1,50}' + '(build|BREAKING|chore|ci|docs|feat|fix|perf|refactor|style|test)(\\((web app|scripts|server|agent mode|controlled mode|aurora|messaging app|built-in command|hotword|python tcp server|bridge\\/(python|nodejs)|tool\\/([\\w-]+)|skill\\/([\\w-]+)|provider\\/(llamacpp|sglang|openrouter|zai|minimax|aimlapi|openai|anthropic|moonshotai|huggingface|cerebras|groq|celeris)))?\\)?: .{1,50}' if (commitMessage.match(regex) !== null) { LogHelper.success('Commit message validated') diff --git a/server/src/config.ts b/server/src/config.ts index 4697fd8fe..0a727ad92 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -120,6 +120,12 @@ const DEFAULT_CONFIG: LeonConfig = { env: 'LEON_MINIMAX_API_KEY' } }, + aimlapi: { + base_url: 'https://api.aimlapi.com/v1', + api_key: { + env: 'LEON_AIMLAPI_API_KEY' + } + }, openai: { api_key: { env: 'LEON_OPENAI_API_KEY' diff --git a/server/src/core/llm-manager/llm-model-catalog.ts b/server/src/core/llm-manager/llm-model-catalog.ts index 08492736a..636299167 100644 --- a/server/src/core/llm-manager/llm-model-catalog.ts +++ b/server/src/core/llm-manager/llm-model-catalog.ts @@ -115,6 +115,29 @@ const ROUTABLE_SPEED = [ * setup and command autocomplete remain deterministic and work offline. */ export const LLM_MODEL_CATALOG: readonly LLMModelCatalogEntry[] = [ + /** + * aimlapi.com is an aggregator, so the model id is the label, as for + * OpenRouter. Every id below was checked against the live catalog + * (`GET /v1/models?include=all`, type `openai/chat-completions`) and + * answered a real completion. The dotted spelling is deliberate: the dashed + * `anthropic/claude-*` variants publish only `streaming` capabilities. + * Reasoning is left on `auto` because the gateway rejects the + * vendor-specific reasoning fields Leon would otherwise attach. + * + * @see https://docs.aimlapi.com/api-references/model-database + */ + { provider: LLMProviders.AIMLAPI, model: 'openai/gpt-5.6-sol', label: 'openai/gpt-5.6-sol', recommended: true, reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** @see https://docs.aimlapi.com/api-references/model-database */ + { provider: LLMProviders.AIMLAPI, model: 'anthropic/claude-opus-4.8', label: 'anthropic/claude-opus-4.8', reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** @see https://docs.aimlapi.com/api-references/model-database */ + { provider: LLMProviders.AIMLAPI, model: 'anthropic/claude-sonnet-4.6', label: 'anthropic/claude-sonnet-4.6', reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** @see https://docs.aimlapi.com/api-references/model-database */ + { provider: LLMProviders.AIMLAPI, model: 'google/gemini-3.8-flash', label: 'google/gemini-3.8-flash', reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** @see https://docs.aimlapi.com/api-references/model-database */ + { provider: LLMProviders.AIMLAPI, model: 'deepseek/deepseek-v4-pro', label: 'deepseek/deepseek-v4-pro', reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** @see https://docs.aimlapi.com/api-references/model-database */ + { provider: LLMProviders.AIMLAPI, model: 'moonshot/kimi-k3', label: 'moonshot/kimi-k3', reasoning: AUTO_REASONING, speed: AUTO_SPEED }, + /** * @see https://docs.celeris.ai/models Fast diffusion model for short agentic calls. */ diff --git a/server/src/core/llm-manager/llm-provider-account-configs.ts b/server/src/core/llm-manager/llm-provider-account-configs.ts index f1bcb8e62..f3c0135ee 100644 --- a/server/src/core/llm-manager/llm-provider-account-configs.ts +++ b/server/src/core/llm-manager/llm-provider-account-configs.ts @@ -15,6 +15,12 @@ export interface LLMProviderAccountConfig { */ export const LLM_PROVIDER_ACCOUNT_CONFIGS: ReadonlyArray = Object.freeze([ + { + label: 'aimlapi.com', + value: LLMProviders.AIMLAPI, + apiKeyEnv: 'LEON_AIMLAPI_API_KEY', + apiKeyURL: 'https://aimlapi.com/app/keys' + }, { label: 'OpenRouter', value: LLMProviders.OpenRouter, diff --git a/server/src/core/llm-manager/llm-provider.ts b/server/src/core/llm-manager/llm-provider.ts index 48c20ca39..7ddfac6bb 100644 --- a/server/src/core/llm-manager/llm-provider.ts +++ b/server/src/core/llm-manager/llm-provider.ts @@ -82,6 +82,7 @@ const LLM_PROVIDERS_MAP = { [LLMProviders.OpenRouter]: 'openrouter-llm-provider', [LLMProviders.ZAI]: 'z-ai-llm-provider', [LLMProviders.MiniMax]: 'minimax-llm-provider', + [LLMProviders.AIMLAPI]: 'aimlapi-llm-provider', [LLMProviders.OpenAI]: 'openai-llm-provider', [LLMProviders.Anthropic]: 'anthropic-llm-provider', [LLMProviders.MoonshotAI]: 'moonshotai-llm-provider', @@ -2674,6 +2675,7 @@ export default class LLMProvider { LLMProviders.SGLang, LLMProviders.ZAI, LLMProviders.MiniMax, + LLMProviders.AIMLAPI, LLMProviders.Anthropic, LLMProviders.MoonshotAI, LLMProviders.Cerebras, diff --git a/server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts b/server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts new file mode 100644 index 000000000..19eed6770 --- /dev/null +++ b/server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts @@ -0,0 +1,80 @@ +import AISDKRemoteLLMProvider from '@/core/llm-manager/llm-providers/ai-sdk-remote-llm-provider' +import type { ResolvedLLMTarget } from '@/core/llm-manager/llm-routing' +import { CONFIG_MANAGER } from '@/config' + +const DEFAULT_BASE_URL = 'https://api.aimlapi.com/v1' +const OFFICIAL_API_HOSTNAME = 'api.aimlapi.com' + +/** + * Attribution sent with AI/ML API requests. `HTTP-Referer` and `X-Title` + * follow the OpenRouter convention and identify Leon, the calling project, + * never the gateway. + */ +const AIMLAPI_ATTRIBUTION_HEADERS: Readonly> = + Object.freeze({ + 'HTTP-Referer': 'https://github.com/leon-ai/leon', + 'X-Title': 'Leon', + 'X-AIMLAPI-Source': 'agent/leon', + 'X-AIMLAPI-Partner-ID': 'part_lcAMsJBHJpF6eW4JFtT3pJfW' + }) + +function resolveBaseURL(): string { + return CONFIG_MANAGER.getProviderBaseURL('aimlapi') || DEFAULT_BASE_URL +} + +/** + * The Base URL is user-configurable, so attribution is scoped to the official + * host. A self-hosted gateway or a third-party proxy fronting the same schema + * must not receive Leon's AI/ML API attribution, and a new object is returned + * on every call so the shared constant can never be mutated. + */ +export function buildAIMLAPIHeaders(baseURL: string): Record { + let hostname: string + + try { + hostname = new URL(baseURL).hostname + } catch { + return {} + } + + if (hostname !== OFFICIAL_API_HOSTNAME) { + return {} + } + + return { ...AIMLAPI_ATTRIBUTION_HEADERS } +} + +/** + * AI/ML API is an aggregator exposing many upstream vendors behind a single + * OpenAI-compatible Chat Completions schema. + * + * Its validator rejects a `null` value for optional request fields + * (`temperature`, `top_p`, `seed`, `reasoning_effort`, `tools`, `tool_choice`, + * `response_format`, `max_tokens`, … all answer HTTP 400), where the upstream + * OpenAI API accepts them. Unset parameters must therefore be omitted from the + * request body rather than sent as `null`, which is what the base class does + * and what `test/agent/unit/aimlapi-llm-provider.spec.ts` guards. + * + * Vendor-specific reasoning extension fields are not forwarded either: the + * field that controls reasoning differs per upstream vendor, so the empty + * provider-options builder stops Leon's generic compatible adapter from + * attaching one that this gateway would reject. + * + * @see https://docs.aimlapi.com/api-references/text-models-llm + */ +export default class AIMLAPILLMProvider extends AISDKRemoteLLMProvider { + constructor(target: ResolvedLLMTarget) { + const baseURL = resolveBaseURL() + + super({ + name: 'AI/ML API LLM Provider', + providerName: 'aimlapi', + apiKeyEnv: 'LEON_AIMLAPI_API_KEY', + model: target.model, + baseURL, + flavor: 'openai-compatible', + headers: () => buildAIMLAPIHeaders(baseURL), + buildProviderOptions: () => ({}) + }) + } +} diff --git a/server/src/core/llm-manager/types.ts b/server/src/core/llm-manager/types.ts index 859ba971c..8d9578fbe 100644 --- a/server/src/core/llm-manager/types.ts +++ b/server/src/core/llm-manager/types.ts @@ -26,6 +26,7 @@ export enum LLMProviders { OpenRouter = 'openrouter', ZAI = 'zai', MiniMax = 'minimax', + AIMLAPI = 'aimlapi', OpenAI = 'openai', Anthropic = 'anthropic', MoonshotAI = 'moonshotai', diff --git a/server/src/core/session-manager/index.ts b/server/src/core/session-manager/index.ts index 52da65a36..c3064b029 100644 --- a/server/src/core/session-manager/index.ts +++ b/server/src/core/session-manager/index.ts @@ -40,6 +40,7 @@ const TITLE_REASONING_MODE_OFF_PROVIDERS = [ LLMProviders.OpenRouter, LLMProviders.ZAI, LLMProviders.MiniMax, + LLMProviders.AIMLAPI, LLMProviders.Anthropic, LLMProviders.MoonshotAI, LLMProviders.HuggingFace, diff --git a/server/src/schemas/core-schemas.ts b/server/src/schemas/core-schemas.ts index 43ed5f03c..72a9c332b 100644 --- a/server/src/schemas/core-schemas.ts +++ b/server/src/schemas/core-schemas.ts @@ -94,6 +94,7 @@ export const configSchemaObject = strictObject({ openrouter: llmProvider, zai: llmProvider, minimax: llmProviderWithBaseURL, + aimlapi: llmProviderWithBaseURL, openai: llmProvider, anthropic: llmProvider, moonshotai: llmProvider, diff --git a/test/agent/e2e/provider-matrix.ts b/test/agent/e2e/provider-matrix.ts index b59eaedd1..be01a895c 100644 --- a/test/agent/e2e/provider-matrix.ts +++ b/test/agent/e2e/provider-matrix.ts @@ -46,6 +46,14 @@ export const PROVIDER_MATRIX = [ requiredEnv: 'LEON_MINIMAX_API_KEY', llmTarget: 'minimax/MiniMax-M3', reasoning: 'none' + }, + { + provider: 'aimlapi', + requiredEnv: 'LEON_AIMLAPI_API_KEY', + llmTarget: 'aimlapi/openai/gpt-5.6-sol', + // The gateway rejects the vendor reasoning fields Leon would attach, so + // the curated catalog exposes auto only. + reasoning: null } ] as const diff --git a/test/agent/unit/aimlapi-llm-provider.spec.ts b/test/agent/unit/aimlapi-llm-provider.spec.ts new file mode 100644 index 000000000..1c708bc8b --- /dev/null +++ b/test/agent/unit/aimlapi-llm-provider.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AIMLAPILLMProvider, { + buildAIMLAPIHeaders +} from '@/core/llm-manager/llm-providers/aimlapi-llm-provider' +import type { ResolvedLLMTarget } from '@/core/llm-manager/llm-routing' +import type { CompletionParams } from '@/core/llm-manager/types' +import { LLMDuties, LLMProviders } from '@/core/llm-manager/types' + +vi.mock('@/config', () => ({ + CONFIG_MANAGER: { + getProviderAPIKeyEnv: vi.fn(() => null), + getProviderAPIKey: vi.fn(() => 'test-aimlapi-key'), + getProviderBaseURL: vi.fn(() => process.env['TEST_AIMLAPI_BASE_URL'] || '') + } +})) + +vi.mock('@/helpers/log-helper', () => ({ + LogHelper: { + title: vi.fn(), + success: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn() + } +})) + +/** + * The partner id is never validated at request time: the gateway silently + * treats a malformed one as untagged usage, so only this assertion catches + * a typo. + */ +const PARTNER_ID_PATTERN = /^part_[A-Za-z0-9]{1,64}$/ + +interface ProviderWithPrivateCallOptions { + buildCallOptions( + prompt: string, + completionParams: CompletionParams + ): Record + runChatCompletion( + prompt: string, + completionParams: CompletionParams + ): Promise<{ data: Record }> +} + +type TestProvider = AIMLAPILLMProvider & ProviderWithPrivateCallOptions + +function createProvider(model = 'openai/gpt-5.6-sol'): TestProvider { + const target: ResolvedLLMTarget = { + provider: LLMProviders.AIMLAPI, + model, + label: `aimlapi/${model}`, + isLocal: false, + isEnabled: true, + isResolved: true + } + + return new AIMLAPILLMProvider(target) as TestProvider +} + +function createCompletionParams( + overrides: Partial = {} +): CompletionParams { + return { + dutyType: LLMDuties.ReAct, + systemPrompt: 'Plan the next step.', + ...overrides + } +} + +function stubChatCompletionFetch(): ReturnType { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-test', + created: 0, + model: 'openai/gpt-5.6-sol', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Done.' }, + finish_reason: 'stop' + } + ], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 2 + } + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + vi.stubGlobal('fetch', fetchMock) + + return fetchMock +} + +function readRequestHeaders(init: RequestInit | undefined): Record< + string, + string +> { + return Object.fromEntries( + Object.entries( + (init?.headers || {}) as Record + ) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key.toLowerCase(), String(value)]) + ) +} + +describe('AIMLAPILLMProvider', () => { + beforeEach(() => { + vi.stubEnv('LEON_AIMLAPI_API_KEY', 'test-aimlapi-key') + vi.stubEnv('TEST_AIMLAPI_BASE_URL', '') + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('calls the OpenAI-compatible chat completions endpoint', async () => { + const fetchMock = stubChatCompletionFetch() + const provider = createProvider() + + await provider.runChatCompletion('Answer directly.', createCompletionParams()) + + const [requestURL] = fetchMock.mock.calls[0]! + + expect(String(requestURL)).toBe( + 'https://api.aimlapi.com/v1/chat/completions' + ) + }) + + it('omits unset optional parameters instead of sending them as null', async () => { + const fetchMock = stubChatCompletionFetch() + const provider = createProvider() + + await provider.runChatCompletion('Answer directly.', createCompletionParams()) + + const [, requestInit] = fetchMock.mock.calls[0]! + const requestBody = JSON.parse(String(requestInit?.body)) as Record< + string, + unknown + > + + /** + * The gateway answers HTTP 400 for a null-valued optional field, so an + * unset parameter has to be absent from the payload. Serialising it as + * null fails every real call while leaving a mocked suite green. + */ + for (const field of [ + 'temperature', + 'top_p', + 'seed', + 'reasoning_effort', + 'tools', + 'tool_choice', + 'response_format', + 'max_tokens', + 'stream' + ]) { + expect(requestBody[field]).not.toBeNull() + } + + expect(Object.entries(requestBody).filter(([, value]) => value === null)) + .toEqual([]) + }) + + it('forwards deterministic generation options when they are set', async () => { + const fetchMock = stubChatCompletionFetch() + const provider = createProvider() + + await provider.runChatCompletion( + 'Answer directly.', + createCompletionParams({ temperature: 0, seed: 7, maxTokens: 64 }) + ) + + const [, requestInit] = fetchMock.mock.calls[0]! + const requestBody = JSON.parse(String(requestInit?.body)) as Record< + string, + unknown + > + + expect(requestBody['temperature']).toBe(0) + expect(requestBody['seed']).toBe(7) + expect(requestBody['max_tokens']).toBe(64) + }) + + it('sends the attribution headers on official endpoint requests', async () => { + const fetchMock = stubChatCompletionFetch() + const provider = createProvider() + + await provider.runChatCompletion('Answer directly.', createCompletionParams()) + + const [, requestInit] = fetchMock.mock.calls[0]! + const headers = readRequestHeaders(requestInit) + + expect(headers['http-referer']).toBe('https://github.com/leon-ai/leon') + expect(headers['x-title']).toBe('Leon') + expect(headers['x-aimlapi-source']).toBe('agent/leon') + expect(headers['x-aimlapi-partner-id']).toBe('part_lcAMsJBHJpF6eW4JFtT3pJfW') + expect(headers['x-aimlapi-partner-id']).toMatch(PARTNER_ID_PATTERN) + }) + + it('keeps attribution off a Base URL that is not the official host', async () => { + vi.stubEnv('TEST_AIMLAPI_BASE_URL', 'https://gateway.example.com/v1') + const fetchMock = stubChatCompletionFetch() + const provider = createProvider() + + await provider.runChatCompletion('Answer directly.', createCompletionParams()) + + const [requestURL, requestInit] = fetchMock.mock.calls[0]! + const headers = readRequestHeaders(requestInit) + + expect(String(requestURL)).toBe( + 'https://gateway.example.com/v1/chat/completions' + ) + expect(headers['x-aimlapi-partner-id']).toBeUndefined() + expect(headers['x-aimlapi-source']).toBeUndefined() + expect(headers['http-referer']).toBeUndefined() + expect(headers['x-title']).toBeUndefined() + }) + + it('builds a new header object per request and rejects invalid Base URLs', () => { + const first = buildAIMLAPIHeaders('https://api.aimlapi.com/v1') + const second = buildAIMLAPIHeaders('https://api.aimlapi.com/v1') + + first['X-AIMLAPI-Partner-ID'] = 'part_mutated' + + expect(second['X-AIMLAPI-Partner-ID']).toBe('part_lcAMsJBHJpF6eW4JFtT3pJfW') + expect(buildAIMLAPIHeaders('not-a-url')).toEqual({}) + expect(buildAIMLAPIHeaders('https://api.aimlapi.com.evil.test/v1')) + .toEqual({}) + }) +}) diff --git a/test/core/unit/config.spec.ts b/test/core/unit/config.spec.ts index 0d0edaeb4..a645f1dbd 100644 --- a/test/core/unit/config.spec.ts +++ b/test/core/unit/config.spec.ts @@ -149,6 +149,12 @@ describe('ConfigManager', () => { env: 'LEON_MINIMAX_API_KEY' } }, + aimlapi: { + base_url: 'https://api.aimlapi.com/v1', + api_key: { + env: 'LEON_AIMLAPI_API_KEY' + } + }, openai: { api_key: { env: 'LEON_OPENAI_API_KEY' @@ -256,5 +262,8 @@ describe('ConfigManager', () => { expect(configManager.getProviderBaseURL('minimax')).toBe( 'https://api.minimaxi.com/anthropic' ) + expect(configManager.getProviderBaseURL('aimlapi')).toBe( + 'https://api.aimlapi.com/v1' + ) }) })