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
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 d’outils",
|
"modelCapabilityTools": "Appels d’outils",
|
||||||
"modelCapabilityVision": "Entrée d’images",
|
"modelCapabilityVision": "Entrée d’images",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "キーが保存されました - 上書きするには新しいキーを入力してください",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "密钥已保存,输入新密钥以替换",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
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({
|
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', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue