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() {