mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 13:09:52 +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,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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user