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