From 18eb95ae0c49ab2fcebb0c1b0e00b28dd2c65048 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 05:53:07 +0500 Subject: [PATCH 1/3] feat(provider/aimlapi): add aimlapi.com LLM support Leon can already reach one aggregator (OpenRouter) and several direct vendors. aimlapi.com is a second aggregator: a single OpenAI-compatible Chat Completions endpoint in front of many upstream vendors, so it lets one key cover the OpenAI, Anthropic, Google, DeepSeek and Moonshot families a user would otherwise configure separately. The provider is a thin subclass of the shared AI SDK remote base class, following the Celeris and MiniMax shape, and needs no base-class change. Two gateway behaviours drive the parts of this that are not boilerplate: - Its request validator rejects a null value for optional fields that the upstream OpenAI API accepts, answering HTTP 400 for temperature, top_p, seed, reasoning_effort, tools, tool_choice, response_format, stream and max_tokens. Leon's base class already omits unset options instead of serialising them as null, so the accompanying spec pins that: a future change to `x ?? null` there would break every real call while a mocked suite stayed green. - The field that switches reasoning on and off differs per upstream vendor, so the empty provider-options builder stops the generic compatible adapter from attaching one the gateway would reject. The curated models therefore expose auto reasoning only. Attribution follows the OpenRouter convention the base class already supports through its headers hook, and is scoped to the official host so a user-configured Base URL pointing at a proxy never receives it. Every model id was checked against the live catalog and answered a real completion. The dotted spelling is deliberate: the dashed anthropic/claude-* ids publish a capability set reduced to streaming. --- .env.sample | 3 + config.sample.yml | 6 + scripts/commit-msg.js | 2 +- server/src/config.ts | 6 + .../src/core/llm-manager/llm-model-catalog.ts | 25 +- .../llm-provider-account-configs.ts | 6 + server/src/core/llm-manager/llm-provider.ts | 2 + .../llm-providers/aimlapi-llm-provider.ts | 80 ++++++ server/src/core/llm-manager/types.ts | 1 + server/src/core/session-manager/index.ts | 1 + server/src/schemas/core-schemas.ts | 1 + test/agent/e2e/provider-matrix.ts | 8 + test/agent/unit/aimlapi-llm-provider.spec.ts | 239 ++++++++++++++++++ test/core/unit/config.spec.ts | 9 + 14 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts create mode 100644 test/agent/unit/aimlapi-llm-provider.spec.ts 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..4db076b71 100644 --- a/server/src/core/llm-manager/llm-model-catalog.ts +++ b/server/src/core/llm-manager/llm-model-catalog.ts @@ -326,7 +326,30 @@ export const LLM_MODEL_CATALOG: readonly LLMModelCatalogEntry[] = [ /** * @see https://platform.kimi.ai/docs/guide/kimi-k2-thinking Model-specific reasoning support. */ - { provider: LLMProviders.MoonshotAI, model: 'kimi-k2.5', label: 'Kimi K2.5', reasoning: TOGGLE_REASONING, speed: AUTO_SPEED } + { provider: LLMProviders.MoonshotAI, model: 'kimi-k2.5', label: 'Kimi K2.5', reasoning: TOGGLE_REASONING, speed: AUTO_SPEED }, + + /** + * 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 } ] /** Returns the catalog entry for an exact provider/model pair. */ 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..0450d6ca2 100644 --- a/server/src/core/llm-manager/llm-provider-account-configs.ts +++ b/server/src/core/llm-manager/llm-provider-account-configs.ts @@ -69,6 +69,12 @@ export const LLM_PROVIDER_ACCOUNT_CONFIGS: ReadonlyArray> = + Object.freeze({ + 'HTTP-Referer': 'https://github.com/leon-ai/leon', + 'X-Title': 'Leon', + 'X-AIMLAPI-Source': 'agent/leon', + 'X-AIMLAPI-Partner-ID': 'part_leon' + }) + +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..90df90028 --- /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_leon') + 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_leon') + 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' + ) }) }) From 64884534401670c4f62447ef5463ebcf420822a0 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 05:55:34 +0500 Subject: [PATCH 2/3] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both lists this touches are hand-ordered, and both feed the first choice a new user sees: `getLLMModelCatalogProviders()` derives the setup wizard's provider order from the catalog's own order, and the account config array is the order used wherever providers are listed. This moves aimlapi.com to the front of each. It is placement, not function — nothing here changes behaviour once a provider is chosen — so it is isolated in its own commit and must be dropped before the provider change is offered upstream. Leon already has a featured concept, `recommended`, used once per provider; openai/gpt-5.6-sol carries it in the provider commit like every other provider's lead model, so no badge is invented here. --- .../src/core/llm-manager/llm-model-catalog.ts | 48 +++++++++---------- .../llm-provider-account-configs.ts | 12 ++--- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/server/src/core/llm-manager/llm-model-catalog.ts b/server/src/core/llm-manager/llm-model-catalog.ts index 4db076b71..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. */ @@ -326,30 +349,7 @@ export const LLM_MODEL_CATALOG: readonly LLMModelCatalogEntry[] = [ /** * @see https://platform.kimi.ai/docs/guide/kimi-k2-thinking Model-specific reasoning support. */ - { provider: LLMProviders.MoonshotAI, model: 'kimi-k2.5', label: 'Kimi K2.5', reasoning: TOGGLE_REASONING, speed: AUTO_SPEED }, - - /** - * 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 } + { provider: LLMProviders.MoonshotAI, model: 'kimi-k2.5', label: 'Kimi K2.5', reasoning: TOGGLE_REASONING, speed: AUTO_SPEED } ] /** Returns the catalog entry for an exact provider/model pair. */ 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 0450d6ca2..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, @@ -69,12 +75,6 @@ export const LLM_PROVIDER_ACCOUNT_CONFIGS: ReadonlyArray Date: Thu, 3 Sep 2026 18:12:50 +0500 Subject: [PATCH 3/3] fix(aimlapi): use the registered partner id The placeholder part_leon was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_lcAMsJBHJpF6eW4JFtT3pJfW. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- .../core/llm-manager/llm-providers/aimlapi-llm-provider.ts | 2 +- test/agent/unit/aimlapi-llm-provider.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 3401feac5..19eed6770 100644 --- a/server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts +++ b/server/src/core/llm-manager/llm-providers/aimlapi-llm-provider.ts @@ -15,7 +15,7 @@ const AIMLAPI_ATTRIBUTION_HEADERS: Readonly> = 'HTTP-Referer': 'https://github.com/leon-ai/leon', 'X-Title': 'Leon', 'X-AIMLAPI-Source': 'agent/leon', - 'X-AIMLAPI-Partner-ID': 'part_leon' + 'X-AIMLAPI-Partner-ID': 'part_lcAMsJBHJpF6eW4JFtT3pJfW' }) function resolveBaseURL(): string { diff --git a/test/agent/unit/aimlapi-llm-provider.spec.ts b/test/agent/unit/aimlapi-llm-provider.spec.ts index 90df90028..1c708bc8b 100644 --- a/test/agent/unit/aimlapi-llm-provider.spec.ts +++ b/test/agent/unit/aimlapi-llm-provider.spec.ts @@ -202,7 +202,7 @@ describe('AIMLAPILLMProvider', () => { 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_leon') + expect(headers['x-aimlapi-partner-id']).toBe('part_lcAMsJBHJpF6eW4JFtT3pJfW') expect(headers['x-aimlapi-partner-id']).toMatch(PARTNER_ID_PATTERN) }) @@ -231,7 +231,7 @@ describe('AIMLAPILLMProvider', () => { first['X-AIMLAPI-Partner-ID'] = 'part_mutated' - expect(second['X-AIMLAPI-Partner-ID']).toBe('part_leon') + 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({})