mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 01:07:56 +08:00
feat(settings): fetch the model list from any provider and flag models that cannot draw
The "Fetch models" button asks the provider for its models (OpenAI-style /models, Anthropic, Google, Ollama, OpenRouter, Vercel Gateway, AIHubMix) and shows them in a searchable picker. This replaces the route that only worked for AIHubMix. A snapshot of models.dev (MIT) says which models support tool calls. Models without them get a "no tool calls" badge in the picker and a hint in the model list, since drawing needs tool calls. Refresh the snapshot with scripts/update-model-catalog.mjs.
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { canListModels, listProviderModels } from "@/lib/provider-models"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
// Public lists need no key
|
||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
])
|
||||
|
||||
/**
|
||||
* The models a provider offers, for the "Fetch models" button in model
|
||||
* settings. Answers { models: null } for providers that cannot list them,
|
||||
* so the dialog keeps its suggested models.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
// Sends requests to a URL the client chose, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
||||
provider: ProviderName
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}
|
||||
if (!canListModels(provider)) {
|
||||
return NextResponse.json({ models: null })
|
||||
}
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||
}
|
||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await listProviderModels(
|
||||
provider,
|
||||
{ apiKey, baseUrl },
|
||||
(baseUrl && redirectGuardedFetch()) || fetch,
|
||||
)
|
||||
return NextResponse.json({ models })
|
||||
} catch (error) {
|
||||
console.warn("[provider-models] Listing failed:", error)
|
||||
const { code, message } = classifyLLMError(error)
|
||||
return NextResponse.json({ code, error: message })
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -7,7 +7,12 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public", "!packages/mcp-server/src/preview"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
+215
-107
@@ -9,6 +9,7 @@ import {
|
||||
Key,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
@@ -33,6 +34,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -42,6 +50,11 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
@@ -55,6 +68,7 @@ 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 { ListedModel } from "@/lib/provider-models"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type {
|
||||
ModelConfig,
|
||||
@@ -146,14 +160,17 @@ export function ModelConfigDialog({
|
||||
// Bumped on every credential edit so a running test can tell that its
|
||||
// results belong to the old credentials
|
||||
const credentialsVersionRef = useRef(0)
|
||||
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
|
||||
Partial<Record<ProviderName, string[]>>
|
||||
// Models fetched from the provider, per provider config
|
||||
const [fetchedModels, setFetchedModels] = useState<
|
||||
Record<string, ListedModel[]>
|
||||
>({})
|
||||
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
|
||||
Partial<Record<ProviderName, boolean>>
|
||||
>({})
|
||||
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
|
||||
useState<ProviderName | null>(null)
|
||||
const [fetchingModels, setFetchingModels] = useState(false)
|
||||
const [fetchModelsError, setFetchModelsError] = useState("")
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false)
|
||||
// models.dev data for hints, loaded with the dialog (it is ~180 KB)
|
||||
const [getModelInfo, setGetModelInfo] = useState<
|
||||
typeof import("@/lib/model-catalog").getModelInfo | null
|
||||
>(null)
|
||||
|
||||
const {
|
||||
config,
|
||||
@@ -185,73 +202,74 @@ export function ModelConfigDialog({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
selectedProvider?.provider !== "aihubmix" ||
|
||||
loadedSuggestedProviders.aihubmix
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!open || getModelInfo) return
|
||||
import("@/lib/model-catalog").then((catalog) =>
|
||||
setGetModelInfo(() => catalog.getModelInfo),
|
||||
)
|
||||
}, [open, getModelInfo])
|
||||
|
||||
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) => ({
|
||||
const handleFetchModels = async () => {
|
||||
if (!selectedProvider) return
|
||||
const providerId = selectedProvider.id
|
||||
setFetchingModels(true)
|
||||
setFetchModelsError("")
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/provider-models"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: selectedProvider.provider,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
baseUrl: selectedProvider.baseUrl,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (Array.isArray(data.models)) {
|
||||
setFetchedModels((current) => ({
|
||||
...current,
|
||||
aihubmix: true,
|
||||
[providerId]: data.models,
|
||||
}))
|
||||
setLoadingSuggestedProvider(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
setModelPickerOpen(true)
|
||||
} else {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
setFetchModelsError(
|
||||
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
||||
`Request failed (${response.status})`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setFetchModelsError(dict.errors.networkError)
|
||||
} finally {
|
||||
setFetchingModels(false)
|
||||
}
|
||||
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
|
||||
}
|
||||
|
||||
// Get suggested models for current provider
|
||||
const suggestedModels = selectedProvider
|
||||
? dynamicSuggestedModels[selectedProvider.provider] ||
|
||||
SUGGESTED_MODELS[selectedProvider.provider] ||
|
||||
[]
|
||||
// The provider's own list once fetched, else the suggested models
|
||||
const suggestedModels: ListedModel[] = selectedProvider
|
||||
? fetchedModels[selectedProvider.id] ||
|
||||
(SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
|
||||
id,
|
||||
}))
|
||||
: []
|
||||
const isLoadingSuggestedModels =
|
||||
selectedProvider?.provider === loadingSuggestedProvider
|
||||
// Tool calls are what drawing needs: false when known to be missing
|
||||
const supportsTools = (model: ListedModel) =>
|
||||
selectedProvider
|
||||
? (model.tools ??
|
||||
getModelInfo?.(selectedProvider.provider, model.id)?.tools)
|
||||
: undefined
|
||||
|
||||
// Filter out already-added models from suggestions
|
||||
const existingModelIds =
|
||||
selectedProvider?.models.map((m) => m.modelId) || []
|
||||
const availableSuggestions = suggestedModels.filter(
|
||||
(modelId) => !existingModelIds.includes(modelId),
|
||||
(model) => !existingModelIds.includes(model.id),
|
||||
)
|
||||
const emptyStateSuggestions = selectedProvider
|
||||
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
||||
@@ -286,6 +304,8 @@ export function ModelConfigDialog({
|
||||
credentialsVersionRef.current++
|
||||
setValidationStatus("idle")
|
||||
setValidatingModelIds(new Set())
|
||||
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
|
||||
setFetchModelsError("")
|
||||
updates.validated = false
|
||||
updates.models = selectedProvider.models.map((m) => ({
|
||||
...m,
|
||||
@@ -908,58 +928,132 @@ export function ModelConfigDialog({
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleAddModel(
|
||||
value,
|
||||
)
|
||||
{PROVIDER_INFO[
|
||||
selectedProvider.provider
|
||||
].modelList && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg"
|
||||
onClick={
|
||||
handleFetchModels
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isLoadingSuggestedModels ||
|
||||
availableSuggestions.length ===
|
||||
0
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
||||
{isLoadingSuggestedModels ? (
|
||||
disabled={
|
||||
fetchingModels
|
||||
}
|
||||
title={
|
||||
dict.modelConfig
|
||||
.fetchModels
|
||||
}
|
||||
aria-label={
|
||||
dict.modelConfig
|
||||
.fetchModels
|
||||
}
|
||||
>
|
||||
{fetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<span className="text-xs">
|
||||
{availableSuggestions.length ===
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Popover
|
||||
open={modelPickerOpen}
|
||||
onOpenChange={
|
||||
setModelPickerOpen
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-28 h-8 rounded-lg text-xs"
|
||||
disabled={
|
||||
availableSuggestions.length ===
|
||||
0
|
||||
? dict
|
||||
.modelConfig
|
||||
.allAdded
|
||||
: dict
|
||||
.modelConfig
|
||||
.suggested}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{availableSuggestions.map(
|
||||
(modelId) => (
|
||||
<SelectItem
|
||||
key={
|
||||
modelId
|
||||
}
|
||||
>
|
||||
{availableSuggestions.length ===
|
||||
0
|
||||
? dict
|
||||
.modelConfig
|
||||
.allAdded
|
||||
: dict
|
||||
.modelConfig
|
||||
.suggested}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0"
|
||||
align="end"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={
|
||||
dict
|
||||
.modelConfig
|
||||
.searchModels
|
||||
}
|
||||
/>
|
||||
<CommandList className="max-h-72">
|
||||
<CommandEmpty>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.noModelsFound
|
||||
}
|
||||
value={
|
||||
modelId
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{modelId}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CommandEmpty>
|
||||
{availableSuggestions.map(
|
||||
(model) => (
|
||||
<CommandItem
|
||||
key={
|
||||
model.id
|
||||
}
|
||||
value={
|
||||
model.id
|
||||
}
|
||||
onSelect={() => {
|
||||
handleAddModel(
|
||||
model.id,
|
||||
)
|
||||
setModelPickerOpen(
|
||||
false,
|
||||
)
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{
|
||||
model.id
|
||||
}
|
||||
</span>
|
||||
{supportsTools(
|
||||
model,
|
||||
) ===
|
||||
false && (
|
||||
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.noTools
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
),
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fetchModelsError && (
|
||||
<p className="mb-2 text-xs text-destructive">
|
||||
{fetchModelsError}
|
||||
</p>
|
||||
)}
|
||||
{/* Model List */}
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
||||
{selectedProvider.models.length ===
|
||||
@@ -1246,6 +1340,20 @@ export function ModelConfigDialog({
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{!model.validationWarning &&
|
||||
getModelInfo?.(
|
||||
selectedProvider.provider,
|
||||
model.modelId,
|
||||
)?.tools ===
|
||||
false && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.mayNotDraw
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{model.validated &&
|
||||
model.validationWarning && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -385,6 +385,9 @@
|
||||
"baseUrl": "Base URL",
|
||||
"optional": "(optional)",
|
||||
"getApiKey": "Get API key",
|
||||
"fetchModels": "Fetch models from the provider",
|
||||
"noTools": "no tool calls",
|
||||
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
|
||||
"requestUrl": "Requests go to {url}",
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
|
||||
@@ -339,6 +339,9 @@
|
||||
"baseUrl": "ベース URL",
|
||||
"optional": "(オプション)",
|
||||
"getApiKey": "API キーを取得",
|
||||
"fetchModels": "プロバイダーからモデル一覧を取得",
|
||||
"noTools": "ツール呼び出し非対応",
|
||||
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
|
||||
@@ -385,6 +385,9 @@
|
||||
"baseUrl": "基礎 URL",
|
||||
"optional": "(可選)",
|
||||
"getApiKey": "取得 API Key",
|
||||
"fetchModels": "從服務商取得模型清單",
|
||||
"noTools": "不支援工具呼叫",
|
||||
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
|
||||
"requestUrl": "請求將傳送至 {url}",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
|
||||
@@ -385,6 +385,9 @@
|
||||
"baseUrl": "基础 URL",
|
||||
"optional": "(可选)",
|
||||
"getApiKey": "获取 API Key",
|
||||
"fetchModels": "从服务商获取模型列表",
|
||||
"noTools": "不支持工具调用",
|
||||
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
|
||||
"requestUrl": "请求将发往 {url}",
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import catalog from "@/lib/model-catalog.json"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
/**
|
||||
* What models.dev knows about a model (scripts/update-model-catalog.mjs).
|
||||
* Only used for hints: requests are sent the same way either way, since
|
||||
* the data can be wrong or out of date.
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
tools: boolean
|
||||
images: boolean
|
||||
reasoning: boolean
|
||||
context?: number
|
||||
output?: number
|
||||
}
|
||||
|
||||
const CATALOG = catalog as Record<string, Record<string, ModelInfo>>
|
||||
|
||||
/**
|
||||
* The entry for a model: an exact match ignoring case, else the longest id
|
||||
* the model id starts with, followed by "-", ":" or ".". So
|
||||
* claude-sonnet-4-5-20250929 finds claude-sonnet-4-5, but gpt-4 does not
|
||||
* find gpt-4o.
|
||||
*/
|
||||
export function getModelInfo(
|
||||
provider: ProviderName,
|
||||
modelId: string,
|
||||
): ModelInfo | undefined {
|
||||
const models = CATALOG[provider]
|
||||
if (!models) return undefined
|
||||
const wanted = modelId.trim().toLowerCase()
|
||||
let best: string | undefined
|
||||
for (const id of Object.keys(models)) {
|
||||
const lower = id.toLowerCase()
|
||||
if (lower === wanted) return models[id]
|
||||
if (
|
||||
wanted.startsWith(lower) &&
|
||||
"-:.".includes(wanted[lower.length]) &&
|
||||
lower.length > (best?.length ?? 0)
|
||||
) {
|
||||
best = id
|
||||
}
|
||||
}
|
||||
return best ? models[best] : undefined
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createGateway } from "ai"
|
||||
import { getModelInfo } from "@/lib/model-catalog"
|
||||
import {
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
/** A model a provider offers. tools is false when it cannot call tools. */
|
||||
export interface ListedModel {
|
||||
id: string
|
||||
tools?: boolean
|
||||
}
|
||||
|
||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
export function canListModels(provider: ProviderName): boolean {
|
||||
return (
|
||||
Object.hasOwn(PROVIDER_INFO, provider) &&
|
||||
!!PROVIDER_INFO[provider].modelList
|
||||
)
|
||||
}
|
||||
|
||||
// Models in OpenAI-style lists that are not for chat
|
||||
const NON_CHAT =
|
||||
/(?:^|[-/_])(?:embed(?:ding)?s?|whisper|tts|transcribe|dall-e|moderation|rerank|realtime|sora)(?:$|[-/_])|gpt-image/i
|
||||
|
||||
const NON_CHAT_AIHUBMIX_TYPES = new Set([
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"rerank",
|
||||
"transcription",
|
||||
"tts",
|
||||
"video",
|
||||
])
|
||||
|
||||
/** Chat model ids from AIHubMix's public model list */
|
||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
||||
const data = (payload as { data?: unknown })?.data
|
||||
if (!Array.isArray(data)) return []
|
||||
const ids = new Set<string>()
|
||||
for (const item of data) {
|
||||
const record = item as { model_id?: unknown; types?: unknown }
|
||||
if (typeof record?.model_id !== "string" || !record.model_id.trim()) {
|
||||
continue
|
||||
}
|
||||
const types = new Set(
|
||||
typeof record.types === "string"
|
||||
? record.types.split(",").map((t) => t.trim())
|
||||
: [],
|
||||
)
|
||||
if (!types.has("llm")) continue
|
||||
if ([...NON_CHAT_AIHUBMIX_TYPES].some((t) => types.has(t))) continue
|
||||
ids.add(record.model_id.trim())
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
/** GET a JSON list; a failed request carries its status for the error hint */
|
||||
async function getJson(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
fetchFn: typeof fetch,
|
||||
): Promise<any> {
|
||||
const response = await fetchFn(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(
|
||||
new Error(`The model list request failed (${response.status})`),
|
||||
{ statusCode: response.status },
|
||||
)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's chat models, with tool support from the provider's own
|
||||
* data or else models.dev. Only the client's key is used, so the server's
|
||||
* keys never go to a URL the client chose.
|
||||
*/
|
||||
export async function listProviderModels(
|
||||
provider: ProviderName,
|
||||
{ apiKey, baseUrl }: { apiKey?: string; baseUrl?: string },
|
||||
fetchFn: typeof fetch = fetch,
|
||||
): Promise<ListedModel[]> {
|
||||
const base = normalizeBaseUrl(
|
||||
baseUrl || PROVIDER_INFO[provider].defaultBaseUrl || "",
|
||||
)
|
||||
const bearer: Record<string, string> = apiKey
|
||||
? { Authorization: `Bearer ${apiKey}` }
|
||||
: {}
|
||||
let models: ListedModel[]
|
||||
|
||||
// AIHubMix has a public list, unless the user points to another
|
||||
// endpoint, which is OpenAI-compatible
|
||||
const style =
|
||||
provider === "aihubmix" &&
|
||||
baseUrl &&
|
||||
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
|
||||
? "openai"
|
||||
: PROVIDER_INFO[provider].modelList
|
||||
|
||||
switch (style) {
|
||||
case "anthropic": {
|
||||
const data = await getJson(
|
||||
`${base}/models?limit=1000`,
|
||||
{
|
||||
"x-api-key": apiKey ?? "",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
fetchFn,
|
||||
)
|
||||
models = (data.data ?? []).map((m: { id: string }) => ({
|
||||
id: m.id,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "google": {
|
||||
// The key goes in a header: in the URL it would end up in logs
|
||||
const data = await getJson(
|
||||
`${base}/models?pageSize=1000`,
|
||||
{ "x-goog-api-key": apiKey ?? "" },
|
||||
fetchFn,
|
||||
)
|
||||
models = (data.models ?? [])
|
||||
.filter((m: { supportedGenerationMethods?: string[] }) =>
|
||||
m.supportedGenerationMethods?.includes("generateContent"),
|
||||
)
|
||||
.map((m: { name: string }) => ({
|
||||
id: m.name.replace(/^models\//, ""),
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "ollama": {
|
||||
const api = base.endsWith("/api") ? base : `${base}/api`
|
||||
const data = await getJson(`${api}/tags`, bearer, fetchFn)
|
||||
models = (data.models ?? []).map((m: { name: string }) => ({
|
||||
id: m.name,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "openrouter": {
|
||||
const data = await getJson(`${base}/models`, bearer, fetchFn)
|
||||
models = (data.data ?? []).map(
|
||||
(m: { id: string; supported_parameters?: string[] }) => ({
|
||||
id: m.id,
|
||||
...(m.supported_parameters && {
|
||||
tools: m.supported_parameters.includes("tools"),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "gateway": {
|
||||
const { models: entries } = await createGateway({
|
||||
...(apiKey && { apiKey }),
|
||||
...(baseUrl && { baseURL: base }),
|
||||
fetch: fetchFn,
|
||||
}).getAvailableModels()
|
||||
models = entries
|
||||
.filter((m) => !m.modelType || m.modelType === "language")
|
||||
.map((m) => ({ id: m.id }))
|
||||
break
|
||||
}
|
||||
case "aihubmix": {
|
||||
const data = await getJson(AIHUBMIX_MODELS_ENDPOINT, {}, fetchFn)
|
||||
models = extractAihubmixModelIds(data).map((id) => ({ id }))
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const data = await getJson(`${base}/models`, bearer, fetchFn)
|
||||
models = (data.data ?? [])
|
||||
.map((m: { id: string }) => ({ id: m.id }))
|
||||
.filter((m: ListedModel) => !NON_CHAT.test(m.id))
|
||||
}
|
||||
}
|
||||
|
||||
return models.map((m) => ({
|
||||
...m,
|
||||
tools: m.tools ?? getModelInfo(provider, m.id)?.tools,
|
||||
}))
|
||||
}
|
||||
@@ -122,25 +122,45 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
atlascloud: "openai",
|
||||
}
|
||||
|
||||
/** How a provider lists its models (see lib/provider-models.ts) */
|
||||
export type ModelListStyle =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "aihubmix"
|
||||
| "gateway"
|
||||
|
||||
// Provider metadata. apiKeyUrl is the page where users create a key.
|
||||
// modelList is missing where a key alone cannot list the models (Bedrock,
|
||||
// Vertex, Azure) or the list is not reliable (Doubao, MiniMax).
|
||||
export const PROVIDER_INFO: Record<
|
||||
ProviderName,
|
||||
{ label: string; defaultBaseUrl?: string; apiKeyUrl?: string }
|
||||
{
|
||||
label: string
|
||||
defaultBaseUrl?: string
|
||||
apiKeyUrl?: string
|
||||
modelList?: ModelListStyle
|
||||
}
|
||||
> = {
|
||||
openai: {
|
||||
label: "OpenAI",
|
||||
defaultBaseUrl: "https://api.openai.com/v1",
|
||||
apiKeyUrl: "https://platform.openai.com/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||
apiKeyUrl: "https://platform.claude.com/settings/keys",
|
||||
modelList: "anthropic",
|
||||
},
|
||||
google: {
|
||||
label: "Google",
|
||||
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
apiKeyUrl: "https://aistudio.google.com/apikey",
|
||||
modelList: "google",
|
||||
},
|
||||
vertexai: { label: "Google Vertex AI" },
|
||||
azure: {
|
||||
@@ -152,35 +172,42 @@ export const PROVIDER_INFO: Record<
|
||||
label: "Ollama",
|
||||
defaultBaseUrl: "https://ollama.com/api",
|
||||
apiKeyUrl: "https://ollama.com/settings/keys",
|
||||
modelList: "ollama",
|
||||
},
|
||||
openrouter: {
|
||||
label: "OpenRouter",
|
||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKeyUrl: "https://openrouter.ai/keys",
|
||||
modelList: "openrouter",
|
||||
},
|
||||
aihubmix: {
|
||||
label: "AIHubMix",
|
||||
defaultBaseUrl: "https://aihubmix.com/v1",
|
||||
apiKeyUrl: "https://aihubmix.com/token",
|
||||
modelList: "aihubmix",
|
||||
},
|
||||
deepseek: {
|
||||
label: "DeepSeek",
|
||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||
apiKeyUrl: "https://platform.deepseek.com/api_keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
siliconflow: {
|
||||
label: "SiliconFlow",
|
||||
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
|
||||
modelList: "openai",
|
||||
},
|
||||
sglang: {
|
||||
label: "SGLang",
|
||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||
modelList: "openai",
|
||||
},
|
||||
gateway: {
|
||||
label: "AI Gateway",
|
||||
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
||||
apiKeyUrl: "https://vercel.com/ai-gateway",
|
||||
modelList: "gateway",
|
||||
},
|
||||
edgeone: { label: "EdgeOne Pages" },
|
||||
doubao: {
|
||||
@@ -193,26 +220,31 @@ export const PROVIDER_INFO: Record<
|
||||
label: "ModelScope",
|
||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
|
||||
modelList: "openai",
|
||||
},
|
||||
glm: {
|
||||
label: "GLM (Zhipu)",
|
||||
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
||||
modelList: "openai",
|
||||
},
|
||||
qwen: {
|
||||
label: "Qwen (Alibaba)",
|
||||
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
|
||||
modelList: "openai",
|
||||
},
|
||||
qiniu: {
|
||||
label: "Qiniu",
|
||||
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||
apiKeyUrl: "https://www.qiniu.com/ai/models",
|
||||
modelList: "openai",
|
||||
},
|
||||
kimi: {
|
||||
label: "Kimi (Moonshot)",
|
||||
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
minimax: {
|
||||
label: "MiniMax",
|
||||
@@ -224,16 +256,19 @@ export const PROVIDER_INFO: Record<
|
||||
label: "Novita AI",
|
||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||
apiKeyUrl: "https://novita.ai/dashboard/key",
|
||||
modelList: "openai",
|
||||
},
|
||||
mimo: {
|
||||
label: "MiMo (Xiaomi)",
|
||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
atlascloud: {
|
||||
label: "Atlas Cloud",
|
||||
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
||||
apiKeyUrl: "https://www.atlascloud.ai/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Refresh lib/model-catalog.json from models.dev (MIT licensed model data):
|
||||
// whether each model can call tools and read images, and its limits. The
|
||||
// app only uses it for hints in model settings. Run before a release:
|
||||
// node scripts/update-model-catalog.mjs
|
||||
import { writeFileSync } from "node:fs"
|
||||
|
||||
// Our provider id -> models.dev provider id
|
||||
const PROVIDERS = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
google: "google",
|
||||
vertexai: "google-vertex",
|
||||
azure: "azure",
|
||||
bedrock: "amazon-bedrock",
|
||||
ollama: "ollama-cloud",
|
||||
openrouter: "openrouter",
|
||||
aihubmix: "aihubmix",
|
||||
deepseek: "deepseek",
|
||||
siliconflow: "siliconflow-cn",
|
||||
gateway: "vercel",
|
||||
doubao: "volcengine",
|
||||
modelscope: "modelscope",
|
||||
glm: "zhipuai",
|
||||
qwen: "alibaba-cn",
|
||||
qiniu: "qiniu-ai",
|
||||
kimi: "moonshotai-cn",
|
||||
minimax: "minimax-cn",
|
||||
novita: "novita-ai",
|
||||
mimo: "xiaomi",
|
||||
}
|
||||
|
||||
const response = await fetch("https://models.dev/api.json")
|
||||
if (!response.ok) throw new Error(`models.dev answered ${response.status}`)
|
||||
const data = await response.json()
|
||||
|
||||
const catalog = {}
|
||||
for (const [provider, source] of Object.entries(PROVIDERS)) {
|
||||
const models = data[source]?.models
|
||||
if (!models) {
|
||||
console.warn(`models.dev has no provider ${source}`)
|
||||
continue
|
||||
}
|
||||
catalog[provider] = {}
|
||||
for (const [id, m] of Object.entries(models).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
// Chat models only, and none that are on their way out
|
||||
if (!m.modalities?.output?.includes("text")) continue
|
||||
if (m.status === "deprecated") continue
|
||||
catalog[provider][id] = {
|
||||
tools: m.tool_call === true,
|
||||
images: m.modalities?.input?.includes("image") === true,
|
||||
reasoning: m.reasoning === true,
|
||||
...(m.limit?.context && { context: m.limit.context }),
|
||||
...(m.limit?.output && { output: m.limit.output }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One model per line, so a refresh shows up as a readable diff
|
||||
const lines = ["{"]
|
||||
const providers = Object.entries(catalog)
|
||||
providers.forEach(([provider, models], p) => {
|
||||
lines.push(` ${JSON.stringify(provider)}: {`)
|
||||
const entries = Object.entries(models)
|
||||
entries.forEach(([id, info], i) => {
|
||||
const comma = i < entries.length - 1 ? "," : ""
|
||||
lines.push(` ${JSON.stringify(id)}: ${JSON.stringify(info)}${comma}`)
|
||||
})
|
||||
lines.push(` }${p < providers.length - 1 ? "," : ""}`)
|
||||
})
|
||||
lines.push("}")
|
||||
writeFileSync(
|
||||
new URL("../lib/model-catalog.json", import.meta.url),
|
||||
`${lines.join("\n")}\n`,
|
||||
)
|
||||
const count = providers.reduce((n, [, m]) => n + Object.keys(m).length, 0)
|
||||
console.log(`Wrote ${count} models for ${providers.length} providers`)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { getIframe } from "./lib/fixtures"
|
||||
|
||||
// qwen-mt-plus is a translation model; models.dev lists no tool calls for it
|
||||
const CONFIG = {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "p1",
|
||||
provider: "qwen",
|
||||
apiKey: "test-key",
|
||||
models: [{ id: "m1", modelId: "qwen-mt-plus" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function openQwenSettings(page: Page) {
|
||||
await page.addInitScript((config) => {
|
||||
localStorage.setItem(
|
||||
"next-ai-draw-io-model-configs",
|
||||
JSON.stringify(config),
|
||||
)
|
||||
}, CONFIG)
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.locator("button:has(svg.lucide-bot)").first().click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
return dialog
|
||||
}
|
||||
|
||||
test("fetches the provider's models and adds one from the picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
let request: Record<string, unknown> | undefined
|
||||
await page.route("**/api/provider-models", async (route) => {
|
||||
request = route.request().postDataJSON()
|
||||
await route.fulfill({
|
||||
json: {
|
||||
models: [
|
||||
{ id: "qwen-new-max", tools: true },
|
||||
{ id: "qwen-text-only", tools: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
const dialog = await openQwenSettings(page)
|
||||
|
||||
await expect(
|
||||
dialog.getByText("may not be able to draw").first(),
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
const picker = page.locator('[role="listbox"]')
|
||||
await expect(picker.getByText("qwen-new-max")).toBeVisible()
|
||||
await expect(
|
||||
picker.getByRole("option", { name: /qwen-text-only/ }),
|
||||
).toContainText("no tool calls")
|
||||
expect(request).toMatchObject({ provider: "qwen", apiKey: "test-key" })
|
||||
|
||||
await page.getByPlaceholder("Search models...").fill("new-max")
|
||||
await picker.getByText("qwen-new-max").click()
|
||||
await expect(dialog.locator('input[title="qwen-new-max"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows a hint when the provider rejects the key", async ({ page }) => {
|
||||
await page.route("**/api/provider-models", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
json: { code: "invalid_api_key", error: "Incorrect API key" },
|
||||
}),
|
||||
)
|
||||
const dialog = await openQwenSettings(page)
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"The provider rejected the API key. Check it in model settings. Incorrect API key",
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -5,31 +5,6 @@ import {
|
||||
resolveBaseURL,
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getModelInfo } from "@/lib/model-catalog"
|
||||
import catalog from "@/lib/model-catalog.json"
|
||||
|
||||
describe("getModelInfo", () => {
|
||||
it("finds a model by its exact id, ignoring case", () => {
|
||||
const id = Object.keys(catalog.openai).find((m) => m === "gpt-4.1")
|
||||
expect(id).toBe("gpt-4.1")
|
||||
expect(getModelInfo("openai", "GPT-4.1")).toEqual(
|
||||
catalog.openai["gpt-4.1"],
|
||||
)
|
||||
})
|
||||
|
||||
it("finds a dated or tagged variant by the longest id it starts with", () => {
|
||||
expect(getModelInfo("openai", "gpt-4.1-2025-04-14")).toEqual(
|
||||
catalog.openai["gpt-4.1"],
|
||||
)
|
||||
})
|
||||
|
||||
it("does not match a different model that shares a prefix", () => {
|
||||
// gpt-4.1-mini is its own entry, not gpt-4.1
|
||||
expect(getModelInfo("openai", "gpt-4.1-mini")).toEqual(
|
||||
catalog.openai["gpt-4.1-mini"],
|
||||
)
|
||||
expect(getModelInfo("openai", "gpt-4.1x")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("knows nothing about providers models.dev does not list", () => {
|
||||
expect(getModelInfo("sglang", "anything")).toBeUndefined()
|
||||
expect(getModelInfo("openai", "not-a-model")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { POST as providerModels } from "@/app/api/provider-models/route"
|
||||
import {
|
||||
canListModels,
|
||||
extractAihubmixModelIds,
|
||||
listProviderModels,
|
||||
} from "@/lib/provider-models"
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** A fetch that answers with this JSON and records the request */
|
||||
function answer(json: unknown, status = 200) {
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = []
|
||||
const fn = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, headers: (init?.headers ?? {}) as any })
|
||||
return new Response(JSON.stringify(json), { status })
|
||||
}) as unknown as typeof fetch
|
||||
return { fn, calls }
|
||||
}
|
||||
|
||||
describe("listProviderModels", () => {
|
||||
it("reads an OpenAI-style list and drops models that are not for chat", async () => {
|
||||
const { fn, calls } = answer({
|
||||
data: [
|
||||
{ id: "gpt-4.1" },
|
||||
{ id: "text-embedding-3-small" },
|
||||
{ id: "whisper-1" },
|
||||
{ id: "gpt-image-1" },
|
||||
],
|
||||
})
|
||||
const models = await listProviderModels("openai", { apiKey: "k" }, fn)
|
||||
expect(models.map((m) => m.id)).toEqual(["gpt-4.1"])
|
||||
// Tool support comes from models.dev when the list has none
|
||||
expect(models[0].tools).toBe(true)
|
||||
expect(calls[0].url).toBe("https://api.openai.com/v1/models")
|
||||
expect(calls[0].headers.Authorization).toBe("Bearer k")
|
||||
})
|
||||
|
||||
it("uses the base URL the user gave, without a pasted path", async () => {
|
||||
const { fn, calls } = answer({ data: [{ id: "m" }] })
|
||||
await listProviderModels(
|
||||
"glm",
|
||||
{
|
||||
apiKey: "k",
|
||||
baseUrl: "https://proxy.example.com/v4/chat/completions",
|
||||
},
|
||||
fn,
|
||||
)
|
||||
expect(calls[0].url).toBe("https://proxy.example.com/v4/models")
|
||||
})
|
||||
|
||||
it("asks Anthropic with its own headers", async () => {
|
||||
const { fn, calls } = answer({ data: [{ id: "claude-sonnet-4-5" }] })
|
||||
await listProviderModels("anthropic", { apiKey: "k" }, fn)
|
||||
expect(calls[0].url).toBe(
|
||||
"https://api.anthropic.com/v1/models?limit=1000",
|
||||
)
|
||||
expect(calls[0].headers["x-api-key"]).toBe("k")
|
||||
})
|
||||
|
||||
it("keeps Gemini models that generate content, without models/", async () => {
|
||||
const { fn, calls } = answer({
|
||||
models: [
|
||||
{
|
||||
name: "models/gemini-2.5-flash",
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
},
|
||||
{
|
||||
name: "models/text-embedding-004",
|
||||
supportedGenerationMethods: ["embedContent"],
|
||||
},
|
||||
],
|
||||
})
|
||||
const models = await listProviderModels("google", { apiKey: "k" }, fn)
|
||||
expect(models.map((m) => m.id)).toEqual(["gemini-2.5-flash"])
|
||||
// The key is a header, not part of the URL
|
||||
expect(calls[0].url).not.toContain("k&")
|
||||
expect(calls[0].headers["x-goog-api-key"]).toBe("k")
|
||||
})
|
||||
|
||||
it("reads Ollama's tags and OpenRouter's tool support", async () => {
|
||||
const ollama = answer({ models: [{ name: "llama3.2" }] })
|
||||
await listProviderModels(
|
||||
"ollama",
|
||||
{ baseUrl: "http://localhost:11434" },
|
||||
ollama.fn,
|
||||
)
|
||||
expect(ollama.calls[0].url).toBe("http://localhost:11434/api/tags")
|
||||
|
||||
const openrouter = answer({
|
||||
data: [
|
||||
{ id: "a/with-tools", supported_parameters: ["tools"] },
|
||||
{ id: "b/no-tools", supported_parameters: ["temperature"] },
|
||||
],
|
||||
})
|
||||
const models = await listProviderModels("openrouter", {}, openrouter.fn)
|
||||
expect(models).toEqual([
|
||||
{ id: "a/with-tools", tools: true },
|
||||
{ id: "b/no-tools", tools: false },
|
||||
])
|
||||
})
|
||||
|
||||
it("turns a failed request into an error with its status", async () => {
|
||||
const { fn } = answer({ error: "bad key" }, 401)
|
||||
await expect(
|
||||
listProviderModels("deepseek", { apiKey: "k" }, fn),
|
||||
).rejects.toMatchObject({ statusCode: 401 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractAihubmixModelIds", () => {
|
||||
it("keeps unique chat models", () => {
|
||||
expect(
|
||||
extractAihubmixModelIds({
|
||||
data: [
|
||||
{ model_id: "claude-sonnet-4-5", 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: "", types: "llm" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["claude-sonnet-4-5", "gpt-5.1"])
|
||||
expect(extractAihubmixModelIds({ data: null })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/provider-models", () => {
|
||||
const post = (body: unknown) =>
|
||||
providerModels(
|
||||
new Request("http://localhost/api/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
|
||||
it("answers null for providers that cannot list models", async () => {
|
||||
expect(canListModels("bedrock")).toBe(false)
|
||||
expect(canListModels("toString" as never)).toBe(false)
|
||||
const res = await post({ provider: "bedrock" })
|
||||
expect(await res.json()).toEqual({ models: null })
|
||||
})
|
||||
|
||||
it("needs the user's key where the list is not public", async () => {
|
||||
const res = await post({ provider: "deepseek" })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it("explains a failure with the error hints", async () => {
|
||||
vi.stubGlobal("fetch", answer({}, 401).fn)
|
||||
const res = await post({ provider: "deepseek", apiKey: "k" })
|
||||
expect(await res.json()).toMatchObject({ code: "invalid_api_key" })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user