mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
feat(settings): providers that can list their models fetch the list instead of a hand-written one
Step 7 of the provider refactor plan, an intended UI behavior change. The hand-written SUGGESTED_MODELS table (179 ids for 19 providers) is gone. The six providers that cannot list their models online (Bedrock, Vertex AI, Azure, EdgeOne, Doubao, MiniMax) keep a short list on their record, still get its first model when added, and still show quick-add chips and the admin panel's dropdown. For the other 18 the settings dialog fetches the provider's own list once the user has entered credentials on the page while it has no model yet (600 ms after the typing pauses), and opens the picker with the result; the 'Get all models' button and the model id input work as before. Credentials a provider already had when its page opened fetch nothing by themselves. The admin panel shows the 'suggested' dropdown only for the six. Two e2e tests follow the change: the first-provider flow mocks the list endpoint and picks the model from the list; the empty-list test checks that a new key fetches again and opens the list once it has models.
This commit is contained in:
@@ -35,7 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDERS, type ProviderName } from "@/lib/providers"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { generateId, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { generateId } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
@@ -68,7 +68,9 @@ function ProviderDetail({
|
||||
>({})
|
||||
|
||||
const info = PROVIDERS[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
// Only providers that cannot list their models have a list here; for
|
||||
// the others the admin types the model id
|
||||
const suggestions = (PROVIDERS[provider.provider].models ?? []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ import type {
|
||||
ProviderConfig,
|
||||
ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
@@ -367,16 +366,18 @@ export function ModelConfigDialog({
|
||||
}
|
||||
|
||||
// The provider's own list, if fetched with the credentials it has now,
|
||||
// else the suggested models
|
||||
// else the record's short list (only providers that cannot list models
|
||||
// have one)
|
||||
const fetched = selectedProvider
|
||||
? fetchedModels[selectedProvider.id]
|
||||
: undefined
|
||||
const recordModels = selectedProvider
|
||||
? (PROVIDERS[selectedProvider.provider].models ?? [])
|
||||
: []
|
||||
const suggestedModels: ListedModel[] = selectedProvider
|
||||
? fetched && fetched.askedWith === credentialsOf(selectedProvider.id)
|
||||
? fetched.models
|
||||
: (SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
|
||||
id,
|
||||
}))
|
||||
: recordModels.map((id) => ({ id }))
|
||||
: []
|
||||
// The picker closes when there is nothing left to pick from, so it does
|
||||
// not open by itself once there is again
|
||||
@@ -399,20 +400,50 @@ export function ModelConfigDialog({
|
||||
const availableSuggestions = suggestedModels.filter(
|
||||
(model) => !existingModelIds.includes(model.id),
|
||||
)
|
||||
// A few suggested models to add with one click
|
||||
const quickSuggestions = selectedProvider
|
||||
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
||||
.filter((modelId) => !existingModelIds.includes(modelId))
|
||||
.slice(0, 4)
|
||||
: []
|
||||
// A few of the record's models to add with one click
|
||||
const quickSuggestions = recordModels
|
||||
.filter((modelId) => !existingModelIds.includes(modelId))
|
||||
.slice(0, 4)
|
||||
|
||||
// A provider that can list its models gets its list fetched by itself,
|
||||
// once, for credentials entered on its page while it has no model yet:
|
||||
// the user picks from the list instead of a hand-written one. Credentials
|
||||
// a provider already had when its page opened fetch nothing (the button
|
||||
// does), nor does a provider with models.
|
||||
const shownWith = useRef<{ id: string; credentials: string } | null>(null)
|
||||
const autoFetched = useRef<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProvider) return
|
||||
const id = selectedProvider.id
|
||||
const now = credentialsOf(id)
|
||||
if (shownWith.current?.id !== id) {
|
||||
shownWith.current = { id, credentials: now }
|
||||
}
|
||||
if (
|
||||
!PROVIDERS[selectedProvider.provider].modelList ||
|
||||
selectedProvider.models.length > 0 ||
|
||||
!hasCredentials(selectedProvider) ||
|
||||
now === shownWith.current.credentials ||
|
||||
autoFetched.current[id] === now
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Once the typing or pasting pauses
|
||||
const timer = setTimeout(() => {
|
||||
autoFetched.current[id] = now
|
||||
void handleFetchModels()
|
||||
}, 600)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, selectedProvider])
|
||||
|
||||
const openProvider = (providerId: string) => setModelsPage({ providerId })
|
||||
|
||||
// Handle adding a new provider
|
||||
const handleAddProvider = (providerType: ProviderName) => {
|
||||
const newProvider = addProvider(providerType)
|
||||
// With the first suggested model picked, a key is all a test needs
|
||||
const firstModel = SUGGESTED_MODELS[providerType]?.[0]
|
||||
// A provider that cannot list its models starts with the first of
|
||||
// the record's; the others fetch their list once the key is in
|
||||
const firstModel = PROVIDERS[providerType].models?.[0]
|
||||
if (firstModel) addModel(newProvider.id, firstModel)
|
||||
openProvider(newProvider.id)
|
||||
}
|
||||
|
||||
@@ -67,267 +67,6 @@ export interface FlattenedModel {
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
openai: [
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-6-astra",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 5 series (latest)
|
||||
"claude-opus-5-5",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
// Claude 4.8 / 4.7 / 4.6 series (dateless pinned IDs)
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
// Claude 4.5 series
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
// Claude 3.7 series
|
||||
"claude-3-7-sonnet-20250219",
|
||||
// Claude 3.5 series
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
],
|
||||
google: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
vertexai: [
|
||||
// Gemini 3 series
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite",
|
||||
// Gemini 2.5 series
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
azure: [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
],
|
||||
// Newer models only answer through an inference profile id (the region
|
||||
// prefix). Each id here was called once on 2026-10-04.
|
||||
bedrock: [
|
||||
// Anthropic Claude ("global." works from any region)
|
||||
"global.anthropic.claude-opus-5-5",
|
||||
"global.anthropic.claude-sonnet-5-5",
|
||||
"global.anthropic.claude-fable-5-1",
|
||||
"global.anthropic.claude-opus-5",
|
||||
"global.anthropic.claude-sonnet-5",
|
||||
"global.anthropic.claude-opus-4-8",
|
||||
"global.anthropic.claude-opus-4-7",
|
||||
"global.anthropic.claude-sonnet-4-6",
|
||||
"global.anthropic.claude-opus-4-6-v1",
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"global.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
// Amazon Nova
|
||||
"us.amazon.nova-2-lite-v1:0",
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
// Meta Llama
|
||||
"us.meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
||||
"us.meta.llama3-3-70b-instruct-v1:0",
|
||||
// Mistral
|
||||
"mistral.mistral-large-3-675b-instruct",
|
||||
"us.mistral.pixtral-large-2502-v1:0",
|
||||
],
|
||||
openrouter: [
|
||||
// Anthropic
|
||||
"anthropic/claude-opus-5.5",
|
||||
"anthropic/claude-sonnet-5.5",
|
||||
"anthropic/claude-fable-5.1",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
// OpenAI
|
||||
"openai/gpt-6.1-sol",
|
||||
"openai/gpt-6-luna",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-4o-mini",
|
||||
// Google
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
// xAI
|
||||
"x-ai/grok-4.3",
|
||||
// Meta Llama
|
||||
"meta-llama/llama-4-maverick",
|
||||
"meta-llama/llama-4-scout",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
// DeepSeek
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v3.2",
|
||||
// Qwen
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3-coder",
|
||||
// MiniMax
|
||||
"minimax/minimax-m3",
|
||||
],
|
||||
aihubmix: [
|
||||
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
|
||||
// Anthropic Claude
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
// OpenAI
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
// Google Gemini
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
// DeepSeek
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
// Qwen
|
||||
"qwen3.7-max",
|
||||
"qwen3-coder-next",
|
||||
// Z.ai
|
||||
"glm-5.1",
|
||||
// Moonshot AI
|
||||
"kimi-k2.6",
|
||||
// MiniMax
|
||||
"minimax-m3",
|
||||
// xAI
|
||||
"grok-4.3",
|
||||
// Baidu
|
||||
"ernie-5.1",
|
||||
// Mistral
|
||||
"mistral-large-3",
|
||||
// Meta
|
||||
"llama-4-maverick",
|
||||
],
|
||||
deepseek: [
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
],
|
||||
siliconflow: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V4-Flash",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
// MiniMax
|
||||
"MiniMaxAI/MiniMax-M3",
|
||||
// Moonshot
|
||||
"moonshotai/Kimi-K2.6",
|
||||
// Z.ai
|
||||
"zai-org/GLM-5",
|
||||
// Qwen
|
||||
"Qwen/Qwen3.6-35B-A3B",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
"Qwen/Qwen3-30B-A3B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-32B-Instruct",
|
||||
// OpenAI open-weights
|
||||
"openai/gpt-oss-120b",
|
||||
],
|
||||
sglang: [
|
||||
// SGLang is OpenAI-compatible, models depend on deployment
|
||||
"default",
|
||||
],
|
||||
gateway: [
|
||||
"anthropic/claude-opus-5.5",
|
||||
"anthropic/claude-sonnet-5.5",
|
||||
"openai/gpt-6.1-sol",
|
||||
"openai/gpt-6-luna",
|
||||
"openai/gpt-5.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"xai/grok-4.3",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5.4-mini",
|
||||
],
|
||||
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
|
||||
doubao: [
|
||||
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
|
||||
"doubao-seed-2-0-pro-260215",
|
||||
"doubao-seed-2-0-lite-260428",
|
||||
"doubao-seed-2-0-mini-260428",
|
||||
"doubao-seed-1-8-251228",
|
||||
"doubao-seed-1-6-251015",
|
||||
"doubao-seed-1-6-flash-250828",
|
||||
"doubao-seed-1-6-vision-250815",
|
||||
"doubao-1-5-pro-32k-250115",
|
||||
"doubao-1-5-lite-32k-250115",
|
||||
],
|
||||
modelscope: [
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-V4-Pro",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-R1",
|
||||
// Qwen
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
"Qwen/Qwen3-32B",
|
||||
"Qwen/Qwen2.5-72B-Instruct",
|
||||
],
|
||||
minimax: [
|
||||
// MiniMax models (Anthropic-compatible API)
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
novita: [
|
||||
// Novita AI models (OpenAI-compatible API)
|
||||
"minimax/minimax-m3",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"zai-org/glm-5.1",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
],
|
||||
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
|
||||
}
|
||||
|
||||
// Helper to generate UUID
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
@@ -51,26 +51,39 @@ test("a first provider: pick it, fill in the key, test, use it", async ({
|
||||
tested.push(route.request().postDataJSON().modelId)
|
||||
await route.fulfill({ json: { valid: true, responseTime: 1400 } })
|
||||
})
|
||||
const listedWith: string[] = []
|
||||
await page.route("**/api/provider-models", async (route) => {
|
||||
listedWith.push(route.request().postDataJSON().apiKey)
|
||||
await route.fulfill({
|
||||
json: {
|
||||
models: [{ id: "deepseek-v4-pro" }, { id: "deepseek-chat" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await openApp(page)
|
||||
await openSettings(page)
|
||||
const dialog = settingsDialog(page)
|
||||
await expect(dialog.getByText("Use your own API key")).toBeVisible()
|
||||
|
||||
await dialog.getByTestId("pick-provider-deepseek").click()
|
||||
// The first suggested model is added, and the key field has the cursor
|
||||
// No model yet (DeepSeek lists its own), and the key field has the cursor
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "DeepSeek", exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(dialog.locator('input[title="deepseek-v4-pro"]')).toBeVisible()
|
||||
await expect(dialog.locator("#api-key")).toBeFocused()
|
||||
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
|
||||
await expect(dialog.getByTestId("step-2-done")).toBeVisible()
|
||||
await expect(dialog.getByTestId("step-2-todo")).toBeVisible()
|
||||
const testButton = dialog.getByTestId("test-models")
|
||||
await expect(testButton).toBeDisabled()
|
||||
await expect(dialog.getByText("Fill in the API key first.")).toBeVisible()
|
||||
|
||||
// The key is in: the list is fetched once and opens; pick a model
|
||||
await dialog.locator("#api-key").fill("sk-1234")
|
||||
await expect(dialog.getByTestId("step-1-done")).toBeVisible()
|
||||
await page.getByRole("option", { name: "deepseek-v4-pro" }).click()
|
||||
expect(listedWith).toEqual(["sk-1234"])
|
||||
await expect(dialog.locator('input[title="deepseek-v4-pro"]')).toBeVisible()
|
||||
await expect(dialog.getByTestId("step-2-done")).toBeVisible()
|
||||
await expect(testButton).toHaveText("Test deepseek-v4-pro")
|
||||
await testButton.click()
|
||||
// The visible panel, not the screen reader's copy
|
||||
@@ -648,10 +661,11 @@ test("a model list that loads leaves a refused test's mark", async ({
|
||||
})
|
||||
|
||||
test("an empty model list says so and opens nothing", async ({ page }) => {
|
||||
let models: { id: string }[] = []
|
||||
await page.route("**/api/provider-models", (route) =>
|
||||
route.fulfill({ json: { models: [] } }),
|
||||
route.fulfill({ json: { models } }),
|
||||
)
|
||||
// OpenAI has suggested models: they come back once the key changes
|
||||
// OpenAI lists its own models: nothing to browse until a list arrives
|
||||
await openApp(page, {
|
||||
config: {
|
||||
version: 1,
|
||||
@@ -663,16 +677,21 @@ test("an empty model list says so and opens nothing", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
const dialog = settingsDialog(page)
|
||||
await dialog.getByTestId("provider-row-o1").click()
|
||||
// A key the provider already had fetches nothing by itself
|
||||
await page.waitForTimeout(800)
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Browse models" }),
|
||||
).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
|
||||
await expect(
|
||||
dialog.getByText("The provider returned no models."),
|
||||
).toBeVisible()
|
||||
await dialog.locator("#api-key").fill("k-new")
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Browse models" }),
|
||||
).toBeVisible()
|
||||
await page.waitForTimeout(300)
|
||||
// The picker did not open by itself
|
||||
await expect(page.getByRole("listbox")).toHaveCount(0)
|
||||
await expect(dialog.locator("#api-key")).toBeFocused()
|
||||
// A new key fetches the list again by itself and opens it once it has models
|
||||
models = [{ id: "fresh-model" }]
|
||||
await dialog.locator("#api-key").fill("k-new")
|
||||
await expect(
|
||||
page.getByRole("option", { name: "fresh-model" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { envVarsOf } from "@/lib/ai-providers"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import { POPULAR_PROVIDERS, PROVIDER_GROUPS } from "@/lib/provider-setup"
|
||||
import { PROVIDER_NAMES, PROVIDERS, publicDefaultUrl } from "@/lib/providers"
|
||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO } from "@/lib/types/model-config"
|
||||
|
||||
vi.mock("@/lib/admin/settings", () => ({
|
||||
loadSettings: () => ({}),
|
||||
@@ -82,7 +82,7 @@ describe("the provider record", () => {
|
||||
if (modelList) {
|
||||
expect([p, models]).toEqual([p, undefined])
|
||||
} else {
|
||||
expect([p, models]).toEqual([p, SUGGESTED_MODELS[p]])
|
||||
expect([p, models?.length ?? 0]).not.toEqual([p, 0])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user