From b02fc1f3a653ce4d8435f2cb546c591d12cccc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 19 Aug 2026 01:30:56 -0300 Subject: [PATCH] Add dynamic AI model catalog and reasoning controls Fetch and cache the models.dev catalog asynchronously, replacing bundled model lists when catalog data is available while preserving local and user-configured providers. Update live AI Assistant instances after refresh, defer startup requests until the UI is ready, and cancel in-flight catalog requests during shutdown. Prioritize providers with configured credentials and infer each provider's cheapest title-generation model from catalog pricing. Add model reasoning metadata, effort controls, request serialization, chat persistence, and localized UI labels. Refresh the bundled fallback models and support API keys for arbitrary catalog providers. Fix ACP Agent mode message chunks enqueue ordering issue (ACP thought chunks were enqueued twice while answer chunks were enqueued once). Fix UIDropDownList WrapContent sizing so dropdown width follows its text, skin, and padding, and tighten UIDropDown automatic height sizing. --- bin/assets/i18n/de.xml | 4 + bin/assets/i18n/en.xml | 4 + bin/assets/i18n/fr.xml | 4 + bin/assets/i18n/zh.xml | 4 + bin/assets/plugins/aiassistant.json | 85 ++++- include/eepp/ui/uidropdownlist.hpp | 2 + src/eepp/ui/uidropdown.cpp | 2 +- src/eepp/ui/uidropdownlist.cpp | 13 + .../plugins/aiassistant/aiassistantplugin.cpp | 243 ++++++++++--- .../plugins/aiassistant/aiassistantplugin.hpp | 11 + .../ecode/plugins/aiassistant/chatui.cpp | 145 +++++++- .../ecode/plugins/aiassistant/chatui.hpp | 10 +- .../plugins/aiassistant/llmmodelcatalog.cpp | 341 ++++++++++++++++++ .../plugins/aiassistant/llmmodelcatalog.hpp | 45 +++ .../ecode/plugins/aiassistant/protocol.hpp | 16 +- 15 files changed, 863 insertions(+), 66 deletions(-) create mode 100644 src/tools/ecode/plugins/aiassistant/llmmodelcatalog.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp diff --git a/bin/assets/i18n/de.xml b/bin/assets/i18n/de.xml index 859b38d52..767bcf720 100644 --- a/bin/assets/i18n/de.xml +++ b/bin/assets/i18n/de.xml @@ -961,4 +961,8 @@ Verwendet strftime-Formatbezeichner. Die Dateierweiterung richtet sich nach dem Das Bildschirmfoto konnte nicht gespeichert werden. Bildschirmfoto gespeichert: Ordner öffnen + Denkaufwand + Aus + Standard + Ein diff --git a/bin/assets/i18n/en.xml b/bin/assets/i18n/en.xml index b4bb94187..21e5eee05 100644 --- a/bin/assets/i18n/en.xml +++ b/bin/assets/i18n/en.xml @@ -945,4 +945,8 @@ Uses strftime format specifiers. The file extension follows the selected screens Couldn't save the screenshot. Screenshot saved: Open Folder + Reasoning Effort + Off + Default + On diff --git a/bin/assets/i18n/fr.xml b/bin/assets/i18n/fr.xml index c28f7677e..b508b0420 100644 --- a/bin/assets/i18n/fr.xml +++ b/bin/assets/i18n/fr.xml @@ -940,4 +940,8 @@ Utilise les spécificateurs de format strftime. L’extension du fichier corresp Impossible d’enregistrer la capture d’écran. Capture d’écran enregistrée : Ouvrir le dossier + Effort de raisonnement + Désactivé + Par défaut + Activé diff --git a/bin/assets/i18n/zh.xml b/bin/assets/i18n/zh.xml index 8e8d1de4e..481214551 100644 --- a/bin/assets/i18n/zh.xml +++ b/bin/assets/i18n/zh.xml @@ -719,4 +719,8 @@ file in the directory tree. 无法保存屏幕截图。 屏幕截图已保存: 打开屏幕截图文件夹 + 推理强度 + 关闭 + 默认 + 开启 diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json index 559022036..91052dfe3 100644 --- a/bin/assets/plugins/aiassistant.json +++ b/bin/assets/plugins/aiassistant.json @@ -4,6 +4,24 @@ "anthropic": { "api_url": "https://api.anthropic.com/v1/messages", "models": [ + { + "display_name": "Claude Opus 5", + "name": "claude-opus-5", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high", "xhigh", "max"] }] + }, + { + "display_name": "Claude Sonnet 5", + "name": "claude-sonnet-5", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high", "xhigh", "max"] }] + }, + { + "display_name": "Claude Opus 4.8", + "name": "claude-opus-4-8", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high", "xhigh", "max"] }] + }, { "display_name": "Claude Opus 4.6", "name": "claude-opus-4-6" @@ -64,6 +82,21 @@ "google": { "api_url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "models": [ + { + "name": "gemini-3.7-flash", + "display_name": "Gemini 3.7 Flash", + "max_tokens": 1000000, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high"] }] + }, + { + "name": "gemini-3.5-flash-lite", + "display_name": "Gemini 3.5 Flash Lite", + "max_tokens": 1000000, + "cheapest": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["minimal", "low", "medium", "high"] }] + }, { "name": "gemini-2.5-flash-lite", "display_name": "Gemini 2.5 Flash Lite", @@ -106,6 +139,21 @@ "mistral": { "api_url": "https://api.mistral.ai/v1/chat/completions", "models": [ + { + "display_name": "Mistral Medium 3.5", + "max_tokens": 131000, + "name": "mistral-medium-latest", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["none", "high"] }] + }, + { + "display_name": "Mistral Small 4", + "max_tokens": 131000, + "name": "mistral-small-latest", + "cheapest": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["none", "high"] }] + }, { "display_name": "codestral-latest", "max_tokens": 256000, @@ -144,6 +192,25 @@ "api_url": "https://api.openai.com/v1/chat/completions", "display_name": "OpenAI", "models": [ + { + "display_name": "GPT-5.6 Terra", + "name": "gpt-5.6-terra", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"] }] + }, + { + "display_name": "GPT-5.6 Sol", + "name": "gpt-5.6-sol", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"] }] + }, + { + "display_name": "GPT-5.6 Luna", + "name": "gpt-5.6-luna", + "cheapest": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["none", "low", "medium", "high", "xhigh", "max"] }] + }, { "max_tokens": 128000, "name": "gpt-4o" @@ -238,6 +305,18 @@ "api_url": "https://api.x.ai/v1/chat/completions", "display_name": "xAI", "models": [ + { + "name": "grok-4.6", + "display_name": "Grok 4.6", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high", "xhigh"] }] + }, + { + "name": "grok-4.5", + "display_name": "Grok 4.5", + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low", "medium", "high"] }] + }, { "name": "grok-4-1-fast-non-reasoning-latest", "cheapest": true @@ -454,7 +533,7 @@ } ] }, - "moonshot": { + "moonshotai": { "api_url": "https://api.moonshot.ai/v1/chat/completions", "display_name": "Moonshot", "models": [ @@ -497,7 +576,7 @@ "fetch_models_url": "http://localhost:8080/api/tags", "open_api": true }, - "together": { + "togetherai": { "api_url": "https://api.together.xyz/v1/chat/completions", "display_name": "Together AI", "models": [ @@ -532,7 +611,7 @@ } ] }, - "xiaomimimo": { + "xiaomi": { "api_url": "https://api.xiaomimimo.com/v1/chat/completions", "display_name": "Xiaomi MiMo", "models": [ diff --git a/include/eepp/ui/uidropdownlist.hpp b/include/eepp/ui/uidropdownlist.hpp index 756332c52..3218a9ae6 100644 --- a/include/eepp/ui/uidropdownlist.hpp +++ b/include/eepp/ui/uidropdownlist.hpp @@ -59,6 +59,8 @@ class EE_API UIDropDownList : public UIDropDown { virtual void onClassChange(); + virtual void onAutoSize(); + void destroyListBox(); void updateClickStep(); diff --git a/src/eepp/ui/uidropdown.cpp b/src/eepp/ui/uidropdown.cpp index 7186e574e..724a0e554 100644 --- a/src/eepp/ui/uidropdown.cpp +++ b/src/eepp/ui/uidropdown.cpp @@ -80,7 +80,7 @@ void UIDropDown::onAutoSize() { if ( mHeightPolicy == SizePolicy::WrapContent ) { setInternalPixelsHeight( eeceil( max + mPaddingPx.Top + mPaddingPx.Bottom ) ); - } else if ( ( ( mFlags & UI_AUTO_SIZE ) || 0 == getSize().getHeight() ) && max > 0 ) { + } else if ( ( mFlags & UI_AUTO_SIZE ) && 0 == getSize().getHeight() && max > 0 ) { setInternalPixelsHeight( eeceil( max ) ); } } diff --git a/src/eepp/ui/uidropdownlist.cpp b/src/eepp/ui/uidropdownlist.cpp index eac2638b7..2190af651 100644 --- a/src/eepp/ui/uidropdownlist.cpp +++ b/src/eepp/ui/uidropdownlist.cpp @@ -98,6 +98,19 @@ Uint32 UIDropDownList::onKeyDown( const KeyEvent& Event ) { return UIDropDown::onKeyDown( Event ); } +void UIDropDownList::onAutoSize() { + UIDropDown::onAutoSize(); + + Float max = eemax( PixelDensity::dpToPxI( getSkinSize().getWidth() ), + getTextWidth() ); + + if ( mWidthPolicy == SizePolicy::WrapContent ) { + setInternalPixelsWidth( eeceil( max + mPaddingPx.Left + mPaddingPx.Right ) ); + } else if ( ( mFlags & UI_AUTO_SIZE ) && 0 == getSize().getWidth() && max > 0 ) { + setInternalPixelsWidth( eeceil( max ) ); + } +} + UIDropDownList* UIDropDownList::showList() { if ( NULL == mListBox ) return this; diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp index 5a0843f38..da3e3e2c1 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp @@ -52,6 +52,12 @@ static std::map parseLLMProviders( const nlohmann::jso if ( providerJson.contains( "version" ) ) { provider.version = providerJson["version"].get(); } + if ( providerJson.contains( "api_key_env_vars" ) && + providerJson["api_key_env_vars"].is_array() ) { + for ( const auto& env : providerJson["api_key_env_vars"] ) + if ( env.is_string() ) + provider.apiKeyEnvVars.emplace_back( env.get() ); + } if ( providerJson.contains( "models" ) ) { const auto& modelsJson = providerJson["models"]; @@ -88,6 +94,9 @@ static std::map parseLLMProviders( const nlohmann::jso if ( modelJson.contains( "tool_calling" ) ) { model.toolCalling = modelJson.value( "tool_calling", false ); } + if ( modelJson.contains( "reasoning_options" ) ) + model.reasoningConfiguration = LLMModelCatalog::parseReasoningConfiguration( + modelJson["reasoning_options"] ); if ( modelJson.contains( "cache_configuration" ) && !modelJson["cache_configuration"].is_null() ) { @@ -112,6 +121,39 @@ static std::map parseLLMProviders( const nlohmann::jso return providers; } +static void mergeLLMProviders( LLMProviders& destination, LLMProviders providers, + bool overrideModels ) { + for ( auto& [key, value] : providers ) { + auto providerIt = destination.find( key ); + if ( providerIt == destination.end() ) { + destination.insert( { key, std::move( value ) } ); + continue; + } + auto& provider = providerIt->second; + if ( !value.apiUrl.empty() ) + provider.apiUrl = std::move( value.apiUrl ); + if ( !value.name.empty() ) + provider.name = std::move( value.name ); + if ( value.displayName ) + provider.displayName = std::move( value.displayName ); + if ( value.fetchModelsUrl ) + provider.fetchModelsUrl = std::move( value.fetchModelsUrl ); + if ( !value.apiKeyEnvVars.empty() ) + provider.apiKeyEnvVars = std::move( value.apiKeyEnvVars ); + for ( auto& model : value.models ) { + auto current = std::find_if( provider.models.begin(), provider.models.end(), + [&model]( const LLMModel& candidate ) { + return candidate.provider == model.provider && + candidate.name == model.name; + } ); + if ( current == provider.models.end() ) + provider.models.emplace_back( std::move( model ) ); + else if ( overrideModels ) + *current = std::move( model ); + } + } +} + static std::map parseACPAgents( const nlohmann::json& j ) { std::map agents; for ( const auto& item : j.items() ) { @@ -166,6 +208,10 @@ AIAssistantPlugin::AIAssistantPlugin( PluginManager* pluginManager, bool sync ) } AIAssistantPlugin::~AIAssistantPlugin() { + { + std::lock_guard lock( mModelCatalogMutex ); + mModelCatalog.reset(); + } if ( SceneManager::existsSingleton() && !SceneManager::instance()->isShuttingDown() ) { getPluginContext()->getSplitter()->forEachWidgetClass( "llm_chatui", []( UIWidget* widget ) { @@ -203,24 +249,53 @@ void AIAssistantPlugin::load( PluginManager* pluginManager ) { return processMessage( notification ); } ); - std::vector paths; - std::string path( pluginManager->getResourcesPath() + "plugins/aiassistant.json" ); - if ( FileSystem::fileExists( path ) ) - paths.emplace_back( path ); - path = pluginManager->getPluginsPath() + "aiassistant.json"; - if ( FileSystem::fileExists( path ) || + const std::string bundledPath( pluginManager->getResourcesPath() + "plugins/aiassistant.json" ); + const std::string userPath( pluginManager->getPluginsPath() + "aiassistant.json" ); + if ( FileSystem::fileExists( userPath ) || FileSystem::fileWrite( - path, "{\n\"config\":{},\n \"keybindings\":{},\n\"providers\":{}\n}\n" ) ) { - mConfigPath = path; - paths.emplace_back( path ); - } - if ( paths.empty() ) + userPath, "{\n\"config\":{},\n \"keybindings\":{},\n\"providers\":{}\n}\n" ) ) + mConfigPath = userPath; + if ( !FileSystem::fileExists( bundledPath ) && mConfigPath.empty() ) return; - for ( const auto& tpath : paths ) { + + if ( FileSystem::fileExists( bundledPath ) ) { try { - loadAIAssistantConfig( tpath, mConfigPath == tpath ); + loadAIAssistantConfig( bundledPath, false ); } catch ( const json::exception& e ) { - Log::error( "Parsing linter \"%s\" failed:\n%s", tpath.c_str(), e.what() ); + Log::error( "Parsing AI assistant config \"%s\" failed:\n%s", bundledPath.c_str(), + e.what() ); + } + } + + LLMModelCatalog::Settings catalogSettings; + catalogSettings.cachePath = getPluginStatePath() + "models.json"; + std::string userData; + if ( !mConfigPath.empty() && FileSystem::fileGet( mConfigPath, userData ) ) { + const auto userJson = json::parse( userData, nullptr, false, true ); + if ( userJson.is_object() && userJson.contains( "config" ) && + userJson["config"].is_object() ) { + const auto& userConfig = userJson["config"]; + if ( userConfig.contains( "model_catalog_enabled" ) && + userConfig["model_catalog_enabled"].is_boolean() ) + catalogSettings.enabled = userConfig["model_catalog_enabled"].get(); + if ( userConfig.contains( "model_catalog_url" ) && + userConfig["model_catalog_url"].is_string() ) + catalogSettings.url = userConfig["model_catalog_url"].get(); + if ( userConfig.contains( "model_catalog_refresh_hours" ) && + userConfig["model_catalog_refresh_hours"].is_number_unsigned() ) + catalogSettings.refreshIntervalHours = + userConfig["model_catalog_refresh_hours"].get(); + } + } + LLMModelCatalog catalog( catalogSettings ); + catalog.loadCached( mProviders ); + + if ( !mConfigPath.empty() ) { + try { + loadAIAssistantConfig( mConfigPath, true ); + } catch ( const json::exception& e ) { + Log::error( "Parsing AI assistant config \"%s\" failed:\n%s", mConfigPath.c_str(), + e.what() ); } } @@ -283,13 +358,74 @@ void AIAssistantPlugin::load( PluginManager* pluginManager ) { }; getPluginContext()->getConfig().addTabWidgetType( "llm_chatui", config ); + { + std::lock_guard lock( mModelCatalogMutex ); + mModelCatalogSettings = catalogSettings; + } if ( mReady ) { fireReadyCbs(); setReady( clock.getElapsedTime() ); + // Re-enabled plugins already have a UI context. Initial startup safely defers this + // request until PluginMessageType::UIReady. + refreshModelCatalogAsync(); } } +void AIAssistantPlugin::refreshModelCatalogAsync() { + LLMModelCatalog::Settings settings; + Node* mainThreadNode; + LLMModelCatalog* catalog; + { + std::lock_guard lock( mModelCatalogMutex ); + if ( mModelCatalogRefreshStarted || !mModelCatalogSettings || + !mModelCatalogSettings->enabled || mModelCatalogSettings->url.empty() ) + return; + mainThreadNode = + mManager->getSplitter() ? mManager->getSplitter()->getBaseLayout() : nullptr; + if ( !mainThreadNode || !mainThreadNode->getSceneNode() ) + return; + settings = *mModelCatalogSettings; + mModelCatalog = std::make_unique( settings ); + catalog = mModelCatalog.get(); + mModelCatalogRefreshStarted = true; + } + auto* manager = mManager; + catalog->refreshAsync( mProviders, [manager, mainThreadNode]( LLMProviders providers ) mutable { + mainThreadNode->runOnMainThread( [providers = std::move( providers ), manager]() mutable { + if ( manager->isClosing() ) + return; + auto* plugin = manager->get( AIAssistantPlugin::Definition().id ); + if ( plugin ) + static_cast( plugin )->applyModelCatalog( + std::move( providers ) ); + else + Log::warning( + "Could not apply refreshed LLM model catalog: plugin is unavailable" ); + } ); + } ); +} + +void AIAssistantPlugin::applyModelCatalog( LLMProviders providers ) { + std::string userData; + if ( !mConfigPath.empty() && FileSystem::fileGet( mConfigPath, userData ) ) { + const auto userJson = json::parse( userData, nullptr, false, true ); + try { + if ( userJson.is_object() && userJson.contains( "providers" ) && + userJson["providers"].is_object() ) + mergeLLMProviders( providers, parseLLMProviders( userJson["providers"] ), true ); + } catch ( const json::exception& error ) { + Log::warning( "Could not reapply AI Assistant user providers after catalog refresh: %s", + error.what() ); + } + } + mProviders = std::move( providers ); + getPluginContext()->getSplitter()->forEachWidgetClass( + "llm_chatui", [this]( UIWidget* widget ) { + static_cast( widget )->setProviders( LLMProviders( mProviders ), true ); + } ); +} + void AIAssistantPlugin::displayBrokenUserConfigFileWarning() { if ( nullptr == getUISceneNode() ) return; @@ -337,6 +473,14 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd if ( j.contains( "config" ) ) { auto& config = j["config"]; + if ( updateConfigFile ) { + if ( !config.contains( "model_catalog_enabled" ) ) + config["model_catalog_enabled"] = true; + if ( !config.contains( "model_catalog_url" ) ) + config["model_catalog_url"] = "https://models.dev/api.json"; + if ( !config.contains( "model_catalog_refresh_hours" ) ) + config["model_catalog_refresh_hours"] = 24; + } if ( config.contains( "display_reasoning" ) && config["display_reasoning"].is_boolean() ) mDisplayReasoning = config.value( "display_reasoning", false ); @@ -389,9 +533,9 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd config["openrouter_api_key"] = mApiKeys["openrouter"]; if ( config.contains( "moonshot_api_key" ) ) - mApiKeys["moonshot"] = config.value( "moonshot_api_key", "" ); + mApiKeys["moonshotai"] = config.value( "moonshot_api_key", "" ); else if ( updateConfigFile ) - config["moonshot_api_key"] = mApiKeys["moonshot"]; + config["moonshot_api_key"] = mApiKeys["moonshotai"]; if ( config.contains( "nvidia_api_key" ) ) mApiKeys["nvidia"] = config.value( "nvidia_api_key", "" ); @@ -399,14 +543,22 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd config["nvidia_api_key"] = mApiKeys["nvidia"]; if ( config.contains( "together_api_key" ) ) - mApiKeys["together"] = config.value( "together_api_key", "" ); + mApiKeys["togetherai"] = config.value( "together_api_key", "" ); else if ( updateConfigFile ) - config["together_api_key"] = mApiKeys["together"]; + config["together_api_key"] = mApiKeys["togetherai"]; if ( config.contains( "mimo_api_key" ) ) - mApiKeys["mimo"] = config.value( "mimo_api_key", "" ); + mApiKeys["xiaomi"] = config.value( "mimo_api_key", "" ); else if ( updateConfigFile ) - config["mimo_api_key"] = mApiKeys["mimo"]; + config["mimo_api_key"] = mApiKeys["xiaomi"]; + + if ( config.contains( "api_keys" ) && config["api_keys"].is_object() ) { + for ( const auto& [provider, apiKey] : config["api_keys"].items() ) + if ( apiKey.is_string() ) + mApiKeys[provider] = apiKey.get(); + } else if ( updateConfigFile ) { + config["api_keys"] = json::object(); + } } if ( mKeyBindings.empty() ) { @@ -460,32 +612,8 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd if ( mProviders.empty() ) { mProviders = std::move( providers ); } else { - for ( const auto& [key, value] : providers ) { - auto providerIt = mProviders.find( key ); - if ( providerIt != mProviders.end() ) { - auto& provider = providerIt->second; - if ( !value.apiUrl.empty() ) - provider.apiUrl = value.apiUrl; - if ( !value.name.empty() ) - provider.name = value.name; - if ( value.displayName ) - provider.displayName = value.displayName; - if ( value.fetchModelsUrl ) - provider.fetchModelsUrl = value.fetchModelsUrl; - // Add model if not exists - for ( auto& model : value.models ) { - if ( std::find_if( provider.models.begin(), provider.models.end(), - [&model]( const LLMModel& cmodel ) { - return cmodel.provider == model.provider && - cmodel.name == model.name; - } ) == provider.models.end() ) { - provider.models.emplace_back( std::move( model ) ); - } - } - } else { - mProviders.insert( { key, std::move( value ) } ); - } - } + // User models override catalog metadata; bundled configs only fill gaps. + mergeLLMProviders( mProviders, std::move( providers ), updateConfigFile ); } if ( getUISceneNode() ) @@ -529,6 +657,8 @@ void AIAssistantPlugin::onUnregisterEditor( UICodeEditor* editor ) { PluginRequestHandle AIAssistantPlugin::processMessage( const PluginMessage& msg ) { switch ( msg.type ) { case ecode::PluginMessageType::UIReady: { + refreshModelCatalogAsync(); + for ( const auto& kb : mKeyBindings ) { if ( !String::startsWith( kb.first, "ai-" ) ) { getPluginContext()->getMainLayout()->getKeyBindings().addKeybindString( @@ -613,18 +743,29 @@ std::optional AIAssistantPlugin::getApiKeyFromProvider( const std:: ret = getenv( "PERPLEXITY_API_KEY" ); } else if ( provider == "openrouter" ) { ret = getenv( "OPENROUTER_API_KEY" ); - } else if ( provider == "moonshot" ) { + } else if ( provider == "moonshotai" ) { ret = getenv( "MOONSHOT_API_KEY" ); } else if ( provider == "nvidia" ) { ret = getenv( "NVIDIA_API_KEY" ); - } else if ( provider == "together" ) { + } else if ( provider == "togetherai" ) { ret = getenv( "TOGETHER_API_KEY" ); - } else if ( provider == "mimo" ) { + } else if ( provider == "xiaomi" ) { ret = getenv( "MIMO_API_KEY" ); } else { const auto& providerModelIt = instance->mProviders.find( provider ); - if ( providerModelIt != instance->mProviders.end() && providerModelIt->second.openApi ) - ret = OPEN_API_KEY; + if ( providerModelIt != instance->mProviders.end() ) { + for ( const auto& env : providerModelIt->second.apiKeyEnvVars ) { + if ( !String::icontains( env, "key" ) && !String::icontains( env, "token" ) ) + continue; + ret = getenv( env.c_str() ); + if ( ret ) + break; + } + if ( !ret && providerModelIt->second.apiKeyEnvVars.size() == 1 ) + ret = getenv( providerModelIt->second.apiKeyEnvVars.front().c_str() ); + if ( !ret && providerModelIt->second.openApi ) + ret = OPEN_API_KEY; + } } if ( ret ) diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp index 827bf34cc..a0fb32949 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp @@ -2,8 +2,11 @@ #include "../plugin.hpp" #include "../pluginmanager.hpp" +#include "llmmodelcatalog.hpp" #include "protocol.hpp" +#include + namespace ecode { class LLMChatUI; @@ -64,6 +67,10 @@ class AIAssistantPlugin : public PluginBase { AIAssistantConfig mConfig; Uint32 mAIChatButtonPosCbId{ 0 }; std::string mConfigFileError; + std::mutex mModelCatalogMutex; + std::optional mModelCatalogSettings; + std::unique_ptr mModelCatalog; + bool mModelCatalogRefreshStarted{ false }; AIAssistantPlugin( PluginManager* pluginManager, bool sync ); @@ -82,6 +89,10 @@ class AIAssistantPlugin : public PluginBase { void initUI(); void displayBrokenUserConfigFileWarning(); + + void refreshModelCatalogAsync(); + + void applyModelCatalog( LLMProviders providers ); }; } // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 873c7b1c0..81e760035 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -333,7 +333,7 @@ class AgentSessionHistoryModel : public Model { }; static const char* DEFAULT_PROVIDER = "google"; -static const char* DEFAULT_MODEL = "gemini-2.5-flash"; +static const char* DEFAULT_MODEL = "gemini-3.7-flash"; const char* LLMChat::roleToString( Role role ) { switch ( role ) { @@ -471,6 +471,11 @@ DropDownList.role_ui { border-color: transparent; background-color: var(--tab-back); } +.reasoning_ui { + min-width: 64dp; + max-width: 96dp; + margin-right: 4dp; +} .llm_chatui DropDownList:hover, .model_ui:hover, .agent_ui:hover { @@ -545,6 +550,7 @@ DropDownList.role_ui { + @@ -613,6 +619,28 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatsList = findByClass( "llm_chats" ); mModelBtn = findByClass( "model_ui" ); + mReasoningEffort = findByClass( "reasoning_ui" ); + mReasoningEffort->getListBox()->on( Event::OnItemSelected, [this]( auto ) { + const auto& config = mCurModel.reasoningConfiguration; + const Int32 selected = mReasoningEffort->getListBox()->getItemSelectedIndex(); + mSelectedReasoningEffort.clear(); + mReasoningEnabled = config && selected > 0; + if ( config && config->type == LLMReasoningType::Effort ) { + if ( selected == 0 && std::find( config->efforts.begin(), config->efforts.end(), + "none" ) != config->efforts.end() ) { + mReasoningEnabled = true; + mSelectedReasoningEffort = "none"; + } else if ( selected > 0 ) { + Int32 index = 1; + for ( const auto& effort : config->efforts ) { + if ( effort != "none" && index++ == selected ) { + mSelectedReasoningEffort = effort; + break; + } + } + } + } + } ); mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); mModelBtn->on( Event::MouseUp, [this]( const Event* event ) { const auto mouseEvent = event->asMouseEvent(); @@ -1087,6 +1115,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : auto providers = getPlugin()->getProviders(); setProviders( std::move( providers ) ); mCurModel = getDefaultModel(); + updateReasoningControl(); mAgents = getPlugin()->getAgents(); @@ -1561,11 +1590,42 @@ bool LLMChatUI::selectModel( std::optional model ) { if ( model ) { mModelBtn->setText( getModelDisplayName( *model ) ); mCurModel = *model; + updateReasoningControl(); return true; } return false; } +void LLMChatUI::updateReasoningControl() { + auto* list = mReasoningEffort->getListBox(); + list->clear(); + mReasoningEnabled = false; + mSelectedReasoningEffort.clear(); + mReasoningBudgetTokens = 0; + const auto& config = mCurModel.reasoningConfiguration; + if ( !config || config->type == LLMReasoningType::None ) { + mReasoningEffort->setVisible( false ); + return; + } + const bool supportsOff = config->type != LLMReasoningType::Effort || + std::find( config->efforts.begin(), config->efforts.end(), "none" ) != + config->efforts.end(); + std::vector items{ supportsOff ? i18n( "reasoning_off", "Off" ) + : i18n( "reasoning_default", "Default" ) }; + if ( config->type == LLMReasoningType::Effort ) { + for ( const auto& effort : config->efforts ) + if ( effort != "none" ) + items.emplace_back( String::capitalize( effort ) ); + } else { + items.emplace_back( i18n( "reasoning_on", "On" ) ); + } + if ( config->type == LLMReasoningType::TokenBudget ) + mReasoningBudgetTokens = config->minBudgetTokens ? config->minBudgetTokens : 1024; + list->addListBoxItems( items ); + list->setSelected( 0 ); + mReasoningEffort->setVisible( true ); +} + bool LLMChatUI::selectAgent( const std::string& agent ) { if ( !agent.empty() && mAgents.find( agent ) != mAgents.end() ) { mAgentBtn->setText( agent ); @@ -1581,11 +1641,22 @@ void LLMChatUI::fillModelDropDownList() { for ( const auto& [_, data] : mProviders ) reserve += data.models.size(); mModels.reserve( reserve + 8 /* extra space for local models */ ); + std::map providerAvailable; for ( const auto& [name, data] : mProviders ) { - if ( !data.enabled ) - continue; - for ( const auto& model : data.models ) - mModels.push_back( model ); + providerAvailable[name] = + data.enabled && + AIAssistantPlugin::getApiKeyFromProvider( name, getPlugin() ).has_value(); + } + // Credentialed and keyless providers are immediately usable, so keep their models at + // the top of both the complete list and filtered search results. The rest of the catalog + // remains discoverable for users looking for a provider they have not configured yet. + for ( const bool available : { true, false } ) { + for ( const auto& [name, data] : mProviders ) { + if ( !data.enabled || providerAvailable[name] != available ) + continue; + for ( const auto& model : data.models ) + mModels.push_back( model ); + } } getUISceneNode()->getThreadPool()->run( [this] { fillApiModels(); } ); } @@ -2321,6 +2392,30 @@ nlohmann::json LLMChatUI::serializeChat( const LLMModel& model, bool forRequest { "model", model.name }, { "stream", true }, { "messages", chatToJson( forRequest ) } }; if ( model.maxOutputTokens ) j["max_tokens"] = *model.maxOutputTokens; + if ( model.hash == mCurModel.hash && mReasoningEnabled && model.reasoningConfiguration ) { + const auto& reasoning = *model.reasoningConfiguration; + if ( reasoning.type == LLMReasoningType::Effort && !mSelectedReasoningEffort.empty() ) { + if ( model.provider == "anthropic" ) { + j["thinking"] = { { "type", "adaptive" } }; + j["output_config"] = { { "effort", mSelectedReasoningEffort } }; + } else if ( model.provider == "openrouter" ) { + j["reasoning"] = { { "effort", mSelectedReasoningEffort } }; + } else { + j["reasoning_effort"] = mSelectedReasoningEffort; + } + } else if ( reasoning.type == LLMReasoningType::Toggle ) { + if ( model.provider == "anthropic" ) + j["thinking"] = { { "type", "adaptive" } }; + else + j["reasoning"] = { { "enabled", true } }; + } else if ( reasoning.type == LLMReasoningType::TokenBudget ) { + if ( model.provider == "anthropic" ) + j["thinking"] = { { "type", "enabled" }, + { "budget_tokens", mReasoningBudgetTokens } }; + else + j["reasoning"] = { { "max_tokens", mReasoningBudgetTokens } }; + } + } return j; } @@ -2339,6 +2434,9 @@ nlohmann::json LLMChatUI::serialize() { j["locked"] = mChatLocked; j["agent_mode"] = mIsAgentMode; j["agent_name"] = mCurAgent; + j["reasoning_enabled"] = mReasoningEnabled; + j["reasoning_effort"] = mSelectedReasoningEffort; + j["reasoning_budget_tokens"] = mReasoningBudgetTokens; if ( mAgentSession && !mAgentSession->getSessionId().empty() ) j["session_id"] = mAgentSession->getSessionId(); return j; @@ -2353,6 +2451,10 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mChatLocked = payload.value( "locked", false ); mIsAgentMode = payload.value( "agent_mode", false ); mCurAgent = payload.value( "agent_name", "" ); + const bool reasoningEnabled = payload.value( "reasoning_enabled", false ); + const std::string reasoningEffort = payload.value( "reasoning_effort", "" ); + const std::size_t reasoningBudget = + payload.value( "reasoning_budget_tokens", std::size_t{ 0 } ); selectAgent( mCurAgent ); @@ -2381,6 +2483,24 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { } else { if ( !selectModel( mCurModel ) ) fillModelDropDownList(); + if ( reasoningEnabled && mCurModel.reasoningConfiguration ) { + const auto& config = *mCurModel.reasoningConfiguration; + Int32 selection = 1; + if ( config.type == LLMReasoningType::Effort ) { + selection = 0; + if ( reasoningEffort != "none" ) { + for ( const auto& effort : config.efforts ) { + if ( effort == "none" ) + continue; + ++selection; + if ( effort == reasoningEffort ) + break; + } + } + } + mReasoningBudgetTokens = reasoningBudget ? reasoningBudget : mReasoningBudgetTokens; + mReasoningEffort->getListBox()->setSelected( selection ); + } } if ( !mIsAgentMode && payload.contains( "chat" ) && payload["chat"].is_object() ) { @@ -2445,6 +2565,11 @@ std::string LLMChatUI::prepareApiUrl( const std::string& apiKey ) { std::string url = provider.apiUrl; String::replaceAll( url, "${model}", mCurModel.name ); String::replaceAll( url, "${api_key}", apiKey ); + for ( const auto& env : provider.apiKeyEnvVars ) { + const char* value = getenv( env.c_str() ); + if ( value ) + String::replaceAll( url, "${" + env + "}", value ); + } return url; } @@ -3070,8 +3195,13 @@ void LLMChatUI::removeLastChat() { } } -void LLMChatUI::setProviders( LLMProviders&& providers ) { +void LLMChatUI::setProviders( LLMProviders&& providers, bool refreshModels ) { mProviders = std::move( providers ); + if ( !refreshModels ) + return; + fillModelDropDownList(); + if ( mLocateModelTable && mLocateModelTable->getModel() ) + loadSelectModel(); } void LLMChatUI::showMsg( String msg ) { @@ -3178,8 +3308,7 @@ void LLMChatUI::deleteOldConversations( int days ) { std::string conversationsPath = plugin->getConversationsPath(); auto history = ChatHistory::getHistory( conversationsPath ); - Int64 olderThanTime = Sys::getUnixTimestamp() - - ( 60 * 60 * 24 * days ); + Int64 olderThanTime = Sys::getUnixTimestamp() - ( 60 * 60 * 24 * days ); for ( const auto& chat : history ) if ( !chat.locked && chat.file.getModificationTime() < olderThanTime ) diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 144553626..a4cc54658 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -51,6 +51,8 @@ class LLMChat { }; class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { + friend class AIAssistantPlugin; + public: static LLMChatUI* New( PluginManager* manager ) { return eeNew( LLMChatUI, ( manager ) ); } @@ -116,6 +118,7 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UISelectButton* mChatAgentMode{ nullptr }; UIScrollView* mChatScrollView{ nullptr }; UIPushButton* mModelBtn{ nullptr }; + UIDropDownList* mReasoningEffort{ nullptr }; UIPushButton* mAgentBtn{ nullptr }; UIPushButton* mAgentConfigBtn{ nullptr }; @@ -162,6 +165,9 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { bool mLinkMode{ false }; bool mDisplayReasoning{ false }; bool mInReasoning{ false }; + bool mReasoningEnabled{ false }; + std::string mSelectedReasoningEffort; + std::size_t mReasoningBudgetTokens{ 0 }; std::vector mNewModels; LLMModel findModel( const std::string& provider, const std::string& model ); @@ -217,6 +223,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { bool selectModel( std::optional model ); + void updateReasoningControl(); + bool selectAgent( const std::string& agent ); void fillModelDropDownList(); @@ -247,7 +255,7 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void removeLastChat(); - void setProviders( LLMProviders&& providers ); + void setProviders( LLMProviders&& providers, bool refreshModels = false ); virtual Uint32 onMessage( const NodeMessage* ); diff --git a/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.cpp b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.cpp new file mode 100644 index 000000000..d92c522b1 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.cpp @@ -0,0 +1,341 @@ +#include "llmmodelcatalog.hpp" + +#include +#include +#include +#include +#include + +#include + +using json = nlohmann::json; +using namespace EE; +using namespace EE::Network; + +namespace ecode { + +static constexpr int CATALOG_VERSION = 3; + +static bool supportsTextChat( const json& model ) { + if ( model.contains( "status" ) && model["status"].is_string() && + model["status"].get() == "deprecated" ) + return false; + if ( !model.contains( "modalities" ) || !model["modalities"].is_object() ) + return true; + const auto& modalities = model["modalities"]; + const auto hasText = []( const json& values ) { + return values.is_array() && + std::find( values.begin(), values.end(), "text" ) != values.end(); + }; + return hasText( modalities.value( "input", json::array() ) ) && + hasText( modalities.value( "output", json::array() ) ); +} + +static json normalizeCatalog( const json& source ) { + json result = { { "version", CATALOG_VERSION }, + { "fetched_at", Sys::getUnixTimestamp() }, + { "providers", json::object() } }; + for ( const auto& [providerId, provider] : source.items() ) { + if ( !provider.is_object() || !provider.contains( "models" ) || + !provider["models"].is_object() ) + continue; + auto& output = result["providers"][providerId]; + const std::string displayName = provider.contains( "name" ) && provider["name"].is_string() + ? provider["name"].get() + : providerId; + output = { { "display_name", displayName }, { "models", json::array() } }; + if ( provider.contains( "env" ) && provider["env"].is_array() ) + output["api_key_env_vars"] = provider["env"]; + const std::string npm = provider.contains( "npm" ) && provider["npm"].is_string() + ? provider["npm"].get() + : ""; + if ( ( npm == "@ai-sdk/openai-compatible" || providerId == "openrouter" ) && + provider.contains( "api" ) && provider["api"].is_string() ) { + std::string apiUrl = provider["api"].get(); + if ( !String::endsWith( apiUrl, "/chat/completions" ) ) { + if ( !String::endsWith( apiUrl, "/" ) ) + apiUrl += '/'; + apiUrl += "chat/completions"; + } + output["api_url"] = std::move( apiUrl ); + } + std::optional> cheapestCost; + std::optional cheapestModel; + for ( const auto& [modelId, model] : provider["models"].items() ) { + if ( !model.is_object() || !supportsTextChat( model ) ) + continue; + const std::string displayName = model.contains( "name" ) && model["name"].is_string() + ? model["name"].get() + : modelId; + const bool reasoning = model.contains( "reasoning" ) && model["reasoning"].is_boolean() + ? model["reasoning"].get() + : false; + const bool toolCalling = + model.contains( "tool_call" ) && model["tool_call"].is_boolean() + ? model["tool_call"].get() + : false; + json normalized = { { "name", modelId }, + { "display_name", displayName }, + { "reasoning", reasoning }, + { "tool_calling", toolCalling } }; + if ( model.contains( "reasoning_options" ) && model["reasoning_options"].is_array() ) + normalized["reasoning_options"] = model["reasoning_options"]; + if ( model.contains( "limit" ) && model["limit"].is_object() ) { + const auto& limit = model["limit"]; + if ( limit.contains( "context" ) && limit["context"].is_number_unsigned() ) + normalized["max_tokens"] = limit["context"]; + if ( limit.contains( "output" ) && limit["output"].is_number_unsigned() ) + normalized["max_output_tokens"] = limit["output"]; + } + const std::size_t modelIndex = output["models"].size(); + output["models"].push_back( std::move( normalized ) ); + if ( model.contains( "cost" ) && model["cost"].is_object() ) { + const auto& cost = model["cost"]; + if ( cost.contains( "input" ) && cost["input"].is_number() && + cost.contains( "output" ) && cost["output"].is_number() ) { + const std::pair modelCost{ cost["input"].get(), + cost["output"].get() }; + if ( !cheapestCost || modelCost < *cheapestCost ) { + cheapestCost = modelCost; + cheapestModel = modelIndex; + } + } + } + } + if ( cheapestModel ) + output["models"][*cheapestModel]["cheapest"] = true; + } + return result; +} + +static bool writeAtomically( const std::string& path, const std::string& data ) { + FileSystem::makeDir( FileSystem::fileRemoveFileName( path ), true ); + const std::string tempPath = path + ".tmp"; + if ( !FileSystem::fileWrite( tempPath, data ) ) + return false; + if ( FileSystem::fileExists( path ) ) + FileSystem::fileRemove( path ); + if ( FileSystem::fileMove( tempPath, path ) ) + return true; + FileSystem::fileRemove( tempPath ); + return false; +} + +LLMModelCatalog::LLMModelCatalog( Settings settings ) : mSettings( std::move( settings ) ) {} + +LLMModelCatalog::~LLMModelCatalog() { + mCancelled->store( true ); + if ( mRequestId && Http::Pool::getGlobal().exists( mRequestURI, mProxyURI ) ) + Http::Pool::getGlobal().get( mRequestURI, mProxyURI )->setCancelRequest( mRequestId ); +} + +std::optional +LLMModelCatalog::parseReasoningConfiguration( const json& options ) { + if ( !options.is_array() ) + return {}; + std::optional fallback; + for ( const auto& option : options ) { + if ( !option.is_object() || !option.contains( "type" ) || !option["type"].is_string() ) + continue; + LLMReasoningConfiguration config; + const std::string type = option["type"].get(); + if ( type == "effort" && option.contains( "values" ) && option["values"].is_array() ) { + config.type = LLMReasoningType::Effort; + for ( const auto& effort : option["values"] ) + if ( effort.is_string() ) + config.efforts.emplace_back( effort.get() ); + if ( !config.efforts.empty() ) + return config; + } else if ( type == "budget_tokens" ) { + config.type = LLMReasoningType::TokenBudget; + if ( option.contains( "min" ) && option["min"].is_number_unsigned() ) + config.minBudgetTokens = option["min"].get(); + if ( option.contains( "max" ) && option["max"].is_number_unsigned() ) + config.maxBudgetTokens = option["max"].get(); + fallback = std::move( config ); + } else if ( type == "toggle" && !fallback ) { + config.type = LLMReasoningType::Toggle; + fallback = std::move( config ); + } + } + return fallback; +} + +bool LLMModelCatalog::applyCatalog( const std::string& data, LLMProviders& providers ) const { + const auto catalog = json::parse( data, nullptr, false, true ); + if ( !catalog.is_object() || !catalog.contains( "version" ) || + !catalog["version"].is_number_integer() || + catalog["version"].get() != CATALOG_VERSION || !catalog.contains( "providers" ) || + !catalog["providers"].is_object() ) + return false; + LLMProviders catalogProviders; + for ( const auto& [providerId, providerData] : catalog["providers"].items() ) { + if ( !providerData.is_object() || !providerData.contains( "models" ) || + !providerData["models"].is_array() ) + continue; + const auto& models = providerData["models"]; + auto configuredProvider = providers.find( providerId ); + LLMProvider parsedProvider; + if ( configuredProvider != providers.end() ) { + // Keep transport-specific settings that models.dev does not describe, but the + // catalog is authoritative for the model list and its metadata. + parsedProvider = configuredProvider->second; + parsedProvider.models.clear(); + } else { + if ( !providerData.contains( "api_url" ) || !providerData["api_url"].is_string() ) + continue; + parsedProvider.name = providerId; + parsedProvider.apiUrl = providerData["api_url"].get(); + } + if ( providerData.contains( "display_name" ) && providerData["display_name"].is_string() ) + parsedProvider.displayName = providerData["display_name"].get(); + if ( providerData.contains( "api_url" ) && providerData["api_url"].is_string() ) + parsedProvider.apiUrl = providerData["api_url"].get(); + if ( providerData.contains( "api_key_env_vars" ) && + providerData["api_key_env_vars"].is_array() ) { + parsedProvider.apiKeyEnvVars.clear(); + for ( const auto& env : providerData["api_key_env_vars"] ) + if ( env.is_string() ) + parsedProvider.apiKeyEnvVars.emplace_back( env.get() ); + } + std::vector parsed; + parsed.reserve( models.size() ); + for ( const auto& value : models ) { + if ( !value.is_object() || !value.contains( "name" ) || !value["name"].is_string() ) + continue; + LLMModel model; + model.name = value["name"].get(); + model.provider = providerId; + if ( value.contains( "display_name" ) && value["display_name"].is_string() ) + model.displayName = value["display_name"].get(); + if ( value.contains( "max_tokens" ) && value["max_tokens"].is_number_unsigned() ) + model.maxTokens = value["max_tokens"].get(); + if ( value.contains( "max_output_tokens" ) && + value["max_output_tokens"].is_number_unsigned() ) + model.maxOutputTokens = value["max_output_tokens"].get(); + model.reasoning = value.contains( "reasoning" ) && value["reasoning"].is_boolean() + ? value["reasoning"].get() + : false; + model.toolCalling = + value.contains( "tool_calling" ) && value["tool_calling"].is_boolean() + ? value["tool_calling"].get() + : false; + model.cheapest = value.contains( "cheapest" ) && value["cheapest"].is_boolean() + ? value["cheapest"].get() + : false; + if ( value.contains( "reasoning_options" ) ) + model.reasoningConfiguration = + parseReasoningConfiguration( value["reasoning_options"] ); + model.hash = hashCombine( std::hash()( model.name ), + std::hash()( model.provider ) ); + parsed.emplace_back( std::move( model ) ); + } + if ( !parsed.empty() ) { + parsedProvider.models = std::move( parsed ); + catalogProviders.insert_or_assign( providerId, std::move( parsedProvider ) ); + } + } + // Local providers discover their models from the running local server and therefore + // have no useful models.dev catalog entries. + for ( auto& [providerId, provider] : providers ) + if ( provider.fetchModelsUrl && + catalogProviders.find( providerId ) == catalogProviders.end() ) + catalogProviders.insert_or_assign( providerId, std::move( provider ) ); + if ( catalogProviders.empty() ) + return false; + providers = std::move( catalogProviders ); + return true; +} + +bool LLMModelCatalog::loadCached( LLMProviders& providers ) const { + if ( !mSettings.enabled ) + return false; + std::string data; + return FileSystem::fileGet( mSettings.cachePath, data ) && applyCatalog( data, providers ); +} + +std::uint64_t +LLMModelCatalog::refreshAsync( LLMProviders providers, + std::function refreshedCallback ) { + if ( !mSettings.enabled || mSettings.url.empty() ) + return 0; + Clock clock; + std::string cached; + json cache; + if ( FileSystem::fileGet( mSettings.cachePath, cached ) ) + cache = json::parse( cached, nullptr, false, true ); + const bool validCache = cache.is_object() && cache.contains( "version" ) && + cache["version"].is_number_integer() && + cache["version"].get() == CATALOG_VERSION; + const std::int64_t fetchedAt = + validCache && cache.contains( "fetched_at" ) && cache["fetched_at"].is_number_integer() + ? cache["fetched_at"].get() + : 0; + const std::int64_t maxAge = + static_cast( mSettings.refreshIntervalHours ) * 60 * 60; + if ( fetchedAt > 0 && Sys::getUnixTimestamp() - fetchedAt < maxAge ) + return 0; + + Http::Request::FieldTable headers; + if ( validCache ) { + const std::string etag = cache.contains( "etag" ) && cache["etag"].is_string() + ? cache["etag"].get() + : ""; + if ( !etag.empty() ) + headers["If-None-Match"] = etag; + } + const Settings settings = mSettings; + mCancelled->store( false ); + mRequestURI = URI( mSettings.url ); + mProxyURI = Http::getEnvProxyURI(); + mRequestId = Http::getAsync( + [settings, cache = std::move( cache ), providers = std::move( providers ), + refreshedCallback = std::move( refreshedCallback ), clock, + cancelled = mCancelled]( const Http&, Http::Request&, Http::Response& response ) mutable { + if ( cancelled->load() ) + return; + if ( response.getStatus() == Http::Response::Status::NotModified ) { + if ( cancelled->load() ) + return; + cache["fetched_at"] = Sys::getUnixTimestamp(); + writeAtomically( settings.cachePath, cache.dump() ); + return; + } + if ( response.getStatus() != Http::Response::Status::Ok ) { + Log::warning( "LLM model catalog refresh failed with HTTP status %d", + static_cast( response.getStatus() ) ); + return; + } + + if ( cancelled->load() ) + return; + const auto source = json::parse( response.getBody(), nullptr, false, true ); + + if ( !source.is_object() ) { + Log::warning( "LLM model catalog refresh returned invalid JSON" ); + return; + } + + if ( cancelled->load() ) + return; + auto normalized = normalizeCatalog( source ); + normalized["etag"] = response.getField( "ETag" ); + const std::string data = normalized.dump(); + LLMModelCatalog catalog( settings ); + if ( !catalog.applyCatalog( data, providers ) ) + return; + if ( cancelled->load() ) + return; + if ( !writeAtomically( settings.cachePath, data ) ) + Log::warning( "Could not persist LLM model catalog cache to %s", + settings.cachePath ); + Log::info( "LLMModelCatalog::refresh just refresh models.dev catalog, took %s", + clock.getElapsedTime().toString() ); + if ( !cancelled->load() && refreshedCallback ) + refreshedCallback( std::move( providers ) ); + }, + mRequestURI, Seconds( 10 ), {}, headers, "", true, mProxyURI ); + return mRequestId; +} + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp new file mode 100644 index 000000000..8fb0e4e9e --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/llmmodelcatalog.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "protocol.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ecode { + +class LLMModelCatalog { + public: + struct Settings { + std::string cachePath; + std::string url{ "https://models.dev/api.json" }; + std::uint32_t refreshIntervalHours{ 24 }; + bool enabled{ true }; + }; + + explicit LLMModelCatalog( Settings settings ); + ~LLMModelCatalog(); + + bool loadCached( LLMProviders& providers ) const; + + std::uint64_t refreshAsync( LLMProviders providers, + std::function refreshedCallback ); + + static std::optional + parseReasoningConfiguration( const nlohmann::json& options ); + + private: + Settings mSettings; + EE::Network::URI mRequestURI; + EE::Network::URI mProxyURI; + std::uint64_t mRequestId{ 0 }; + std::shared_ptr mCancelled{ std::make_shared( false ) }; + + bool applyCatalog( const std::string& data, LLMProviders& providers ) const; +}; + +} // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/protocol.hpp b/src/tools/ecode/plugins/aiassistant/protocol.hpp index cba36436b..1b40468ce 100644 --- a/src/tools/ecode/plugins/aiassistant/protocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/protocol.hpp @@ -1,10 +1,11 @@ #pragma once +#include #include #include #include -#include #include +#include namespace ecode { @@ -14,6 +15,15 @@ struct LLMCacheConfiguration { bool shouldSpeculate; }; +enum class LLMReasoningType : std::uint8_t { None, Toggle, Effort, TokenBudget }; + +struct LLMReasoningConfiguration { + std::vector efforts; + std::uint32_t minBudgetTokens{ 0 }; + std::uint32_t maxBudgetTokens{ 0 }; + LLMReasoningType type{ LLMReasoningType::None }; +}; + struct LLMModel { std::size_t hash{ 0 }; std::string name; @@ -23,6 +33,7 @@ struct LLMModel { std::optional maxOutputTokens; std::optional defaultTemperature; std::optional cacheConfiguration; + std::optional reasoningConfiguration; bool isEphemeral{ false }; bool cheapest{ false }; bool reasoning{ false }; @@ -37,6 +48,7 @@ struct LLMProvider { std::string apiUrl; std::optional fetchModelsUrl; std::optional version; + std::vector apiKeyEnvVars; std::vector models; }; @@ -52,4 +64,4 @@ struct ACPAgent { using ACPAgents = std::map; -} // namespace ecode \ No newline at end of file +} // namespace ecode