mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-19 05:45:26 +00:00
♻️ refactor: refactor sensenova provider with LobeOpenAICompatibleFactory (#5116)
This commit is contained in:
+1
-1
@@ -196,7 +196,7 @@ ENV \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SenseNova
|
||||
SENSENOVA_ACCESS_KEY_ID="" SENSENOVA_ACCESS_KEY_SECRET="" SENSENOVA_MODEL_LIST="" \
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ ENV \
|
||||
# Qwen
|
||||
QWEN_API_KEY="" QWEN_MODEL_LIST="" QWEN_PROXY_URL="" \
|
||||
# SenseNova
|
||||
SENSENOVA_ACCESS_KEY_ID="" SENSENOVA_ACCESS_KEY_SECRET="" SENSENOVA_MODEL_LIST="" \
|
||||
SENSENOVA_API_KEY="" SENSENOVA_MODEL_LIST="" \
|
||||
# SiliconCloud
|
||||
SILICONCLOUD_API_KEY="" SILICONCLOUD_MODEL_LIST="" SILICONCLOUD_PROXY_URL="" \
|
||||
# Spark
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SenseNovaProviderCard } from '@/config/modelProviders';
|
||||
import { GlobalLLMProviderKey } from '@/types/user/settings';
|
||||
|
||||
import { KeyVaultsConfigKey } from '../../const';
|
||||
import { ProviderItem } from '../../type';
|
||||
|
||||
const providerKey: GlobalLLMProviderKey = 'sensenova';
|
||||
|
||||
export const useSenseNovaProvider = (): ProviderItem => {
|
||||
const { t } = useTranslation('modelProvider');
|
||||
|
||||
return {
|
||||
...SenseNovaProviderCard,
|
||||
apiKeyItems: [
|
||||
{
|
||||
children: (
|
||||
<Input.Password
|
||||
autoComplete={'new-password'}
|
||||
placeholder={t(`${providerKey}.sensenovaAccessKeyID.placeholder`)}
|
||||
/>
|
||||
),
|
||||
desc: t(`${providerKey}.sensenovaAccessKeyID.desc`),
|
||||
label: t(`${providerKey}.sensenovaAccessKeyID.title`),
|
||||
name: [KeyVaultsConfigKey, providerKey, 'sensenovaAccessKeyID'],
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Input.Password
|
||||
autoComplete={'new-password'}
|
||||
placeholder={t(`${providerKey}.sensenovaAccessKeySecret.placeholder`)}
|
||||
/>
|
||||
),
|
||||
desc: t(`${providerKey}.sensenovaAccessKeySecret.desc`),
|
||||
label: t(`${providerKey}.sensenovaAccessKeySecret.title`),
|
||||
name: [KeyVaultsConfigKey, providerKey, 'sensenovaAccessKeySecret'],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
OpenRouterProviderCard,
|
||||
PerplexityProviderCard,
|
||||
QwenProviderCard,
|
||||
SenseNovaProviderCard,
|
||||
SiliconCloudProviderCard,
|
||||
SparkProviderCard,
|
||||
StepfunProviderCard,
|
||||
@@ -39,7 +40,6 @@ import { useGithubProvider } from './Github';
|
||||
import { useHuggingFaceProvider } from './HuggingFace';
|
||||
import { useOllamaProvider } from './Ollama';
|
||||
import { useOpenAIProvider } from './OpenAI';
|
||||
import { useSenseNovaProvider } from './SenseNova';
|
||||
import { useWenxinProvider } from './Wenxin';
|
||||
|
||||
export const useProviderList = (): ProviderItem[] => {
|
||||
@@ -51,7 +51,6 @@ export const useProviderList = (): ProviderItem[] => {
|
||||
const GithubProvider = useGithubProvider();
|
||||
const HuggingFaceProvider = useHuggingFaceProvider();
|
||||
const WenxinProvider = useWenxinProvider();
|
||||
const SenseNovaProvider = useSenseNovaProvider();
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
@@ -81,7 +80,7 @@ export const useProviderList = (): ProviderItem[] => {
|
||||
SparkProviderCard,
|
||||
ZhiPuProviderCard,
|
||||
ZeroOneProviderCard,
|
||||
SenseNovaProvider,
|
||||
SenseNovaProviderCard,
|
||||
StepfunProviderCard,
|
||||
MoonshotProviderCard,
|
||||
BaichuanProviderCard,
|
||||
@@ -102,7 +101,6 @@ export const useProviderList = (): ProviderItem[] => {
|
||||
GithubProvider,
|
||||
WenxinProvider,
|
||||
HuggingFaceProvider,
|
||||
SenseNovaProvider,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
+3
-6
@@ -113,8 +113,7 @@ export const getLLMConfig = () => {
|
||||
HUGGINGFACE_API_KEY: z.string().optional(),
|
||||
|
||||
ENABLED_SENSENOVA: z.boolean(),
|
||||
SENSENOVA_ACCESS_KEY_ID: z.string().optional(),
|
||||
SENSENOVA_ACCESS_KEY_SECRET: z.string().optional(),
|
||||
SENSENOVA_API_KEY: z.string().optional(),
|
||||
|
||||
ENABLED_XAI: z.boolean(),
|
||||
XAI_API_KEY: z.string().optional(),
|
||||
@@ -234,10 +233,8 @@ export const getLLMConfig = () => {
|
||||
ENABLED_HUGGINGFACE: !!process.env.HUGGINGFACE_API_KEY,
|
||||
HUGGINGFACE_API_KEY: process.env.HUGGINGFACE_API_KEY,
|
||||
|
||||
ENABLED_SENSENOVA:
|
||||
!!process.env.SENSENOVA_ACCESS_KEY_ID && !!process.env.SENSENOVA_ACCESS_KEY_SECRET,
|
||||
SENSENOVA_ACCESS_KEY_ID: process.env.SENSENOVA_ACCESS_KEY_ID,
|
||||
SENSENOVA_ACCESS_KEY_SECRET: process.env.SENSENOVA_ACCESS_KEY_SECRET,
|
||||
ENABLED_SENSENOVA: !!process.env.SENSENOVA_API_KEY,
|
||||
SENSENOVA_API_KEY: process.env.SENSENOVA_API_KEY,
|
||||
|
||||
ENABLED_XAI: !!process.env.XAI_API_KEY,
|
||||
XAI_API_KEY: process.env.XAI_API_KEY,
|
||||
|
||||
@@ -42,9 +42,6 @@ export interface JWTPayload {
|
||||
wenxinAccessKey?: string;
|
||||
wenxinSecretKey?: string;
|
||||
|
||||
sensenovaAccessKeyID?: string;
|
||||
sensenovaAccessKeySecret?: string;
|
||||
|
||||
/**
|
||||
* user id
|
||||
* in client db mode it's a uuid
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { SenseNova } from '@lobehub/icons';
|
||||
import { Input } from 'antd';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ModelProvider } from '@/libs/agent-runtime';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { keyVaultsConfigSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { FormAction } from '../style';
|
||||
|
||||
const SenseNovaForm = memo(() => {
|
||||
const { t } = useTranslation('modelProvider');
|
||||
|
||||
const [sensenovaAccessKeyID, sensenovaAccessKeySecret, setConfig] = useUserStore((s) => [
|
||||
keyVaultsConfigSelectors.sensenovaConfig(s).sensenovaAccessKeyID,
|
||||
keyVaultsConfigSelectors.sensenovaConfig(s).sensenovaAccessKeySecret,
|
||||
s.updateKeyVaultConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FormAction
|
||||
avatar={<SenseNova color={SenseNova.colorPrimary} size={56} />}
|
||||
description={t('sensenova.unlock.description')}
|
||||
title={t('sensenova.unlock.title')}
|
||||
>
|
||||
<Input.Password
|
||||
autoComplete={'new-password'}
|
||||
onChange={(e) => {
|
||||
setConfig(ModelProvider.SenseNova, { sensenovaAccessKeyID: e.target.value });
|
||||
}}
|
||||
placeholder={t('sensenova.sensenovaAccessKeyID.placeholder')}
|
||||
type={'block'}
|
||||
value={sensenovaAccessKeyID}
|
||||
/>
|
||||
<Input.Password
|
||||
autoComplete={'new-password'}
|
||||
onChange={(e) => {
|
||||
setConfig(ModelProvider.SenseNova, { sensenovaAccessKeySecret: e.target.value });
|
||||
}}
|
||||
placeholder={t('sensenova.sensenovaAccessKeySecret.placeholder')}
|
||||
type={'block'}
|
||||
value={sensenovaAccessKeySecret}
|
||||
/>
|
||||
</FormAction>
|
||||
);
|
||||
});
|
||||
|
||||
export default SenseNovaForm;
|
||||
@@ -10,7 +10,6 @@ import { GlobalLLMProviderKey } from '@/types/user/settings';
|
||||
|
||||
import BedrockForm from './Bedrock';
|
||||
import ProviderApiKeyForm from './ProviderApiKeyForm';
|
||||
import SenseNovaForm from './SenseNova';
|
||||
import WenxinForm from './Wenxin';
|
||||
|
||||
interface APIKeyFormProps {
|
||||
@@ -67,8 +66,6 @@ const APIKeyForm = memo<APIKeyFormProps>(({ id, provider }) => {
|
||||
<Center gap={16} style={{ maxWidth: 300 }}>
|
||||
{provider === ModelProvider.Bedrock ? (
|
||||
<BedrockForm />
|
||||
) : provider === ModelProvider.SenseNova ? (
|
||||
<SenseNovaForm />
|
||||
) : provider === ModelProvider.Wenxin ? (
|
||||
<WenxinForm />
|
||||
) : (
|
||||
|
||||
@@ -333,7 +333,7 @@ class AgentRuntime {
|
||||
}
|
||||
|
||||
case ModelProvider.SenseNova: {
|
||||
runtimeModel = await LobeSenseNovaAI.fromAPIKey(params.sensenova);
|
||||
runtimeModel = new LobeSenseNovaAI(params.sensenova);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export { LobeOpenAI } from './openai';
|
||||
export { LobeOpenRouterAI } from './openrouter';
|
||||
export { LobePerplexityAI } from './perplexity';
|
||||
export { LobeQwenAI } from './qwen';
|
||||
export { LobeSenseNovaAI } from './sensenova';
|
||||
export { LobeTogetherAI } from './togetherai';
|
||||
export * from './types';
|
||||
export { AgentRuntimeError } from './utils/createError';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { generateApiToken } from './authToken';
|
||||
|
||||
describe('generateApiToken', () => {
|
||||
it('should throw an error if no apiKey is provided', async () => {
|
||||
await expect(generateApiToken()).rejects.toThrow('Invalid apiKey');
|
||||
});
|
||||
|
||||
it('should throw an error if apiKey is invalid', async () => {
|
||||
await expect(generateApiToken('invalid')).rejects.toThrow('Invalid apiKey');
|
||||
});
|
||||
|
||||
it('should return a token if a valid apiKey is provided', async () => {
|
||||
const apiKey = 'id:secret';
|
||||
const token = await generateApiToken(apiKey);
|
||||
expect(token).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
// https://console.sensecore.cn/help/docs/model-as-a-service/nova/overview/Authorization
|
||||
export const generateApiToken = async (apiKey?: string): Promise<string> => {
|
||||
if (!apiKey) {
|
||||
throw new Error('Invalid apiKey');
|
||||
}
|
||||
|
||||
const [id, secret] = apiKey.split(':');
|
||||
if (!id || !secret) {
|
||||
throw new Error('Invalid apiKey');
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload = {
|
||||
exp: currentTime + 1800,
|
||||
iss: id,
|
||||
nbf: currentTime - 5,
|
||||
};
|
||||
|
||||
const jwt = await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
|
||||
return jwt;
|
||||
};
|
||||
@@ -1,142 +1,49 @@
|
||||
// @vitest-environment node
|
||||
import { OpenAI } from 'openai';
|
||||
import OpenAI from 'openai';
|
||||
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatStreamCallbacks, LobeOpenAI } from '@/libs/agent-runtime';
|
||||
import * as debugStreamModule from '@/libs/agent-runtime/utils/debugStream';
|
||||
import { LobeOpenAICompatibleRuntime } from '@/libs/agent-runtime';
|
||||
import { ModelProvider } from '@/libs/agent-runtime';
|
||||
import { AgentRuntimeErrorType } from '@/libs/agent-runtime';
|
||||
|
||||
import * as authTokenModule from './authToken';
|
||||
import * as debugStreamModule from '../utils/debugStream';
|
||||
import { LobeSenseNovaAI } from './index';
|
||||
|
||||
const bizErrorType = 'ProviderBizError';
|
||||
const invalidErrorType = 'InvalidProviderAPIKey';
|
||||
const provider = ModelProvider.SenseNova;
|
||||
const defaultBaseURL = 'https://api.sensenova.cn/compatible-mode/v1';
|
||||
const bizErrorType = AgentRuntimeErrorType.ProviderBizError;
|
||||
const invalidErrorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
|
||||
|
||||
// Mock相关依赖
|
||||
vi.mock('./authToken');
|
||||
// Mock the console.error to avoid polluting test output
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
let instance: LobeOpenAICompatibleRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new LobeSenseNovaAI({ apiKey: 'test' });
|
||||
|
||||
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
|
||||
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
||||
new ReadableStream() as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LobeSenseNovaAI', () => {
|
||||
beforeEach(() => {
|
||||
// Mock generateApiToken
|
||||
vi.spyOn(authTokenModule, 'generateApiToken').mockResolvedValue('mocked_token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('fromAPIKey', () => {
|
||||
describe('init', () => {
|
||||
it('should correctly initialize with an API key', async () => {
|
||||
const lobeSenseNovaAI = await LobeSenseNovaAI.fromAPIKey({ apiKey: 'test_api_key' });
|
||||
expect(lobeSenseNovaAI).toBeInstanceOf(LobeSenseNovaAI);
|
||||
expect(lobeSenseNovaAI.baseURL).toEqual('https://api.sensenova.cn/compatible-mode/v1');
|
||||
});
|
||||
|
||||
it('should throw an error if API key is invalid', async () => {
|
||||
vi.spyOn(authTokenModule, 'generateApiToken').mockRejectedValue(new Error('Invalid API Key'));
|
||||
try {
|
||||
await LobeSenseNovaAI.fromAPIKey({ apiKey: 'asd' });
|
||||
} catch (e) {
|
||||
expect(e).toEqual({ errorType: invalidErrorType });
|
||||
}
|
||||
const instance = new LobeSenseNovaAI({ apiKey: 'test_api_key' });
|
||||
expect(instance).toBeInstanceOf(LobeSenseNovaAI);
|
||||
expect(instance.baseURL).toEqual(defaultBaseURL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
let instance: LobeSenseNovaAI;
|
||||
|
||||
beforeEach(async () => {
|
||||
instance = await LobeSenseNovaAI.fromAPIKey({
|
||||
apiKey: 'test_api_key',
|
||||
});
|
||||
|
||||
// Mock chat.completions.create
|
||||
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
||||
new ReadableStream() as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a StreamingTextResponse on successful API call', async () => {
|
||||
const result = await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it('should handle callback and headers correctly', async () => {
|
||||
// 模拟 chat.completions.create 方法返回一个可读流
|
||||
const mockCreateMethod = vi
|
||||
.spyOn(instance['client'].chat.completions, 'create')
|
||||
.mockResolvedValue(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
id: 'chatcmpl-8xDx5AETP8mESQN7UB30GxTN2H1SO',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1709125675,
|
||||
model: 'gpt-3.5-turbo-0125',
|
||||
system_fingerprint: 'fp_86156a94a0',
|
||||
choices: [
|
||||
{ index: 0, delta: { content: 'hello' }, logprobs: null, finish_reason: null },
|
||||
],
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
|
||||
// 准备 callback 和 headers
|
||||
const mockCallback: ChatStreamCallbacks = {
|
||||
onStart: vi.fn(),
|
||||
onToken: vi.fn(),
|
||||
};
|
||||
const mockHeaders = { 'Custom-Header': 'TestValue' };
|
||||
|
||||
// 执行测试
|
||||
const result = await instance.chat(
|
||||
{
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
},
|
||||
{ callback: mockCallback, headers: mockHeaders },
|
||||
);
|
||||
|
||||
// 验证 callback 被调用
|
||||
await result.text(); // 确保流被消费
|
||||
|
||||
// 验证 headers 被正确传递
|
||||
expect(result.headers.get('Custom-Header')).toEqual('TestValue');
|
||||
|
||||
// 清理
|
||||
mockCreateMethod.mockRestore();
|
||||
});
|
||||
|
||||
it('should transform messages correctly', async () => {
|
||||
const spyOn = vi.spyOn(instance['client'].chat.completions, 'create');
|
||||
|
||||
await instance.chat({
|
||||
frequency_penalty: 0,
|
||||
messages: [
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{ content: [{ type: 'text', text: 'Hello again' }], role: 'user' },
|
||||
],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
top_p: 1,
|
||||
});
|
||||
|
||||
const calledWithParams = spyOn.mock.calls[0][0];
|
||||
|
||||
expect(calledWithParams.frequency_penalty).toBeUndefined(); // frequency_penalty 0 should be undefined
|
||||
expect(calledWithParams.messages[1].content).toEqual([{ type: 'text', text: 'Hello again' }]);
|
||||
expect(calledWithParams.temperature).toBeUndefined(); // temperature 0 should be undefined
|
||||
expect(calledWithParams.top_p).toBeUndefined(); // top_p 1 should be undefined
|
||||
});
|
||||
|
||||
describe('Error', () => {
|
||||
it('should return SenseNovaAIBizError with an openai error response when OpenAI.APIError is thrown', async () => {
|
||||
it('should return QwenBizError with an openai error response when OpenAI.APIError is thrown', async () => {
|
||||
// Arrange
|
||||
const apiError = new OpenAI.APIError(
|
||||
400,
|
||||
@@ -156,31 +63,31 @@ describe('LobeSenseNovaAI', () => {
|
||||
try {
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
model: 'max-32k',
|
||||
temperature: 0.999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({
|
||||
endpoint: 'https://api.sensenova.cn/compatible-mode/v1',
|
||||
endpoint: defaultBaseURL,
|
||||
error: {
|
||||
error: { message: 'Bad Request' },
|
||||
status: 400,
|
||||
},
|
||||
errorType: bizErrorType,
|
||||
provider: 'sensenova',
|
||||
provider,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AgentRuntimeError with NoOpenAIAPIKey if no apiKey is provided', async () => {
|
||||
it('should throw AgentRuntimeError with InvalidQwenAPIKey if no apiKey is provided', async () => {
|
||||
try {
|
||||
await LobeSenseNovaAI.fromAPIKey({ apiKey: '' });
|
||||
new LobeSenseNovaAI({});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({ errorType: invalidErrorType });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return OpenAIBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
|
||||
it('should return QwenBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
|
||||
// Arrange
|
||||
const errorInfo = {
|
||||
stack: 'abc',
|
||||
@@ -196,23 +103,23 @@ describe('LobeSenseNovaAI', () => {
|
||||
try {
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0.2,
|
||||
model: 'max-32k',
|
||||
temperature: 0.999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({
|
||||
endpoint: 'https://api.sensenova.cn/compatible-mode/v1',
|
||||
endpoint: defaultBaseURL,
|
||||
error: {
|
||||
cause: { message: 'api is undefined' },
|
||||
stack: 'abc',
|
||||
},
|
||||
errorType: bizErrorType,
|
||||
provider: 'sensenova',
|
||||
provider,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return OpenAIBizError with an cause response with desensitize Url', async () => {
|
||||
it('should return QwenBizError with an cause response with desensitize Url', async () => {
|
||||
// Arrange
|
||||
const errorInfo = {
|
||||
stack: 'abc',
|
||||
@@ -220,10 +127,10 @@ describe('LobeSenseNovaAI', () => {
|
||||
};
|
||||
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
|
||||
|
||||
instance = await LobeSenseNovaAI.fromAPIKey({
|
||||
instance = new LobeSenseNovaAI({
|
||||
apiKey: 'test',
|
||||
|
||||
baseURL: 'https://abc.com/v2',
|
||||
baseURL: 'https://api.abc.com/v1',
|
||||
});
|
||||
|
||||
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
|
||||
@@ -232,18 +139,40 @@ describe('LobeSenseNovaAI', () => {
|
||||
try {
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'gpt-3.5-turbo',
|
||||
temperature: 0,
|
||||
model: 'max-32k',
|
||||
temperature: 0.999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({
|
||||
endpoint: 'https://***.com/v2',
|
||||
endpoint: 'https://api.***.com/v1',
|
||||
error: {
|
||||
cause: { message: 'api is undefined' },
|
||||
stack: 'abc',
|
||||
},
|
||||
errorType: bizErrorType,
|
||||
provider: 'sensenova',
|
||||
provider,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an InvalidQwenAPIKey error type on 401 status code', async () => {
|
||||
// Mock the API call to simulate a 401 error
|
||||
const error = new Error('InvalidApiKey') as any;
|
||||
error.status = 401;
|
||||
vi.mocked(instance['client'].chat.completions.create).mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'max-32k',
|
||||
temperature: 0.999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({
|
||||
endpoint: defaultBaseURL,
|
||||
error: new Error('InvalidApiKey'),
|
||||
errorType: invalidErrorType,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -258,14 +187,14 @@ describe('LobeSenseNovaAI', () => {
|
||||
try {
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
model: 'max-32k',
|
||||
temperature: 0.999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toEqual({
|
||||
endpoint: 'https://api.sensenova.cn/compatible-mode/v1',
|
||||
endpoint: defaultBaseURL,
|
||||
errorType: 'AgentRuntimeError',
|
||||
provider: 'sensenova',
|
||||
provider,
|
||||
error: {
|
||||
name: genericError.name,
|
||||
cause: genericError.cause,
|
||||
@@ -278,7 +207,7 @@ describe('LobeSenseNovaAI', () => {
|
||||
});
|
||||
|
||||
describe('DEBUG', () => {
|
||||
it('should call debugStream and return StreamingTextResponse when DEBUG_OPENAI_CHAT_COMPLETION is 1', async () => {
|
||||
it('should call debugStream and return StreamingTextResponse when DEBUG_SENSENOVA_CHAT_COMPLETION is 1', async () => {
|
||||
// Arrange
|
||||
const mockProdStream = new ReadableStream() as any; // 模拟的 prod 流
|
||||
const mockDebugStream = new ReadableStream({
|
||||
@@ -306,8 +235,9 @@ describe('LobeSenseNovaAI', () => {
|
||||
// 假设的测试函数调用,你可能需要根据实际情况调整
|
||||
await instance.chat({
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'SenseChat',
|
||||
temperature: 0,
|
||||
model: 'max-32k',
|
||||
stream: true,
|
||||
temperature: 0.999,
|
||||
});
|
||||
|
||||
// 验证 debugStream 被调用
|
||||
|
||||
@@ -1,98 +1,23 @@
|
||||
import OpenAI, { ClientOptions } from 'openai';
|
||||
import { ModelProvider } from '../types';
|
||||
import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
|
||||
|
||||
import { LobeRuntimeAI } from '../BaseAI';
|
||||
import { AgentRuntimeErrorType } from '../error';
|
||||
import { ChatCompetitionOptions, ChatStreamPayload, ModelProvider } from '../types';
|
||||
import { AgentRuntimeError } from '../utils/createError';
|
||||
import { debugStream } from '../utils/debugStream';
|
||||
import { desensitizeUrl } from '../utils/desensitizeUrl';
|
||||
import { handleOpenAIError } from '../utils/handleOpenAIError';
|
||||
import { convertOpenAIMessages } from '../utils/openaiHelpers';
|
||||
import { StreamingResponse } from '../utils/response';
|
||||
import { OpenAIStream } from '../utils/streams';
|
||||
import { generateApiToken } from './authToken';
|
||||
export const LobeSenseNovaAI = LobeOpenAICompatibleFactory({
|
||||
baseURL: 'https://api.sensenova.cn/compatible-mode/v1',
|
||||
chatCompletion: {
|
||||
handlePayload: (payload) => {
|
||||
const { frequency_penalty, temperature, top_p, ...rest } = payload;
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.sensenova.cn/compatible-mode/v1';
|
||||
|
||||
export class LobeSenseNovaAI implements LobeRuntimeAI {
|
||||
private client: OpenAI;
|
||||
|
||||
baseURL: string;
|
||||
|
||||
constructor(oai: OpenAI) {
|
||||
this.client = oai;
|
||||
this.baseURL = this.client.baseURL;
|
||||
}
|
||||
|
||||
static async fromAPIKey({ apiKey, baseURL = DEFAULT_BASE_URL, ...res }: ClientOptions = {}) {
|
||||
const invalidSenseNovaAPIKey = AgentRuntimeError.createError(
|
||||
AgentRuntimeErrorType.InvalidProviderAPIKey,
|
||||
);
|
||||
|
||||
if (!apiKey) throw invalidSenseNovaAPIKey;
|
||||
|
||||
let token: string;
|
||||
|
||||
try {
|
||||
token = await generateApiToken(apiKey);
|
||||
} catch {
|
||||
throw invalidSenseNovaAPIKey;
|
||||
}
|
||||
|
||||
const header = { Authorization: `Bearer ${token}` };
|
||||
|
||||
const llm = new OpenAI({ apiKey, baseURL, defaultHeaders: header, ...res });
|
||||
|
||||
return new LobeSenseNovaAI(llm);
|
||||
}
|
||||
|
||||
async chat(payload: ChatStreamPayload, options?: ChatCompetitionOptions) {
|
||||
try {
|
||||
const params = await this.buildCompletionsParams(payload);
|
||||
|
||||
const response = await this.client.chat.completions.create(
|
||||
params as unknown as OpenAI.ChatCompletionCreateParamsStreaming,
|
||||
);
|
||||
|
||||
const [prod, debug] = response.tee();
|
||||
|
||||
if (process.env.DEBUG_SENSENOVA_CHAT_COMPLETION === '1') {
|
||||
debugStream(debug.toReadableStream()).catch(console.error);
|
||||
}
|
||||
|
||||
return StreamingResponse(OpenAIStream(prod), {
|
||||
headers: options?.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
const { errorResult, RuntimeError } = handleOpenAIError(error);
|
||||
|
||||
const errorType = RuntimeError || AgentRuntimeErrorType.ProviderBizError;
|
||||
let desensitizedEndpoint = this.baseURL;
|
||||
|
||||
if (this.baseURL !== DEFAULT_BASE_URL) {
|
||||
desensitizedEndpoint = desensitizeUrl(this.baseURL);
|
||||
}
|
||||
throw AgentRuntimeError.chat({
|
||||
endpoint: desensitizedEndpoint,
|
||||
error: errorResult,
|
||||
errorType,
|
||||
provider: ModelProvider.SenseNova,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async buildCompletionsParams(payload: ChatStreamPayload) {
|
||||
const { frequency_penalty, messages, temperature, top_p, ...params } = payload;
|
||||
|
||||
return {
|
||||
messages: await convertOpenAIMessages(messages as any),
|
||||
...params,
|
||||
frequency_penalty: (frequency_penalty !== undefined && frequency_penalty > 0 && frequency_penalty <= 2) ? frequency_penalty : undefined,
|
||||
stream: true,
|
||||
temperature: (temperature !== undefined && temperature > 0 && temperature <= 2) ? temperature : undefined,
|
||||
top_p: (top_p !== undefined && top_p > 0 && top_p < 1) ? top_p : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default LobeSenseNovaAI;
|
||||
return {
|
||||
...rest,
|
||||
frequency_penalty: (frequency_penalty !== undefined && frequency_penalty > 0 && frequency_penalty <= 2) ? frequency_penalty : undefined,
|
||||
stream: true,
|
||||
temperature: (temperature !== undefined && temperature > 0 && temperature <= 2) ? temperature : undefined,
|
||||
top_p: (top_p !== undefined && top_p > 0 && top_p < 1) ? top_p : undefined,
|
||||
} as any;
|
||||
},
|
||||
},
|
||||
debug: {
|
||||
chatCompletion: () => process.env.DEBUG_SENSENOVA_CHAT_COMPLETION === '1',
|
||||
},
|
||||
provider: ModelProvider.SenseNova,
|
||||
});
|
||||
|
||||
@@ -134,23 +134,6 @@ export default {
|
||||
title: '下载指定的 Ollama 模型',
|
||||
},
|
||||
},
|
||||
sensenova: {
|
||||
sensenovaAccessKeyID: {
|
||||
desc: '填入 SenseNova Access Key ID',
|
||||
placeholder: 'SenseNova Access Key ID',
|
||||
title: 'Access Key ID',
|
||||
},
|
||||
sensenovaAccessKeySecret: {
|
||||
desc: '填入 SenseNova Access Key Secret',
|
||||
placeholder: 'SenseNova Access Key Secret',
|
||||
title: 'Access Key Secret',
|
||||
},
|
||||
unlock: {
|
||||
description:
|
||||
'输入你的 Access Key ID / Access Key Secret 即可开始会话。应用不会记录你的鉴权配置',
|
||||
title: '使用自定义 SenseNova 鉴权信息',
|
||||
},
|
||||
},
|
||||
wenxin: {
|
||||
accessKey: {
|
||||
desc: '填入百度千帆平台的 Access Key',
|
||||
|
||||
@@ -100,21 +100,6 @@ const getLlmOptionsFromPayload = (provider: string, payload: JWTPayload) => {
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
case ModelProvider.SenseNova: {
|
||||
const { SENSENOVA_ACCESS_KEY_ID, SENSENOVA_ACCESS_KEY_SECRET } = llmConfig;
|
||||
|
||||
const sensenovaAccessKeyID = apiKeyManager.pick(
|
||||
payload?.sensenovaAccessKeyID || SENSENOVA_ACCESS_KEY_ID,
|
||||
);
|
||||
const sensenovaAccessKeySecret = apiKeyManager.pick(
|
||||
payload?.sensenovaAccessKeySecret || SENSENOVA_ACCESS_KEY_SECRET,
|
||||
);
|
||||
|
||||
const apiKey = sensenovaAccessKeyID + ':' + sensenovaAccessKeySecret;
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,20 +25,6 @@ export const getProviderAuthPayload = (provider: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
case ModelProvider.SenseNova: {
|
||||
const { sensenovaAccessKeyID, sensenovaAccessKeySecret } = keyVaultsConfigSelectors.sensenovaConfig(
|
||||
useUserStore.getState(),
|
||||
);
|
||||
|
||||
const apiKey = (sensenovaAccessKeyID || '') + ':' + (sensenovaAccessKeySecret || '')
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
sensenovaAccessKeyID: sensenovaAccessKeyID,
|
||||
sensenovaAccessKeySecret: sensenovaAccessKeySecret,
|
||||
};
|
||||
}
|
||||
|
||||
case ModelProvider.Wenxin: {
|
||||
const { secretKey, accessKey } = keyVaultsConfigSelectors.wenxinConfig(
|
||||
useUserStore.getState(),
|
||||
|
||||
@@ -16,7 +16,6 @@ const openAIConfig = (s: UserStore) => keyVaultsSettings(s).openai || {};
|
||||
const bedrockConfig = (s: UserStore) => keyVaultsSettings(s).bedrock || {};
|
||||
const wenxinConfig = (s: UserStore) => keyVaultsSettings(s).wenxin || {};
|
||||
const ollamaConfig = (s: UserStore) => keyVaultsSettings(s).ollama || {};
|
||||
const sensenovaConfig = (s: UserStore) => keyVaultsSettings(s).sensenova || {};
|
||||
const azureConfig = (s: UserStore) => keyVaultsSettings(s).azure || {};
|
||||
const cloudflareConfig = (s: UserStore) => keyVaultsSettings(s).cloudflare || {};
|
||||
const getVaultByProvider = (provider: GlobalLLMProviderKey) => (s: UserStore) =>
|
||||
@@ -46,6 +45,5 @@ export const keyVaultsConfigSelectors = {
|
||||
ollamaConfig,
|
||||
openAIConfig,
|
||||
password,
|
||||
sensenovaConfig,
|
||||
wenxinConfig,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@ const bedrockConfig = (s: UserStore) => currentLLMSettings(s).bedrock;
|
||||
const ollamaConfig = (s: UserStore) => currentLLMSettings(s).ollama;
|
||||
const azureConfig = (s: UserStore) => currentLLMSettings(s).azure;
|
||||
const cloudflareConfig = (s: UserStore) => currentLLMSettings(s).cloudflare;
|
||||
const sensenovaConfig = (s: UserStore) => currentLLMSettings(s).sensenova;
|
||||
|
||||
const isAzureEnabled = (s: UserStore) => currentLLMSettings(s).azure.enabled;
|
||||
|
||||
@@ -89,5 +88,4 @@ export const modelConfigSelectors = {
|
||||
|
||||
ollamaConfig,
|
||||
openAIConfig,
|
||||
sensenovaConfig,
|
||||
};
|
||||
|
||||
@@ -21,11 +21,6 @@ export interface CloudflareKeyVault {
|
||||
baseURLOrAccountID?: string;
|
||||
}
|
||||
|
||||
export interface SenseNovaKeyVault {
|
||||
sensenovaAccessKeyID?: string;
|
||||
sensenovaAccessKeySecret?: string;
|
||||
}
|
||||
|
||||
export interface WenxinKeyVault {
|
||||
accessKey?: string;
|
||||
secretKey?: string;
|
||||
@@ -60,7 +55,7 @@ export interface UserKeyVaults {
|
||||
password?: string;
|
||||
perplexity?: OpenAICompatibleKeyVault;
|
||||
qwen?: OpenAICompatibleKeyVault;
|
||||
sensenova?: SenseNovaKeyVault;
|
||||
sensenova?: OpenAICompatibleKeyVault;
|
||||
siliconcloud?: OpenAICompatibleKeyVault;
|
||||
spark?: OpenAICompatibleKeyVault;
|
||||
stepfun?: OpenAICompatibleKeyVault;
|
||||
|
||||
Reference in New Issue
Block a user