diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/__tests__/llm-providers.test.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/__tests__/llm-providers.test.tsx
index 95f658eee3c51..052982a4081b1 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/__tests__/llm-providers.test.tsx
+++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/__tests__/llm-providers.test.tsx
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest';
import { createMockClient } from '@nocobase/client-v2';
import PluginAIClientV2 from '../plugin';
import {
+ aimlapiProviderOptions,
builtinLLMProviderOptions,
deepseekProviderOptions,
getBuiltinLLMProviderModelOptionFields,
@@ -20,6 +21,7 @@ import {
shengsuanyunProviderOptions,
} from '../llm-providers';
import {
+ AimlapiProviderSettingsForm,
EmptyProviderSettingsForm,
OrcaRouterProviderSettingsForm,
ProviderSettingsForm,
@@ -40,6 +42,7 @@ const V1_REGISTERED_PROVIDERS = [
'mistral',
'orcarouter',
'shengsuanyun',
+ 'aimlapi',
];
describe('plugin-ai client-v2 LLM providers', () => {
@@ -59,6 +62,7 @@ describe('plugin-ai client-v2 LLM providers', () => {
expect(plugin.aiManager.llmProviders.get('ollama')).toBe(ollamaProviderOptions);
expect(plugin.aiManager.llmProviders.get('orcarouter')).toBe(orcarouterProviderOptions);
expect(plugin.aiManager.llmProviders.get('shengsuanyun')).toBe(shengsuanyunProviderOptions);
+ expect(plugin.aiManager.llmProviders.get('aimlapi')).toBe(aimlapiProviderOptions);
});
it('uses v2 provider settings components without v1 schema forms', () => {
@@ -66,6 +70,7 @@ describe('plugin-ai client-v2 LLM providers', () => {
expect(ollamaProviderOptions.components.ProviderSettingsForm).toBe(EmptyProviderSettingsForm);
expect(orcarouterProviderOptions.components.ProviderSettingsForm).toBe(OrcaRouterProviderSettingsForm);
expect(shengsuanyunProviderOptions.components.ProviderSettingsForm).toBe(ShengSuanYunProviderSettingsForm);
+ expect(aimlapiProviderOptions.components.ProviderSettingsForm).toBe(AimlapiProviderSettingsForm);
expect(shengsuanyunProviderOptions.components.ModelSettingsForm).toBeDefined();
expect(openaiResponsesProviderOptions.components.ModelSettingsForm).toBeDefined();
});
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/forms.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/forms.tsx
index 302068520378a..0b4f10bceac2f 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/forms.tsx
+++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/forms.tsx
@@ -89,6 +89,32 @@ export const OrcaRouterProviderSettingsForm: React.FC = () => {
);
};
+export const AimlapiProviderSettingsForm: React.FC = () => {
+ const t = useT();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
export const ShengSuanYunProviderSettingsForm: React.FC = () => {
const t = useT();
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/index.ts b/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/index.ts
index ccf6d586a28a4..f650d2f95ef8f 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/index.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/llm-providers/index.ts
@@ -9,6 +9,7 @@
import type { LLMProviderOptions } from '../manager/ai-manager';
import {
+ AimlapiProviderSettingsForm,
createModelSettingsForm,
deepSeekCompletionFields,
EmptyProviderSettingsForm,
@@ -83,6 +84,10 @@ export const shengsuanyunProviderOptions = createProviderOptions(
},
);
+export const aimlapiProviderOptions = createProviderOptions(createModelSettingsForm(openAICompletionFields), {
+ ProviderSettingsForm: AimlapiProviderSettingsForm,
+});
+
export const ollamaProviderOptions = createProviderOptions(createModelSettingsForm(ollamaCompletionFields), {
ProviderSettingsForm: EmptyProviderSettingsForm,
});
@@ -101,6 +106,7 @@ export const builtinLLMProviderOptions: Array<[string, LLMProviderOptions]> = [
['mistral', mistralProviderOptions],
['orcarouter', orcarouterProviderOptions],
['shengsuanyun', shengsuanyunProviderOptions],
+ ['aimlapi', aimlapiProviderOptions],
];
const builtinLLMProviderModelOptionFields = new Map([
@@ -117,6 +123,7 @@ const builtinLLMProviderModelOptionFields = new Map([
['mistral', mistralCompletionFields],
['orcarouter', orcaRouterCompletionFields],
['shengsuanyun', shengSuanYunCompletionFields],
+ ['aimlapi', openAICompletionFields],
]);
export const getBuiltinLLMProviderModelOptionFields = (provider?: string): OptionField[] =>
diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/pages/LLMServicesPage.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/pages/LLMServicesPage.tsx
index 738e043b50c08..ec33bd9baa312 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/pages/LLMServicesPage.tsx
+++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/pages/LLMServicesPage.tsx
@@ -343,6 +343,7 @@ export async function testLLMServiceFlight(
const getProviderSortIndex = (value: string) => {
const sortOrder = [
+ 'aimlapi',
'google-genai',
'openai',
'anthropic',
@@ -374,6 +375,7 @@ const getProviderDescription = (provider: string, t: ReturnType) =>
mistral: 'Mistral models',
orcarouter: 'OrcaRouter (model routing gateway)',
shengsuanyun: '300+ latest mainstream models across leading model families',
+ aimlapi: 'Models from OpenAI, Anthropic, Google, DeepSeek and others through one OpenAI-compatible API',
};
return descriptions[provider] ? t(descriptions[provider]) : '';
};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/common/recommended-models.ts b/packages/plugins/@nocobase/plugin-ai/src/common/recommended-models.ts
index b26b3672849ca..d0c2a4b253fbc 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/common/recommended-models.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/common/recommended-models.ts
@@ -11,7 +11,14 @@
* NocoBase officially recommended models for each LLM provider.
* These models are tested to ensure quality and compatibility.
*/
-export const recommendedModels: Record = {};
+export const recommendedModels: Record = {
+ aimlapi: [
+ { label: 'GPT-4o', value: 'openai/gpt-4o' },
+ { label: 'Claude Sonnet 4.5', value: 'anthropic/claude-sonnet-4.5' },
+ { label: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' },
+ { label: 'DeepSeek Chat', value: 'deepseek/deepseek-chat' },
+ ],
+};
/**
* Check if a model is recommended for a given provider
diff --git a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json
index 7ec968bccf652..6437eee366cd6 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json
+++ b/packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json
@@ -196,6 +196,7 @@
"Messages": "Messages",
"Mistral models": "Mistral models",
"300+ latest mainstream models across leading model families": "300+ latest mainstream models across leading model families",
+ "Models from OpenAI, Anthropic, Google, DeepSeek and others through one OpenAI-compatible API": "Models from OpenAI, Anthropic, Google, DeepSeek and others through one OpenAI-compatible API",
"Model": "Model",
"Model ID already exists": "Model ID already exists",
"Model ID is required": "Model ID is required",
diff --git a/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json
index 431967cab2ef3..37926dd2f2b99 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json
+++ b/packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json
@@ -197,6 +197,7 @@
"Messages": "消息",
"Mistral models": "Mistral 模型",
"300+ latest mainstream models across leading model families": "300+最新各系列主流模型",
+ "Models from OpenAI, Anthropic, Google, DeepSeek and others through one OpenAI-compatible API": "通过一个 OpenAI 兼容 API 使用 OpenAI、Anthropic、Google、DeepSeek 等厂商的模型",
"Model": "模型",
"Model ID already exists": "模型标识已存在",
"Model ID is required": "请输入模型标识",
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/__tests__/aimlapi.test.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/__tests__/aimlapi.test.ts
new file mode 100644
index 0000000000000..0d0ae0a69e09e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/__tests__/aimlapi.test.ts
@@ -0,0 +1,167 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import type { Application } from '@nocobase/server';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const serverRequestMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@nocobase/utils', async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ serverRequest: serverRequestMock,
+ };
+});
+
+import {
+ AIMLAPI_ATTRIBUTION_HEADERS,
+ AimlapiProvider,
+ aimlapiProviderOptions,
+ supportsChatCompletions,
+} from '../aimlapi';
+
+function createApp(): Application {
+ return {
+ environment: {
+ renderJsonTemplate: (value: Record) => value,
+ },
+ } as unknown as Application;
+}
+
+const originalWhitelist = process.env.SERVER_REQUEST_WHITELIST;
+
+describe('AimlapiProvider', () => {
+ afterEach(() => {
+ process.env.SERVER_REQUEST_WHITELIST = originalWhitelist;
+ serverRequestMock.mockReset();
+ });
+
+ it('uses the AI/ML API OpenAI-compatible API base URL', () => {
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ serviceOptions: { apiKey: 'test-key' },
+ });
+
+ expect(provider.baseURL).toBe('https://api.aimlapi.com/v1');
+ });
+
+ it('uses the aimlapi.com brand name in provider selectors', () => {
+ expect(aimlapiProviderOptions.title).toBe('aimlapi.com');
+ });
+
+ // A partner id that does not match the gateway's pattern is dropped silently: the request still succeeds, it is
+ // simply never attributed. A typo would therefore be invisible at runtime, hence this assertion.
+ it('ships a partner id in the format the gateway accepts', () => {
+ expect(AIMLAPI_ATTRIBUTION_HEADERS['X-AIMLAPI-Partner-ID']).toMatch(/^part_[A-Za-z0-9]{1,64}$/);
+ });
+
+ it('ships a source in the / format the gateway accepts', () => {
+ expect(AIMLAPI_ATTRIBUTION_HEADERS['X-AIMLAPI-Source']).toMatch(/^(web|agent|mcp)\/[a-z0-9-]{1,32}$/);
+ });
+
+ it('identifies NocoBase, not AI/ML API, as the calling application', () => {
+ expect(AIMLAPI_ATTRIBUTION_HEADERS['HTTP-Referer']).toBe('https://github.com/nocobase/nocobase');
+ expect(AIMLAPI_ATTRIBUTION_HEADERS['X-Title']).toBe('NocoBase');
+ });
+
+ it('adds the attribution headers to chat requests', () => {
+ process.env.SERVER_REQUEST_WHITELIST = 'api.aimlapi.com';
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ serviceOptions: { apiKey: 'test-key' },
+ modelOptions: { model: 'openai/gpt-4o-mini' },
+ });
+
+ expect(provider.chatModel.clientConfig).toMatchObject({
+ baseURL: 'https://api.aimlapi.com/v1',
+ defaultHeaders: { ...AIMLAPI_ATTRIBUTION_HEADERS },
+ });
+ });
+
+ it('lets a configured referer and title win over the defaults without dropping the AI/ML API headers', () => {
+ process.env.SERVER_REQUEST_WHITELIST = 'api.aimlapi.com';
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ serviceOptions: { apiKey: 'test-key', httpReferer: 'https://example.test', xTitle: 'Test App' },
+ modelOptions: { model: 'openai/gpt-4o-mini' },
+ });
+
+ expect(provider.chatModel.clientConfig.defaultHeaders).toEqual({
+ 'HTTP-Referer': 'https://example.test',
+ 'X-Title': 'Test App',
+ 'X-AIMLAPI-Partner-ID': AIMLAPI_ATTRIBUTION_HEADERS['X-AIMLAPI-Partner-ID'],
+ 'X-AIMLAPI-Source': AIMLAPI_ATTRIBUTION_HEADERS['X-AIMLAPI-Source'],
+ });
+ expect(AIMLAPI_ATTRIBUTION_HEADERS['X-Title']).toBe('NocoBase');
+ });
+
+ // baseURL is user-configurable. Attribution that rode along to another vendor, or to a proxy fronting AI/ML API,
+ // would tag traffic that is not ours.
+ it('does not send attribution to a base URL outside the AI/ML API origin', () => {
+ process.env.SERVER_REQUEST_WHITELIST = 'api.aimlapi.com,proxy.example.test';
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ serviceOptions: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' },
+ modelOptions: { model: 'openai/gpt-4o-mini' },
+ });
+
+ expect(provider.chatModel.clientConfig.defaultHeaders).toBeUndefined();
+ });
+
+ it('recognizes Chat Completions-compatible models', () => {
+ expect(supportsChatCompletions({ id: 'openai/gpt-4o', type: 'openai/chat-completions' })).toBe(true);
+ expect(supportsChatCompletions({ id: 'openai/o3-deep-research', type: 'openai/responses/submit' })).toBe(false);
+ expect(supportsChatCompletions({ id: 'flux/dev', type: 'openai/image-generations' })).toBe(false);
+ expect(supportsChatCompletions({ id: 'legacy-model' })).toBe(true);
+ });
+
+ it('loads and filters the model catalog with the attribution headers attached', async () => {
+ process.env.SERVER_REQUEST_WHITELIST = 'api.aimlapi.com';
+ serverRequestMock.mockResolvedValue({
+ data: {
+ object: 'list',
+ data: [
+ { id: 'openai/gpt-4o', type: 'openai/chat-completions' },
+ { id: 'flux/dev', type: 'openai/image-generations' },
+ { id: 'legacy-model' },
+ ],
+ },
+ });
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ serviceOptions: { apiKey: 'test-key' },
+ });
+
+ await expect(provider.listModels()).resolves.toEqual({
+ models: [{ id: 'openai/gpt-4o' }, { id: 'legacy-model' }],
+ });
+ expect(serverRequestMock).toHaveBeenCalledWith({
+ method: 'GET',
+ url: 'https://api.aimlapi.com/v1/models',
+ headers: {
+ Authorization: 'Bearer test-key',
+ ...AIMLAPI_ATTRIBUTION_HEADERS,
+ },
+ });
+ });
+
+ it('requires an API key before loading models', async () => {
+ process.env.SERVER_REQUEST_WHITELIST = 'api.aimlapi.com';
+ const provider = new AimlapiProvider({
+ app: createApp(),
+ });
+
+ await expect(provider.listModels()).resolves.toEqual({
+ code: 400,
+ errMsg: 'API Key required',
+ });
+ expect(serverRequestMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/aimlapi.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/aimlapi.ts
new file mode 100644
index 0000000000000..31f44968cb152
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/aimlapi.ts
@@ -0,0 +1,152 @@
+/**
+ * This file is part of the NocoBase (R) project.
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
+ * Authors: NocoBase Team.
+ *
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
+ * For more information, please refer to: https://www.nocobase.com/agreement.
+ */
+
+import { ChatOpenAI } from '@langchain/openai';
+import { serverRequest } from '@nocobase/utils';
+import { LLMProviderMeta, SupportedModel } from '../manager/ai-manager';
+import { LLMProvider } from './provider';
+
+const AIMLAPI_BASE_URL = 'https://api.aimlapi.com/v1';
+const AIMLAPI_HOSTNAME = 'api.aimlapi.com';
+const CHAT_COMPLETIONS_MODEL_TYPE = 'openai/chat-completions';
+
+/**
+ * Attribution headers sent with every AI/ML API request. `HTTP-Referer` and `X-Title` follow the OpenRouter
+ * convention and identify the calling application (NocoBase, not AI/ML API); the `X-AIMLAPI-*` pair identifies the
+ * integration itself so AI/ML API can attribute traffic to it. Frozen because it is shared by every provider
+ * instance — callers get a copy, never this object.
+ */
+export const AIMLAPI_ATTRIBUTION_HEADERS: Readonly> = Object.freeze({
+ 'HTTP-Referer': 'https://github.com/nocobase/nocobase',
+ 'X-Title': 'NocoBase',
+ 'X-AIMLAPI-Partner-ID': 'part_vIQFIgEDs9Yk0yizVcgoM5Sp',
+ 'X-AIMLAPI-Source': 'agent/nocobase',
+});
+
+export type AimlapiModel = {
+ id: string;
+ type?: string;
+};
+
+/**
+ * The catalog exposes every endpoint family (image, video, speech, embeddings, ...) under the same list, so entries
+ * have to be narrowed to the Chat Completions surface this provider talks to. Entries with no `type` are kept: a user
+ * may point `baseURL` at another OpenAI-compatible endpoint whose catalog carries no such metadata.
+ */
+export function supportsChatCompletions(model: AimlapiModel): boolean {
+ return typeof model.type !== 'string' || model.type === CHAT_COMPLETIONS_MODEL_TYPE;
+}
+
+export class AimlapiProvider extends LLMProvider {
+ declare chatModel: ChatOpenAI;
+
+ get baseURL() {
+ return AIMLAPI_BASE_URL;
+ }
+
+ /**
+ * Attribution travels to AI/ML API only. `baseURL` is user-configurable, so it may point at another vendor or at a
+ * proxy that merely fronts the same API, and neither should receive these headers.
+ */
+ protected buildDefaultHeaders(): Record {
+ const { httpReferer, xTitle } = this.serviceOptions || {};
+ const headers: Record = this.isAimlapiOrigin() ? { ...AIMLAPI_ATTRIBUTION_HEADERS } : {};
+ if (httpReferer) {
+ headers['HTTP-Referer'] = httpReferer;
+ }
+ if (xTitle) {
+ headers['X-Title'] = xTitle;
+ }
+ return headers;
+ }
+
+ protected isAimlapiOrigin(): boolean {
+ try {
+ return new URL(this.getResolvedBaseURL()).hostname === AIMLAPI_HOSTNAME;
+ } catch {
+ return false;
+ }
+ }
+
+ createModel() {
+ const { apiKey } = this.serviceOptions || {};
+ const { responseFormat, structuredOutput } = this.modelOptions || {};
+ const { name, schema } = structuredOutput || {};
+ const responseFormatOptions: Record = {
+ type: responseFormat ?? 'text',
+ };
+
+ if (responseFormat === 'json_schema' && schema) {
+ responseFormatOptions.json_schema = {
+ schema,
+ name: name ?? 'schema',
+ };
+ }
+
+ const defaultHeaders = this.buildDefaultHeaders();
+
+ return new ChatOpenAI({
+ apiKey,
+ ...this.modelOptions,
+ modelKwargs: {
+ response_format: responseFormatOptions,
+ },
+ configuration: {
+ baseURL: this.getResolvedBaseURL(),
+ ...(Object.keys(defaultHeaders).length ? { defaultHeaders } : {}),
+ },
+ });
+ }
+
+ async listModels(): Promise<{
+ models?: { id: string }[];
+ code?: number;
+ errMsg?: string;
+ }> {
+ const { apiKey } = this.serviceOptions || {};
+ let url: string;
+
+ try {
+ url = this.buildRequestURL('models');
+ } catch (error) {
+ return { code: 400, errMsg: error instanceof Error ? error.message : String(error) };
+ }
+
+ if (!apiKey) {
+ return { code: 400, errMsg: 'API Key required' };
+ }
+
+ try {
+ const response = await serverRequest({
+ method: 'GET',
+ url,
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ ...this.buildDefaultHeaders(),
+ },
+ });
+ const models = Array.isArray(response?.data?.data) ? (response.data.data as AimlapiModel[]) : [];
+
+ return {
+ models: models.filter(supportsChatCompletions).map(({ id }) => ({ id })),
+ };
+ } catch (error) {
+ return {
+ code: 500,
+ errMsg: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+}
+
+export const aimlapiProviderOptions: LLMProviderMeta = {
+ title: 'aimlapi.com',
+ supportedModel: [SupportedModel.LLM],
+ provider: AimlapiProvider,
+};
diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
index bf37dcf4450db..0d4f332dcd03b 100644
--- a/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
+++ b/packages/plugins/@nocobase/plugin-ai/src/server/plugin.ts
@@ -48,6 +48,7 @@ import { mimoProviderOptions } from './llm-providers/mimo';
import { mistralProviderOptions } from './llm-providers/mistral';
import { orcarouterProviderOptions } from './llm-providers/orcarouter';
import { shengsuanyunProviderOptions } from './llm-providers/shengsuanyun';
+import { aimlapiProviderOptions } from './llm-providers/aimlapi';
import { SubAgentsDispatcher } from './ai-employees/sub-agents';
import {
AIEmployeeInstruction,
@@ -189,6 +190,7 @@ export class PluginAIServer extends Plugin {
this.aiManager.registerLLMProvider('xai', xaiProviderOptions);
this.aiManager.registerLLMProvider('orcarouter', orcarouterProviderOptions);
this.aiManager.registerLLMProvider('shengsuanyun', shengsuanyunProviderOptions);
+ this.aiManager.registerLLMProvider('aimlapi', aimlapiProviderOptions);
}
registerTools() {