diff --git a/Dockerfile b/Dockerfile index ff604c1ec879c..570c382d32e18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -146,6 +146,8 @@ ENV \ TAICHU_API_KEY="" \ # TogetherAI TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \ + # Upstage + UPSTAGE_API_KEY="" \ # 01.AI ZEROONE_API_KEY="" \ # Zhipu diff --git a/Dockerfile.database b/Dockerfile.database index 9f616979023cc..ed389e6e8273a 100644 --- a/Dockerfile.database +++ b/Dockerfile.database @@ -178,6 +178,8 @@ ENV \ TAICHU_API_KEY="" \ # TogetherAI TOGETHERAI_API_KEY="" TOGETHERAI_MODEL_LIST="" \ + # Upstage + UPSTAGE_API_KEY="" \ # 01.AI ZEROONE_API_KEY="" \ # Zhipu diff --git a/src/app/(main)/settings/llm/ProviderList/providers.tsx b/src/app/(main)/settings/llm/ProviderList/providers.tsx index 59c1f46d7eaf9..645c7d3367966 100644 --- a/src/app/(main)/settings/llm/ProviderList/providers.tsx +++ b/src/app/(main)/settings/llm/ProviderList/providers.tsx @@ -18,6 +18,7 @@ import { StepfunProviderCard, TaichuProviderCard, TogetherAIProviderCard, + UpstageProviderCard, ZeroOneProviderCard, ZhiPuProviderCard, } from '@/config/modelProviders'; @@ -59,6 +60,7 @@ export const useProviderList = (): ProviderItem[] => { TaichuProviderCard, Ai360ProviderCard, SiliconCloudProviderCard, + UpstageProviderCard, ], [AzureProvider, OllamaProvider, OpenAIProvider, BedrockProvider], ); diff --git a/src/app/api/chat/agentRuntime.ts b/src/app/api/chat/agentRuntime.ts index c3532301a7c89..05571c96ef0e9 100644 --- a/src/app/api/chat/agentRuntime.ts +++ b/src/app/api/chat/agentRuntime.ts @@ -208,6 +208,13 @@ const getLlmOptionsFromPayload = (provider: string, payload: JWTPayload) => { return { apiKey, baseURL }; } + case ModelProvider.Upstage: { + const { UPSTAGE_API_KEY } = getLLMConfig(); + + const apiKey = apiKeyManager.pick(payload?.apiKey || UPSTAGE_API_KEY); + + return { apiKey }; + } } }; diff --git a/src/config/llm.ts b/src/config/llm.ts index a13b9b46fc210..ed280847650ae 100644 --- a/src/config/llm.ts +++ b/src/config/llm.ts @@ -93,6 +93,9 @@ export const getLLMConfig = () => { SILICONCLOUD_API_KEY: z.string().optional(), SILICONCLOUD_MODEL_LIST: z.string().optional(), SILICONCLOUD_PROXY_URL: z.string().optional(), + + ENABLED_UPSTAGE: z.boolean(), + UPSTAGE_API_KEY: z.string().optional(), }, runtimeEnv: { API_KEY_SELECT_MODE: process.env.API_KEY_SELECT_MODE, @@ -183,6 +186,9 @@ export const getLLMConfig = () => { SILICONCLOUD_API_KEY: process.env.SILICONCLOUD_API_KEY, SILICONCLOUD_MODEL_LIST: process.env.SILICONCLOUD_MODEL_LIST, SILICONCLOUD_PROXY_URL: process.env.SILICONCLOUD_PROXY_URL, + + ENABLED_UPSTAGE: !!process.env.UPSTAGE_API_KEY, + UPSTAGE_API_KEY: process.env.UPSTAGE_API_KEY, }, }); }; diff --git a/src/config/modelProviders/index.ts b/src/config/modelProviders/index.ts index d5d54f79bf8d5..f2f382f17b9b1 100644 --- a/src/config/modelProviders/index.ts +++ b/src/config/modelProviders/index.ts @@ -21,6 +21,7 @@ import SiliconCloudProvider from './siliconcloud'; import StepfunProvider from './stepfun'; import TaichuProvider from './taichu'; import TogetherAIProvider from './togetherai'; +import UpstageProvider from './upstage'; import ZeroOneProvider from './zeroone'; import ZhiPuProvider from './zhipu'; @@ -47,6 +48,7 @@ export const LOBE_DEFAULT_MODEL_LIST: ChatModelCard[] = [ TaichuProvider.chatModels, Ai360Provider.chatModels, SiliconCloudProvider.chatModels, + UpstageProvider.chatModels, ].flat(); export const DEFAULT_MODEL_PROVIDER_LIST = [ @@ -73,6 +75,7 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [ TaichuProvider, Ai360Provider, SiliconCloudProvider, + UpstageProvider, ]; export const filterEnabledModels = (provider: ModelProviderCard) => { @@ -105,5 +108,6 @@ export { default as SiliconCloudProviderCard } from './siliconcloud'; export { default as StepfunProviderCard } from './stepfun'; export { default as TaichuProviderCard } from './taichu'; export { default as TogetherAIProviderCard } from './togetherai'; +export { default as UpstageProviderCard } from './upstage'; export { default as ZeroOneProviderCard } from './zeroone'; export { default as ZhiPuProviderCard } from './zhipu'; diff --git a/src/config/modelProviders/upstage.ts b/src/config/modelProviders/upstage.ts new file mode 100644 index 0000000000000..6d991b4bbb57c --- /dev/null +++ b/src/config/modelProviders/upstage.ts @@ -0,0 +1,45 @@ +import { ModelProviderCard } from '@/types/llm'; + +// ref https://developers.upstage.ai/docs/getting-started/models +const Upstage: ModelProviderCard = { + chatModels: [ + { + description: 'A compact LLM offering superior performance to GPT-3.5, with robust multilingual capabilities for both English and Korean, delivering high efficiency in a smaller package. solar-1-mini-chat is alias for our latest solar-1-mini-chat model. (Currently solar-1-mini-chat-240612)', + displayName: 'Solar 1 Mini Chat', + enabled: true, + functionCall: true, + id: 'solar-1-mini-chat', + tokens: 32_768, + }, + { + description: 'A compact LLM that extends the capabilities of solar-mini-chat with specialization in Japanese, while maintaining high efficiency and performance in English and Korean. solar-1-mini-chat-ja is alias for our latest solar-1-mini-chat-ja model.(Currently solar-1-mini-chat-ja-240612)', + displayName: 'Solar 1 Mini Chat Ja', + enabled: true, + functionCall: false, + id: 'solar-1-mini-chat-ja', + tokens: 32_768, + }, + { + description: 'English-to-Korean translation specialized model based on the solar-mini. Maximum context length is 32k tokens. solar-1-mini-translate-enko is alias for our latest solar-1-mini-translate-enko model. (Currently solar-1-mini-translate-enko-240507)', + displayName: 'Solar 1 Mini Translate EnKo', + enabled: false, + functionCall: false, + id: 'solar-1-mini-translate-enko', + tokens: 32_768, + }, + { + description: 'Korean-to-English translation specialized model based on the solar-mini. Maximum context length is 32k tokens. solar-1-mini-translate-koen is alias for our latest solar-1-mini-translate-koen model. (Currently solar-1-mini-translate-koen-240507)', + displayName: 'Solar 1 Mini Translate KoEn', + enabled: false, + functionCall: false, + id: 'solar-1-mini-translate-koen', + tokens: 32_768, + }, + ], + checkModel: 'solar-1-mini-chat', + id: 'upstage', + modelList: { showModelFetcher: true }, + name: 'Upstage', +}; + +export default Upstage; diff --git a/src/const/settings/llm.ts b/src/const/settings/llm.ts index 4491d9ad0f9c3..6056265a074f2 100644 --- a/src/const/settings/llm.ts +++ b/src/const/settings/llm.ts @@ -19,6 +19,7 @@ import { StepfunProviderCard, TaichuProviderCard, TogetherAIProviderCard, + UpstageProviderCard, ZeroOneProviderCard, ZhiPuProviderCard, filterEnabledModels, @@ -111,6 +112,10 @@ export const DEFAULT_LLM_CONFIG: UserModelProviderConfig = { enabled: false, enabledModels: filterEnabledModels(TogetherAIProviderCard), }, + upstage: { + enabled: false, + enabledModels: filterEnabledModels(UpstageProviderCard), + }, zeroone: { enabled: false, enabledModels: filterEnabledModels(ZeroOneProviderCard), diff --git a/src/features/Conversation/Error/APIKeyForm/ProviderAvatar.tsx b/src/features/Conversation/Error/APIKeyForm/ProviderAvatar.tsx index 35dfda03f61cb..1b6f7ee60fac1 100644 --- a/src/features/Conversation/Error/APIKeyForm/ProviderAvatar.tsx +++ b/src/features/Conversation/Error/APIKeyForm/ProviderAvatar.tsx @@ -16,6 +16,7 @@ import { Stepfun, Together, Tongyi, + Upstage, ZeroOne, Zhipu, } from '@lobehub/icons'; @@ -103,6 +104,10 @@ const ProviderAvatar = memo(({ provider }) => { return ; } + case ModelProvider.Upstage: { + return ; + } + default: case ModelProvider.OpenAI: { return ; diff --git a/src/libs/agent-runtime/AgentRuntime.ts b/src/libs/agent-runtime/AgentRuntime.ts index a19319533c7c7..0454e3bbde0a2 100644 --- a/src/libs/agent-runtime/AgentRuntime.ts +++ b/src/libs/agent-runtime/AgentRuntime.ts @@ -24,6 +24,7 @@ import { LobeSiliconCloudAI } from './siliconcloud'; import { LobeStepfunAI } from './stepfun'; import { LobeTaichuAI } from './taichu'; import { LobeTogetherAI } from './togetherai'; +import { LobeUpstageAI } from './upstage'; import { ChatCompetitionOptions, ChatStreamPayload, @@ -134,6 +135,7 @@ class AgentRuntime { stepfun: Partial; taichu: Partial; togetherai: Partial; + upstage: Partial; zeroone: Partial; zhipu: Partial; }>, @@ -261,6 +263,11 @@ class AgentRuntime { runtimeModel = new LobeSiliconCloudAI(params.siliconcloud ?? {}); break; } + + case ModelProvider.Upstage: { + runtimeModel = new LobeUpstageAI(params.upstage); + break + } } return new AgentRuntime(runtimeModel); diff --git a/src/libs/agent-runtime/types/type.ts b/src/libs/agent-runtime/types/type.ts index f8ecd3232aaf3..8c0999f9c1202 100644 --- a/src/libs/agent-runtime/types/type.ts +++ b/src/libs/agent-runtime/types/type.ts @@ -43,6 +43,7 @@ export enum ModelProvider { Stepfun = 'stepfun', Taichu = 'taichu', TogetherAI = 'togetherai', + Upstage = 'upstage', ZeroOne = 'zeroone', ZhiPu = 'zhipu', } diff --git a/src/libs/agent-runtime/upstage/index.test.ts b/src/libs/agent-runtime/upstage/index.test.ts new file mode 100644 index 0000000000000..6ce0c3ee7305c --- /dev/null +++ b/src/libs/agent-runtime/upstage/index.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment node +import OpenAI from 'openai'; +import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ChatStreamCallbacks, + LobeOpenAICompatibleRuntime, + ModelProvider, +} from '@/libs/agent-runtime'; + +import * as debugStreamModule from '../utils/debugStream'; +import { LobeUpstageAI } from './index'; + +const provider = ModelProvider.Upstage; +const defaultBaseURL = 'https://api.upstage.ai/v1/solar'; + +const bizErrorType = 'ProviderBizError'; +const invalidErrorType = 'InvalidProviderAPIKey'; + +// Mock the console.error to avoid polluting test output +vi.spyOn(console, 'error').mockImplementation(() => {}); + +let instance: LobeOpenAICompatibleRuntime; + +beforeEach(() => { + instance = new LobeUpstageAI({ apiKey: 'test' }); + + // 使用 vi.spyOn 来模拟 chat.completions.create 方法 + vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue( + new ReadableStream() as any, + ); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('LobeUpstageAI', () => { + describe('init', () => { + it('should correctly initialize with an API key', async () => { + const instance = new LobeUpstageAI({ apiKey: 'test_api_key' }); + expect(instance).toBeInstanceOf(LobeUpstageAI); + expect(instance.baseURL).toEqual(defaultBaseURL); + }); + }); + + describe('chat', () => { + describe('Error', () => { + it('should return OpenAIBizError with an openai error response when OpenAI.APIError is thrown', async () => { + // Arrange + const apiError = new OpenAI.APIError( + 400, + { + status: 400, + error: { + message: 'Bad Request', + }, + }, + 'Error message', + {}, + ); + + vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError); + + // Act + try { + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + temperature: 0, + }); + } catch (e) { + expect(e).toEqual({ + endpoint: defaultBaseURL, + error: { + error: { message: 'Bad Request' }, + status: 400, + }, + errorType: bizErrorType, + provider, + }); + } + }); + + it('should throw AgentRuntimeError with NoOpenAIAPIKey if no apiKey is provided', async () => { + try { + new LobeUpstageAI({}); + } catch (e) { + expect(e).toEqual({ errorType: invalidErrorType }); + } + }); + + it('should return OpenAIBizError with the cause when OpenAI.APIError is thrown with cause', async () => { + // Arrange + const errorInfo = { + stack: 'abc', + cause: { + message: 'api is undefined', + }, + }; + const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {}); + + vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError); + + // Act + try { + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + temperature: 0, + }); + } catch (e) { + expect(e).toEqual({ + endpoint: defaultBaseURL, + error: { + cause: { message: 'api is undefined' }, + stack: 'abc', + }, + errorType: bizErrorType, + provider, + }); + } + }); + + it('should return OpenAIBizError with an cause response with desensitize Url', async () => { + // Arrange + const errorInfo = { + stack: 'abc', + cause: { message: 'api is undefined' }, + }; + const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {}); + + instance = new LobeUpstageAI({ + apiKey: 'test', + + baseURL: 'https://api.abc.com/v1', + }); + + vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError); + + // Act + try { + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + temperature: 0, + }); + } catch (e) { + expect(e).toEqual({ + endpoint: 'https://api.***.com/v1', + error: { + cause: { message: 'api is undefined' }, + stack: 'abc', + }, + errorType: bizErrorType, + provider, + }); + } + }); + + it('should throw an InvalidUpstageAPIKey error type on 401 status code', async () => { + // Mock the API call to simulate a 401 error + const error = new Error('Unauthorized') as any; + error.status = 401; + vi.mocked(instance['client'].chat.completions.create).mockRejectedValue(error); + + try { + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + temperature: 0, + }); + } catch (e) { + // Expect the chat method to throw an error with InvalidUpstageAPIKey + expect(e).toEqual({ + endpoint: defaultBaseURL, + error: new Error('Unauthorized'), + errorType: invalidErrorType, + provider, + }); + } + }); + + it('should return AgentRuntimeError for non-OpenAI errors', async () => { + // Arrange + const genericError = new Error('Generic Error'); + + vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(genericError); + + // Act + try { + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + temperature: 0, + }); + } catch (e) { + expect(e).toEqual({ + endpoint: defaultBaseURL, + errorType: 'AgentRuntimeError', + provider, + error: { + name: genericError.name, + cause: genericError.cause, + message: genericError.message, + stack: genericError.stack, + }, + }); + } + }); + }); + + describe('DEBUG', () => { + it('should call debugStream and return StreamingTextResponse when DEBUG_UPSTAGE_CHAT_COMPLETION is 1', async () => { + // Arrange + const mockProdStream = new ReadableStream() as any; // 模拟的 prod 流 + const mockDebugStream = new ReadableStream({ + start(controller) { + controller.enqueue('Debug stream content'); + controller.close(); + }, + }) as any; + mockDebugStream.toReadableStream = () => mockDebugStream; // 添加 toReadableStream 方法 + + // 模拟 chat.completions.create 返回值,包括模拟的 tee 方法 + (instance['client'].chat.completions.create as Mock).mockResolvedValue({ + tee: () => [mockProdStream, { toReadableStream: () => mockDebugStream }], + }); + + // 保存原始环境变量值 + const originalDebugValue = process.env.DEBUG_UPSTAGE_CHAT_COMPLETION; + + // 模拟环境变量 + process.env.DEBUG_UPSTAGE_CHAT_COMPLETION = '1'; + vi.spyOn(debugStreamModule, 'debugStream').mockImplementation(() => Promise.resolve()); + + // 执行测试 + // 运行你的测试函数,确保它会在条件满足时调用 debugStream + // 假设的测试函数调用,你可能需要根据实际情况调整 + await instance.chat({ + messages: [{ content: 'Hello', role: 'user' }], + model: 'solar-1-mini-chat', + stream: true, + temperature: 0, + }); + + // 验证 debugStream 被调用 + expect(debugStreamModule.debugStream).toHaveBeenCalled(); + + // 恢复原始环境变量值 + process.env.DEBUG_UPSTAGE_CHAT_COMPLETION = originalDebugValue; + }); + }); + }); +}); diff --git a/src/libs/agent-runtime/upstage/index.ts b/src/libs/agent-runtime/upstage/index.ts new file mode 100644 index 0000000000000..b3576292b2585 --- /dev/null +++ b/src/libs/agent-runtime/upstage/index.ts @@ -0,0 +1,10 @@ +import { ModelProvider } from '../types'; +import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory'; + +export const LobeUpstageAI = LobeOpenAICompatibleFactory({ + baseURL: 'https://api.upstage.ai/v1/solar', + debug: { + chatCompletion: () => process.env.DEBUG_UPSTAGE_CHAT_COMPLETION === '1', + }, + provider: ModelProvider.Upstage, +}); diff --git a/src/server/globalConfig/index.ts b/src/server/globalConfig/index.ts index bf5a808ca1d2b..32772dffbf306 100644 --- a/src/server/globalConfig/index.ts +++ b/src/server/globalConfig/index.ts @@ -47,6 +47,8 @@ export const getServerGlobalConfig = () => { ENABLED_SILICONCLOUD, SILICONCLOUD_MODEL_LIST, + ENABLED_UPSTAGE, + ENABLED_AZURE_OPENAI, AZURE_MODEL_LIST, @@ -138,6 +140,7 @@ export const getServerGlobalConfig = () => { modelString: TOGETHERAI_MODEL_LIST, }), }, + upstage: { enabled: ENABLED_UPSTAGE }, zeroone: { enabled: ENABLED_ZEROONE }, zhipu: { enabled: ENABLED_ZHIPU, diff --git a/src/types/user/settings/keyVaults.ts b/src/types/user/settings/keyVaults.ts index 080b4ba64a922..523a1e8aa0c5f 100644 --- a/src/types/user/settings/keyVaults.ts +++ b/src/types/user/settings/keyVaults.ts @@ -39,6 +39,7 @@ export interface UserKeyVaults { stepfun?: OpenAICompatibleKeyVault; taichu?: OpenAICompatibleKeyVault; togetherai?: OpenAICompatibleKeyVault; + upstage?: OpenAICompatibleKeyVault; zeroone?: OpenAICompatibleKeyVault; zhipu?: OpenAICompatibleKeyVault; }