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
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- 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 { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
|
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
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 { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
@@ -153,6 +158,28 @@ export async function POST(req: Request) {
|
|||||||
break
|
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": {
|
case "deepseek": {
|
||||||
if (baseUrl || apiKey) {
|
if (baseUrl || apiKey) {
|
||||||
const ds = createDeepSeek({
|
const ds = createDeepSeek({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||||
@@ -132,6 +133,14 @@ export function ModelConfigDialog({
|
|||||||
modelId: string
|
modelId: string
|
||||||
message: string
|
message: string
|
||||||
} | null>(null)
|
} | 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 {
|
const {
|
||||||
config,
|
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
|
// Get suggested models for current provider
|
||||||
const suggestedModels = selectedProvider
|
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
|
// Filter out already-added models from suggestions
|
||||||
const existingModelIds =
|
const existingModelIds =
|
||||||
@@ -168,6 +235,11 @@ export function ModelConfigDialog({
|
|||||||
const availableSuggestions = suggestedModels.filter(
|
const availableSuggestions = suggestedModels.filter(
|
||||||
(modelId) => !existingModelIds.includes(modelId),
|
(modelId) => !existingModelIds.includes(modelId),
|
||||||
)
|
)
|
||||||
|
const emptyStateSuggestions = selectedProvider
|
||||||
|
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
||||||
|
.filter((modelId) => !existingModelIds.includes(modelId))
|
||||||
|
.slice(0, 4)
|
||||||
|
: []
|
||||||
|
|
||||||
// Handle adding a new provider
|
// Handle adding a new provider
|
||||||
const handleAddProvider = (providerType: ProviderName) => {
|
const handleAddProvider = (providerType: ProviderName) => {
|
||||||
@@ -773,21 +845,26 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
|
isLoadingSuggestedModels ||
|
||||||
availableSuggestions.length ===
|
availableSuggestions.length ===
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
||||||
<span className="text-xs">
|
{isLoadingSuggestedModels ? (
|
||||||
{availableSuggestions.length ===
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
0
|
) : (
|
||||||
? dict
|
<span className="text-xs">
|
||||||
.modelConfig
|
{availableSuggestions.length ===
|
||||||
.allAdded
|
0
|
||||||
: dict
|
? dict
|
||||||
.modelConfig
|
.modelConfig
|
||||||
.suggested}
|
.allAdded
|
||||||
</span>
|
: dict
|
||||||
|
.modelConfig
|
||||||
|
.suggested}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{availableSuggestions.map(
|
{availableSuggestions.map(
|
||||||
@@ -816,7 +893,12 @@ export function ModelConfigDialog({
|
|||||||
0 ? (
|
0 ? (
|
||||||
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
<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">
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{
|
{
|
||||||
@@ -824,6 +906,36 @@ export function ModelConfigDialog({
|
|||||||
.noModelsConfigured
|
.noModelsConfigured
|
||||||
}
|
}
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ npm run dev
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
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
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -300,7 +315,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```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`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```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
|
## Server-Side Multi-Model Configuration
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
- Ollama
|
- Ollama
|
||||||
- OpenRouter
|
- OpenRouter
|
||||||
|
- AIHubMix
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
- ModelScope
|
- ModelScope
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
|
|||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
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
|
### Anthropic
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -300,7 +315,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```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 Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# 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
|
# Default: bedrock
|
||||||
AI_PROVIDER=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_API_KEY=sk-or-v1-...
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
|
# 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 Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# 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 { createGoogleGenerativeAI, google } from "@ai-sdk/google"
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
import { createVertex } from "@ai-sdk/google-vertex"
|
||||||
import { createOpenAI, openai } from "@ai-sdk/openai"
|
import { createOpenAI, openai } from "@ai-sdk/openai"
|
||||||
|
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
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 type { ProviderName }
|
||||||
|
|
||||||
|
export const AIHUBMIX_APP_CODE = "MSBS9675"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
providerOptions?: any
|
providerOptions?: any
|
||||||
@@ -57,6 +60,18 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
|||||||
return { baseURL, isAnthropicCompatible }
|
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 {
|
export interface ClientOverrides {
|
||||||
provider?: string | null
|
provider?: string | null
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
@@ -86,6 +101,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"azure",
|
"azure",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
|
"aihubmix",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
"sglang",
|
"sglang",
|
||||||
@@ -513,6 +529,7 @@ function buildProviderOptions(
|
|||||||
|
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
|
case "aihubmix":
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
@@ -546,6 +563,7 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
azure: "AZURE_API_KEY",
|
azure: "AZURE_API_KEY",
|
||||||
ollama: null, // No credentials needed for local Ollama
|
ollama: null, // No credentials needed for local Ollama
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
|
aihubmix: "AIHUBMIX_API_KEY",
|
||||||
deepseek: "DEEPSEEK_API_KEY",
|
deepseek: "DEEPSEEK_API_KEY",
|
||||||
siliconflow: "SILICONFLOW_API_KEY",
|
siliconflow: "SILICONFLOW_API_KEY",
|
||||||
sglang: "SGLANG_API_KEY",
|
sglang: "SGLANG_API_KEY",
|
||||||
@@ -662,7 +680,7 @@ function validateProviderCredentials(
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* 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
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -674,6 +692,7 @@ function validateProviderCredentials(
|
|||||||
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
* - 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)
|
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
||||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||||
|
* - AIHUBMIX_API_KEY: AIHubMix API key
|
||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
@@ -761,6 +780,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
|
||||||
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
`- AWS_ACCESS_KEY_ID for Bedrock\n` +
|
||||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||||
|
`- AIHUBMIX_API_KEY for AIHubMix\n` +
|
||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
@@ -1003,6 +1023,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
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": {
|
case "deepseek": {
|
||||||
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
|
||||||
const serverBaseUrl = resolveBaseUrlEnv(
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
@@ -1322,7 +1378,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
overrides?.apiKey,
|
overrides?.apiKey,
|
||||||
overrides?.baseUrl,
|
overrides?.baseUrl,
|
||||||
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
|
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
|
||||||
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
|
PROVIDER_INFO.kimi?.defaultBaseUrl,
|
||||||
)
|
)
|
||||||
// Use createDeepSeek to properly handle reasoning_content for Kimi
|
// Use createDeepSeek to properly handle reasoning_content for Kimi
|
||||||
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
|
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
|
||||||
@@ -1335,7 +1391,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
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"
|
| "bedrock"
|
||||||
| "ollama"
|
| "ollama"
|
||||||
| "openrouter"
|
| "openrouter"
|
||||||
|
| "aihubmix"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
| "sglang"
|
| "sglang"
|
||||||
@@ -102,6 +103,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
azure: "azure",
|
azure: "azure",
|
||||||
bedrock: "amazon-bedrock",
|
bedrock: "amazon-bedrock",
|
||||||
openrouter: "openrouter",
|
openrouter: "openrouter",
|
||||||
|
aihubmix: "aihubmix",
|
||||||
deepseek: "deepseek",
|
deepseek: "deepseek",
|
||||||
siliconflow: "siliconflow",
|
siliconflow: "siliconflow",
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible
|
sglang: "openai", // SGLang is OpenAI-compatible
|
||||||
@@ -145,6 +147,10 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||||
},
|
},
|
||||||
|
aihubmix: {
|
||||||
|
label: "AIHubMix",
|
||||||
|
defaultBaseUrl: "https://aihubmix.com/v1",
|
||||||
|
},
|
||||||
deepseek: {
|
deepseek: {
|
||||||
label: "DeepSeek",
|
label: "DeepSeek",
|
||||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||||
@@ -317,6 +323,41 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
// MiniMax
|
// MiniMax
|
||||||
"minimax/minimax-m3",
|
"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: [
|
||||||
"deepseek-v4-pro",
|
"deepseek-v4-pro",
|
||||||
"deepseek-v4-flash",
|
"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/google-vertex": "^4.0.16",
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
|
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
@@ -320,6 +321,51 @@
|
|||||||
"zod": "^3.25.76 || ^4.1.8"
|
"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": {
|
"node_modules/@ai-sdk/provider": {
|
||||||
"version": "3.0.8",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
"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"
|
"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": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"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/google-vertex": "^4.0.16",
|
||||||
"@ai-sdk/openai": "^3.0.0",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
|
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
|
isAihubmixStandardBaseURL,
|
||||||
resolveBaseURL,
|
resolveBaseURL,
|
||||||
supportsImageInput,
|
supportsImageInput,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} 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", () => {
|
describe("resolveBaseURL", () => {
|
||||||
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
const SERVER_BASE_URL = "https://server-proxy.example.com"
|
||||||
@@ -256,6 +282,70 @@ vi.mock("@ai-sdk/deepseek", () => {
|
|||||||
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
|
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", () => {
|
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
|
||||||
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
let createDeepSeekMock: ReturnType<typeof vi.fn>
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
const savedEnv: Record<string, string | undefined> = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user