fix(ai): simplify model configuration

- Separate model, connection, and advanced settings

- Resolve capabilities and output limits from provider catalogs

- Keep custom model compatibility declarations explicit
This commit is contained in:
Danila Poyarkov 2026-08-10 12:55:35 +03:00
parent c7c505ccc9
commit 4986fb8b54
17 changed files with 453 additions and 43 deletions

View file

@ -2,7 +2,7 @@
## Unreleased ## Unreleased
## 0.14.0 - 2026-08-07 ## 0.14.0 - 2026-08-10
### Breaking changes ### Breaking changes
@ -40,6 +40,7 @@
### Changed ### Changed
- Simplify AI model setup with clearly separated model, connection, and advanced settings, automatic compatibility and output-limit defaults for recognized models, and an explicit custom-model option.
- Redesign the editor chrome and Design panel with denser, better-aligned controls, clearer selection and section states, improved menus and overlays, consistent light/dark theming, and better keyboard and screen-reader support. - Redesign the editor chrome and Design panel with denser, better-aligned controls, clearer selection and section states, improved menus and overlays, consistent light/dark theming, and better keyboard and screen-reader support.
- Choose Freeform, vertical, horizontal, or grid flow directly from the contextual Layout section, with sizing controls grouped alongside it. - Choose Freeform, vertical, horizontal, or grid flow directly from the contextual Layout section, with sizing controls grouped alongside it.
- Choose Auto width, Auto height, or Fixed size directly from the Layout section for text layers. - Choose Auto width, Auto height, or Fixed size directly from the Layout section for text layers.

View file

@ -188,6 +188,8 @@ export interface ModelOption {
id: string id: string
name: string name: string
tag?: string tag?: string
capabilities?: readonly ('tools' | 'vision')[]
recommendedMaxOutputTokens?: number
} }
export interface AIProviderDef { export interface AIProviderDef {

View file

@ -85,7 +85,11 @@
"editModel": "Modell bearbeiten", "editModel": "Modell bearbeiten",
"modelEditorDescription": "Anbieter, Modell, Zugangsdaten und Fähigkeiten.", "modelEditorDescription": "Anbieter, Modell, Zugangsdaten und Fähigkeiten.",
"modelName": "Name", "modelName": "Name",
"modelConfiguration": "Modell",
"connectionSettings": "Verbindung",
"modelCapabilities": "Fähigkeiten", "modelCapabilities": "Fähigkeiten",
"modelCapabilitiesDetected": "Aus dem ausgewählten Modell erkannt.",
"modelCapabilitiesManual": "Kompatibilität für dieses benutzerdefinierte Modell angeben.",
"modelCapabilityTools": "Werkzeugaufrufe", "modelCapabilityTools": "Werkzeugaufrufe",
"modelCapabilityVision": "Bildeingabe", "modelCapabilityVision": "Bildeingabe",
"modelCapabilityToolsShort": "Werkzeuge", "modelCapabilityToolsShort": "Werkzeuge",
@ -137,6 +141,14 @@
"apiType": "API Type", "apiType": "API Type",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "Model ID", "modelID": "Model ID",
"customModelID": "Benutzerdefinierte Modell-ID",
"customModel": "Benutzerdefiniertes Modell…",
"advancedModelSettings": "Erweiterte Einstellungen",
"outputLimit": "Ausgabelimit",
"outputLimitAutomatic": "Automatische Empfehlung",
"supported": "Unterstützt",
"unsupported": "Nicht unterstützt",
"tokens": "Token",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Max output tokens",
"clear": "Clear", "clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace", "keySavedReplace": "Key saved — enter new to replace",

View file

@ -85,7 +85,11 @@
"editModel": "Editar modelo", "editModel": "Editar modelo",
"modelEditorDescription": "Proveedor, modelo, credenciales y capacidades.", "modelEditorDescription": "Proveedor, modelo, credenciales y capacidades.",
"modelName": "Nombre", "modelName": "Nombre",
"modelConfiguration": "Modelo",
"connectionSettings": "Conexión",
"modelCapabilities": "Capacidades", "modelCapabilities": "Capacidades",
"modelCapabilitiesDetected": "Detectadas a partir del modelo seleccionado.",
"modelCapabilitiesManual": "Declara la compatibilidad de este modelo personalizado.",
"modelCapabilityTools": "Uso de herramientas", "modelCapabilityTools": "Uso de herramientas",
"modelCapabilityVision": "Entrada de imágenes", "modelCapabilityVision": "Entrada de imágenes",
"modelCapabilityToolsShort": "Herramientas", "modelCapabilityToolsShort": "Herramientas",
@ -137,6 +141,14 @@
"apiType": "Tipo API", "apiType": "Tipo API",
"baseURL": "URL base", "baseURL": "URL base",
"modelID": "ID Modelo", "modelID": "ID Modelo",
"customModelID": "ID de modelo personalizado",
"customModel": "Modelo personalizado…",
"advancedModelSettings": "Ajustes avanzados",
"outputLimit": "Límite de salida",
"outputLimitAutomatic": "Recomendación automática",
"supported": "Compatible",
"unsupported": "No compatible",
"tokens": "tokens",
"maxOutputTokens": "Tokens de salida máx.", "maxOutputTokens": "Tokens de salida máx.",
"clear": "Limpiar", "clear": "Limpiar",
"keySavedReplace": "Clave guardada - introduce una nueva para reemplazarla", "keySavedReplace": "Clave guardada - introduce una nueva para reemplazarla",

View file

@ -85,7 +85,11 @@
"editModel": "Modifier le modèle", "editModel": "Modifier le modèle",
"modelEditorDescription": "Fournisseur, modèle, identifiants et capacités.", "modelEditorDescription": "Fournisseur, modèle, identifiants et capacités.",
"modelName": "Nom", "modelName": "Nom",
"modelConfiguration": "Modèle",
"connectionSettings": "Connexion",
"modelCapabilities": "Capacités", "modelCapabilities": "Capacités",
"modelCapabilitiesDetected": "Détectées à partir du modèle sélectionné.",
"modelCapabilitiesManual": "Déclarez la compatibilité de ce modèle personnalisé.",
"modelCapabilityTools": "Appels doutils", "modelCapabilityTools": "Appels doutils",
"modelCapabilityVision": "Entrée dimages", "modelCapabilityVision": "Entrée dimages",
"modelCapabilityToolsShort": "Outils", "modelCapabilityToolsShort": "Outils",
@ -137,6 +141,14 @@
"apiType": "API Type", "apiType": "API Type",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "Model ID", "modelID": "Model ID",
"customModelID": "ID du modèle personnalisé",
"customModel": "Modèle personnalisé…",
"advancedModelSettings": "Paramètres avancés",
"outputLimit": "Limite de sortie",
"outputLimitAutomatic": "Recommandation automatique",
"supported": "Pris en charge",
"unsupported": "Non pris en charge",
"tokens": "jetons",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Max output tokens",
"clear": "Clear", "clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace", "keySavedReplace": "Key saved — enter new to replace",

View file

@ -85,7 +85,11 @@
"editModel": "Modifica modello", "editModel": "Modifica modello",
"modelEditorDescription": "Provider, modello, credenziali e funzionalità.", "modelEditorDescription": "Provider, modello, credenziali e funzionalità.",
"modelName": "Nome", "modelName": "Nome",
"modelConfiguration": "Modello",
"connectionSettings": "Connessione",
"modelCapabilities": "Funzionalità", "modelCapabilities": "Funzionalità",
"modelCapabilitiesDetected": "Rilevate dal modello selezionato.",
"modelCapabilitiesManual": "Dichiara la compatibilità di questo modello personalizzato.",
"modelCapabilityTools": "Chiamate agli strumenti", "modelCapabilityTools": "Chiamate agli strumenti",
"modelCapabilityVision": "Input immagini", "modelCapabilityVision": "Input immagini",
"modelCapabilityToolsShort": "Strumenti", "modelCapabilityToolsShort": "Strumenti",
@ -137,6 +141,14 @@
"apiType": "API Type", "apiType": "API Type",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "Model ID", "modelID": "Model ID",
"customModelID": "ID modello personalizzato",
"customModel": "Modello personalizzato…",
"advancedModelSettings": "Impostazioni avanzate",
"outputLimit": "Limite di output",
"outputLimitAutomatic": "Raccomandazione automatica",
"supported": "Supportato",
"unsupported": "Non supportato",
"tokens": "token",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Max output tokens",
"clear": "Clear", "clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace", "keySavedReplace": "Key saved — enter new to replace",

View file

@ -85,7 +85,11 @@
"editModel": "モデルを編集", "editModel": "モデルを編集",
"modelEditorDescription": "プロバイダー、モデル、認証情報、機能。", "modelEditorDescription": "プロバイダー、モデル、認証情報、機能。",
"modelName": "名前", "modelName": "名前",
"modelConfiguration": "モデル",
"connectionSettings": "接続",
"modelCapabilities": "機能", "modelCapabilities": "機能",
"modelCapabilitiesDetected": "選択したモデルから検出されました。",
"modelCapabilitiesManual": "このカスタムモデルの互換性を指定します。",
"modelCapabilityTools": "ツール呼び出し", "modelCapabilityTools": "ツール呼び出し",
"modelCapabilityVision": "画像入力", "modelCapabilityVision": "画像入力",
"modelCapabilityToolsShort": "ツール", "modelCapabilityToolsShort": "ツール",
@ -137,6 +141,14 @@
"apiType": "APIタイプ", "apiType": "APIタイプ",
"baseURL": "ベースURL", "baseURL": "ベースURL",
"modelID": "モデルID", "modelID": "モデルID",
"customModelID": "カスタムモデル ID",
"customModel": "カスタムモデル…",
"advancedModelSettings": "詳細設定",
"outputLimit": "出力上限",
"outputLimitAutomatic": "自動推奨値",
"supported": "対応",
"unsupported": "非対応",
"tokens": "トークン",
"maxOutputTokens": "最大出力トークン数", "maxOutputTokens": "最大出力トークン数",
"clear": "クリア", "clear": "クリア",
"keySavedReplace": "キーが保存されました - 上書きするには新しいキーを入力してください", "keySavedReplace": "キーが保存されました - 上書きするには新しいキーを入力してください",

View file

@ -85,7 +85,11 @@
"editModel": "Edytuj model", "editModel": "Edytuj model",
"modelEditorDescription": "Dostawca, model, dane logowania i możliwości.", "modelEditorDescription": "Dostawca, model, dane logowania i możliwości.",
"modelName": "Nazwa", "modelName": "Nazwa",
"modelConfiguration": "Model",
"connectionSettings": "Połączenie",
"modelCapabilities": "Możliwości", "modelCapabilities": "Możliwości",
"modelCapabilitiesDetected": "Wykryte na podstawie wybranego modelu.",
"modelCapabilitiesManual": "Określ zgodność tego modelu niestandardowego.",
"modelCapabilityTools": "Wywołania narzędzi", "modelCapabilityTools": "Wywołania narzędzi",
"modelCapabilityVision": "Obrazy wejściowe", "modelCapabilityVision": "Obrazy wejściowe",
"modelCapabilityToolsShort": "Narzędzia", "modelCapabilityToolsShort": "Narzędzia",
@ -137,6 +141,14 @@
"apiType": "API Type", "apiType": "API Type",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "Model ID", "modelID": "Model ID",
"customModelID": "Identyfikator modelu niestandardowego",
"customModel": "Model niestandardowy…",
"advancedModelSettings": "Ustawienia zaawansowane",
"outputLimit": "Limit wyjścia",
"outputLimitAutomatic": "Automatyczna rekomendacja",
"supported": "Obsługiwane",
"unsupported": "Nieobsługiwane",
"tokens": "tokenów",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Max output tokens",
"clear": "Clear", "clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace", "keySavedReplace": "Key saved — enter new to replace",

View file

@ -85,7 +85,11 @@
"editModel": "Изменить модель", "editModel": "Изменить модель",
"modelEditorDescription": "Провайдер, модель, учётные данные и возможности.", "modelEditorDescription": "Провайдер, модель, учётные данные и возможности.",
"modelName": "Название", "modelName": "Название",
"modelConfiguration": "Модель",
"connectionSettings": "Подключение",
"modelCapabilities": "Возможности", "modelCapabilities": "Возможности",
"modelCapabilitiesDetected": "Определены по выбранной модели.",
"modelCapabilitiesManual": "Укажите совместимость этой пользовательской модели.",
"modelCapabilityTools": "Вызов инструментов", "modelCapabilityTools": "Вызов инструментов",
"modelCapabilityVision": "Ввод изображений", "modelCapabilityVision": "Ввод изображений",
"modelCapabilityToolsShort": "Инструменты", "modelCapabilityToolsShort": "Инструменты",
@ -137,6 +141,14 @@
"apiType": "API Type", "apiType": "API Type",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "Model ID", "modelID": "Model ID",
"customModelID": "ID пользовательской модели",
"customModel": "Пользовательская модель…",
"advancedModelSettings": "Расширенные настройки",
"outputLimit": "Лимит вывода",
"outputLimitAutomatic": "Автоматическая рекомендация",
"supported": "Поддерживается",
"unsupported": "Не поддерживается",
"tokens": "токенов",
"maxOutputTokens": "Max output tokens", "maxOutputTokens": "Max output tokens",
"clear": "Clear", "clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace", "keySavedReplace": "Key saved — enter new to replace",

View file

@ -85,7 +85,11 @@
"editModel": "编辑模型", "editModel": "编辑模型",
"modelEditorDescription": "提供商、模型、凭据和能力。", "modelEditorDescription": "提供商、模型、凭据和能力。",
"modelName": "名称", "modelName": "名称",
"modelConfiguration": "模型",
"connectionSettings": "连接",
"modelCapabilities": "能力", "modelCapabilities": "能力",
"modelCapabilitiesDetected": "根据所选模型检测。",
"modelCapabilitiesManual": "声明此自定义模型的兼容性。",
"modelCapabilityTools": "工具调用", "modelCapabilityTools": "工具调用",
"modelCapabilityVision": "图像输入", "modelCapabilityVision": "图像输入",
"modelCapabilityToolsShort": "工具", "modelCapabilityToolsShort": "工具",
@ -137,6 +141,14 @@
"apiType": "API 类型", "apiType": "API 类型",
"baseURL": "Base URL", "baseURL": "Base URL",
"modelID": "模型 ID", "modelID": "模型 ID",
"customModelID": "自定义模型 ID",
"customModel": "自定义模型…",
"advancedModelSettings": "高级设置",
"outputLimit": "输出限制",
"outputLimitAutomatic": "自动建议",
"supported": "支持",
"unsupported": "不支持",
"tokens": "token",
"maxOutputTokens": "最大输出 token 数", "maxOutputTokens": "最大输出 token 数",
"clear": "清除", "clear": "清除",
"keySavedReplace": "密钥已保存,输入新密钥以替换", "keySavedReplace": "密钥已保存,输入新密钥以替换",

View file

@ -134,7 +134,11 @@ export const dialogMessageDefaults = {
editModel: 'Edit model', editModel: 'Edit model',
modelEditorDescription: 'Provider, model, credentials, and capabilities.', modelEditorDescription: 'Provider, model, credentials, and capabilities.',
modelName: 'Name', modelName: 'Name',
modelConfiguration: 'Model',
connectionSettings: 'Connection',
modelCapabilities: 'Capabilities', modelCapabilities: 'Capabilities',
modelCapabilitiesDetected: 'Detected from the selected model.',
modelCapabilitiesManual: 'Declare compatibility for this custom model.',
modelCapabilityTools: 'Tool calling', modelCapabilityTools: 'Tool calling',
modelCapabilityVision: 'Image input', modelCapabilityVision: 'Image input',
modelCapabilityToolsShort: 'Tools', modelCapabilityToolsShort: 'Tools',
@ -187,6 +191,14 @@ export const dialogMessageDefaults = {
apiType: 'API Type', apiType: 'API Type',
baseURL: 'Base URL', baseURL: 'Base URL',
modelID: 'Model ID', modelID: 'Model ID',
customModelID: 'Custom model ID',
customModel: 'Custom model…',
advancedModelSettings: 'Advanced settings',
outputLimit: 'Output limit',
outputLimitAutomatic: 'Automatic recommendation',
supported: 'Supported',
unsupported: 'Not supported',
tokens: 'tokens',
maxOutputTokens: 'Max output tokens', maxOutputTokens: 'Max output tokens',
clear: 'Clear', clear: 'Clear',
keySavedReplace: 'Key saved — enter new to replace', keySavedReplace: 'Key saved — enter new to replace',

View file

@ -7,6 +7,12 @@ type OpenRouterModel = {
id?: unknown id?: unknown
name?: unknown name?: unknown
supported_parameters?: unknown supported_parameters?: unknown
architecture?: {
input_modalities?: unknown
}
top_provider?: {
max_completion_tokens?: unknown
}
} }
type OpenRouterModelsResponse = { type OpenRouterModelsResponse = {
@ -31,9 +37,20 @@ function isToolCapableOpenRouterModel(model: OpenRouterModel) {
export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption | null { export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption | null {
if (!isToolCapableOpenRouterModel(model)) return null if (!isToolCapableOpenRouterModel(model)) return null
if (typeof model.id !== 'string' || !model.id) return null if (typeof model.id !== 'string' || !model.id) return null
const inputModalities = model.architecture?.input_modalities
const maxOutputTokens = model.top_provider?.max_completion_tokens
return { return {
id: model.id, id: model.id,
name: typeof model.name === 'string' && model.name ? model.name : model.id name: typeof model.name === 'string' && model.name ? model.name : model.id,
capabilities: [
'tools',
...(Array.isArray(inputModalities) && inputModalities.includes('image')
? (['vision'] as const)
: [])
],
...(typeof maxOutputTokens === 'number' && Number.isFinite(maxOutputTokens)
? { recommendedMaxOutputTokens: Math.min(128_000, Math.max(1024, maxOutputTokens)) }
: {})
} }
} }

View file

@ -0,0 +1,104 @@
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
import { readCacheJson, writeCacheJson } from '@/app/cache'
const MODELS_DEV_URL = 'https://models.dev/api.json'
const MODELS_DEV_CACHE_KEY = 'models-dev/catalog'
const MODELS_DEV_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const PROVIDER_KEYS: Partial<Record<AIProviderID, readonly string[]>> = {
openrouter: ['openrouter'],
anthropic: ['anthropic'],
openai: ['openai'],
google: ['google'],
deepseek: ['deepseek'],
zai: ['zhipuai'],
minimax: ['minimax']
}
type ModelsDevModel = {
id?: unknown
name?: unknown
attachment?: unknown
tool_call?: unknown
limit?: { output?: unknown }
}
type ModelsDevProvider = {
models?: Record<string, ModelsDevModel>
}
type ModelsDevCatalog = Record<string, ModelsDevProvider>
let catalogPromise: Promise<ModelsDevCatalog | null> | null = null
function normalizeModel(id: string, model: ModelsDevModel): ModelOption {
const capabilities: ('tools' | 'vision')[] = []
if (model.tool_call === true) capabilities.push('tools')
if (model.attachment === true) capabilities.push('vision')
const output = model.limit?.output
return {
id,
name: typeof model.name === 'string' && model.name ? model.name : id,
capabilities,
...(typeof output === 'number' && Number.isFinite(output)
? { recommendedMaxOutputTokens: Math.min(128_000, Math.max(1024, output)) }
: {})
}
}
async function loadCatalog(fetcher: typeof fetch): Promise<ModelsDevCatalog | null> {
const cached = await readCacheJson<ModelsDevCatalog>(
MODELS_DEV_CACHE_KEY,
MODELS_DEV_CACHE_TTL_MS
)
if (cached) return cached
try {
const response = await fetcher(MODELS_DEV_URL)
if (!response.ok) throw new Error(`models.dev catalog request failed: ${response.status}`)
const catalog = (await response.json()) as ModelsDevCatalog
await writeCacheJson(MODELS_DEV_CACHE_KEY, catalog)
return catalog
} catch {
return null
}
}
function modelIDCandidates(providerKey: string, modelID: string): string[] {
const unprefixed = modelID.startsWith(`${providerKey}/`)
? modelID.slice(providerKey.length + 1)
: modelID
return [
...new Set([
modelID,
unprefixed,
unprefixed.replace(/-\d{8}$/, ''),
unprefixed.replace(/:[a-z0-9-]+$/, '')
])
]
}
export async function resolveModelsDevModel(
providerID: AIProviderID,
modelID: string,
fetcher: typeof fetch = fetch
): Promise<ModelOption | null> {
const providerKeys = PROVIDER_KEYS[providerID]
if (!providerKeys?.length || !modelID) return null
catalogPromise ??= loadCatalog(fetcher)
const catalog = await catalogPromise
if (!catalog) return null
for (const providerKey of providerKeys) {
const models = catalog[providerKey]?.models
for (const candidate of modelIDCandidates(providerKey, modelID)) {
const matched = models?.[candidate]
if (matched) return normalizeModel(modelID, matched)
}
}
return null
}
export function resetModelsDevCatalogForTests(): void {
catalogPromise = null
}

View file

@ -4,13 +4,17 @@ import {
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogDescription, AlertDialogDescription,
AlertDialogTitle AlertDialogTitle,
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui' } from 'reka-ui'
import { useI18n } from '@open-pencil/vue' import { useI18n } from '@open-pencil/vue'
import { ACP_AGENTS, AI_PROVIDERS, type AIProviderID } from '@open-pencil/core/constants' import { ACP_AGENTS, AI_PROVIDERS, type AIProviderID } from '@open-pencil/core/constants'
import { refreshAIProviderStatus } from '@/app/ai/chat/storage' import { refreshAIProviderStatus } from '@/app/ai/chat/storage'
import { resolveModelsDevModel } from '@/app/ai/models/catalog'
import { import {
testProviderConnection, testProviderConnection,
type ProviderConnectionTestFailureReason type ProviderConnectionTestFailureReason
@ -38,6 +42,9 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue' import AppSwitch from '@/components/ui/AppSwitch.vue'
import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog' import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog'
const CUSTOM_MODEL_VALUE = '__custom__'
const DEFAULT_MAX_OUTPUT_TOKENS = 16_384
const { profileId } = defineProps<{ profileId?: string }>() const { profileId } = defineProps<{ profileId?: string }>()
const emit = defineEmits<{ done: []; deleted: [] }>() const emit = defineEmits<{ done: []; deleted: [] }>()
const { dialogs } = useI18n() const { dialogs } = useI18n()
@ -48,6 +55,9 @@ const saveError = ref<string | null>(null)
const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle') const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle')
const connectionTestReason = ref<ProviderConnectionTestFailureReason | null>(null) const connectionTestReason = ref<ProviderConnectionTestFailureReason | null>(null)
const deleteOpen = ref(false) const deleteOpen = ref(false)
const advancedOpen = ref(Boolean(draft.customModelID.trim()))
const customModelSelected = ref(Boolean(draft.customModelID.trim()))
const catalogModel = ref<(typeof providerDef.value.models)[number] | null>(null)
const providerDef = computed( const providerDef = computed(
() => AI_PROVIDERS.find((provider) => provider.id === draft.providerID) ?? AI_PROVIDERS[0] () => AI_PROVIDERS.find((provider) => provider.id === draft.providerID) ?? AI_PROVIDERS[0]
@ -58,8 +68,28 @@ const providerDisplayName = computed(() => {
const agentID = draft.providerID.slice('acp:'.length) const agentID = draft.providerID.slice('acp:'.length)
return ACP_AGENTS.find((agent) => agent.id === agentID)?.name ?? draft.providerID return ACP_AGENTS.find((agent) => agent.id === agentID)?.name ?? draft.providerID
}) })
const modelOptions = computed(() => const modelOptions = computed(() => [
providerDef.value.models.map((model) => ({ value: model.id, label: model.name })) ...providerDef.value.models.map((model) => ({ value: model.id, label: model.name })),
...(providerDef.value.supportsCustomModel
? [{ value: CUSTOM_MODEL_VALUE, label: dialogs.value.customModel }]
: [])
])
const selectedModelValue = computed(() =>
customModelSelected.value ? CUSTOM_MODEL_VALUE : draft.modelID
)
const knownModel = computed(() => {
if (draft.customModelID.trim()) return catalogModel.value
return (
catalogModel.value ??
providerDef.value.models.find((model) => model.id === draft.modelID) ??
null
)
})
const knownCapabilities = computed<AIModelCapability[]>(() => [
...(knownModel.value?.capabilities ?? ['tools'])
])
const outputTokenRecommendation = computed(
() => knownModel.value?.recommendedMaxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS
) )
const modelDisplayName = computed(() => { const modelDisplayName = computed(() => {
const modelId = draft.customModelID.trim() || draft.modelID const modelId = draft.customModelID.trim() || draft.modelID
@ -72,13 +102,18 @@ const visionEnabled = capabilityModel('vision')
const canSave = computed( const canSave = computed(
() => () =>
Boolean(draft.name.trim()) && Boolean(draft.name.trim()) &&
(isACP.value || Boolean(draft.customModelID.trim() || draft.modelID.trim())) (isACP.value ||
(customModelSelected.value
? Boolean(draft.customModelID.trim())
: Boolean(draft.modelID.trim())))
) )
const canTest = computed(() => { const canTest = computed(() => {
if (isACP.value) return false if (isACP.value) return false
if (!keyInput.value.trim() && !hasExistingKey.value) return false if (!keyInput.value.trim() && !hasExistingKey.value) return false
if (providerDef.value.supportsCustomBaseURL && !draft.customBaseURL.trim()) return false if (providerDef.value.supportsCustomBaseURL && !draft.customBaseURL.trim()) return false
return Boolean(draft.customModelID.trim() || draft.modelID.trim()) return customModelSelected.value
? Boolean(draft.customModelID.trim())
: Boolean(draft.modelID.trim())
}) })
function capabilityModel(capability: AIModelCapability) { function capabilityModel(capability: AIModelCapability) {
@ -103,6 +138,24 @@ async function refreshKeyStatus(): Promise<void> {
keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing' keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing'
} }
function effectiveModelID(): string {
return customModelSelected.value ? draft.customModelID.trim() : draft.modelID.trim()
}
async function refreshCatalogModel(): Promise<void> {
const providerID = draft.providerID
const modelID = effectiveModelID()
catalogModel.value = await resolveModelsDevModel(providerID, modelID)
if (providerID !== draft.providerID || modelID !== effectiveModelID()) return
applyKnownModelMetadata()
}
function applyKnownModelMetadata(): void {
if (!knownModel.value) return
draft.capabilities = [...knownCapabilities.value]
draft.maxOutputTokens = outputTokenRecommendation.value
}
function updateProvider(providerID: AIProviderID): void { function updateProvider(providerID: AIProviderID): void {
draft.providerID = providerID draft.providerID = providerID
draft.sourceConnectionId = null draft.sourceConnectionId = null
@ -111,17 +164,34 @@ function updateProvider(providerID: AIProviderID): void {
draft.customModelID = '' draft.customModelID = ''
draft.customBaseURL = '' draft.customBaseURL = ''
draft.customAPIType = 'completions' draft.customAPIType = 'completions'
advancedOpen.value = false
customModelSelected.value = false
if (providerID.startsWith('acp:')) { if (providerID.startsWith('acp:')) {
draft.capabilities = ['tools'] draft.capabilities = ['tools']
if (!draft.name.trim()) draft.name = providerDisplayName.value if (!draft.name.trim()) draft.name = providerDisplayName.value
} else {
applyKnownModelMetadata()
} }
keyInput.value = '' keyInput.value = ''
resetConnectionTest() resetConnectionTest()
void refreshCatalogModel()
void refreshKeyStatus() void refreshKeyStatus()
} }
function updateModel(modelID: string): void { function updateModel(modelID: string): void {
if (modelID === CUSTOM_MODEL_VALUE) {
customModelSelected.value = true
draft.customModelID = ''
advancedOpen.value = true
catalogModel.value = null
resetConnectionTest()
return
}
draft.modelID = modelID draft.modelID = modelID
customModelSelected.value = false
draft.customModelID = ''
applyKnownModelMetadata()
void refreshCatalogModel()
if (!draft.name.trim()) draft.name = modelDisplayName.value if (!draft.name.trim()) draft.name = modelDisplayName.value
resetConnectionTest() resetConnectionTest()
} }
@ -129,6 +199,7 @@ function updateModel(modelID: string): void {
async function save(): Promise<void> { async function save(): Promise<void> {
saveError.value = null saveError.value = null
try { try {
applyKnownModelMetadata()
if (!draft.name.trim()) draft.name = modelDisplayName.value || providerDisplayName.value if (!draft.name.trim()) draft.name = modelDisplayName.value || providerDisplayName.value
const profile = saveModelProfileDraft(draft) const profile = saveModelProfileDraft(draft)
if (keyInput.value.trim()) { if (keyInput.value.trim()) {
@ -188,6 +259,13 @@ watch(
() => [draft.customBaseURL, draft.customModelID, draft.customAPIType, draft.modelID], () => [draft.customBaseURL, draft.customModelID, draft.customAPIType, draft.modelID],
resetConnectionTest resetConnectionTest
) )
watch(
() => draft.customModelID,
() => {
if (customModelSelected.value) void refreshCatalogModel()
}
)
void refreshCatalogModel()
void refreshKeyStatus() void refreshKeyStatus()
</script> </script>
@ -235,15 +313,41 @@ void refreshKeyStatus()
</ProviderSettingsField> </ProviderSettingsField>
<template v-if="!isACP"> <template v-if="!isACP">
<div class="flex items-center gap-2 pt-1">
<p class="text-[10px] font-medium uppercase tracking-wide text-muted">
{{ dialogs.modelConfiguration }}
</p>
<div class="h-px flex-1 bg-border" />
</div>
<ProviderSettingsField v-if="modelOptions.length" :label="dialogs.modelID"> <ProviderSettingsField v-if="modelOptions.length" :label="dialogs.modelID">
<AppSelect <AppSelect
:model-value="draft.modelID" :model-value="selectedModelValue"
:options="modelOptions" :options="modelOptions"
:label="dialogs.modelID" :label="dialogs.modelID"
@update:model-value="updateModel(String($event))" @update:model-value="updateModel(String($event))"
/> />
</ProviderSettingsField> </ProviderSettingsField>
<ProviderSettingsField
v-if="providerDef.supportsCustomModel && selectedModelValue === CUSTOM_MODEL_VALUE"
:label="dialogs.customModelID"
>
<ProviderSettingsInput
v-model="draft.customModelID"
:aria-label="dialogs.customModelID"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
/>
</ProviderSettingsField>
<div class="flex items-center gap-2 pt-1">
<p class="text-[10px] font-medium uppercase tracking-wide text-muted">
{{ dialogs.connectionSettings }}
</p>
<div class="h-px flex-1 bg-border" />
</div>
<ProviderSettingsField v-if="providerDef.supportsCustomBaseURL" :label="dialogs.baseURL"> <ProviderSettingsField v-if="providerDef.supportsCustomBaseURL" :label="dialogs.baseURL">
<ProviderSettingsInput <ProviderSettingsInput
v-model="draft.customBaseURL" v-model="draft.customBaseURL"
@ -252,15 +356,6 @@ void refreshKeyStatus()
/> />
</ProviderSettingsField> </ProviderSettingsField>
<ProviderSettingsField v-if="providerDef.supportsCustomModel" :label="dialogs.modelID">
<ProviderSettingsInput
v-model="draft.customModelID"
:aria-label="dialogs.modelID"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
/>
</ProviderSettingsField>
<ProviderSettingsField <ProviderSettingsField
v-if="draft.providerID === 'openai-compatible'" v-if="draft.providerID === 'openai-compatible'"
:label="dialogs.apiType" :label="dialogs.apiType"
@ -275,17 +370,6 @@ void refreshKeyStatus()
/> />
</ProviderSettingsField> </ProviderSettingsField>
<ProviderSettingsField :label="dialogs.maxOutputTokens">
<ProviderSettingsInput
v-model.number="draft.maxOutputTokens"
:aria-label="dialogs.maxOutputTokens"
type="number"
:min="1024"
:max="128000"
:step="1024"
/>
</ProviderSettingsField>
<ProviderSettingsKeyField <ProviderSettingsKeyField
v-model="keyInput" v-model="keyInput"
:label="dialogs.apiKey" :label="dialogs.apiKey"
@ -305,19 +389,58 @@ void refreshKeyStatus()
/> />
</template> </template>
<div class="rounded border border-border p-2.5"> <CollapsibleRoot v-model:open="advancedOpen" class="rounded border border-border">
<p class="mb-2 text-[11px] font-medium text-surface">{{ dialogs.modelCapabilities }}</p> <CollapsibleTrigger
class="flex w-full items-center gap-2 px-2.5 py-2 text-left text-[11px] text-muted hover:text-surface"
>
<icon-lucide-chevron-right
class="size-3 transition-transform [[data-state=open]>&]:rotate-90"
/>
{{ dialogs.advancedModelSettings }}
</CollapsibleTrigger>
<CollapsibleContent class="border-t border-border p-2.5">
<div class="flex flex-col gap-3">
<div>
<p class="text-[11px] font-medium text-surface">{{ dialogs.modelCapabilities }}</p>
<p class="mt-0.5 text-[10px] text-muted">
{{
knownModel ? dialogs.modelCapabilitiesDetected : dialogs.modelCapabilitiesManual
}}
</p>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class="text-[11px] text-muted">{{ dialogs.modelCapabilityTools }}</span> <span class="text-[11px] text-muted">{{ dialogs.modelCapabilityTools }}</span>
<AppSwitch v-model="toolsEnabled" :label="dialogs.modelCapabilityTools" /> <span v-if="knownModel" class="text-[10px] text-surface">
{{
knownCapabilities.includes('tools') ? dialogs.supported : dialogs.unsupported
}}
</span>
<AppSwitch v-else v-model="toolsEnabled" :label="dialogs.modelCapabilityTools" />
</div> </div>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class="text-[11px] text-muted">{{ dialogs.modelCapabilityVision }}</span> <span class="text-[11px] text-muted">{{ dialogs.modelCapabilityVision }}</span>
<AppSwitch v-model="visionEnabled" :label="dialogs.modelCapabilityVision" /> <span v-if="knownModel" class="text-[10px] text-surface">
{{
knownCapabilities.includes('vision') ? dialogs.supported : dialogs.unsupported
}}
</span>
<AppSwitch v-else v-model="visionEnabled" :label="dialogs.modelCapabilityVision" />
</div> </div>
</div> </div>
<div class="border-t border-border pt-2.5">
<p class="text-[11px] font-medium text-surface">{{ dialogs.outputLimit }}</p>
<p class="mt-0.5 text-[10px] text-muted">
{{ dialogs.outputLimitAutomatic }} ·
{{ outputTokenRecommendation.toLocaleString() }}
{{ dialogs.tokens }}
</p>
</div> </div>
</div>
</CollapsibleContent>
</CollapsibleRoot>
<p v-if="saveError" class="text-[10px] text-danger" role="alert">{{ saveError }}</p> <p v-if="saveError" class="text-[10px] text-danger" role="alert">{{ saveError }}</p>
</div> </div>

View file

@ -242,6 +242,8 @@ test('OpenRouter accepts a custom model ID from provider settings', async () =>
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await page.getByTestId('provider-settings-trigger').click() await page.getByTestId('provider-settings-trigger').click()
await page.locator('[data-model-id]').first().click() await page.locator('[data-model-id]').first().click()
await page.getByLabel('Model ID').click()
await page.getByRole('option', { name: 'Custom model…' }).click()
const customModelInput = page.getByTestId('provider-settings-custom-model') const customModelInput = page.getByTestId('provider-settings-custom-model')
await expect(customModelInput).toBeVisible() await expect(customModelInput).toBeVisible()
await customModelInput.fill(customModel) await customModelInput.fill(customModel)
@ -253,7 +255,10 @@ test('OpenRouter accepts a custom model ID from provider settings', async () =>
await page.getByTestId('provider-settings-trigger').click() await page.getByTestId('provider-settings-trigger').click()
await page.locator('[data-model-id]').first().click() await page.locator('[data-model-id]').first().click()
await page.getByTestId('provider-settings-custom-model').fill('') const savedCustomModelInput = page.getByTestId('provider-settings-custom-model')
await savedCustomModelInput.fill('')
await page.getByRole('combobox', { name: 'Model ID' }).click()
await page.getByRole('option', { name: /Claude Sonnet 4\.6/ }).click()
await page.getByRole('button', { name: 'Save model' }).click() await page.getByRole('button', { name: 'Save model' }).click()
await page.getByTestId('app-settings-done').click() await page.getByTestId('app-settings-done').click()

View file

@ -0,0 +1,43 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { resetModelsDevCatalogForTests, resolveModelsDevModel } from '@/app/ai/models/catalog'
function catalogResponse(body: unknown): typeof fetch {
return (async () => new Response(JSON.stringify(body), { status: 200 })) as typeof fetch
}
afterEach(() => resetModelsDevCatalogForTests())
describe('models.dev catalog', () => {
test('resolves provider model capabilities and output limits', async () => {
const model = await resolveModelsDevModel(
'anthropic',
'claude-sonnet-4-6-20260301',
catalogResponse({
anthropic: {
models: {
'claude-sonnet-4-6': {
name: 'Claude Sonnet 4.6',
tool_call: true,
attachment: true,
limit: { output: 64_000 }
}
}
}
})
)
expect(model).toEqual({
id: 'claude-sonnet-4-6-20260301',
name: 'Claude Sonnet 4.6',
capabilities: ['tools', 'vision'],
recommendedMaxOutputTokens: 64_000
})
})
test('returns null when the provider or model is unknown', async () => {
expect(
await resolveModelsDevModel('openai-compatible', 'local', catalogResponse({}))
).toBeNull()
})
})

View file

@ -114,9 +114,16 @@ describe('normalizeOpenRouterModel', () => {
normalizeOpenRouterModel({ normalizeOpenRouterModel({
id: 'meta-llama/llama-3.3-70b-instruct', id: 'meta-llama/llama-3.3-70b-instruct',
name: 'Llama 3.3 70B Instruct', name: 'Llama 3.3 70B Instruct',
supported_parameters: ['tools'] supported_parameters: ['tools'],
architecture: { input_modalities: ['text', 'image'] },
top_provider: { max_completion_tokens: 32_768 }
})
).toEqual({
id: 'meta-llama/llama-3.3-70b-instruct',
name: 'Llama 3.3 70B Instruct',
capabilities: ['tools', 'vision'],
recommendedMaxOutputTokens: 32_768
}) })
).toEqual({ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B Instruct' })
}) })
test('skips OpenRouter models without tool support', () => { test('skips OpenRouter models without tool support', () => {