diff --git a/common/api_type.go b/common/api_type.go index 39c1fe9a540..8e575b6a4a6 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeReplicate case constant.ChannelTypeCodex: apiType = constant.APITypeCodex + case constant.ChannelTypeXiaomi: + apiType = constant.APITypeXiaomi } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 536ebd2c719..b13425a2435 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -36,5 +36,6 @@ const ( APITypeMiniMax APITypeReplicate APITypeCodex + APITypeXiaomi APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 48502bedc52..eff38903f39 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeXiaomi = 58 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "https://api.xiaomimimo.com", //58 } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", + ChannelTypeXiaomi: "Xiaomi", } func GetChannelTypeName(channelType int) string { diff --git a/model/pricing_default.go b/model/pricing_default.go index db64cafbb1e..17e4d38db95 100644 --- a/model/pricing_default.go +++ b/model/pricing_default.go @@ -19,6 +19,7 @@ var defaultVendorRules = map[string]string{ "glm-": "智谱", "qwen": "阿里巴巴", "deepseek": "DeepSeek", + "mimo": "小米", "abab": "MiniMax", "ernie": "百度", "spark": "讯飞", @@ -46,6 +47,7 @@ var defaultVendorIcons = map[string]string{ "智谱": "Zhipu.Color", "阿里巴巴": "Qwen.Color", "DeepSeek": "DeepSeek.Color", + "小米": "XiaomiMiMo", "MiniMax": "Minimax.Color", "百度": "Wenxin.Color", "讯飞": "Spark.Color", diff --git a/relay/channel/xiaomi/adaptor.go b/relay/channel/xiaomi/adaptor.go new file mode 100644 index 00000000000..f2a9a5b7106 --- /dev/null +++ b/relay/channel/xiaomi/adaptor.go @@ -0,0 +1,75 @@ +package xiaomi + +import ( + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + openai.Adaptor +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode != relayconstant.RelayModeAudioSpeech { + return a.Adaptor.GetRequestURL(info) + } + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + if err := a.Adaptor.SetupRequestHeader(c, req, info); err != nil { + return err + } + req.Set("api-key", info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, fmt.Errorf("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + if info.RelayMode != relayconstant.RelayModeAudioSpeech { + return a.Adaptor.ConvertAudioRequest(c, info, request) + } + return convertTTSRequest(c, request) +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if info.RelayMode == relayconstant.RelayModeAudioTranscription || + info.RelayMode == relayconstant.RelayModeAudioTranslation || + info.RelayMode == relayconstant.RelayModeImagesEdits { + return channel.DoFormRequest(a, c, info, requestBody) + } + if info.RelayMode == relayconstant.RelayModeRealtime { + return channel.DoWssRequest(a, c, info, requestBody) + } + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == relayconstant.RelayModeAudioSpeech { + return handleTTSResponse(c, resp, info) + } + return a.Adaptor.DoResponse(c, resp, info) +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/xiaomi/constants.go b/relay/channel/xiaomi/constants.go new file mode 100644 index 00000000000..7568b292edb --- /dev/null +++ b/relay/channel/xiaomi/constants.go @@ -0,0 +1,17 @@ +package xiaomi + +const ( + ChannelName = "xiaomi" + contextKeyAudioFormat = "xiaomi_audio_format" + defaultMimoVoice = "mimo_default" +) + +var ModelList = []string{ + "mimo-v2-pro", + "mimo-v2-flash", + "mimo-v2-omni", + "mimo-v2-tts", + "mimo-v2.5-tts", + "mimo-v2.5-pro", + "mimo-v2.5", +} diff --git a/relay/channel/xiaomi/tts.go b/relay/channel/xiaomi/tts.go new file mode 100644 index 00000000000..ebd4a8fc983 --- /dev/null +++ b/relay/channel/xiaomi/tts.go @@ -0,0 +1,140 @@ +package xiaomi + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type xiaomiTTSMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type xiaomiTTSAudio struct { + Voice string `json:"voice"` + Format string `json:"format"` +} + +type xiaomiTTSRequest struct { + Model string `json:"model"` + Messages []xiaomiTTSMessage `json:"messages"` + Audio xiaomiTTSAudio `json:"audio"` +} + +type xiaomiTTSResponse struct { + Choices []struct { + Message struct { + Audio struct { + Data string `json:"data"` + } `json:"audio"` + } `json:"message"` + } `json:"choices"` + Usage dto.Usage `json:"usage"` +} + +func convertTTSRequest(c *gin.Context, request dto.AudioRequest) (io.Reader, error) { + audioFormat := normalizeMimoAudioFormat(request.ResponseFormat) + c.Set(contextKeyAudioFormat, audioFormat) + + voice := request.Voice + if voice == "" { + voice = defaultMimoVoice + } + + messages := make([]xiaomiTTSMessage, 0, 2) + if request.Instructions != "" { + messages = append(messages, xiaomiTTSMessage{Role: "user", Content: request.Instructions}) + } + messages = append(messages, xiaomiTTSMessage{Role: "assistant", Content: request.Input}) + + jsonData, err := common.Marshal(xiaomiTTSRequest{ + Model: request.Model, + Messages: messages, + Audio: xiaomiTTSAudio{Voice: voice, Format: audioFormat}, + }) + if err != nil { + return nil, err + } + return bytes.NewReader(jsonData), nil +} + +func normalizeMimoAudioFormat(format string) string { + switch format { + case "": + return "wav" + case "pcm": + return "pcm16" + default: + return format + } +} + +func getTTSContentType(format string) string { + switch format { + case "mp3": + return "audio/mpeg" + case "pcm", "pcm16": + return "audio/pcm" + default: + return "audio/wav" + } +} + +func handleTTSResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (any, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("invalid xiaomi TTS response"), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ) + } + defer resp.Body.Close() + audioFormat := c.GetString(contextKeyAudioFormat) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("read xiaomi TTS response: %w", err), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ) + } + + var ttsResp xiaomiTTSResponse + if err := common.Unmarshal(body, &ttsResp); err != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("unmarshal xiaomi TTS response: %w", err), + types.ErrorCodeBadResponseBody, + http.StatusBadGateway, + ) + } + + if len(ttsResp.Choices) == 0 || ttsResp.Choices[0].Message.Audio.Data == "" { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("xiaomi TTS response missing audio data"), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + + audioData, err := base64.StdEncoding.DecodeString(ttsResp.Choices[0].Message.Audio.Data) + if err != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("decode xiaomi TTS audio payload: %w", err), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + + c.Data(http.StatusOK, getTTSContentType(audioFormat), audioData) + + return &ttsResp.Usage, nil +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfa..200da406510 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -328,6 +328,7 @@ var streamSupportedChannels = map[int]bool{ constant.ChannelTypeMoonshot: true, constant.ChannelTypeMiniMax: true, constant.ChannelTypeSiliconFlow: true, + constant.ChannelTypeXiaomi: true, } func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo { diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4..7e837dc74e2 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/claude" "github.com/QuantumNous/new-api/relay/channel/cloudflare" "github.com/QuantumNous/new-api/relay/channel/codex" + "github.com/QuantumNous/new-api/relay/channel/xiaomi" "github.com/QuantumNous/new-api/relay/channel/cohere" "github.com/QuantumNous/new-api/relay/channel/coze" "github.com/QuantumNous/new-api/relay/channel/deepseek" @@ -120,6 +121,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &replicate.Adaptor{} case constant.APITypeCodex: return &codex.Adaptor{} + case constant.APITypeXiaomi: + return &xiaomi.Adaptor{} } return nil } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 80702ee42ad..3c3ee31796f 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -249,6 +249,11 @@ var defaultModelRatio = map[string]float64{ "deepseek-chat": 0.27 / 2, "deepseek-coder": 0.27 / 2, "deepseek-reasoner": 0.55 / 2, // 0.55 / 1k tokens + "mimo-v2.5-pro": 0.5, + "mimo-v2-pro": 0.5, + "mimo-v2.5": 0.2, + "mimo-v2-flash": 0.05, + "mimo-v2-omni": 0.2, // Perplexity online 模型对搜索额外收费,有需要应自行调整,此处不计入搜索费用 "llama-3-sonar-small-32k-chat": 0.2 / 1000 * USD, "llama-3-sonar-small-32k-online": 0.2 / 1000 * USD, diff --git a/web/classic/src/constants/channel.constants.js b/web/classic/src/constants/channel.constants.js index 9fa78779de8..a52a84d85e5 100644 --- a/web/classic/src/constants/channel.constants.js +++ b/web/classic/src/constants/channel.constants.js @@ -58,6 +58,7 @@ export const CHANNEL_OPTIONS = [ }, { value: 39, color: 'grey', label: 'Cloudflare' }, { value: 43, color: 'blue', label: 'DeepSeek' }, + { value: 58, color: 'orange', label: 'Xiaomi MiMo' }, { value: 15, color: 'blue', diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx index 46c95b23683..dec01a8561a 100644 --- a/web/classic/src/helpers/render.jsx +++ b/web/classic/src/helpers/render.jsx @@ -50,17 +50,6 @@ import { XAI, Ollama, Doubao, - Suno, - Xinference, - OpenRouter, - Dify, - Coze, - SiliconCloud, - FastGPT, - Kling, - Jimeng, - Perplexity, - Replicate, } from '@lobehub/icons'; import { @@ -323,6 +312,51 @@ export const getModelCategories = (() => { }; })(); +const channelTypeIconMap = { + 1: 'OpenAI', // OpenAI + 3: 'OpenAI', // Azure OpenAI + 57: 'OpenAI', // Codex + 2: 'Midjourney', // Midjourney Proxy + 5: 'Midjourney', // Midjourney Proxy Plus + 36: 'Suno', // Suno API + 4: 'Ollama', // Ollama + 14: 'Claude.Color', // Anthropic Claude + 33: 'Claude.Color', // AWS Claude + 41: 'Gemini.Color', // Vertex AI + 34: 'Cohere.Color', // Cohere + 39: 'Cloudflare.Color', // Cloudflare + 43: 'DeepSeek.Color', // DeepSeek + 58: 'XiaomiMiMo', // Xiaomi MiMo + 15: 'Wenxin.Color', // 百度文心千帆 + 46: 'Wenxin.Color', // 百度文心千帆V2 + 17: 'Qwen.Color', // 阿里通义千问 + 18: 'Spark.Color', // 讯飞星火认知 + 16: 'Zhipu.Color', // 智谱 ChatGLM + 26: 'Zhipu.Color', // 智谱 GLM-4V + 24: 'Gemini.Color', // Google Gemini + 11: 'Gemini.Color', // Google PaLM2 + 47: 'Xinference.Color', // Xinference + 25: 'Moonshot', // Moonshot + 27: 'Perplexity.Color', // Perplexity + 20: 'OpenRouter', // OpenRouter + 19: 'Ai360.Color', // 360 智脑 + 23: 'Hunyuan.Color', // 腾讯混元 + 31: 'Yi.Color', // 零一万物 + 35: 'Minimax.Color', // MiniMax + 37: 'Dify.Color', // Dify + 38: 'Jina', // Jina + 40: 'SiliconCloud.Color', // SiliconCloud + 42: 'Mistral.Color', // Mistral AI + 45: 'Doubao.Color', // 字节火山方舟、豆包通用 + 48: 'XAI', // xAI + 49: 'Coze', // Coze + 50: 'Kling.Color', // 可灵 Kling + 51: 'Jimeng.Color', // 即梦 Jimeng + 54: 'Doubao.Color', // 豆包视频 Doubao Video + 56: 'Replicate', // Replicate + 22: 'FastGPT.Color', // 知识库:FastGPT +}; + /** * 根据渠道类型返回对应的厂商图标 * @param {number} channelType - 渠道类型值 @@ -330,89 +364,11 @@ export const getModelCategories = (() => { */ export function getChannelIcon(channelType) { const iconSize = 14; - - switch (channelType) { - case 1: // OpenAI - case 3: // Azure OpenAI - case 57: // Codex - return ; - case 2: // Midjourney Proxy - case 5: // Midjourney Proxy Plus - return ; - case 36: // Suno API - return ; - case 4: // Ollama - return ; - case 14: // Anthropic Claude - case 33: // AWS Claude - return ; - case 41: // Vertex AI - return ; - case 34: // Cohere - return ; - case 39: // Cloudflare - return ; - case 43: // DeepSeek - return ; - case 15: // 百度文心千帆 - case 46: // 百度文心千帆V2 - return ; - case 17: // 阿里通义千问 - return ; - case 18: // 讯飞星火认知 - return ; - case 16: // 智谱 ChatGLM - case 26: // 智谱 GLM-4V - return ; - case 24: // Google Gemini - case 11: // Google PaLM2 - return ; - case 47: // Xinference - return ; - case 25: // Moonshot - return ; - case 27: // Perplexity - return ; - case 20: // OpenRouter - return ; - case 19: // 360 智脑 - return ; - case 23: // 腾讯混元 - return ; - case 31: // 零一万物 - return ; - case 35: // MiniMax - return ; - case 37: // Dify - return ; - case 38: // Jina - return ; - case 40: // SiliconCloud - return ; - case 42: // Mistral AI - return ; - case 45: // 字节火山方舟、豆包通用 - return ; - case 48: // xAI - return ; - case 49: // Coze - return ; - case 50: // 可灵 Kling - return ; - case 51: // 即梦 Jimeng - return ; - case 54: // 豆包视频 Doubao Video - return ; - case 56: // Replicate - return ; - case 8: // 自定义渠道 - case 22: // 知识库:FastGPT - return ; - case 21: // 知识库:AI Proxy - case 44: // 嵌入模型:MokaAI M3E - default: - return null; // 未知类型或自定义渠道不显示图标 + const iconName = channelTypeIconMap[channelType]; + if (!iconName) { + return null; } + return getLobeHubIcon(iconName, iconSize); } /** @@ -429,7 +385,7 @@ export function getLobeHubIcon(iconName, size = 14) { if (typeof iconName === 'string') iconName = iconName.trim(); // 如果没有图标名称,返回 Avatar if (!iconName) { - return ?; + return ?; } // 解析组件路径与点号链式属性 @@ -441,59 +397,59 @@ export function getLobeHubIcon(iconName, size = 14) { let propStartIndex = 1; if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) { - IconComponent = BaseIcon[segments[1]]; - propStartIndex = 2; + IconComponent = BaseIcon[segments[1]]; + propStartIndex = 2; } else { - IconComponent = LobeIcons[baseKey]; - propStartIndex = 1; + IconComponent = LobeIcons[baseKey]; + propStartIndex = 1; } // 失败兜底 if ( - !IconComponent || - (typeof IconComponent !== 'function' && typeof IconComponent !== 'object') + !IconComponent || + (typeof IconComponent !== 'function' && typeof IconComponent !== 'object') ) { - const firstLetter = String(iconName).charAt(0).toUpperCase(); - return {firstLetter}; + const firstLetter = String(iconName).charAt(0).toUpperCase(); + return {firstLetter}; } // 解析点号链式属性,形如:key={...}、key='...'、key="..."、key=123、key、key=true/false const props = {}; const parseValue = (raw) => { - if (raw == null) return true; - let v = String(raw).trim(); - // 去除一层花括号包裹 - if (v.startsWith('{') && v.endsWith('}')) { - v = v.slice(1, -1).trim(); - } - // 去除引号 - if ( - (v.startsWith('"') && v.endsWith('"')) || - (v.startsWith("'") && v.endsWith("'")) - ) { - return v.slice(1, -1); - } - // 布尔 - if (v === 'true') return true; - if (v === 'false') return false; - // 数字 - if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v); - // 其他原样返回字符串 - return v; - }; + if (raw == null) return true; + let v = String(raw).trim(); + // 去除一层花括号包裹 + if (v.startsWith('{') && v.endsWith('}')) { + v = v.slice(1, -1).trim(); + } + // 去除引号 + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + return v.slice(1, -1); + } + // 布尔 + if (v === 'true') return true; + if (v === 'false') return false; + // 数字 + if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v); + // 其他原样返回字符串 + return v; +}; for (let i = propStartIndex; i < segments.length; i++) { - const seg = segments[i]; - if (!seg) continue; - const eqIdx = seg.indexOf('='); - if (eqIdx === -1) { - props[seg.trim()] = true; - continue; - } - const key = seg.slice(0, eqIdx).trim(); - const valRaw = seg.slice(eqIdx + 1).trim(); - props[key] = parseValue(valRaw); + const seg = segments[i]; + if (!seg) continue; + const eqIdx = seg.indexOf('='); + if (eqIdx === -1) { + props[seg.trim()] = true; + continue; + } + const key = seg.slice(0, eqIdx).trim(); + const valRaw = seg.slice(eqIdx + 1).trim(); + props[key] = parseValue(valRaw); } // 兼容第二参数 size,若字符串中未显式指定 size,则使用函数入参 diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index 21e15161508..a1a82f30f18 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -58,6 +58,7 @@ export const CHANNEL_TYPES = { 55: 'Sora', 56: 'Replicate', 57: 'Codex', + 58: 'Xiaomi', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ diff --git a/web/default/src/features/channels/lib/channel-utils.ts b/web/default/src/features/channels/lib/channel-utils.ts index 9d96b62e0f9..9d189c034e1 100644 --- a/web/default/src/features/channels/lib/channel-utils.ts +++ b/web/default/src/features/channels/lib/channel-utils.ts @@ -79,6 +79,7 @@ export function getChannelTypeIcon(type: number): string { 50: 'Kling', // Kling 51: 'Jimeng', // Jimeng 52: 'Vidu', // Vidu + 58: 'XiaomiMiMo', // Xiaomi 36: 'Suno', // SunoAPI 55: 'OpenAI', // Sora 54: 'Doubao', // DoubaoVideo