Add dynamic AI model catalog and reasoning controls

Fetch and cache the models.dev catalog asynchronously, replacing bundled model lists when catalog data is available while preserving local and user-configured providers.

Update live AI Assistant instances after refresh, defer startup requests until the UI is ready, and cancel in-flight catalog requests during shutdown. Prioritize providers with configured credentials and infer each provider's cheapest title-generation model from catalog pricing.

Add model reasoning metadata, effort controls, request serialization, chat persistence, and localized UI labels. Refresh the bundled fallback models and support API keys for arbitrary catalog providers.

Fix ACP Agent mode message chunks enqueue ordering issue (ACP thought chunks were enqueued twice while answer chunks were enqueued once).

Fix UIDropDownList WrapContent sizing so dropdown width follows its text, skin, and padding, and tighten UIDropDown automatic height sizing.
This commit is contained in:
Martín Lucas Golini
2026-08-19 01:30:56 -03:00
parent 01ebd7a82d
commit b02fc1f3a6
15 changed files with 863 additions and 66 deletions

View File

@@ -961,4 +961,8 @@ Verwendet strftime-Formatbezeichner. Die Dateierweiterung richtet sich nach dem
<string name="couldnt_save_screenshot">Das Bildschirmfoto konnte nicht gespeichert werden.</string>
<string name="screenshot_saved">Bildschirmfoto gespeichert:</string>
<string name="open_screenshot_folder">Ordner öffnen</string>
<string name="reasoning_effort">Denkaufwand</string>
<string name="reasoning_off">Aus</string>
<string name="reasoning_default">Standard</string>
<string name="reasoning_on">Ein</string>
</resources>

View File

@@ -945,4 +945,8 @@ Uses strftime format specifiers. The file extension follows the selected screens
<string name="couldnt_save_screenshot">Couldn't save the screenshot.</string>
<string name="screenshot_saved">Screenshot saved:</string>
<string name="open_screenshot_folder">Open Folder</string>
<string name="reasoning_effort">Reasoning Effort</string>
<string name="reasoning_off">Off</string>
<string name="reasoning_default">Default</string>
<string name="reasoning_on">On</string>
</resources>

View File

@@ -940,4 +940,8 @@ Utilise les spécificateurs de format strftime. Lextension du fichier corresp
<string name="couldnt_save_screenshot">Impossible denregistrer la capture décran.</string>
<string name="screenshot_saved">Capture décran enregistrée :</string>
<string name="open_screenshot_folder">Ouvrir le dossier</string>
<string name="reasoning_effort">Effort de raisonnement</string>
<string name="reasoning_off">Désactivé</string>
<string name="reasoning_default">Par défaut</string>
<string name="reasoning_on">Activé</string>
</resources>

View File

@@ -719,4 +719,8 @@ file in the directory tree.</string>
<string name="couldnt_save_screenshot">无法保存屏幕截图。</string>
<string name="screenshot_saved">屏幕截图已保存:</string>
<string name="open_screenshot_folder">打开屏幕截图文件夹</string>
<string name="reasoning_effort">推理强度</string>
<string name="reasoning_off">关闭</string>
<string name="reasoning_default">默认</string>
<string name="reasoning_on">开启</string>
</resources>

View File

@@ -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": [

View File

@@ -59,6 +59,8 @@ class EE_API UIDropDownList : public UIDropDown {
virtual void onClassChange();
virtual void onAutoSize();
void destroyListBox();
void updateClickStep();

View File

@@ -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 ) );
}
}

View File

