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:
dayuan.jiang
2026-10-04 14:02:54 +09:00
parent ed1722ee9d
commit 6236124338
18 changed files with 2595 additions and 274 deletions
-61
View File
@@ -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()
}
}
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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">
-79
View File
@@ -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
}
+3
View File
@@ -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",
+3
View File
@@ -339,6 +339,9 @@
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"fetchModels": "プロバイダーからモデル一覧を取得",
"noTools": "ツール呼び出し非対応",
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL",
+3
View File
@@ -385,6 +385,9 @@
"baseUrl": "基礎 URL",
"optional": "(可選)",
"getApiKey": "取得 API Key",
"fetchModels": "從服務商取得模型清單",
"noTools": "不支援工具呼叫",
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"customEndpoint": "自訂端點 URL",
+3
View File
@@ -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
+45
View File
@@ -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
}
+184
View File
@@ -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,
}))
}
+36 -1
View File
@@ -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",
},
}
+78
View File
@@ -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`)
+85
View File
@@ -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()
})
-25
View File
@@ -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"
+32
View File
@@ -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()
})
})
+158
View File
@@ -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" })
})
})