From 6bc43c3a025e1f8609ab5390d63c4804be01f6a4 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 5 Mar 2026 15:23:32 +0800 Subject: [PATCH] feat(stage-ui): new aihubmix provider, bring back LM Studio, Azure AI Foundry --- cspell.config.yaml | 2 + packages/i18n/src/locales/en/settings.yaml | 36 +++++-- .../providers/providers/aihubmix/index.ts | 58 ++++++++++ .../providers/providers/anthropic/index.ts | 2 +- .../providers/azure-ai-foundry/index.ts | 101 ++++++++++++++++++ .../providers/google-generative-ai/index.ts | 2 +- .../src/libs/providers/providers/index.ts | 3 + .../providers/providers/lm-studio/index.ts | 62 +++++++++++ .../libs/providers/providers/ollama/index.ts | 2 + .../providers/openai-compatible/index.ts | 2 +- .../libs/providers/providers/openai/index.ts | 2 +- .../providers/validators/openai-compatible.ts | 37 +++++-- 12 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 packages/stage-ui/src/libs/providers/providers/aihubmix/index.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/azure-ai-foundry/index.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts diff --git a/cspell.config.yaml b/cspell.config.yaml index eb329e5e2..2cf9eee8e 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -5,6 +5,7 @@ dictionaries: [] words: - acubismmotion - Aerisita + - aihubmix - airi - airi-vtuber - Alaya @@ -153,6 +154,7 @@ words: - live2dcubismframework - livechat - Llmmarker + - lmstudio - lobehub - logg - Lorebook diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index d06e3b339..baf8b6c0b 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -121,7 +121,9 @@ pages: scenario: You recently woke up and forgot everything about your previous life. systemprompt: You will receive messages, answer to them like a real human. posthistoryinstructions: Remember to imitate an human. - modules_info: Configure modules for this character card. If not set, default module configuration will be used. + modules_info: >- + Configure modules for this character card. If not set, default module + configuration will be used. use_default: Use default use_default_not_configured: Use default (not configured) fields_info: @@ -155,7 +157,6 @@ pages: systemprompt: 'Error: Please, provide a system prompt.' posthistoryinstructions: 'Error: Post history prompt is required.' modules: Modules - name_asc: Name (A-Z) name_desc: Name (Z-A) no_cards: No cards yet. Click the button above to upload one! @@ -442,7 +443,9 @@ pages: apply-and-restart: Save and restart stdio MCP messages: opened: Config file opened at {path} - restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped} + restarted: >- + MCP servers restarted. Started {started}, failed {failed}, skipped + {skipped} providers: explained: chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama. @@ -466,7 +469,9 @@ pages: validation: failed: title: Please check your provider settings, validation failed. - description: You can still save the current configuration if you want to proceed. + description: >- + You can still save the current configuration if you want to + proceed. action: Save anyway config: common: @@ -520,7 +525,9 @@ pages: needed for making it pass for Ollama. You need to set OLLAMA_HOST explicitly when start the Ollama application or Ollama CLI server, for detailed - information, check Ollama integration guide. + information, check Ollama + integration guide. common: continueAnyway: Continue Anyway fields: @@ -659,7 +666,9 @@ pages: description: Smaller models load faster but may have slightly lower quality. models: fp32-webgpu: - description: Full precision model using WebGPU - Recommended for supported devices + description: >- + Full precision model using WebGPU - Recommended for supported + devices fp32: description: Full precision model fp16: @@ -779,6 +788,9 @@ pages: transcriptions: playground: title: Transcription Playground + aihubmix: + title: AIHubMix + description: https://aihubmix.com (10% off) title: Providers connection: description: Configure WebSocket server connection @@ -846,7 +858,9 @@ pages: title: Polaroid description: Utility for taking shots of models context-flow: - description: Inspect incoming context updates and outgoing chat stream events + description: >- + Inspect incoming context updates and outgoing chat stream + events websocket-inspector: title: WebSocket Inspector description: Inspect raw WebSocket traffic @@ -855,7 +869,9 @@ pages: description: Test color extraction providers-transcription-realtime-aliyun-nls: title: Aliyun Real-time Transcriber - description: Stream microphone audio to Aliyun NLS and inspect live transcripts + description: >- + Stream microphone audio to Aliyun NLS and inspect live + transcripts performance-playground: title: Performance Playground description: 'Test performance runtime: VRM expressions + TTS lip sync' @@ -988,7 +1004,9 @@ vrm: websocket-secure-enabled: title: Enable Secure WebSocket (WSS) description: >- - Enable WSS (WebSocket Secure) to allow HTTPS connections for mobile (pocket) in development mode. This will automatically generate a self-signed certificate. + Enable WSS (WebSocket Secure) to allow HTTPS connections for mobile (pocket) + in development mode. This will automatically generate a self-signed + certificate. wip: title: Work in Progress description: >- diff --git a/packages/stage-ui/src/libs/providers/providers/aihubmix/index.ts b/packages/stage-ui/src/libs/providers/providers/aihubmix/index.ts new file mode 100644 index 000000000..1584ab0cf --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/aihubmix/index.ts @@ -0,0 +1,58 @@ +import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils' +import { z } from 'zod' + +import { createOpenAICompatibleValidators } from '../../validators/openai-compatible' +import { defineProvider } from '../registry' + +const aihubmixConfigSchema = z.object({ + apiKey: z + .string('API Key'), + baseUrl: z + .string('Base URL') + .optional() + .default('https://aihubmix.com/v1/'), +}) + +type AIHubMixConfig = z.input + +export const providerAIHubMix = defineProvider({ + id: 'aihubmix', + order: 1, + name: 'AIHubMix', + nameLocalize: ({ t }) => t('settings.pages.providers.provider.aihubmix.title'), + description: 'AIHubMix', + descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.aihubmix.description'), + tasks: ['chat'], + icon: 'i-lobe-icons:aihubmix', + iconColor: 'i-lobe-icons:aihubmix-color', + + createProviderConfig: ({ t }) => aihubmixConfigSchema.extend({ + apiKey: aihubmixConfigSchema.shape.apiKey.meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'), + placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'), + type: 'password', + }), + baseUrl: aihubmixConfigSchema.shape.baseUrl.meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'), + placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'), + }), + }), + createProvider(config) { + return merge( + createChatProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + createEmbedProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + createModelProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + ) + }, + + validationRequiredWhen(config) { + return !!config.apiKey?.trim() + }, + validators: { + ...createOpenAICompatibleValidators({ + checks: ['model_list'], + }), + }, +}) diff --git a/packages/stage-ui/src/libs/providers/providers/anthropic/index.ts b/packages/stage-ui/src/libs/providers/providers/anthropic/index.ts index d0dca18ec..ec0d9992f 100644 --- a/packages/stage-ui/src/libs/providers/providers/anthropic/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/anthropic/index.ts @@ -38,7 +38,7 @@ function createAnthropic(apiKey: string, baseURL: string = 'https://api.anthropi export const providerAnthropic = defineProvider({ id: 'anthropic', - order: 5, + order: 6, name: 'Anthropic', nameLocalize: ({ t }) => t('settings.pages.providers.provider.anthropic.title'), description: 'anthropic.com', diff --git a/packages/stage-ui/src/libs/providers/providers/azure-ai-foundry/index.ts b/packages/stage-ui/src/libs/providers/providers/azure-ai-foundry/index.ts new file mode 100644 index 000000000..2be1bfd18 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/azure-ai-foundry/index.ts @@ -0,0 +1,101 @@ +import type { ModelInfo } from '../../types' + +import { createAzure } from '@xsai-ext/providers/special/create' +import { z } from 'zod' + +import { defineProvider } from '../registry' + +const azureAIFoundryConfigSchema = z.object({ + apiKey: z.string('API Key'), + resourceName: z.string('Resource Name'), + modelId: z.string('Model ID'), + apiVersion: z.string('API Version').optional(), +}) + +type AzureAIFoundryConfig = z.input + +export const providerAzureAIFoundry = defineProvider({ + id: 'azure-ai-foundry', + order: 17, + name: 'Azure AI Foundry', + nameLocalize: ({ t }) => t('settings.pages.providers.provider.azure-ai-foundry.title'), + description: 'azure.com', + descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.azure-ai-foundry.description'), + tasks: ['chat'], + icon: 'i-lobe-icons:microsoft', + + createProviderConfig: ({ t }) => azureAIFoundryConfigSchema.extend({ + apiKey: azureAIFoundryConfigSchema.shape.apiKey.meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'), + placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'), + type: 'password', + }), + resourceName: azureAIFoundryConfigSchema.shape.resourceName.meta({ + labelLocalized: 'Resource Name', + descriptionLocalized: 'Prefix used in https://.services.ai.azure.com', + placeholderLocalized: 'my-resource', + }), + modelId: azureAIFoundryConfigSchema.shape.modelId.meta({ + labelLocalized: 'Model ID', + descriptionLocalized: 'Model ID on Azure AI Foundry', + placeholderLocalized: 'gpt-4o', + }), + apiVersion: azureAIFoundryConfigSchema.shape.apiVersion.meta({ + labelLocalized: 'API Version', + descriptionLocalized: 'API version for snapshot of the models', + placeholderLocalized: '2025-04-01-preview', + section: 'advanced', + }), + }), + createProvider(config) { + return createAzure({ + apiKey: async () => config.apiKey.trim(), + resourceName: config.resourceName.trim(), + apiVersion: config.apiVersion?.trim(), + }) as any + }, + + extraMethods: { + listModels: async config => + [{ + id: config.modelId, + name: config.modelId, + provider: 'azure-ai-foundry', + description: '', + contextLength: 0, + deprecated: false, + } satisfies ModelInfo], + }, + validationRequiredWhen(config) { + return !!config.apiKey?.trim() || !!config.resourceName?.trim() || !!config.modelId?.trim() + }, + validators: { + validateConfig: [ + ({ t }) => ({ + id: 'azure-ai-foundry:check-config', + name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'), + validator: async (config) => { + const errors: Array<{ error: unknown }> = [] + const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : '' + const resourceName = typeof config.resourceName === 'string' ? config.resourceName.trim() : '' + const modelId = typeof config.modelId === 'string' ? config.modelId.trim() : '' + + if (!apiKey) + errors.push({ error: new Error('API key is required.') }) + if (!resourceName) + errors.push({ error: new Error('Resource name is required.') }) + if (!modelId) + errors.push({ error: new Error('Model ID is required.') }) + + return { + errors, + reason: errors.length > 0 ? errors.map(item => (item.error as Error).message).join(', ') : '', + reasonKey: '', + valid: errors.length === 0, + } + }, + }), + ], + }, +}) diff --git a/packages/stage-ui/src/libs/providers/providers/google-generative-ai/index.ts b/packages/stage-ui/src/libs/providers/providers/google-generative-ai/index.ts index 7792751f1..956853a99 100644 --- a/packages/stage-ui/src/libs/providers/providers/google-generative-ai/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/google-generative-ai/index.ts @@ -17,7 +17,7 @@ type GoogleGenerativeConfig = z.input export const providerGoogleGenerativeAI = defineProvider({ id: 'google-generative-ai', - order: 6, + order: 8, name: 'Google Gemini', nameLocalize: ({ t }) => t('settings.pages.providers.provider.google-generative-ai.title'), description: 'ai.google.dev', diff --git a/packages/stage-ui/src/libs/providers/providers/index.ts b/packages/stage-ui/src/libs/providers/providers/index.ts index d8f1f852d..5c169cc95 100644 --- a/packages/stage-ui/src/libs/providers/providers/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/index.ts @@ -1,4 +1,6 @@ import './openai' +import './aihubmix' +import './lm-studio' import './openai-compatible' import './n1n' import './openrouter-ai' @@ -21,6 +23,7 @@ import './moonshot-ai' import './modelscope' import './ollama' import './cloudflare-workers-ai' +import './azure-ai-foundry' export { getDefinedProvider, diff --git a/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts b/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts new file mode 100644 index 000000000..1e2fd8f89 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts @@ -0,0 +1,62 @@ +import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils' +import { z } from 'zod' + +import { createOpenAICompatibleValidators } from '../../validators/openai-compatible' +import { defineProvider } from '../registry' + +const lmStudioConfigSchema = z.object({ + apiKey: z + .string('API Key'), + baseUrl: z + .string('Base URL') + .optional() + .default('http://localhost:1234/v1/'), +}) + +type LMStudioConfig = z.input + +export const providerLmStudio = defineProvider({ + id: 'lm-studio', + order: 3, + name: 'LM Studio', + nameLocalize: ({ t }) => t('settings.pages.providers.provider.lm-studio.title'), + description: 'LM Studio', + descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.lm-studio.description'), + tasks: ['chat'], + icon: 'i-lobe-icons:lmstudio', + iconColor: 'i-lobe-icons:lmstudio', + + createProviderConfig: ({ t }) => lmStudioConfigSchema.extend({ + apiKey: lmStudioConfigSchema.shape.apiKey.meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'), + placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'), + type: 'password', + }), + baseUrl: lmStudioConfigSchema.shape.baseUrl.meta({ + labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'), + descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'), + placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'), + }), + }), + createProvider(config) { + return merge( + createChatProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + createEmbedProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + createModelProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }), + ) + }, + + validationRequiredWhen(config) { + return !!config.apiKey?.trim() + }, + validators: { + ...createOpenAICompatibleValidators({ + checks: ['connectivity', 'model_list'], + connectivityFailureReason: ({ errorMessage }) => + `Failed to reach LM Studio server, error: ${errorMessage} occurred.\n\nMake sure LM Studio is running and the local server is started. You can start the local server in LM Studio by going to the 'Local Server' tab and clicking 'Start Server'.`, + modelListFailureReason: ({ errorMessage }) => + `Failed to reach LM Studio server, error: ${errorMessage} occurred.\n\nMake sure LM Studio is running and the local server is started. You can start the local server in LM Studio by going to the 'Local Server' tab and clicking 'Start Server'.`, + }), + }, +}) diff --git a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts index 2d16b9f92..4fbe0c54a 100644 --- a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts @@ -69,6 +69,8 @@ export const providerOllama = defineProvider({ ], validateProvider: createOpenAICompatibleValidators({ checks: ['connectivity', 'model_list', 'chat_completions'], + connectivityFailureReason: ({ errorMessage }) => + `Failed to reach Ollama server, error: ${errorMessage} occurred.\n\nIf you are using Ollama locally, this is likely the CORS (Cross-Origin Resource Sharing) security issue, where you will need to set OLLAMA_ORIGINS=* or OLLAMA_ORIGINS=https://airi.moeru.ai,http://localhost environment variable before launching Ollama server to make this work.`, })!.validateProvider, }, business: ({ t }) => ({ diff --git a/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts b/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts index 5332d4133..1a7a80e2b 100644 --- a/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/openai-compatible/index.ts @@ -18,7 +18,7 @@ type OpenAICompatibleConfig = z.input export const providerOpenAICompatible = defineProvider({ id: 'openai-compatible', - order: 3, + order: 4, name: 'OpenAI Compatible', nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai-compatible.title'), description: 'OpenAI-compatible chat APIs with API key authentication.', diff --git a/packages/stage-ui/src/libs/providers/providers/openai/index.ts b/packages/stage-ui/src/libs/providers/providers/openai/index.ts index b61f509e7..02b0b3539 100644 --- a/packages/stage-ui/src/libs/providers/providers/openai/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/openai/index.ts @@ -17,7 +17,7 @@ type OpenAICompatibleConfig = z.input export const providerOpenAI = defineProvider({ id: 'openai', - order: 1, + order: 5, name: 'OpenAI', nameLocalize: ({ t }) => t('settings.pages.providers.provider.openai.title'), description: 'OpenAI', diff --git a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts index 6e7abf382..dda3e92dd 100644 --- a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts +++ b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts @@ -12,6 +12,13 @@ import { isModelProvider } from '../types' type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_completions' +interface OpenAICompatibleValidationOptions { + checks?: OpenAICompatibleValidationCheck[] + additionalHeaders?: Record + connectivityFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string + modelListFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string +} + function extractStatusCode(error: unknown): number | null { if (!error) return null @@ -89,10 +96,9 @@ async function pickValidationModel(options?: { - checks?: OpenAICompatibleValidationCheck[] - additionalHeaders?: Record -}): ProviderDefinition['validators'] { +export function createOpenAICompatibleValidators( + options?: OpenAICompatibleValidationOptions, +): ProviderDefinition['validators'] { const checks = options?.checks ?? ['connectivity', 'model_list', 'chat_completions'] const additionalHeaders = options?.additionalHeaders @@ -100,6 +106,7 @@ export function createOpenAICompatibleValidators | undefined, contextOptions?: { validationCache?: Map }, - ) => { + ): Promise => { const cache = contextOptions?.validationCache const existing = cache?.get(chatCheckCacheKey) as Promise | undefined if (existing) @@ -131,11 +138,11 @@ export function createOpenAICompatibleValidators= 200 && status < 300) + const chatOk = status === 400 || Boolean(status && status >= 200 && status < 300) return { connectivityOk: true, chatOk, errorMessage: errorMessageFrom(e) } } } @@ -169,11 +176,11 @@ export function createOpenAICompatibleValidators= 200 && status < 300) + const chatOk = status === 400 || Boolean(status && status >= 200 && status < 300) return { connectivityOk: true, chatOk, errorMessage: errorMessageFrom(e) } } })() @@ -238,7 +245,11 @@ export function createOpenAICompatibleValidators } | undefined, ) if (!result.connectivityOk) { - errors.push({ error: new Error(`Connectivity check failed: ${result.errorMessage || 'Unknown error.'}`) }) + const errorMessage = result.errorMessage || 'Unknown error.' + const reason = options?.connectivityFailureReason + ? options.connectivityFailureReason({ config, error: result.error, errorMessage }) + : `Connectivity check failed: ${errorMessage}` + errors.push({ error: new Error(reason) }) } return { @@ -290,7 +301,11 @@ export function createOpenAICompatibleValidators