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:
parent
c7c505ccc9
commit
4986fb8b54
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ export interface ModelOption {
|
|||
id: string
|
||||
name: string
|
||||
tag?: string
|
||||
capabilities?: readonly ('tools' | 'vision')[]
|
||||
recommendedMaxOutputTokens?: number
|
||||
}
|
||||
|
||||
export interface AIProviderDef {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "キーが保存されました - 上書きするには新しいキーを入力してください",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "密钥已保存,输入新密钥以替换",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
104
src/app/ai/models/catalog.ts
Normal file
104
src/app/ai/models/catalog.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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<string | null>(null)
|
|||
const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
const connectionTestReason = ref<ProviderConnectionTestFailureReason | null>(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<AIModelCapability[]>(() => [
|
||||
...(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<void> {
|
|||
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 {
|
||||
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<void> {
|
||||
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()
|
||||
</script>
|
||||
|
||||
|
|
@ -235,15 +313,41 @@ void refreshKeyStatus()
|
|||
</ProviderSettingsField>
|
||||
|
||||
<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">
|
||||
<AppSelect
|
||||
:model-value="draft.modelID"
|
||||
:model-value="selectedModelValue"
|
||||
:options="modelOptions"
|
||||
:label="dialogs.modelID"
|
||||
@update:model-value="updateModel(String($event))"
|
||||
/>
|
||||
</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">
|
||||
<ProviderSettingsInput
|
||||
v-model="draft.customBaseURL"
|
||||
|
|
@ -252,15 +356,6 @@ void refreshKeyStatus()
|
|||
/>
|
||||
</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
|
||||
v-if="draft.providerID === 'openai-compatible'"
|
||||
:label="dialogs.apiType"
|
||||
|
|
@ -275,17 +370,6 @@ void refreshKeyStatus()
|
|||
/>
|
||||
</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
|
||||
v-model="keyInput"
|
||||
:label="dialogs.apiKey"
|
||||
|
|
@ -305,19 +389,58 @@ void refreshKeyStatus()
|
|||
/>
|
||||
</template>
|
||||
|
||||
<div class="rounded border border-border p-2.5">
|
||||
<p class="mb-2 text-[11px] font-medium text-surface">{{ dialogs.modelCapabilities }}</p>
|
||||
<CollapsibleRoot v-model:open="advancedOpen" class="rounded border border-border">
|
||||
<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 items-center justify-between gap-3">
|
||||
<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 class="flex items-center justify-between gap-3">
|
||||
<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 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>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
|
||||
<p v-if="saveError" class="text-[10px] text-danger" role="alert">{{ saveError }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
43
tests/engine/app/ai/catalog.test.ts
Normal file
43
tests/engine/app/ai/catalog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue