diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9b2a087..579f8a53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -## 0.14.0 - 2026-08-07 +## 0.14.0 - 2026-08-10 ### Breaking changes @@ -40,6 +40,7 @@ ### 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. - 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. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index ba0fb76d8..97afdd2c7 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -188,6 +188,8 @@ export interface ModelOption { id: string name: string tag?: string + capabilities?: readonly ('tools' | 'vision')[] + recommendedMaxOutputTokens?: number } export interface AIProviderDef { diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 084f974d1..e07e4baf6 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Modell bearbeiten", "modelEditorDescription": "Anbieter, Modell, Zugangsdaten und Fähigkeiten.", "modelName": "Name", + "modelConfiguration": "Modell", + "connectionSettings": "Verbindung", "modelCapabilities": "Fähigkeiten", + "modelCapabilitiesDetected": "Aus dem ausgewählten Modell erkannt.", + "modelCapabilitiesManual": "Kompatibilität für dieses benutzerdefinierte Modell angeben.", "modelCapabilityTools": "Werkzeugaufrufe", "modelCapabilityVision": "Bildeingabe", "modelCapabilityToolsShort": "Werkzeuge", @@ -137,6 +141,14 @@ "apiType": "API Type", "baseURL": "Base URL", "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", "clear": "Clear", "keySavedReplace": "Key saved — enter new to replace", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index bdd552560..3598a4182 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Editar modelo", "modelEditorDescription": "Proveedor, modelo, credenciales y capacidades.", "modelName": "Nombre", + "modelConfiguration": "Modelo", + "connectionSettings": "Conexión", "modelCapabilities": "Capacidades", + "modelCapabilitiesDetected": "Detectadas a partir del modelo seleccionado.", + "modelCapabilitiesManual": "Declara la compatibilidad de este modelo personalizado.", "modelCapabilityTools": "Uso de herramientas", "modelCapabilityVision": "Entrada de imágenes", "modelCapabilityToolsShort": "Herramientas", @@ -137,6 +141,14 @@ "apiType": "Tipo API", "baseURL": "URL base", "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.", "clear": "Limpiar", "keySavedReplace": "Clave guardada - introduce una nueva para reemplazarla", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 1e2ff138a..8cf9ce7d9 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Modifier le modèle", "modelEditorDescription": "Fournisseur, modèle, identifiants et capacités.", "modelName": "Nom", + "modelConfiguration": "Modèle", + "connectionSettings": "Connexion", "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 d’outils", "modelCapabilityVision": "Entrée d’images", "modelCapabilityToolsShort": "Outils", @@ -137,6 +141,14 @@ "apiType": "API Type", "baseURL": "Base URL", "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", "clear": "Clear", "keySavedReplace": "Key saved — enter new to replace", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 045b65c90..52f85389a 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Modifica modello", "modelEditorDescription": "Provider, modello, credenziali e funzionalità.", "modelName": "Nome", + "modelConfiguration": "Modello", + "connectionSettings": "Connessione", "modelCapabilities": "Funzionalità", + "modelCapabilitiesDetected": "Rilevate dal modello selezionato.", + "modelCapabilitiesManual": "Dichiara la compatibilità di questo modello personalizzato.", "modelCapabilityTools": "Chiamate agli strumenti", "modelCapabilityVision": "Input immagini", "modelCapabilityToolsShort": "Strumenti", @@ -137,6 +141,14 @@ "apiType": "API Type", "baseURL": "Base URL", "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", "clear": "Clear", "keySavedReplace": "Key saved — enter new to replace", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index 139e2d35d..c0a169d2c 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -85,7 +85,11 @@ "editModel": "モデルを編集", "modelEditorDescription": "プロバイダー、モデル、認証情報、機能。", "modelName": "名前", + "modelConfiguration": "モデル", + "connectionSettings": "接続", "modelCapabilities": "機能", + "modelCapabilitiesDetected": "選択したモデルから検出されました。", + "modelCapabilitiesManual": "このカスタムモデルの互換性を指定します。", "modelCapabilityTools": "ツール呼び出し", "modelCapabilityVision": "画像入力", "modelCapabilityToolsShort": "ツール", @@ -137,6 +141,14 @@ "apiType": "APIタイプ", "baseURL": "ベースURL", "modelID": "モデルID", + "customModelID": "カスタムモデル ID", + "customModel": "カスタムモデル…", + "advancedModelSettings": "詳細設定", + "outputLimit": "出力上限", + "outputLimitAutomatic": "自動推奨値", + "supported": "対応", + "unsupported": "非対応", + "tokens": "トークン", "maxOutputTokens": "最大出力トークン数", "clear": "クリア", "keySavedReplace": "キーが保存されました - 上書きするには新しいキーを入力してください", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index 425f74a0d..2052b974e 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Edytuj model", "modelEditorDescription": "Dostawca, model, dane logowania i możliwości.", "modelName": "Nazwa", + "modelConfiguration": "Model", + "connectionSettings": "Połączenie", "modelCapabilities": "Możliwości", + "modelCapabilitiesDetected": "Wykryte na podstawie wybranego modelu.", + "modelCapabilitiesManual": "Określ zgodność tego modelu niestandardowego.", "modelCapabilityTools": "Wywołania narzędzi", "modelCapabilityVision": "Obrazy wejściowe", "modelCapabilityToolsShort": "Narzędzia", @@ -137,6 +141,14 @@ "apiType": "API Type", "baseURL": "Base URL", "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", "clear": "Clear", "keySavedReplace": "Key saved — enter new to replace", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 658863c6e..ee5cdf33b 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -85,7 +85,11 @@ "editModel": "Изменить модель", "modelEditorDescription": "Провайдер, модель, учётные данные и возможности.", "modelName": "Название", + "modelConfiguration": "Модель", + "connectionSettings": "Подключение", "modelCapabilities": "Возможности", + "modelCapabilitiesDetected": "Определены по выбранной модели.", + "modelCapabilitiesManual": "Укажите совместимость этой пользовательской модели.", "modelCapabilityTools": "Вызов инструментов", "modelCapabilityVision": "Ввод изображений", "modelCapabilityToolsShort": "Инструменты", @@ -137,6 +141,14 @@ "apiType": "API Type", "baseURL": "Base URL", "modelID": "Model ID", + "customModelID": "ID пользовательской модели", + "customModel": "Пользовательская модель…", + "advancedModelSettings": "Расширенные настройки", + "outputLimit": "Лимит вывода", + "outputLimitAutomatic": "Автоматическая рекомендация", + "supported": "Поддерживается", + "unsupported": "Не поддерживается", + "tokens": "токенов", "maxOutputTokens": "Max output tokens", "clear": "Clear", "keySavedReplace": "Key saved — enter new to replace", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index b7366c9a5..43e2fb8c4 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -85,7 +85,11 @@ "editModel": "编辑模型", "modelEditorDescription": "提供商、模型、凭据和能力。", "modelName": "名称", + "modelConfiguration": "模型", + "connectionSettings": "连接", "modelCapabilities": "能力", + "modelCapabilitiesDetected": "根据所选模型检测。", + "modelCapabilitiesManual": "声明此自定义模型的兼容性。", "modelCapabilityTools": "工具调用", "modelCapabilityVision": "图像输入", "modelCapabilityToolsShort": "工具", @@ -137,6 +141,14 @@ "apiType": "API 类型", "baseURL": "Base URL", "modelID": "模型 ID", + "customModelID": "自定义模型 ID", + "customModel": "自定义模型…", + "advancedModelSettings": "高级设置", + "outputLimit": "输出限制", + "outputLimitAutomatic": "自动建议", + "supported": "支持", + "unsupported": "不支持", + "tokens": "token", "maxOutputTokens": "最大输出 token 数", "clear": "清除", "keySavedReplace": "密钥已保存,输入新密钥以替换", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index d136b33cc..fde5d7293 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -134,7 +134,11 @@ export const dialogMessageDefaults = { editModel: 'Edit model', modelEditorDescription: 'Provider, model, credentials, and capabilities.', modelName: 'Name', + modelConfiguration: 'Model', + connectionSettings: 'Connection', modelCapabilities: 'Capabilities', + modelCapabilitiesDetected: 'Detected from the selected model.', + modelCapabilitiesManual: 'Declare compatibility for this custom model.', modelCapabilityTools: 'Tool calling', modelCapabilityVision: 'Image input', modelCapabilityToolsShort: 'Tools', @@ -187,6 +191,14 @@ export const dialogMessageDefaults = { apiType: 'API Type', baseURL: 'Base URL', 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', clear: 'Clear', keySavedReplace: 'Key saved — enter new to replace', diff --git a/src/app/ai/chat/provider-models.ts b/src/app/ai/chat/provider-models.ts index 2e84177fe..d0de36797 100644 --- a/src/app/ai/chat/provider-models.ts +++ b/src/app/ai/chat/provider-models.ts @@ -7,6 +7,12 @@ type OpenRouterModel = { id?: unknown name?: unknown supported_parameters?: unknown + architecture?: { + input_modalities?: unknown + } + top_provider?: { + max_completion_tokens?: unknown + } } type OpenRouterModelsResponse = { @@ -31,9 +37,20 @@ function isToolCapableOpenRouterModel(model: OpenRouterModel) { export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption | null { if (!isToolCapableOpenRouterModel(model)) 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 { 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)) } + : {}) } } diff --git a/src/app/ai/models/catalog.ts b/src/app/ai/models/catalog.ts new file mode 100644 index 000000000..161cabce6 --- /dev/null +++ b/src/app/ai/models/catalog.ts @@ -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> = { + 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 +} + +type ModelsDevCatalog = Record + +let catalogPromise: Promise | 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 { + const cached = await readCacheJson( + 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 { + 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 +} diff --git a/src/components/settings/models/ProfileEditor.vue b/src/components/settings/models/ProfileEditor.vue index 1c3739f84..6c69e274b 100644 --- a/src/components/settings/models/ProfileEditor.vue +++ b/src/components/settings/models/ProfileEditor.vue @@ -4,13 +4,17 @@ import { AlertDialogAction, AlertDialogCancel, AlertDialogDescription, - AlertDialogTitle + AlertDialogTitle, + CollapsibleContent, + CollapsibleRoot, + CollapsibleTrigger } from 'reka-ui' import { useI18n } from '@open-pencil/vue' import { ACP_AGENTS, AI_PROVIDERS, type AIProviderID } from '@open-pencil/core/constants' import { refreshAIProviderStatus } from '@/app/ai/chat/storage' +import { resolveModelsDevModel } from '@/app/ai/models/catalog' import { testProviderConnection, type ProviderConnectionTestFailureReason @@ -38,6 +42,9 @@ import AppSelect from '@/components/ui/AppSelect.vue' import AppSwitch from '@/components/ui/AppSwitch.vue' 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 emit = defineEmits<{ done: []; deleted: [] }>() const { dialogs } = useI18n() @@ -48,6 +55,9 @@ const saveError = ref(null) const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle') const connectionTestReason = ref(null) 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( () => 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) return ACP_AGENTS.find((agent) => agent.id === agentID)?.name ?? draft.providerID }) -const modelOptions = computed(() => - providerDef.value.models.map((model) => ({ value: model.id, label: model.name })) +const modelOptions = computed(() => [ + ...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(() => [ + ...(knownModel.value?.capabilities ?? ['tools']) +]) +const outputTokenRecommendation = computed( + () => knownModel.value?.recommendedMaxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS ) const modelDisplayName = computed(() => { const modelId = draft.customModelID.trim() || draft.modelID @@ -72,13 +102,18 @@ const visionEnabled = capabilityModel('vision') const canSave = computed( () => 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(() => { if (isACP.value) return false if (!keyInput.value.trim() && !hasExistingKey.value) 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) { @@ -103,6 +138,24 @@ async function refreshKeyStatus(): Promise { keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing' } +function effectiveModelID(): string { + return customModelSelected.value ? draft.customModelID.trim() : draft.modelID.trim() +} + +async function refreshCatalogModel(): Promise { + 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 { draft.providerID = providerID draft.sourceConnectionId = null @@ -111,17 +164,34 @@ function updateProvider(providerID: AIProviderID): void { draft.customModelID = '' draft.customBaseURL = '' draft.customAPIType = 'completions' + advancedOpen.value = false + customModelSelected.value = false if (providerID.startsWith('acp:')) { draft.capabilities = ['tools'] if (!draft.name.trim()) draft.name = providerDisplayName.value + } else { + applyKnownModelMetadata() } keyInput.value = '' resetConnectionTest() + void refreshCatalogModel() void refreshKeyStatus() } 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 + customModelSelected.value = false + draft.customModelID = '' + applyKnownModelMetadata() + void refreshCatalogModel() if (!draft.name.trim()) draft.name = modelDisplayName.value resetConnectionTest() } @@ -129,6 +199,7 @@ function updateModel(modelID: string): void { async function save(): Promise { saveError.value = null try { + applyKnownModelMetadata() if (!draft.name.trim()) draft.name = modelDisplayName.value || providerDisplayName.value const profile = saveModelProfileDraft(draft) if (keyInput.value.trim()) { @@ -188,6 +259,13 @@ watch( () => [draft.customBaseURL, draft.customModelID, draft.customAPIType, draft.modelID], resetConnectionTest ) +watch( + () => draft.customModelID, + () => { + if (customModelSelected.value) void refreshCatalogModel() + } +) +void refreshCatalogModel() void refreshKeyStatus() @@ -235,15 +313,41 @@ void refreshKeyStatus() -
-

