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