mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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>
This commit is contained in:
@@ -211,6 +211,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
|
||||
61
app/api/aihubmix-models/route.ts
Normal file
61
app/api/aihubmix-models/route.ts
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Record<ProviderName, string[]>>
|
||||
>({})
|
||||
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
|
||||
Partial<Record<ProviderName, boolean>>
|
||||
>({})
|
||||
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
|
||||
useState<ProviderName | null>(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,11 +845,15 @@ export function ModelConfigDialog({
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isLoadingSuggestedModels ||
|
||||
availableSuggestions.length ===
|
||||
0
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
||||
{isLoadingSuggestedModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<span className="text-xs">
|
||||
{availableSuggestions.length ===
|
||||
0
|
||||
@@ -788,6 +864,7 @@ export function ModelConfigDialog({
|
||||
.modelConfig
|
||||
.suggested}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{availableSuggestions.map(
|
||||
@@ -816,7 +893,12 @@ export function ModelConfigDialog({
|
||||
0 ? (
|
||||
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
<ProviderLogo
|
||||
provider={
|
||||
selectedProvider.provider
|
||||
}
|
||||
className="size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
@@ -824,6 +906,36 @@ export function ModelConfigDialog({
|
||||
.noModelsConfigured
|
||||
}
|
||||
</p>
|
||||
{emptyStateSuggestions.length >
|
||||
0 && (
|
||||
<div className="mt-4 flex max-w-full flex-wrap items-center justify-center gap-2">
|
||||
{emptyStateSuggestions.map(
|
||||
(modelId) => (
|
||||
<Button
|
||||
key={
|
||||
modelId
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 max-w-[220px] rounded-lg px-2 font-mono text-[11px]"
|
||||
onClick={() =>
|
||||
handleAddModel(
|
||||
modelId,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{
|
||||
modelId
|
||||
}
|
||||
</span>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
|
||||
@@ -204,6 +204,7 @@ npm run dev
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -203,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ProviderName, string | null> = {
|
||||
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`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
79
lib/aihubmix-models.ts
Normal file
79
lib/aihubmix-models.ts
Normal file
@@ -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<string> {
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type ProviderName =
|
||||
| "bedrock"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "aihubmix"
|
||||
| "deepseek"
|
||||
| "siliconflow"
|
||||
| "sglang"
|
||||
@@ -102,6 +103,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
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<Record<ProviderName, string[]>> = {
|
||||
// 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",
|
||||
|
||||
66
package-lock.json
generated
66
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
|
||||
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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
|
||||
Reference in New Issue
Block a user