From 0f9699843fda2e9567f820d6ffd6428dd950a34a Mon Sep 17 00:00:00 2001 From: YOYO-do Date: Mon, 15 Jun 2026 12:54:18 +0800 Subject: [PATCH] feat: add AIHubMix provider (#865) * feat: add AIHubMix provider * feat: load AIHubMix models dynamically * feat: polish AIHubMix model setup * feat: send AIHubMix app code * docs: remove redundant AIHubMix recommendation --------- Co-authored-by: LL <13697272357@163.com> --- README.md | 1 + app/api/aihubmix-models/route.ts | 61 +++++++++++++ app/api/validate-model/route.ts | 29 +++++- components/model-config-dialog.tsx | 138 ++++++++++++++++++++++++++--- docs/cn/README_CN.md | 1 + docs/cn/ai-providers.md | 17 +++- docs/en/ai-providers.md | 17 +++- docs/ja/README_JA.md | 1 + docs/ja/ai-providers.md | 17 +++- env.example | 6 +- lib/ai-providers.ts | 62 ++++++++++++- lib/aihubmix-models.ts | 79 +++++++++++++++++ lib/types/model-config.ts | 41 +++++++++ package-lock.json | 66 ++++++++++++++ package.json | 1 + tests/unit/ai-providers.test.ts | 90 +++++++++++++++++++ 16 files changed, 606 insertions(+), 21 deletions(-) create mode 100644 app/api/aihubmix-models/route.ts create mode 100644 lib/aihubmix-models.ts diff --git a/README.md b/README.md index 61a1222..738ccaf 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building- - Azure OpenAI - Ollama - OpenRouter +- AIHubMix - DeepSeek - SiliconFlow - ModelScope diff --git a/app/api/aihubmix-models/route.ts b/app/api/aihubmix-models/route.ts new file mode 100644 index 0000000..9e4d0e8 --- /dev/null +++ b/app/api/aihubmix-models/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server" +import { + AIHUBMIX_MODELS_ENDPOINT, + extractAihubmixModelIds, +} from "@/lib/aihubmix-models" +import { SUGGESTED_MODELS } from "@/lib/types/model-config" + +const SUCCESS_CACHE_CONTROL = + "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400" + +function fallbackResponse() { + return NextResponse.json( + { + models: SUGGESTED_MODELS.aihubmix || [], + source: "fallback", + }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ) +} + +export async function GET() { + try { + const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, { + next: { revalidate: 3600 }, + }) + + if (!response.ok) { + console.warn( + `[aihubmix-models] Failed to fetch models: ${response.status}`, + ) + return fallbackResponse() + } + + const payload = await response.json() + const models = extractAihubmixModelIds(payload) + + if (models.length === 0) { + console.warn("[aihubmix-models] Model list response was empty") + return fallbackResponse() + } + + return NextResponse.json( + { + models, + source: "aihubmix", + }, + { + headers: { + "Cache-Control": SUCCESS_CACHE_CONTROL, + }, + }, + ) + } catch (error) { + console.warn("[aihubmix-models] Failed to load models:", error) + return fallbackResponse() + } +} diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index 34534cb..1b40c48 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -5,11 +5,16 @@ import { createGateway } from "@ai-sdk/gateway" import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI } from "@ai-sdk/openai" +import { createAihubmix } from "@aihubmix/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { generateText } from "ai" import { NextResponse } from "next/server" import { createOllama } from "ollama-ai-provider-v2" -import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers" +import { + AIHUBMIX_APP_CODE, + isAihubmixStandardBaseURL, + normalizeMiniMaxBaseURL, +} from "@/lib/ai-providers" import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" @@ -153,6 +158,28 @@ export async function POST(req: Request) { break } + case "aihubmix": { + const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl + + if ( + isAihubmixStandardBaseURL(baseUrl) || + baseUrl === defaultBaseURL + ) { + const aihubmix = createAihubmix({ + apiKey, + appCode: AIHUBMIX_APP_CODE, + }) + model = aihubmix(modelId) + } else { + const aihubmixCompatible = createOpenAI({ + apiKey, + baseURL: baseUrl, + }) + model = aihubmixCompatible.chat(modelId) + } + break + } + case "deepseek": { if (baseUrl || apiKey) { const ds = createDeepSeek({ diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 747125e..2dd4473 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -54,6 +54,7 @@ import { import { Switch } from "@/components/ui/switch" import { useDictionary } from "@/hooks/use-dictionary" import type { UseModelConfigReturn } from "@/hooks/use-model-config" +import { getApiEndpoint } from "@/lib/base-path" import { formatMessage } from "@/lib/i18n/utils" import type { ProviderConfig, ProviderName } from "@/lib/types/model-config" import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" @@ -132,6 +133,14 @@ export function ModelConfigDialog({ modelId: string message: string } | null>(null) + const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState< + Partial> + >({}) + const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState< + Partial> + >({}) + const [loadingSuggestedProvider, setLoadingSuggestedProvider] = + useState(null) const { config, @@ -157,10 +166,68 @@ export function ModelConfigDialog({ } }, []) + useEffect(() => { + if ( + !open || + selectedProvider?.provider !== "aihubmix" || + loadedSuggestedProviders.aihubmix + ) { + return + } + + let cancelled = false + setLoadingSuggestedProvider("aihubmix") + + fetch(getApiEndpoint("/api/aihubmix-models")) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load models: ${response.status}`) + } + return response.json() + }) + .then((data: { models?: unknown }) => { + if (cancelled || !Array.isArray(data.models)) { + return + } + + const models = data.models.filter( + (model): model is string => typeof model === "string", + ) + if (models.length > 0) { + setDynamicSuggestedModels((current) => ({ + ...current, + aihubmix: models, + })) + } + }) + .catch((error) => { + console.warn("Failed to load AIHubMix models:", error) + }) + .finally(() => { + if (cancelled) { + return + } + + setLoadedSuggestedProviders((current) => ({ + ...current, + aihubmix: true, + })) + setLoadingSuggestedProvider(null) + }) + + return () => { + cancelled = true + } + }, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix]) + // Get suggested models for current provider const suggestedModels = selectedProvider - ? SUGGESTED_MODELS[selectedProvider.provider] || [] + ? dynamicSuggestedModels[selectedProvider.provider] || + SUGGESTED_MODELS[selectedProvider.provider] || + [] : [] + const isLoadingSuggestedModels = + selectedProvider?.provider === loadingSuggestedProvider // Filter out already-added models from suggestions const existingModelIds = @@ -168,6 +235,11 @@ export function ModelConfigDialog({ const availableSuggestions = suggestedModels.filter( (modelId) => !existingModelIds.includes(modelId), ) + const emptyStateSuggestions = selectedProvider + ? (SUGGESTED_MODELS[selectedProvider.provider] || []) + .filter((modelId) => !existingModelIds.includes(modelId)) + .slice(0, 4) + : [] // Handle adding a new provider const handleAddProvider = (providerType: ProviderName) => { @@ -773,21 +845,26 @@ export function ModelConfigDialog({ } }} disabled={ + isLoadingSuggestedModels || availableSuggestions.length === - 0 + 0 } > - - {availableSuggestions.length === - 0 - ? dict - .modelConfig - .allAdded - : dict - .modelConfig - .suggested} - + {isLoadingSuggestedModels ? ( + + ) : ( + + {availableSuggestions.length === + 0 + ? dict + .modelConfig + .allAdded + : dict + .modelConfig + .suggested} + + )} {availableSuggestions.map( @@ -816,7 +893,12 @@ export function ModelConfigDialog({ 0 ? (
- +

{ @@ -824,6 +906,36 @@ export function ModelConfigDialog({ .noModelsConfigured }

+ {emptyStateSuggestions.length > + 0 && ( +
+ {emptyStateSuggestions.map( + (modelId) => ( + + ), + )} +
+ )}
) : (
diff --git a/docs/cn/README_CN.md b/docs/cn/README_CN.md index be2e4cb..ec518a6 100644 --- a/docs/cn/README_CN.md +++ b/docs/cn/README_CN.md @@ -204,6 +204,7 @@ npm run dev - Azure OpenAI - Ollama - OpenRouter +- AIHubMix - DeepSeek - SiliconFlow - ModelScope diff --git a/docs/cn/ai-providers.md b/docs/cn/ai-providers.md index f41edbb..8522fdb 100644 --- a/docs/cn/ai-providers.md +++ b/docs/cn/ai-providers.md @@ -46,6 +46,21 @@ AI_MODEL=gpt-4o OPENAI_BASE_URL=https://your-custom-endpoint/v1 ``` +### AIHubMix + +AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。 + +```bash +AIHUBMIX_API_KEY=your_api_key +AI_MODEL=claude-sonnet-4-5-20250929 +``` + +可选的自定义端点: + +```bash +AIHUBMIX_BASE_URL=https://aihubmix.com/v1 +``` + ### Anthropic ```bash @@ -300,7 +315,7 @@ QINIU_BASE_URL=https://your-custom-endpoint 如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`: ```bash -AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu +AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu ``` ## 服务端多模型配置 diff --git a/docs/en/ai-providers.md b/docs/en/ai-providers.md index e0c9957..4e1e702 100644 --- a/docs/en/ai-providers.md +++ b/docs/en/ai-providers.md @@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services): OPENAI_BASE_URL=https://your-custom-endpoint/v1 ``` +### AIHubMix + +AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key. + +```bash +AIHUBMIX_API_KEY=your_api_key +AI_MODEL=claude-sonnet-4-5-20250929 +``` + +Optional custom endpoint: + +```bash +AIHUBMIX_BASE_URL=https://aihubmix.com/v1 +``` + ### Anthropic ```bash @@ -315,7 +330,7 @@ If you only configure **one** provider's API key, the system will automatically If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: ```bash -AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu +AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu ``` ## Server-Side Multi-Model Configuration diff --git a/docs/ja/README_JA.md b/docs/ja/README_JA.md index be2f66f..757e49e 100644 --- a/docs/ja/README_JA.md +++ b/docs/ja/README_JA.md @@ -203,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成 - Azure OpenAI - Ollama - OpenRouter +- AIHubMix - DeepSeek - SiliconFlow - ModelScope diff --git a/docs/ja/ai-providers.md b/docs/ja/ai-providers.md index 0752be0..89eeee3 100644 --- a/docs/ja/ai-providers.md +++ b/docs/ja/ai-providers.md @@ -46,6 +46,21 @@ AI_MODEL=gpt-4o OPENAI_BASE_URL=https://your-custom-endpoint/v1 ``` +### AIHubMix + +AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。 + +```bash +AIHUBMIX_API_KEY=your_api_key +AI_MODEL=claude-sonnet-4-5-20250929 +``` + +任意のカスタムエンドポイント: + +```bash +AIHUBMIX_BASE_URL=https://aihubmix.com/v1 +``` + ### Anthropic ```bash @@ -300,7 +315,7 @@ QINIU_BASE_URL=https://your-custom-endpoint **複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります: ```bash -AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu +AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu ``` ## サーバーサイドマルチモデル設定 diff --git a/env.example b/env.example index 9109d65..d1a98c1 100644 --- a/env.example +++ b/env.example @@ -1,6 +1,6 @@ # AI Provider Configuration # AI_PROVIDER: Which provider to use -# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita +# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita # Default: bedrock AI_PROVIDER=bedrock @@ -69,6 +69,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint +# AIHubMix Configuration +# AIHUBMIX_API_KEY=your-aihubmix-api-key +# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint + # DeepSeek Configuration # DEEPSEEK_API_KEY=sk-... # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index d352ab2..8704f42 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -6,6 +6,7 @@ import { createGateway, gateway } from "@ai-sdk/gateway" import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI, openai } from "@ai-sdk/openai" +import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOllama, ollama } from "ollama-ai-provider-v2" @@ -13,6 +14,8 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" export type { ProviderName } +export const AIHUBMIX_APP_CODE = "MSBS9675" + interface ModelConfig { model: any providerOptions?: any @@ -57,6 +60,18 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): { return { baseURL, isAnthropicCompatible } } +export function isAihubmixStandardBaseURL( + rawUrl: string | null | undefined, +): boolean { + if (!rawUrl) return true + + const baseURL = rawUrl.replace(/\/+$/, "") + return ( + baseURL === "https://aihubmix.com" || + baseURL === "https://aihubmix.com/v1" + ) +} + export interface ClientOverrides { provider?: string | null baseUrl?: string | null @@ -86,6 +101,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ "azure", "bedrock", "openrouter", + "aihubmix", "deepseek", "siliconflow", "sglang", @@ -513,6 +529,7 @@ function buildProviderOptions( case "deepseek": case "openrouter": + case "aihubmix": case "siliconflow": case "sglang": case "gateway": @@ -546,6 +563,7 @@ export const PROVIDER_ENV_VARS: Record = { azure: "AZURE_API_KEY", ollama: null, // No credentials needed for local Ollama openrouter: "OPENROUTER_API_KEY", + aihubmix: "AIHUBMIX_API_KEY", deepseek: "DEEPSEEK_API_KEY", siliconflow: "SILICONFLOW_API_KEY", sglang: "SGLANG_API_KEY", @@ -662,7 +680,7 @@ function validateProviderCredentials( * Get the AI model based on environment variables * * Environment variables: - * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope) + * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope) * - AI_MODEL: The model ID/name for the selected provider * * Provider-specific env vars: @@ -674,6 +692,7 @@ function validateProviderCredentials( * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) * - OPENROUTER_API_KEY: OpenRouter API key + * - AIHUBMIX_API_KEY: AIHubMix API key * - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - SILICONFLOW_API_KEY: SiliconFlow API key @@ -761,6 +780,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` + `- OPENROUTER_API_KEY for OpenRouter\n` + + `- AIHUBMIX_API_KEY for AIHubMix\n` + `- AZURE_API_KEY for Azure\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SGLANG_API_KEY for SGLang\n` + @@ -1003,6 +1023,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { break } + case "aihubmix": { + const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY") + const serverBaseUrl = resolveBaseUrlEnv( + overrides, + "AIHUBMIX_BASE_URL", + ) + const baseURL = resolveBaseURL( + overrides?.apiKey, + overrides?.baseUrl, + serverBaseUrl, + PROVIDER_INFO.aihubmix.defaultBaseUrl, + ) + const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl + + if ( + isAihubmixStandardBaseURL(baseURL) || + baseURL === defaultBaseURL + ) { + const aihubmixProvider = + overrides?.apiKey || apiKey + ? createAihubmix({ + apiKey, + appCode: AIHUBMIX_APP_CODE, + }) + : aihubmix + model = aihubmixProvider(modelId) + } else { + const aihubmixCompatibleProvider = createOpenAI({ + apiKey, + baseURL, + }) + model = aihubmixCompatibleProvider.chat(modelId) + } + break + } + case "deepseek": { const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( @@ -1322,7 +1378,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { overrides?.apiKey, overrides?.baseUrl, resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"), - PROVIDER_INFO["kimi"]?.defaultBaseUrl, + PROVIDER_INFO.kimi?.defaultBaseUrl, ) // Use createDeepSeek to properly handle reasoning_content for Kimi // thinking models (e.g., kimi-k2.6). Kimi's API uses the same @@ -1335,7 +1391,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { default: throw new Error( - `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`, + `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`, ) } diff --git a/lib/aihubmix-models.ts b/lib/aihubmix-models.ts new file mode 100644 index 0000000..f52bae5 --- /dev/null +++ b/lib/aihubmix-models.ts @@ -0,0 +1,79 @@ +export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models" + +const NON_CHAT_MODEL_TYPES = new Set([ + "embedding", + "image_generation", + "rerank", + "transcription", + "tts", + "video", +]) + +type AihubmixModelListPayload = { + data?: unknown +} + +type AihubmixModelRecord = { + model_id?: unknown + types?: unknown +} + +function getModelTypes(types: unknown): Set { + if (typeof types !== "string") { + return new Set() + } + + return new Set( + types + .split(",") + .map((type) => type.trim()) + .filter(Boolean), + ) +} + +function isChatModel(record: AihubmixModelRecord): record is { + model_id: string + types: string +} { + if (typeof record.model_id !== "string" || !record.model_id.trim()) { + return false + } + + const types = getModelTypes(record.types) + if (!types.has("llm")) { + return false + } + + return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type)) +} + +export function extractAihubmixModelIds(payload: unknown): string[] { + const data = (payload as AihubmixModelListPayload)?.data + if (!Array.isArray(data)) { + return [] + } + + const seen = new Set() + const modelIds: string[] = [] + + for (const item of data) { + if (!item || typeof item !== "object") { + continue + } + + const record = item as AihubmixModelRecord + if (!isChatModel(record)) { + continue + } + + const modelId = record.model_id.trim() + if (seen.has(modelId)) { + continue + } + + seen.add(modelId) + modelIds.push(modelId) + } + + return modelIds +} diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index 4b089eb..c715b3f 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -9,6 +9,7 @@ export type ProviderName = | "bedrock" | "ollama" | "openrouter" + | "aihubmix" | "deepseek" | "siliconflow" | "sglang" @@ -102,6 +103,7 @@ export const PROVIDER_LOGO_MAP: Record = { azure: "azure", bedrock: "amazon-bedrock", openrouter: "openrouter", + aihubmix: "aihubmix", deepseek: "deepseek", siliconflow: "siliconflow", sglang: "openai", // SGLang is OpenAI-compatible @@ -145,6 +147,10 @@ export const PROVIDER_INFO: Record< label: "OpenRouter", defaultBaseUrl: "https://openrouter.ai/api/v1", }, + aihubmix: { + label: "AIHubMix", + defaultBaseUrl: "https://aihubmix.com/v1", + }, deepseek: { label: "DeepSeek", defaultBaseUrl: "https://api.deepseek.com/v1", @@ -317,6 +323,41 @@ export const SUGGESTED_MODELS: Partial> = { // MiniMax "minimax/minimax-m3", ], + aihubmix: [ + // Fallback list. The settings UI loads the live model list from AIHubMix when available. + // Anthropic Claude + "claude-fable-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + // OpenAI + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + // Google Gemini + "gemini-3.5-flash", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + // DeepSeek + "deepseek-v4-pro", + "deepseek-v4-flash", + // Qwen + "qwen3.7-max", + "qwen3-coder-next", + // Z.ai + "glm-5.1", + // Moonshot AI + "kimi-k2.6", + // MiniMax + "minimax-m3", + // xAI + "grok-4.3", + // Baidu + "ernie-5.1", + // Mistral + "mistral-large-3", + // Meta + "llama-4-maverick", + ], deepseek: [ "deepseek-v4-pro", "deepseek-v4-flash", diff --git a/package-lock.json b/package-lock.json index f4299e6..b3703ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@ai-sdk/google-vertex": "^4.0.16", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.1", + "@aihubmix/ai-sdk-provider": "^2.1.0", "@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/credential-providers": "^3.943.0", "@extractus/article-extractor": "^8.0.18", @@ -320,6 +321,51 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz", + "integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", @@ -367,6 +413,26 @@ "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, + "node_modules/@aihubmix/ai-sdk-provider": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.1.0.tgz", + "integrity": "sha512-AqK10PV5B4zWFBav5PRUhrWGYTHjC0s6cIbd4v9hBo6D9SqKv5o41B+dkl6IeIPrnZrMGLEE9Mn5R+VaEkhbdg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "^3.0.0", + "@ai-sdk/google": "^3.0.0", + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/openai-compatible": "^2.0.37", + "@ai-sdk/provider": "^3.0.0", + "@ai-sdk/provider-utils": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", diff --git a/package.json b/package.json index ef60bc8..aa8a1fc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@ai-sdk/google-vertex": "^4.0.16", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.1", + "@aihubmix/ai-sdk-provider": "^2.1.0", "@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/credential-providers": "^3.943.0", "@extractus/article-extractor": "^8.0.18", diff --git a/tests/unit/ai-providers.test.ts b/tests/unit/ai-providers.test.ts index 8ae108f..46b4684 100644 --- a/tests/unit/ai-providers.test.ts +++ b/tests/unit/ai-providers.test.ts @@ -1,10 +1,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { getAIModel, + isAihubmixStandardBaseURL, resolveBaseURL, supportsImageInput, supportsPromptCaching, } from "@/lib/ai-providers" +import { extractAihubmixModelIds } from "@/lib/aihubmix-models" + +describe("extractAihubmixModelIds", () => { + it("extracts unique chat model IDs from the AIHubMix model list payload", () => { + const models = extractAihubmixModelIds({ + data: [ + { model_id: "claude-sonnet-4-5-20250929", types: "llm" }, + { model_id: "gpt-5.1", types: "llm" }, + { model_id: "gpt-5.1", types: "llm" }, + { model_id: "gpt-image-2", types: "image_generation,llm" }, + { model_id: "cohere-rerank-v4.0", types: "rerank" }, + { model_id: "", types: "llm" }, + { types: "llm" }, + ], + }) + + expect(models).toEqual(["claude-sonnet-4-5-20250929", "gpt-5.1"]) + }) + + it("returns an empty list for malformed payloads", () => { + expect(extractAihubmixModelIds({ data: null })).toEqual([]) + expect(extractAihubmixModelIds({})).toEqual([]) + expect(extractAihubmixModelIds(null)).toEqual([]) + }) +}) describe("resolveBaseURL", () => { const SERVER_BASE_URL = "https://server-proxy.example.com" @@ -256,6 +282,70 @@ vi.mock("@ai-sdk/deepseek", () => { return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek } }) +vi.mock("@aihubmix/ai-sdk-provider", () => { + const mockModel = { modelId: "test-model" } + const mockProviderFn = vi.fn(() => mockModel) + const mockCreateAihubmix = vi.fn(() => mockProviderFn) + const mockAihubmix = vi.fn(() => mockModel) + return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix } +}) + +describe("AIHubMix provider", () => { + let createAihubmixMock: ReturnType + const savedEnv: Record = {} + + beforeEach(async () => { + savedEnv.AIHUBMIX_API_KEY = process.env.AIHUBMIX_API_KEY + savedEnv.AIHUBMIX_BASE_URL = process.env.AIHUBMIX_BASE_URL + delete process.env.AIHUBMIX_BASE_URL + + const mod = await import("@aihubmix/ai-sdk-provider") + createAihubmixMock = mod.createAihubmix as ReturnType + createAihubmixMock.mockClear() + }) + + afterEach(() => { + process.env.AIHUBMIX_API_KEY = savedEnv.AIHUBMIX_API_KEY + process.env.AIHUBMIX_BASE_URL = savedEnv.AIHUBMIX_BASE_URL + }) + + it("uses AIHUBMIX_API_KEY for server configured AIHubMix", () => { + process.env.AIHUBMIX_API_KEY = "server-aihubmix-key" + + getAIModel({ + provider: "aihubmix", + modelId: "claude-sonnet-4-5-20250929", + }) + + expect(createAihubmixMock).toHaveBeenCalledWith({ + apiKey: "server-aihubmix-key", + appCode: "MSBS9675", + }) + }) + + it("uses client BYOK API key for AIHubMix", () => { + getAIModel({ + provider: "aihubmix", + apiKey: "client-aihubmix-key", + modelId: "gpt-5.1", + }) + + expect(createAihubmixMock).toHaveBeenCalledWith({ + apiKey: "client-aihubmix-key", + appCode: "MSBS9675", + }) + }) + + it("recognizes AIHubMix standard endpoints", () => { + expect(isAihubmixStandardBaseURL(undefined)).toBe(true) + expect(isAihubmixStandardBaseURL("https://aihubmix.com")).toBe(true) + expect(isAihubmixStandardBaseURL("https://aihubmix.com/v1/")).toBe(true) + expect(isAihubmixStandardBaseURL("https://proxy.example.com/v1")).toBe( + false, + ) + }) +}) + describe("Kimi provider uses createDeepSeek for reasoning_content support", () => { let createDeepSeekMock: ReturnType const savedEnv: Record = {}