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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +21,7 @@ import {
shengsuanyunProviderOptions,
} from '../llm-providers';
import {
AimlapiProviderSettingsForm,
EmptyProviderSettingsForm,
OrcaRouterProviderSettingsForm,
ProviderSettingsForm,
Expand All @@ -40,6 +42,7 @@ const V1_REGISTERED_PROVIDERS = [
'mistral',
'orcarouter',
'shengsuanyun',
'aimlapi',
];

describe('plugin-ai client-v2 LLM providers', () => {
Expand All @@ -59,13 +62,15 @@ 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', () => {
expect(openaiResponsesProviderOptions.components.ProviderSettingsForm).toBe(ProviderSettingsForm);
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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,32 @@ export const OrcaRouterProviderSettingsForm: React.FC = () => {
);
};

export const AimlapiProviderSettingsForm: React.FC = () => {
const t = useT();

return (
<>
<Form.Item name={['options', 'apiKey']} label={t('API Key')} rules={[{ required: true }]}>
<EnvVariableInput password />
</Form.Item>
<Form.Item
name={['options', 'httpReferer']}
label={t('HTTP Referer')}
tooltip={t('Optional. Sent as the "HTTP-Referer" header for app attribution.')}
>
<Input />
</Form.Item>
<Form.Item
name={['options', 'xTitle']}
label={t('App Title')}
tooltip={t('Optional. Sent as the "X-Title" header for app attribution.')}
>
<Input />
</Form.Item>
</>
);
};

export const ShengSuanYunProviderSettingsForm: React.FC = () => {
const t = useT();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type { LLMProviderOptions } from '../manager/ai-manager';
import {
AimlapiProviderSettingsForm,
createModelSettingsForm,
deepSeekCompletionFields,
EmptyProviderSettingsForm,
Expand Down Expand Up @@ -83,6 +84,10 @@ export const shengsuanyunProviderOptions = createProviderOptions(
},
);

export const aimlapiProviderOptions = createProviderOptions(createModelSettingsForm(openAICompletionFields), {
ProviderSettingsForm: AimlapiProviderSettingsForm,
});

export const ollamaProviderOptions = createProviderOptions(createModelSettingsForm(ollamaCompletionFields), {
ProviderSettingsForm: EmptyProviderSettingsForm,
});
Expand All @@ -101,6 +106,7 @@ export const builtinLLMProviderOptions: Array<[string, LLMProviderOptions]> = [
['mistral', mistralProviderOptions],
['orcarouter', orcarouterProviderOptions],
['shengsuanyun', shengsuanyunProviderOptions],
['aimlapi', aimlapiProviderOptions],
];

const builtinLLMProviderModelOptionFields = new Map<string, OptionField[]>([
Expand All @@ -117,6 +123,7 @@ const builtinLLMProviderModelOptionFields = new Map<string, OptionField[]>([
['mistral', mistralCompletionFields],
['orcarouter', orcaRouterCompletionFields],
['shengsuanyun', shengSuanYunCompletionFields],
['aimlapi', openAICompletionFields],
]);

export const getBuiltinLLMProviderModelOptionFields = (provider?: string): OptionField[] =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export async function testLLMServiceFlight(

const getProviderSortIndex = (value: string) => {
const sortOrder = [
'aimlapi',
'google-genai',
'openai',
'anthropic',
Expand Down Expand Up @@ -374,6 +375,7 @@ const getProviderDescription = (provider: string, t: ReturnType<typeof useT>) =>
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]) : '';
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { label: string; value: string }[]> = {};
export const recommendedModels: Record<string, { label: string; value: string }[]> = {
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
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/@nocobase/plugin-ai/src/locale/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/@nocobase/plugin-ai/src/locale/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "请输入模型标识",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@nocobase/utils')>();
return {
...original,
serverRequest: serverRequestMock,
};
});

import {
AIMLAPI_ATTRIBUTION_HEADERS,
AimlapiProvider,
aimlapiProviderOptions,
supportsChatCompletions,
} from '../aimlapi';

function createApp(): Application {
return {
environment: {
renderJsonTemplate: (value: Record<string, unknown>) => 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 <channel>/<client> 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();
});
});
Loading