{{ dialogs.modelCapabilities }}

-
-
- {{ dialogs.modelCapabilityTools }} - + + + + {{ dialogs.advancedModelSettings }} + + +
+
+

{{ dialogs.modelCapabilities }}

+

+ {{ + knownModel ? dialogs.modelCapabilitiesDetected : dialogs.modelCapabilitiesManual + }} +

+
+ +
+
+ {{ dialogs.modelCapabilityTools }} + + {{ + knownCapabilities.includes('tools') ? dialogs.supported : dialogs.unsupported + }} + + +
+
+ {{ dialogs.modelCapabilityVision }} + + {{ + knownCapabilities.includes('vision') ? dialogs.supported : dialogs.unsupported + }} + + +
+
+ +
+

{{ dialogs.outputLimit }}

+

+ {{ dialogs.outputLimitAutomatic }} · + {{ outputTokenRecommendation.toLocaleString() }} + {{ dialogs.tokens }} +

+
-
- {{ dialogs.modelCapabilityVision }} - -
-
-
+ +
diff --git a/tests/e2e/chat/panel.spec.ts b/tests/e2e/chat/panel.spec.ts index b5847ed0a..8bb451aca 100644 --- a/tests/e2e/chat/panel.spec.ts +++ b/tests/e2e/chat/panel.spec.ts @@ -242,6 +242,8 @@ test('OpenRouter accepts a custom model ID from provider settings', async () => await page.keyboard.press('Escape') await page.getByTestId('provider-settings-trigger').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') await expect(customModelInput).toBeVisible() 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.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.getByTestId('app-settings-done').click() diff --git a/tests/engine/app/ai/catalog.test.ts b/tests/engine/app/ai/catalog.test.ts new file mode 100644 index 000000000..8eee2e622 --- /dev/null +++ b/tests/engine/app/ai/catalog.test.ts @@ -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() + }) +}) diff --git a/tests/engine/app/ai/model.test.ts b/tests/engine/app/ai/model.test.ts index 9feb4889e..1bdd7bbfa 100644 --- a/tests/engine/app/ai/model.test.ts +++ b/tests/engine/app/ai/model.test.ts @@ -114,9 +114,16 @@ describe('normalizeOpenRouterModel', () => { normalizeOpenRouterModel({ id: 'meta-llama/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' }) + ).toEqual({ + id: 'meta-llama/llama-3.3-70b-instruct', + name: 'Llama 3.3 70B Instruct', + capabilities: ['tools', 'vision'], + recommendedMaxOutputTokens: 32_768 + }) }) test('skips OpenRouter models without tool support', () => {