@@ -98,6 +98,19 @@ Uint32 UIDropDownList::onKeyDown( const KeyEvent& Event ) {
return UIDropDown::onKeyDown( Event );
}
void UIDropDownList::onAutoSize() {
UIDropDown::onAutoSize();
Float max = eemax<Float>( 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;

View File

@@ -52,6 +52,12 @@ static std::map<std::string, LLMProvider> parseLLMProviders( const nlohmann::jso
if ( providerJson.contains( "version" ) ) {
provider.version = providerJson["version"].get<int>();
}
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<std::string>() );
}
if ( providerJson.contains( "models" ) ) {
const auto& modelsJson = providerJson["models"];
@@ -88,6 +94,9 @@ static std::map<std::string, LLMProvider> 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<std::string, LLMProvider> 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<std::string, ACPAgent> parseACPAgents( const nlohmann::json& j ) {
std::map<std::string, ACPAgent> agents;
for ( const auto& item : j.items() ) {
@@ -166,6 +208,10 @@ AIAssistantPlugin::AIAssistantPlugin( PluginManager* pluginManager, bool sync )
}
AIAssistantPlugin::~AIAssistantPlugin() {
{
std::lock_guard<std::mutex> 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<std::string> 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<bool>();
if ( userConfig.contains( "model_catalog_url" ) &&
userConfig["model_catalog_url"].is_string() )
catalogSettings.url = userConfig["model_catalog_url"].get<std::string>();
if ( userConfig.contains( "model_catalog_refresh_hours" ) &&
userConfig["model_catalog_refresh_hours"].is_number_unsigned() )
catalogSettings.refreshIntervalHours =
userConfig["model_catalog_refresh_hours"].get<std::uint32_t>();
}
}
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<std::mutex> 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<std::mutex> 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<LLMModelCatalog>( 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<AIAssistantPlugin*>( 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<LLMChatUI*>( 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<std::string>();
} 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<std::string> 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 )

View File

@@ -2,8 +2,11 @@
#include "../plugin.hpp"
#include "../pluginmanager.hpp"
#include "llmmodelcatalog.hpp"
#include "protocol.hpp"
#include <mutex>
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<LLMModelCatalog::Settings> mModelCatalogSettings;
std::unique_ptr<LLMModelCatalog> 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

View File

@@ -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 {
<PushButton id="llm_chat_history" class="llm_button" text="@string(chat_history, Chat History)" tooltip="@string(chat_history, Chat History)" icon="icon(chat-history, 14dp)" min-width="32dp" margin-right="4dp" />
<PushButton id="llm_more" class="llm_button" tooltip="@string(more_options, More Options)" icon="icon(more-fill, 14dp)" min-width="32dp" />
<PushButton class="model_ui" lw="0" lw8="1" lh="mp" margin-left="4dp" margin-right="4dp" tooltip="@string(select_model, Select Model)" />
<DropDownList class="reasoning_ui" lw="wc" lh="mp" tooltip='@string(reasoning_effort, "Reasoning Effort")' visible="false" />
<PushButton class="agent_ui" lw="0" lw8="1" lh="mp" margin-left="4dp" margin-right="4dp" tooltip="@string(select_agent, Select Agent)" visible="false" />
<SelectButton id="llm_agent_mode" class="llm_button" tooltip="@string(toggle_agent_mode, Toggle Agent Mode)" icon="icon(robot-2, 14dp)" min-width="32dp" margin-right="4dp" select-on-click="true" />
<PushButton class="agent_config_ui" tooltip="@string(agent_config, Agent Config)" icon="icon(agent, 14dp)" min-width="32dp" margin-right="4dp" visible="false" />
@@ -613,6 +619,28 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
mChatsList = findByClass( "llm_chats" );
mModelBtn = findByClass<UIPushButton>( "model_ui" );
mReasoningEffort = findByClass<UIDropDownList>( "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<LLMModel> 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<String> 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<std::string, bool> 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 )

View File

@@ -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<LLMModel> 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<LLMModel> 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* );

View File

@@ -0,0 +1,341 @@
#include "llmmodelcatalog.hpp"
#include <eepp/network/http.hpp>
#include <eepp/system/clock.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/log.hpp>
#include <eepp/system/sys.hpp>
#include <nlohmann/json.hpp>
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<std::string>() == "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<std::string>()
: 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<std::string>()
: "";
if ( ( npm == "@ai-sdk/openai-compatible" || providerId == "openrouter" ) &&
provider.contains( "api" ) && provider["api"].is_string() ) {
std::string apiUrl = provider["api"].get<std::string>();
if ( !String::endsWith( apiUrl, "/chat/completions" ) ) {
if ( !String::endsWith( apiUrl, "/" ) )
apiUrl += '/';
apiUrl += "chat/completions";
}
output["api_url"] = std::move( apiUrl );
}
std::optional<std::pair<double, double>> cheapestCost;
std::optional<std::size_t> 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<std::string>()
: modelId;
const bool reasoning = model.contains( "reasoning" ) && model["reasoning"].is_boolean()
? model["reasoning"].get<bool>()
: false;
const bool toolCalling =
model.contains( "tool_call" ) && model["tool_call"].is_boolean()
? model["tool_call"].get<bool>()
: 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<double, double> modelCost{ cost["input"].get<double>(),
cost["output"].get<double>() };
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<LLMReasoningConfiguration>
LLMModelCatalog::parseReasoningConfiguration( const json& options ) {
if ( !options.is_array() )
return {};
std::optional<LLMReasoningConfiguration> 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<std::string>();
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<std::string>() );
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<std::uint32_t>();
if ( option.contains( "max" ) && option["max"].is_number_unsigned() )
config.maxBudgetTokens = option["max"].get<std::uint32_t>();
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<int>() != 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<std::string>();
}
if ( providerData.contains( "display_name" ) && providerData["display_name"].is_string() )
parsedProvider.displayName = providerData["display_name"].get<std::string>();
if ( providerData.contains( "api_url" ) && providerData["api_url"].is_string() )
parsedProvider.apiUrl = providerData["api_url"].get<std::string>();
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::string>() );
}
std::vector<LLMModel> 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<std::string>();
model.provider = providerId;
if ( value.contains( "display_name" ) && value["display_name"].is_string() )
model.displayName = value["display_name"].get<std::string>();
if ( value.contains( "max_tokens" ) && value["max_tokens"].is_number_unsigned() )
model.maxTokens = value["max_tokens"].get<std::size_t>();
if ( value.contains( "max_output_tokens" ) &&
value["max_output_tokens"].is_number_unsigned() )
model.maxOutputTokens = value["max_output_tokens"].get<std::size_t>();
model.reasoning = value.contains( "reasoning" ) && value["reasoning"].is_boolean()
? value["reasoning"].get<bool>()
: false;
model.toolCalling =
value.contains( "tool_calling" ) && value["tool_calling"].is_boolean()
? value["tool_calling"].get<bool>()
: false;
model.cheapest = value.contains( "cheapest" ) && value["cheapest"].is_boolean()
? value["cheapest"].get<bool>()
: false;
if ( value.contains( "reasoning_options" ) )
model.reasoningConfiguration =
parseReasoningConfiguration( value["reasoning_options"] );
model.hash = hashCombine( std::hash<std::string>()( model.name ),
std::hash<std::string>()( 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<void( LLMProviders )> 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<int>() == CATALOG_VERSION;
const std::int64_t fetchedAt =
validCache && cache.contains( "fetched_at" ) && cache["fetched_at"].is_number_integer()
? cache["fetched_at"].get<std::int64_t>()
: 0;
const std::int64_t maxAge =
static_cast<std::int64_t>( 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<std::string>()
: "";
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<int>( 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

View File

@@ -0,0 +1,45 @@
#pragma once
#include "protocol.hpp"
#include <atomic>
#include <cstdint>
#include <eepp/network/http.hpp>
#include <functional>
#include <memory>
#include <nlohmann/json_fwd.hpp>
#include <string>
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<void( LLMProviders )> refreshedCallback );
static std::optional<LLMReasoningConfiguration>
parseReasoningConfiguration( const nlohmann::json& options );
private:
Settings mSettings;
EE::Network::URI mRequestURI;
EE::Network::URI mProxyURI;
std::uint64_t mRequestId{ 0 };
std::shared_ptr<std::atomic_bool> mCancelled{ std::make_shared<std::atomic_bool>( false ) };
bool applyCatalog( const std::string& data, LLMProviders& providers ) const;
};
} // namespace ecode

View File

@@ -1,10 +1,11 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <vector>
#include <unordered_map>
#include <vector>
namespace ecode {
@@ -14,6 +15,15 @@ struct LLMCacheConfiguration {
bool shouldSpeculate;
};
enum class LLMReasoningType : std::uint8_t { None, Toggle, Effort, TokenBudget };
struct LLMReasoningConfiguration {
std::vector<std::string> 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<std::size_t> maxOutputTokens;
std::optional<double> defaultTemperature;
std::optional<LLMCacheConfiguration> cacheConfiguration;
std::optional<LLMReasoningConfiguration> reasoningConfiguration;
bool isEphemeral{ false };
bool cheapest{ false };
bool reasoning{ false };
@@ -37,6 +48,7 @@ struct LLMProvider {
std::string apiUrl;
std::optional<std::string> fetchModelsUrl;
std::optional<int> version;
std::vector<std::string> apiKeyEnvVars;
std::vector<LLMModel> models;
};
@@ -52,4 +64,4 @@ struct ACPAgent {
using ACPAgents = std::map<std::string, ACPAgent>;
} // namespace ecode
} // namespace ecode