feat: Add settings page for providers (#353)

This commit is contained in:
Petr Mironychev
2026-05-21 19:30:32 +02:00
committed by GitHub
parent ca3baa7597
commit e193d1e1fa
45 changed files with 3835 additions and 2 deletions

10
sources/external/CMakeLists.txt vendored Normal file
View File

@@ -0,0 +1,10 @@
include(FetchContent)
FetchContent_Declare(tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
GIT_TAG v3.4.0
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(tomlplusplus)
add_subdirectory(llmqore)

View File

@@ -0,0 +1,27 @@
add_library(ProvidersConfig STATIC
ProviderInstance.hpp ProviderInstance.cpp
ProviderInstanceLoader.hpp ProviderInstanceLoader.cpp
ProviderInstanceWriter.hpp ProviderInstanceWriter.cpp
ProviderInstanceFactory.hpp ProviderInstanceFactory.cpp
ProviderSecretsStore.hpp ProviderSecretsStore.cpp
ProviderLauncher.hpp ProviderLauncher.cpp
provider_instances.qrc
)
target_link_libraries(ProvidersConfig
PUBLIC
Qt::Core
Qt::Network
QtCreator::Core
QtCreator::Utils
QtCreator::TerminalLib
PRIVATE
QodeAssistLogger
TomlSerializer
tomlplusplus::tomlplusplus
)
target_include_directories(ProvidersConfig
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -0,0 +1,51 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderInstance.hpp"
#include <QUrl>
namespace QodeAssist::Providers {
QString ProviderInstance::validate(
const ProviderInstance &inst, const QStringList &knownClientApis)
{
if (inst.name.isEmpty())
return QStringLiteral("Provider instance has no name");
if (inst.clientApi.isEmpty())
return QStringLiteral("Provider instance '%1' has no client_api").arg(inst.name);
if (!knownClientApis.isEmpty() && !knownClientApis.contains(inst.clientApi)) {
return QStringLiteral("Provider instance '%1' references unknown client_api '%2'")
.arg(inst.name, inst.clientApi);
}
if (inst.url.isEmpty())
return QStringLiteral("Provider instance '%1' has no URL").arg(inst.name);
const QUrl parsed(inst.url);
if (!parsed.isValid()
|| (parsed.scheme() != QLatin1StringView{"http"}
&& parsed.scheme() != QLatin1StringView{"https"})) {
return QStringLiteral("Provider instance '%1' has an invalid or unsafe URL: %2")
.arg(inst.name, inst.url);
}
return {};
}
QString ProviderInstance::warnings(const ProviderInstance &inst)
{
const QUrl parsed(inst.url);
if (parsed.scheme() == QLatin1StringView{"http"} && !inst.apiKeyRef.isEmpty()) {
const QString host = parsed.host();
const bool isLoopback = host == QLatin1StringView{"localhost"}
|| host == QLatin1StringView{"127.0.0.1"}
|| host == QLatin1StringView{"::1"};
if (!isLoopback) {
return QStringLiteral(
"URL uses plaintext http:// to '%1' but the provider has an API key. "
"Any request will transmit the key unencrypted — prefer https://.")
.arg(host);
}
}
return {};
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,57 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <chrono>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist::Providers {
struct LaunchConfig
{
QString command;
QStringList args;
QString cwd;
QHash<QString, QString> env;
QString readyUrl;
std::chrono::seconds readyTimeout{30};
bool autoStart = false;
bool detach = false;
[[nodiscard]] bool isEmpty() const noexcept { return command.isEmpty(); }
};
struct ProviderInstance
{
QString name;
QString clientApi;
QString description;
QString url;
QString apiKeyRef;
QJsonObject extras;
LaunchConfig launch;
QString extendsName;
bool abstract = false;
QString sourcePath;
bool overridesBundled = false;
[[nodiscard]] bool isUserSource() const
{
return !sourcePath.startsWith(QLatin1StringView{":/"});
}
[[nodiscard]] static QString validate(
const ProviderInstance &inst, const QStringList &knownClientApis);
[[nodiscard]] static QString warnings(const ProviderInstance &inst);
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,198 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderInstanceFactory.hpp"
#include <QDir>
#include <QFileInfo>
#include <QFileSystemWatcher>
#include <QLoggingCategory>
#include <QSet>
#include <QThread>
#include <QTimer>
#include <coreplugin/icore.h>
#include <algorithm>
#include "ProviderInstanceLoader.hpp"
#include "Logger.hpp"
static inline void initProviderInstancesResource()
{
Q_INIT_RESOURCE(provider_instances);
}
namespace {
Q_LOGGING_CATEGORY(providerInstanceFactoryLog, "qodeassist.providerinstancefactory")
QString instanceQrcPrefix() { return QStringLiteral(":/provider-instances"); }
} // namespace
namespace QodeAssist::Providers {
ProviderInstanceFactory::ProviderInstanceFactory(QObject *parent)
: QObject(parent)
{
::initProviderInstancesResource();
m_watcher = new QFileSystemWatcher(this);
m_reloadDebounce = new QTimer(this);
m_reloadDebounce->setSingleShot(true);
m_reloadDebounce->setInterval(150);
connect(m_reloadDebounce, &QTimer::timeout, this, [this] { reload(); });
auto kick = [this](const QString &) { m_reloadDebounce->start(); };
connect(m_watcher, &QFileSystemWatcher::fileChanged, this, kick);
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, kick);
reload();
}
ProviderInstanceFactory::~ProviderInstanceFactory() = default;
QString ProviderInstanceFactory::userInstancesDir()
{
return Core::ICore::userResourcePath(
QStringLiteral("qodeassist/config/providers")).toFSPathString();
}
void ProviderInstanceFactory::reload()
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
clear();
auto result = ProviderInstanceLoader::load(instanceQrcPrefix(), userInstancesDir());
for (const QString &err : result.errors)
LOG_MESSAGE(QString("[ProviderInstances] error: %1").arg(err));
for (const QString &warn : result.warnings)
LOG_MESSAGE(QString("[ProviderInstances] warning: %1").arg(warn));
LOG_MESSAGE(QString("[ProviderInstances] Loaded %1 instances (qrc=%2, user=%3)")
.arg(result.instances.size())
.arg(instanceQrcPrefix(), userInstancesDir()));
for (auto &inst : result.instances) {
LOG_MESSAGE(QString("[ProviderInstances] Loaded: %1 (client_api=%2, url=%3)")
.arg(inst.name, inst.clientApi, inst.url));
m_instances.push_back(std::move(inst));
}
m_errors = std::move(result.errors);
m_warnings = std::move(result.warnings);
rebuildIndexes();
rewatchUserDir();
emit instancesReloaded();
}
void ProviderInstanceFactory::rebuildIndexes()
{
m_nameIndex.clear();
m_instanceNamesCache.clear();
m_knownClientApisCache.clear();
m_nameIndex.reserve(static_cast<qsizetype>(m_instances.size()));
m_instanceNamesCache.reserve(static_cast<qsizetype>(m_instances.size()));
std::sort(m_instances.begin(), m_instances.end(),
[](const ProviderInstance &a, const ProviderInstance &b) {
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
});
QSet<QString> seenApis;
for (qsizetype i = 0; i < static_cast<qsizetype>(m_instances.size()); ++i) {
const ProviderInstance &inst = m_instances[i];
m_nameIndex.insert(inst.name.toCaseFolded(), i);
m_instanceNamesCache.append(inst.name);
if (!seenApis.contains(inst.clientApi)) {
seenApis.insert(inst.clientApi);
m_knownClientApisCache.append(inst.clientApi);
}
}
std::sort(m_knownClientApisCache.begin(), m_knownClientApisCache.end(),
[](const QString &a, const QString &b) {
return a.compare(b, Qt::CaseInsensitive) < 0;
});
}
void ProviderInstanceFactory::rewatchUserDir()
{
if (!m_watcher)
return;
const QStringList stale = m_watcher->files() + m_watcher->directories();
if (!stale.isEmpty())
m_watcher->removePaths(stale);
const QString userDir = userInstancesDir();
QDir().mkpath(userDir);
m_watcher->addPath(userDir);
QDir d(userDir);
for (const QFileInfo &fi : d.entryInfoList({"*.toml"}, QDir::Files))
m_watcher->addPath(fi.absoluteFilePath());
}
void ProviderInstanceFactory::registerInstance(ProviderInstance instance)
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
const QString validation = ProviderInstance::validate(instance, knownClientApis());
if (!validation.isEmpty()) {
qCWarning(providerInstanceFactoryLog).noquote()
<< "Refusing to register provider instance:" << validation;
return;
}
const QString name = instance.name;
for (auto &existing : m_instances) {
if (existing.name == name) {
existing = std::move(instance);
emit instanceChanged(name);
return;
}
}
m_instances.push_back(std::move(instance));
rebuildIndexes();
emit instanceChanged(name);
}
const ProviderInstance *ProviderInstanceFactory::instanceByName(const QString &name) const
{
const auto it = m_nameIndex.constFind(name.toCaseFolded());
if (it == m_nameIndex.constEnd())
return nullptr;
return &m_instances[it.value()];
}
QStringList ProviderInstanceFactory::instanceNames() const
{
return m_instanceNamesCache;
}
QStringList ProviderInstanceFactory::instanceNamesForClientApi(const QString &clientApi) const
{
QStringList out;
for (const auto &inst : m_instances) {
if (inst.clientApi == clientApi)
out.append(inst.name);
}
return out;
}
QStringList ProviderInstanceFactory::knownClientApis() const
{
return m_knownClientApisCache;
}
void ProviderInstanceFactory::clear()
{
Q_ASSERT_X(QThread::currentThread() == thread(),
Q_FUNC_INFO, "ProviderInstanceFactory must be used from its owner thread");
m_instances.clear();
m_nameIndex.clear();
m_instanceNamesCache.clear();
m_knownClientApisCache.clear();
m_errors.clear();
m_warnings.clear();
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,67 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <vector>
#include <QHash>
#include <QObject>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
class QFileSystemWatcher;
class QTimer;
namespace QodeAssist::Providers {
class ProviderInstanceFactory : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderInstanceFactory)
public:
explicit ProviderInstanceFactory(QObject *parent = nullptr);
~ProviderInstanceFactory() override;
void reload();
[[nodiscard]] static QString userInstancesDir();
[[nodiscard]] const ProviderInstance *instanceByName(const QString &name) const;
[[nodiscard]] QStringList instanceNames() const;
[[nodiscard]] QStringList instanceNamesForClientApi(const QString &clientApi) const;
[[nodiscard]] QStringList knownClientApis() const;
[[nodiscard]] const std::vector<ProviderInstance> &instances() const noexcept
{
return m_instances;
}
[[nodiscard]] QStringList lastLoadErrors() const { return m_errors; }
[[nodiscard]] QStringList lastLoadWarnings() const { return m_warnings; }
void registerInstance(ProviderInstance instance);
void clear();
signals:
void instanceChanged(const QString &name);
void instancesReloaded();
private:
void rewatchUserDir();
void rebuildIndexes();
std::vector<ProviderInstance> m_instances;
QHash<QString, qsizetype> m_nameIndex;
QStringList m_instanceNamesCache;
QStringList m_knownClientApisCache;
QStringList m_errors;
QStringList m_warnings;
QFileSystemWatcher *m_watcher = nullptr;
QTimer *m_reloadDebounce = nullptr;
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,270 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderInstanceLoader.hpp"
#include <QDir>
#include <QFile>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <toml++/toml.hpp>
#include <algorithm>
#include <sstream>
namespace QodeAssist::Providers {
namespace {
QJsonValue tomlToJson(const toml::node &node)
{
if (auto *table = node.as_table()) {
QJsonObject obj;
for (const auto &[key, value] : *table) {
obj.insert(QString::fromStdString(std::string{key.str()}), tomlToJson(value));
}
return obj;
}
if (auto *array = node.as_array()) {
QJsonArray arr;
for (const auto &item : *array)
arr.append(tomlToJson(item));
return arr;
}
if (auto *str = node.as_string())
return QString::fromStdString(str->get());
if (auto *integer = node.as_integer())
return static_cast<qint64>(integer->get());
if (auto *floating = node.as_floating_point())
return floating->get();
if (auto *boolean = node.as_boolean())
return boolean->get();
return QJsonValue::Null;
}
QJsonObject deepMerge(const QJsonObject &base, const QJsonObject &overlay)
{
QJsonObject result = base;
for (auto it = overlay.constBegin(); it != overlay.constEnd(); ++it) {
const QJsonValue baseVal = result.value(it.key());
const QJsonValue overlayVal = it.value();
if (baseVal.isObject() && overlayVal.isObject())
result[it.key()] = deepMerge(baseVal.toObject(), overlayVal.toObject());
else
result[it.key()] = overlayVal;
}
return result;
}
QString readUtf8(const QString &path, QString *error)
{
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
if (error)
*error = QStringLiteral("Cannot open: %1").arg(path);
return {};
}
return QString::fromUtf8(f.readAll());
}
std::optional<QJsonObject> parseTomlFile(const QString &path, QString *error)
{
QString readErr;
const QString contents = readUtf8(path, &readErr);
if (!readErr.isEmpty()) {
if (error)
*error = readErr;
return std::nullopt;
}
toml::table tbl;
try {
tbl = toml::parse(contents.toStdString(), path.toStdString());
} catch (const toml::parse_error &e) {
std::ostringstream oss;
oss << e;
if (error) {
*error = QStringLiteral("TOML parse error in %1: %2")
.arg(path, QString::fromStdString(oss.str()));
}
return std::nullopt;
}
return tomlToJson(tbl).toObject();
}
QStringList stringArray(const QJsonValue &v)
{
QStringList out;
if (!v.isArray())
return out;
for (const auto &elem : v.toArray()) {
if (elem.isString())
out.append(elem.toString());
}
return out;
}
LaunchConfig launchConfigFromObject(const QJsonObject &launchObj)
{
LaunchConfig l;
if (launchObj.isEmpty())
return l;
l.command = launchObj.value("command").toString();
l.args = stringArray(launchObj.value("args"));
l.cwd = launchObj.value("cwd").toString();
const QJsonObject envObj = launchObj.value("env").toObject();
for (auto it = envObj.constBegin(); it != envObj.constEnd(); ++it) {
if (it.value().isString())
l.env.insert(it.key(), it.value().toString());
}
l.readyUrl = launchObj.value("ready_url").toString();
if (launchObj.contains("ready_timeout_s")) {
const int raw = launchObj.value("ready_timeout_s")
.toInt(static_cast<int>(l.readyTimeout.count()));
l.readyTimeout = std::chrono::seconds{std::max(1, raw)};
}
l.autoStart = launchObj.value("auto_start").toBool(false);
l.detach = launchObj.value("detach").toBool(false);
return l;
}
ProviderInstance instanceFromMerged(const QJsonObject &obj)
{
ProviderInstance inst;
inst.name = obj.value("name").toString();
inst.clientApi = obj.value("client_api").toString();
inst.description = obj.value("description").toString();
inst.url = obj.value("url").toString();
inst.apiKeyRef = obj.value("api_key_ref").toString();
inst.extras = obj.value("extras").toObject();
inst.launch = launchConfigFromObject(obj.value("launch").toObject());
inst.extendsName = obj.value("extends").toString();
inst.abstract = obj.value("abstract").toBool(false);
return inst;
}
struct RawEntry
{
QJsonObject obj;
QString filePath;
bool overridesBundled = false;
};
constexpr int kMaxExtendsDepth = 16;
QJsonObject resolveExtends(
const QString &name,
const QHash<QString, RawEntry> &raw,
QSet<QString> &visiting,
QStringList &errors,
int depth = 0)
{
if (depth > kMaxExtendsDepth) {
errors.append(QStringLiteral("Provider instance extends chain too deep (>%1) at '%2'")
.arg(kMaxExtendsDepth)
.arg(name));
return {};
}
if (visiting.contains(name)) {
errors.append(QStringLiteral("Cyclic 'extends' involving provider instance '%1'").arg(name));
return {};
}
if (!raw.contains(name)) {
errors.append(QStringLiteral("Unknown parent provider instance '%1'").arg(name));
return {};
}
visiting.insert(name);
QJsonObject self = raw.value(name).obj;
const QString parent = self.value("extends").toString();
if (!parent.isEmpty()) {
const QJsonObject parentMerged
= resolveExtends(parent, raw, visiting, errors, depth + 1);
self.remove("extends");
QJsonObject merged = deepMerge(parentMerged, self);
merged["name"] = name;
if (self.contains("abstract"))
merged["abstract"] = self.value("abstract");
else
merged.remove("abstract");
self = merged;
}
visiting.remove(name);
return self;
}
} // namespace
std::optional<ProviderInstance> ProviderInstanceLoader::parseFile(
const QString &path, QString *error)
{
auto objOpt = parseTomlFile(path, error);
if (!objOpt)
return std::nullopt;
ProviderInstance inst = instanceFromMerged(*objOpt);
inst.sourcePath = path;
return inst;
}
ProviderInstanceLoader::LoadResult ProviderInstanceLoader::load(
const QString &qrcPrefix, const QString &userDir)
{
LoadResult result;
QHash<QString, RawEntry> raw;
auto scan = [&](const QString &dir, bool isUserLayer) {
if (dir.isEmpty())
return;
QDir d(dir);
if (!d.exists())
return;
const QStringList files = d.entryList({"*.toml"}, QDir::Files);
for (const QString &fname : files) {
const QString fullPath = d.filePath(fname);
QString err;
auto objOpt = parseTomlFile(fullPath, &err);
if (!objOpt) {
result.errors.append(err);
continue;
}
const QString name = objOpt->value("name").toString();
if (name.isEmpty()) {
result.errors.append(
QStringLiteral("Provider instance at %1 has no 'name'").arg(fullPath));
continue;
}
const bool overrides = isUserLayer && raw.contains(name);
raw.insert(name, {*objOpt, fullPath, overrides});
}
};
scan(qrcPrefix, /*isUserLayer=*/false);
scan(userDir, /*isUserLayer=*/true);
for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) {
const QString &name = it.key();
QSet<QString> visiting;
const QJsonObject merged = resolveExtends(name, raw, visiting, result.errors);
if (merged.isEmpty())
continue;
ProviderInstance inst = instanceFromMerged(merged);
inst.sourcePath = it.value().filePath;
inst.overridesBundled = it.value().overridesBundled;
if (inst.abstract)
continue;
result.instances.push_back(std::move(inst));
}
std::sort(result.instances.begin(), result.instances.end(),
[](const ProviderInstance &a, const ProviderInstance &b) {
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
});
return result;
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,32 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <optional>
#include <vector>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
namespace QodeAssist::Providers {
class ProviderInstanceLoader
{
public:
struct LoadResult
{
std::vector<ProviderInstance> instances;
QStringList errors;
QStringList warnings;
};
static LoadResult load(const QString &qrcPrefix, const QString &userDir);
static std::optional<ProviderInstance> parseFile(
const QString &path, QString *error);
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,165 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderInstanceWriter.hpp"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QSet>
#include "ProviderInstanceFactory.hpp"
#include "TomlWriter.hpp"
namespace QodeAssist::Providers {
namespace {
struct Tr
{
Q_DECLARE_TR_FUNCTIONS(QtC::QodeAssist)
};
constexpr int kIdentityKeyColumn = 11; // longest key: "description"
constexpr int kLaunchKeyColumn = 15; // longest key: "ready_timeout_s"
void writeIdentityBlock(TomlSerializer::TomlWriter &w, const ProviderInstance &inst)
{
w.setKeyColumnWidth(0);
w.writeInt(QStringLiteral("schema_version"), 1);
w.writeBlankLine();
w.setKeyColumnWidth(kIdentityKeyColumn);
w.writeString(QStringLiteral("name"), inst.name);
w.writeString(QStringLiteral("client_api"), inst.clientApi);
if (!inst.description.isEmpty())
w.writeString(QStringLiteral("description"), inst.description);
w.writeBlankLine();
w.writeString(QStringLiteral("url"), inst.url);
if (!inst.apiKeyRef.isEmpty())
w.writeString(QStringLiteral("api_key_ref"), inst.apiKeyRef);
}
void writeExtrasBlock(TomlSerializer::TomlWriter &w, const QJsonObject &extras)
{
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("extras"));
w.setKeyColumnWidth(0);
w.writeJsonPrimitives(extras);
}
void writeLaunchBlock(TomlSerializer::TomlWriter &w, const LaunchConfig &l)
{
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("launch"));
w.setKeyColumnWidth(kLaunchKeyColumn);
w.writeString(QStringLiteral("command"), l.command);
if (!l.args.isEmpty())
w.writeStringArray(QStringLiteral("args"), l.args);
if (!l.cwd.isEmpty())
w.writeString(QStringLiteral("cwd"), l.cwd);
if (!l.readyUrl.isEmpty())
w.writeString(QStringLiteral("ready_url"), l.readyUrl);
w.writeInt(QStringLiteral("ready_timeout_s"), l.readyTimeout.count());
w.writeBool(QStringLiteral("auto_start"), l.autoStart);
w.writeBool(QStringLiteral("detach"), l.detach);
if (!l.env.isEmpty()) {
w.writeBlankLine();
w.writeTableHeader(QStringLiteral("launch.env"));
w.setKeyColumnWidth(0);
w.writeStringDict(l.env);
}
}
} // namespace
QString ProviderInstanceWriter::toToml(const ProviderInstance &inst)
{
TomlSerializer::TomlWriter w;
writeIdentityBlock(w, inst);
if (!inst.extras.isEmpty())
writeExtrasBlock(w, inst.extras);
if (!inst.launch.isEmpty())
writeLaunchBlock(w, inst.launch);
return w.result();
}
QString ProviderInstanceWriter::deriveBaseName(const QString &name)
{
QString baseName;
for (QChar c : name) {
if (c.isLetterOrNumber())
baseName.append(c.toLower());
else if (c == QLatin1Char(' ') || c == QLatin1Char('-') || c == QLatin1Char('_'))
baseName.append(QLatin1Char('_'));
}
while (baseName.startsWith(QLatin1Char('_')))
baseName.remove(0, 1);
while (baseName.endsWith(QLatin1Char('_')))
baseName.chop(1);
if (baseName.isEmpty())
baseName = QStringLiteral("instance");
return baseName;
}
namespace {
constexpr int kMaxCollisionRetries = 1000;
} // namespace
QString ProviderInstanceWriter::pickUserFilePath(
const QString &userDir, const QString &name, const QString &previousPath)
{
const QDir dir(userDir);
const QString base = deriveBaseName(name);
const QString preferred = dir.filePath(base + QLatin1String(".toml"));
if (!previousPath.isEmpty()
&& QFileInfo(previousPath).absolutePath() == dir.absolutePath()
&& QFileInfo(previousPath).absoluteFilePath() == QFileInfo(preferred).absoluteFilePath())
return preferred;
QSet<QString> taken;
for (const QString &existing : dir.entryList({"*.toml"}, QDir::Files))
taken.insert(existing);
if (!taken.contains(base + QLatin1String(".toml")))
return preferred;
for (int i = 2; i < kMaxCollisionRetries; ++i) {
const QString candidate = QStringLiteral("%1_%2.toml").arg(base).arg(i);
if (!taken.contains(candidate))
return dir.filePath(candidate);
}
return {};
}
QString ProviderInstanceWriter::writeToUserDir(
const ProviderInstance &inst, const QString &previousPath, QString *errorOut)
{
const QString userDir = ProviderInstanceFactory::userInstancesDir();
if (!QDir().mkpath(userDir)) {
if (errorOut)
*errorOut = Tr::tr("Cannot create user provider folder:\n%1").arg(userDir);
return {};
}
const QString filePath = pickUserFilePath(userDir, inst.name, previousPath);
if (filePath.isEmpty()) {
if (errorOut)
*errorOut = Tr::tr("Cannot pick a free filename in:\n%1").arg(userDir);
return {};
}
QSaveFile f(filePath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
if (errorOut)
*errorOut = Tr::tr("Cannot write %1:\n%2").arg(filePath, f.errorString());
return {};
}
const QByteArray bytes = toToml(inst).toUtf8();
if (f.write(bytes) != bytes.size() || !f.commit()) {
if (errorOut)
*errorOut = Tr::tr("Write failed for %1:\n%2").arg(filePath, f.errorString());
return {};
}
return filePath;
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,27 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include "ProviderInstance.hpp"
namespace QodeAssist::Providers {
class ProviderInstanceWriter
{
public:
[[nodiscard]] static QString toToml(const ProviderInstance &inst);
[[nodiscard]] static QString writeToUserDir(
const ProviderInstance &inst,
const QString &previousPath,
QString *errorOut = nullptr);
[[nodiscard]] static QString pickUserFilePath(
const QString &userDir,
const QString &name,
const QString &previousPath);
[[nodiscard]] static QString deriveBaseName(const QString &name);
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,637 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderLauncher.hpp"
#include <QDir>
#include <QElapsedTimer>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSslError>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTimer>
#include <QUrl>
#include <utils/commandline.h>
#include <utils/environment.h>
#include <utils/filepath.h>
#include <utils/processinterface.h>
#include <utils/qtcprocess.h>
#include "Logger.hpp"
#include <algorithm>
#include <chrono>
#ifdef Q_OS_WIN
#include <windows.h>
#else
#include <signal.h>
#endif
namespace QodeAssist::Providers {
namespace {
Q_LOGGING_CATEGORY(launcherLog, "qodeassist.providerlauncher")
constexpr std::chrono::milliseconds kProbeInterval{500};
constexpr std::chrono::milliseconds kProbeTransferTimeout{2000};
constexpr std::chrono::milliseconds kAdoptionTransferTimeout{1500};
constexpr std::chrono::milliseconds kStartTimeout{2000};
constexpr int kScrollbackBytesMax = 1 * 1024 * 1024; // 1 MiB cap per slot
} // namespace
struct ProviderLauncher::Slot
{
QString name;
LaunchConfig cfg;
State state = Idle;
Utils::Process *process = nullptr;
qint64 detachedPid = 0;
bool adoptedExternal = false;
bool started = false;
QTimer *probeTimer = nullptr;
QTimer *startTimer = nullptr; // fail-fast timer for QProcess::started
QElapsedTimer probeStart;
QPointer<QNetworkReply> probeReply;
QList<QPointer<QNetworkReply>> oneShotProbes;
int generation = 0;
QString lastError;
QByteArray scrollback;
};
ProviderLauncher::ProviderLauncher(QObject *parent)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{
connect(m_nam, &QNetworkAccessManager::sslErrors, this,
[this](QNetworkReply *reply, const QList<QSslError> &errors) {
QStringList msgs;
msgs.reserve(errors.size());
for (const QSslError &e : errors)
msgs.append(e.errorString());
qCWarning(launcherLog).noquote()
<< "SSL errors on probe to" << reply->url().toString() << ":"
<< msgs.join(QStringLiteral("; "));
});
}
ProviderLauncher::~ProviderLauncher()
{
m_nam->disconnect(this);
for (Slot *slot : m_slots) {
if (slot->cfg.detach) {
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
} else {
teardownSlot(slot);
}
delete slot;
}
m_slots.clear();
}
void ProviderLauncher::start(const QString &instanceName, const LaunchConfig &cfg)
{
if (instanceName.isEmpty() || cfg.isEmpty())
return;
Slot *slot = m_slots.value(instanceName, nullptr);
if (slot) {
if (slot->state == Starting || slot->state == Probing || slot->state == Ready) {
slot->cfg = cfg;
return;
}
teardownSlot(slot);
} else {
slot = new Slot;
slot->name = instanceName;
m_slots.insert(instanceName, slot);
}
slot->cfg = cfg;
slot->scrollback.clear();
slot->lastError.clear();
slot->detachedPid = 0;
slot->adoptedExternal = false;
slot->started = false;
++slot->generation;
const int gen = slot->generation;
if (!cfg.readyUrl.isEmpty()) {
changeState(slot, Starting);
const QString name = instanceName;
const QString readyUrl = cfg.readyUrl;
probeOnceAsync(slot, gen, readyUrl, [this, name, readyUrl](bool ok) {
Slot *s = m_slots.value(name, nullptr);
if (!s || s->state != Starting)
return;
if (ok) {
s->adoptedExternal = true;
s->detachedPid = 0;
appendLog(s, QStringLiteral(
"[adopt] %1 is already up — reusing the running process (no pid).")
.arg(readyUrl));
changeState(s, Ready);
return;
}
if (s->cfg.detach)
launchDetached(s);
else
launchProcess(s);
});
return;
}
if (cfg.detach)
launchDetached(slot);
else
launchProcess(slot);
}
void ProviderLauncher::stop(const QString &instanceName)
{
Slot *slot = m_slots.value(instanceName, nullptr);
if (!slot)
return;
if (slot->state == Idle || slot->state == Failed) {
changeState(slot, Idle);
return;
}
changeState(slot, Stopping);
if (slot->cfg.detach) {
const qint64 pid = slot->detachedPid;
const QString readyUrl = slot->cfg.readyUrl;
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
slot->detachedPid = 0;
slot->adoptedExternal = false;
if (pid <= 0) {
appendLog(slot, QStringLiteral(
"[stop] no pid recorded (process was adopted via probe) — "
"cannot terminate from the plugin; kill manually if needed."));
changeState(slot, Idle);
return;
}
if (readyUrl.isEmpty()) {
appendLog(slot, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
killByPid(pid);
changeState(slot, Idle);
return;
}
const QString name = instanceName;
++slot->generation;
const int gen = slot->generation;
probeOnceAsync(slot, gen, readyUrl, [this, name, pid](bool stillUp) {
Slot *s = m_slots.value(name, nullptr);
if (!s)
return;
if (stillUp) {
appendLog(s, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid));
killByPid(pid);
} else {
appendLog(s, QStringLiteral(
"[stop] pid=%1 no longer responsive on ready_url — "
"skipping kill to avoid hitting a reused PID.").arg(pid));
}
if (s->state == Stopping)
changeState(s, Idle);
});
return;
}
teardownSlot(slot);
changeState(slot, Idle);
}
void ProviderLauncher::restart(const QString &instanceName, const LaunchConfig &cfg)
{
stop(instanceName);
start(instanceName, cfg);
}
ProviderLauncher::State ProviderLauncher::state(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->state : Idle;
}
bool ProviderLauncher::isReady(const QString &instanceName) const
{
return state(instanceName) == Ready;
}
QString ProviderLauncher::lastError(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->lastError : QString{};
}
QByteArray ProviderLauncher::scrollback(const QString &instanceName) const
{
const Slot *slot = m_slots.value(instanceName, nullptr);
return slot ? slot->scrollback : QByteArray{};
}
QStringList ProviderLauncher::activeInstances() const
{
QStringList out;
for (auto it = m_slots.constBegin(); it != m_slots.constEnd(); ++it) {
if (it.value()->state != Idle)
out.append(it.key());
}
std::sort(out.begin(), out.end(),
[](const QString &a, const QString &b) {
return a.compare(b, Qt::CaseInsensitive) < 0;
});
return out;
}
void ProviderLauncher::launchProcess(Slot *slot)
{
const LaunchConfig &cfg = slot->cfg;
const QString command = expandVars(cfg.command, slot);
const QStringList args = expandVars(cfg.args, slot);
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
const QString name = slot->name;
auto *proc = new Utils::Process(this);
slot->process = proc;
Utils::Environment env = Utils::Environment::systemEnvironment();
env.set(QStringLiteral("PROVIDER_NAME"), slot->name);
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
env.set(it.key(), it.value());
proc->setEnvironment(env);
proc->setWorkingDirectory(Utils::FilePath::fromString(cwd));
proc->setCommand(Utils::CommandLine{Utils::FilePath::fromString(command), args});
proc->setPtyData(Utils::Pty::Data{});
connect(proc, &Utils::Process::readyReadStandardOutput, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray chunk = s->process->readAllRawStandardOutput();
if (!chunk.isEmpty()) {
appendScrollback(s, chunk);
emit bytesReceived(s->name, chunk);
}
});
connect(proc, &Utils::Process::readyReadStandardError, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray chunk = s->process->readAllRawStandardError();
if (!chunk.isEmpty()) {
appendScrollback(s, chunk);
emit bytesReceived(s->name, chunk);
}
});
connect(proc, &Utils::Process::started, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s)
return;
s->started = true;
if (s->startTimer) {
s->startTimer->stop();
s->startTimer->deleteLater();
s->startTimer = nullptr;
}
if (s->state != Starting)
return;
if (s->cfg.readyUrl.isEmpty()) {
changeState(s, Ready);
return;
}
s->probeStart.start();
changeState(s, Probing);
scheduleReadyProbe(s);
});
connect(proc, &Utils::Process::done, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->process) return;
const QByteArray tailOut = s->process->readAllRawStandardOutput();
const QByteArray tailErr = s->process->readAllRawStandardError();
if (!tailOut.isEmpty()) { appendScrollback(s, tailOut); emit bytesReceived(s->name, tailOut); }
if (!tailErr.isEmpty()) { appendScrollback(s, tailErr); emit bytesReceived(s->name, tailErr); }
const int code = s->process->exitCode();
const QProcess::ExitStatus status = s->process->exitStatus();
appendLog(s, QStringLiteral("[exit] code=%1 status=%2")
.arg(code)
.arg(status == QProcess::NormalExit ? "normal" : "crashed"));
const State prev = s->state;
teardownSlot(s);
if (prev != Stopping && code != 0) {
s->lastError = QStringLiteral("Process exited (code %1)").arg(code);
changeState(s, Failed);
} else {
changeState(s, Idle);
}
});
appendLog(slot, QStringLiteral("[spawn] %1 %2")
.arg(command, args.join(QLatin1Char(' '))));
changeState(slot, Starting);
proc->start();
if (slot->startTimer) {
slot->startTimer->stop();
slot->startTimer->deleteLater();
}
slot->startTimer = new QTimer(this);
slot->startTimer->setSingleShot(true);
const QString slotName = slot->name;
connect(slot->startTimer, &QTimer::timeout, this, [this, slotName] {
Slot *s = m_slots.value(slotName, nullptr);
if (!s || s->started || s->state != Starting)
return;
s->lastError = s->process && !s->process->errorString().isEmpty()
? s->process->errorString()
: QStringLiteral("Process failed to start");
appendLog(s, QStringLiteral("[error] %1").arg(s->lastError));
teardownSlot(s);
changeState(s, Failed);
});
slot->startTimer->start(kStartTimeout);
}
void ProviderLauncher::launchDetached(Slot *slot)
{
const LaunchConfig &cfg = slot->cfg;
const QString command = expandVars(cfg.command, slot);
const QStringList args = expandVars(cfg.args, slot);
const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot);
appendLog(slot, QStringLiteral("[spawn-detached] %1 %2")
.arg(command, args.join(QLatin1Char(' '))));
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("PROVIDER_NAME"), slot->name);
for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it)
env.insert(it.key(), it.value());
QProcess tmp;
tmp.setProgram(command);
tmp.setArguments(args);
tmp.setWorkingDirectory(cwd);
tmp.setProcessEnvironment(env);
tmp.setStandardOutputFile(QProcess::nullDevice());
tmp.setStandardErrorFile(QProcess::nullDevice());
qint64 pid = 0;
const bool ok = tmp.startDetached(&pid);
if (!ok || pid <= 0) {
slot->lastError = tmp.errorString().isEmpty()
? QStringLiteral("Detached spawn failed")
: tmp.errorString();
appendLog(slot, QStringLiteral("[error] %1").arg(slot->lastError));
changeState(slot, Failed);
return;
}
slot->detachedPid = pid;
appendLog(slot, QStringLiteral("[detached] pid=%1 (stdout/stderr discarded)").arg(pid));
if (cfg.readyUrl.isEmpty()) {
changeState(slot, Ready);
return;
}
slot->probeStart.start();
changeState(slot, Probing);
scheduleReadyProbe(slot);
}
void ProviderLauncher::probeOnceAsync(
Slot *slot, int expectedGeneration, const QString &url,
std::function<void(bool)> onResult)
{
QNetworkRequest req(QUrl{url});
req.setTransferTimeout(kAdoptionTransferTimeout);
QNetworkReply *reply = m_nam->get(req);
if (slot)
slot->oneShotProbes.append(QPointer<QNetworkReply>(reply));
const QString name = slot ? slot->name : QString{};
connect(reply, &QNetworkReply::finished, this,
[this, reply, name, expectedGeneration, cb = std::move(onResult)] {
reply->deleteLater();
Slot *s = m_slots.value(name, nullptr);
if (s) {
s->oneShotProbes.removeAll(QPointer<QNetworkReply>(reply));
if (s->generation != expectedGeneration)
return;
}
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const bool ok = reply->error() == QNetworkReply::NoError && http >= 200 && http < 300;
cb(ok);
});
}
void ProviderLauncher::killByPid(qint64 pid)
{
if (pid <= 0)
return;
#ifdef Q_OS_WIN
HANDLE h = ::OpenProcess(PROCESS_TERMINATE, FALSE, static_cast<DWORD>(pid));
if (h) {
::TerminateProcess(h, 1);
::CloseHandle(h);
}
#else
::kill(static_cast<pid_t>(pid), SIGTERM);
#endif
}
void ProviderLauncher::scheduleReadyProbe(Slot *slot)
{
if (!slot->probeTimer) {
const QString name = slot->name;
slot->probeTimer = new QTimer(this);
slot->probeTimer->setSingleShot(true);
connect(slot->probeTimer, &QTimer::timeout, this, [this, name] {
if (Slot *s = m_slots.value(name, nullptr))
runReadyProbe(s);
});
}
slot->probeTimer->start(kProbeInterval);
}
void ProviderLauncher::runReadyProbe(Slot *slot)
{
if (!slot || slot->state != Probing)
return;
const auto elapsed = std::chrono::milliseconds{slot->probeStart.elapsed()};
if (elapsed > slot->cfg.readyTimeout) {
slot->lastError = QStringLiteral("Ready probe timed out after %1 s")
.arg(slot->cfg.readyTimeout.count());
appendLog(slot, QStringLiteral("[probe] timeout — %1").arg(slot->lastError));
changeState(slot, Failed);
teardownSlot(slot);
return;
}
QNetworkRequest req(QUrl{slot->cfg.readyUrl});
req.setTransferTimeout(kProbeTransferTimeout);
slot->probeReply = m_nam->get(req);
const QString name = slot->name;
connect(slot->probeReply, &QNetworkReply::finished, this, [this, name] {
Slot *s = m_slots.value(name, nullptr);
if (!s || !s->probeReply)
return;
QNetworkReply *reply = s->probeReply;
s->probeReply.clear();
reply->deleteLater();
if (s->state != Probing)
return;
const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && http >= 200 && http < 300) {
appendLog(s, QStringLiteral("[probe] %1 → %2 OK").arg(s->cfg.readyUrl).arg(http));
changeState(s, Ready);
return;
}
if (reply->error() != QNetworkReply::NoError) {
appendLog(s, QStringLiteral("[probe] %1 → %2")
.arg(s->cfg.readyUrl, reply->errorString()));
}
scheduleReadyProbe(s);
});
}
void ProviderLauncher::teardownSlot(Slot *slot)
{
if (!slot)
return;
++slot->generation;
if (slot->probeTimer) {
slot->probeTimer->stop();
slot->probeTimer->deleteLater();
slot->probeTimer = nullptr;
}
if (slot->startTimer) {
slot->startTimer->stop();
slot->startTimer->deleteLater();
slot->startTimer = nullptr;
}
if (slot->probeReply) {
slot->probeReply->abort();
slot->probeReply->deleteLater();
slot->probeReply.clear();
}
for (const QPointer<QNetworkReply> &probe : slot->oneShotProbes) {
if (probe) {
probe->abort();
probe->deleteLater();
}
}
slot->oneShotProbes.clear();
if (slot->process) {
Utils::Process *p = slot->process;
slot->process = nullptr;
p->disconnect(this);
if (p->state() == QProcess::NotRunning) {
p->deleteLater();
} else {
QObject::connect(p, &Utils::Process::done, p, &QObject::deleteLater);
QTimer::singleShot(std::chrono::seconds{15}, p, &QObject::deleteLater);
p->stop();
}
}
}
void ProviderLauncher::appendLog(Slot *slot, const QString &line)
{
if (line.isEmpty())
return;
const QByteArray bytes = (line + QStringLiteral("\r\n")).toUtf8();
appendScrollback(slot, bytes);
emit bytesReceived(slot->name, bytes);
}
void ProviderLauncher::appendScrollback(Slot *slot, const QByteArray &chunk)
{
if (chunk.isEmpty())
return;
slot->scrollback.append(chunk);
if (slot->scrollback.size() > kScrollbackBytesMax) {
const int over = slot->scrollback.size() - kScrollbackBytesMax;
slot->scrollback.remove(0, over);
}
}
void ProviderLauncher::changeState(Slot *slot, State newState)
{
if (slot->state == newState)
return;
slot->state = newState;
const QString name = slot->name;
qCDebug(launcherLog).noquote() << name << "→ state" << newState;
emit stateChanged(name, newState);
}
QString ProviderLauncher::expandOne(
const QString &input, const Slot *slot, const QProcessEnvironment &sys)
{
if (!input.contains(QLatin1String("${")))
return input;
QString out = input;
int searchFrom = 0;
while (searchFrom < out.size()) {
const int open = out.indexOf(QLatin1String("${"), searchFrom);
if (open < 0)
break;
const int close = out.indexOf(QLatin1Char('}'), open + 2);
if (close < 0)
break;
const QString key = out.mid(open + 2, close - open - 2);
QString value;
if (slot && slot->cfg.env.contains(key))
value = slot->cfg.env.value(key);
else if (key == QLatin1String("PROVIDER_NAME") && slot)
value = slot->name;
else if (sys.contains(key))
value = sys.value(key);
out.replace(open, close - open + 1, value);
searchFrom = open + value.size();
}
return out;
}
QString ProviderLauncher::expandVars(const QString &input, const Slot *slot) const
{
if (!input.contains(QLatin1String("${")))
return input;
return expandOne(input, slot, QProcessEnvironment::systemEnvironment());
}
QStringList ProviderLauncher::expandVars(const QStringList &args, const Slot *slot) const
{
if (args.isEmpty())
return {};
const QProcessEnvironment sys = QProcessEnvironment::systemEnvironment();
QStringList out;
out.reserve(args.size());
for (const QString &a : args)
out.append(expandOne(a, slot, sys));
return out;
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,82 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <functional>
#include <QByteArray>
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include "ProviderInstance.hpp"
class QNetworkAccessManager;
class QNetworkReply;
class QProcessEnvironment;
class QTimer;
namespace Utils { class Process; }
namespace QodeAssist::Providers {
class ProviderLauncher : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderLauncher)
public:
enum State : quint8 {
Idle, // no process running
Starting, // QProcess::start invoked, waiting for it to be alive
Probing, // process alive, polling ready_url
Ready, // ready probe succeeded (or no probe configured)
Stopping, // termination requested; waiting for QProcess to exit
Failed, // process exited unexpectedly or readiness probe gave up
};
Q_ENUM(State)
explicit ProviderLauncher(QObject *parent = nullptr);
~ProviderLauncher() override;
void start(const QString &instanceName, const LaunchConfig &cfg);
void stop(const QString &instanceName);
void restart(const QString &instanceName, const LaunchConfig &cfg);
[[nodiscard]] State state(const QString &instanceName) const;
[[nodiscard]] bool isReady(const QString &instanceName) const;
[[nodiscard]] QString lastError(const QString &instanceName) const;
[[nodiscard]] QByteArray scrollback(const QString &instanceName) const;
[[nodiscard]] QStringList activeInstances() const;
signals:
void stateChanged(const QString &instanceName, State newState);
void bytesReceived(const QString &instanceName, const QByteArray &chunk);
private:
struct Slot;
void teardownSlot(Slot *slot);
void launchProcess(Slot *slot);
void launchDetached(Slot *slot);
void appendScrollback(Slot *slot, const QByteArray &chunk);
void scheduleReadyProbe(Slot *slot);
void runReadyProbe(Slot *slot);
void probeOnceAsync(Slot *slot, int expectedGeneration, const QString &url,
std::function<void(bool)> onResult);
void appendLog(Slot *slot, const QString &line);
void changeState(Slot *slot, State newState);
QString expandVars(const QString &input, const Slot *slot) const;
QStringList expandVars(const QStringList &args, const Slot *slot) const;
static QString expandOne(const QString &input, const Slot *slot,
const QProcessEnvironment &sys);
static void killByPid(qint64 pid);
QHash<QString, Slot *> m_slots;
QNetworkAccessManager *m_nam = nullptr;
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,75 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderSecretsStore.hpp"
#include <coreplugin/icore.h>
#include <utils/qtcsettings.h>
namespace QodeAssist::Providers {
namespace {
constexpr auto kGroup = "QodeAssist/Keychain";
Utils::Key settingsKey(const QString &ref)
{
return Utils::Key(QStringLiteral("%1/%2")
.arg(QLatin1StringView(kGroup))
.arg(ref)
.toUtf8());
}
} // namespace
ProviderSecretsStore::ProviderSecretsStore(QObject *parent)
: QObject(parent)
{}
ProviderSecretsStore::~ProviderSecretsStore() = default;
QString ProviderSecretsStore::readKeySync(const QString &ref) const
{
if (ref.isEmpty())
return {};
auto *s = Core::ICore::settings();
if (!s)
return {};
return s->value(settingsKey(ref)).toString();
}
void ProviderSecretsStore::writeKey(const QString &ref, const QString &value)
{
if (ref.isEmpty())
return;
auto *s = Core::ICore::settings();
if (!s)
return;
s->setValue(settingsKey(ref), value);
s->sync();
emit keyChanged(ref);
}
void ProviderSecretsStore::eraseKey(const QString &ref)
{
if (ref.isEmpty())
return;
auto *s = Core::ICore::settings();
if (!s)
return;
s->remove(settingsKey(ref));
s->sync();
emit keyChanged(ref);
}
bool ProviderSecretsStore::hasKey(const QString &ref) const
{
if (ref.isEmpty())
return false;
auto *s = Core::ICore::settings();
if (!s)
return false;
return s->contains(settingsKey(ref));
}
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QString>
namespace QodeAssist::Providers {
class ProviderSecretsStore : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ProviderSecretsStore)
public:
explicit ProviderSecretsStore(QObject *parent = nullptr);
~ProviderSecretsStore() override;
[[nodiscard]] QString readKeySync(const QString &ref) const;
void writeKey(const QString &ref, const QString &value);
void eraseKey(const QString &ref);
[[nodiscard]] bool hasKey(const QString &ref) const;
signals:
void keyChanged(const QString &ref);
};
} // namespace QodeAssist::Providers

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Claude"
client_api = "Claude"
description = "Anthropic's hosted Claude API."
url = "https://api.anthropic.com"
api_key_ref = "qodeassist/providers/Claude"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Codestral"
client_api = "Codestral"
description = "Mistral's Codestral FIM-capable code model API."
url = "https://codestral.mistral.ai"
api_key_ref = "qodeassist/providers/Codestral"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Google AI"
client_api = "Google AI"
description = "Google AI Studio (Gemini) hosted API."
url = "https://generativelanguage.googleapis.com/v1beta"
api_key_ref = "qodeassist/providers/Google AI"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "llama.cpp"
client_api = "llama.cpp"
description = "Local llama.cpp server (llama-server)."
url = "http://localhost:8080"
api_key_ref = "qodeassist/providers/llama.cpp"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "LM Studio (Chat Completions)"
client_api = "LM Studio (Chat Completions)"
description = "Local LM Studio server over the /v1/chat/completions endpoint."
url = "http://localhost:1234"
api_key_ref = "qodeassist/providers/LM Studio (Chat Completions)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "LM Studio (Responses API)"
client_api = "LM Studio (Responses API)"
description = "Local LM Studio server over the /v1/responses endpoint."
url = "http://localhost:1234"
api_key_ref = "qodeassist/providers/LM Studio (Responses API)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Mistral AI"
client_api = "Mistral AI"
description = "Mistral's hosted chat / completions API."
url = "https://api.mistral.ai"
api_key_ref = "qodeassist/providers/Mistral AI"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Ollama (OpenAI-compatible)"
client_api = "Ollama (OpenAI-compatible)"
description = "Local Ollama daemon spoken to via the OpenAI-compatible /v1 routes."
url = "http://localhost:11434"
api_key_ref = "qodeassist/providers/Ollama (OpenAI-compatible)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "Ollama (Native)"
client_api = "Ollama (Native)"
description = "Default local Ollama daemon over its native /api/* endpoints."
url = "http://localhost:11434"
api_key_ref = "qodeassist/providers/Ollama (Native)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "OpenAI (Chat Completions)"
client_api = "OpenAI (Chat Completions)"
description = "OpenAI's hosted /v1/chat/completions endpoint."
url = "https://api.openai.com/v1"
api_key_ref = "qodeassist/providers/OpenAI (Chat Completions)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "OpenAI Compatible"
client_api = "OpenAI Compatible"
description = "Self-hosted OpenAI-compatible server (vLLM, TGI, ...). Edit the URL to match your deployment."
url = "http://localhost:1234/v1"
api_key_ref = "qodeassist/providers/OpenAI Compatible"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "OpenAI (Responses API)"
client_api = "OpenAI (Responses API)"
description = "OpenAI's hosted /v1/responses endpoint."
url = "https://api.openai.com/v1"
api_key_ref = "qodeassist/providers/OpenAI (Responses API)"

View File

@@ -0,0 +1,8 @@
schema_version = 1
name = "OpenRouter"
client_api = "OpenRouter"
description = "OpenRouter aggregator (https://openrouter.ai)."
url = "https://openrouter.ai/api"
api_key_ref = "qodeassist/providers/OpenRouter"

View File

@@ -0,0 +1,17 @@
<RCC>
<qresource prefix="/provider-instances">
<file>ollama_native.toml</file>
<file>ollama_compat.toml</file>
<file>claude.toml</file>
<file>openai_chat.toml</file>
<file>openai_responses.toml</file>
<file>openai_compat.toml</file>
<file>lmstudio_chat.toml</file>
<file>lmstudio_responses.toml</file>
<file>openrouter.toml</file>
<file>mistral.toml</file>
<file>codestral.toml</file>
<file>googleai.toml</file>
<file>llamacpp.toml</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,12 @@
add_library(TomlSerializer STATIC
TomlWriter.hpp TomlWriter.cpp
)
target_link_libraries(TomlSerializer
PUBLIC
Qt::Core
)
target_include_directories(TomlSerializer
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@@ -0,0 +1,135 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "TomlWriter.hpp"
#include <QJsonValue>
namespace QodeAssist::TomlSerializer {
QString escapeBasic(const QString &s)
{
QString out;
out.reserve(s.size());
for (QChar c : s) {
const ushort u = c.unicode();
switch (u) {
case '\\': out += QLatin1String("\\\\"); break;
case '"': out += QLatin1String("\\\""); break;
case '\b': out += QLatin1String("\\b"); break;
case '\t': out += QLatin1String("\\t"); break;
case '\n': out += QLatin1String("\\n"); break;
case '\f': out += QLatin1String("\\f"); break;
case '\r': out += QLatin1String("\\r"); break;
default:
if (u < 0x20 || u == 0x7f)
out += QStringLiteral("\\u%1").arg(u, 4, 16, QLatin1Char('0'));
else
out += c;
break;
}
}
return out;
}
void TomlWriter::writeBlankLine()
{
m_out += QLatin1Char('\n');
}
void TomlWriter::writeComment(const QString &line)
{
m_out += QLatin1String("# ");
m_out += line;
m_out += QLatin1Char('\n');
}
void TomlWriter::writeTableHeader(const QString &name)
{
m_out += QLatin1Char('[');
m_out += name;
m_out += QLatin1String("]\n");
}
void TomlWriter::writeKeyPrefix(const QString &key)
{
m_out += key;
if (m_keyColumnWidth > key.size())
m_out += QString(m_keyColumnWidth - key.size(), QLatin1Char(' '));
m_out += QLatin1String(" = ");
}
void TomlWriter::writeString(const QString &key, const QString &value)
{
writeKeyPrefix(key);
m_out += QLatin1Char('"');
m_out += escapeBasic(value);
m_out += QLatin1String("\"\n");
}
void TomlWriter::writeBool(const QString &key, bool value)
{
writeKeyPrefix(key);
m_out += value ? QLatin1String("true") : QLatin1String("false");
m_out += QLatin1Char('\n');
}
void TomlWriter::writeInt(const QString &key, qint64 value)
{
writeKeyPrefix(key);
m_out += QString::number(value);
m_out += QLatin1Char('\n');
}
void TomlWriter::writeDouble(const QString &key, double value)
{
writeKeyPrefix(key);
m_out += QString::number(value);
m_out += QLatin1Char('\n');
}
void TomlWriter::writeStringArray(const QString &key, const QStringList &values)
{
writeKeyPrefix(key);
m_out += QLatin1Char('[');
bool first = true;
for (const QString &v : values) {
if (!first)
m_out += QLatin1String(", ");
m_out += QLatin1Char('"');
m_out += escapeBasic(v);
m_out += QLatin1Char('"');
first = false;
}
m_out += QLatin1String("]\n");
}
void TomlWriter::writeJsonPrimitives(const QJsonObject &obj)
{
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) {
const QJsonValue &v = it.value();
switch (v.type()) {
case QJsonValue::String: writeString(it.key(), v.toString()); break;
case QJsonValue::Bool: writeBool(it.key(), v.toBool()); break;
case QJsonValue::Double: {
const double d = v.toDouble();
const qint64 i = static_cast<qint64>(d);
if (static_cast<double>(i) == d)
writeInt(it.key(), i);
else
writeDouble(it.key(), d);
break;
}
default:
break;
}
}
}
void TomlWriter::writeStringDict(const QHash<QString, QString> &dict)
{
for (auto it = dict.constBegin(); it != dict.constEnd(); ++it)
writeString(it.key(), it.value());
}
} // namespace QodeAssist::TomlSerializer

View File

@@ -0,0 +1,47 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace QodeAssist::TomlSerializer {
[[nodiscard]] QString escapeBasic(const QString &s);
class TomlWriter
{
public:
TomlWriter() = default;
explicit TomlWriter(int keyColumnWidth) : m_keyColumnWidth(keyColumnWidth) {}
void setKeyColumnWidth(int width) { m_keyColumnWidth = width; }
void writeBlankLine();
void writeComment(const QString &line); // "# line\n"
void writeTableHeader(const QString &name); // "[name]\n"
void writeString(const QString &key, const QString &value);
void writeBool(const QString &key, bool value);
void writeInt(const QString &key, qint64 value);
void writeDouble(const QString &key, double value);
void writeStringArray(const QString &key, const QStringList &values);
void writeJsonPrimitives(const QJsonObject &obj);
void writeStringDict(const QHash<QString, QString> &dict);
[[nodiscard]] QString result() const { return m_out; }
[[nodiscard]] QByteArray toUtf8() const { return m_out.toUtf8(); }
private:
void writeKeyPrefix(const QString &key);
QString m_out;
int m_keyColumnWidth = 0;
};
} // namespace QodeAssist::TomlSerializer