v0.8.15: custom providers and model parameters under Advanced options

The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.

Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
  engine's own user layer, <config>/cst-agent/models.json:
  {"providers": {"myllm": {"api_base": ..., "key_env": ...,
                           "default_model": ..., "models": [...]}}}
  and reload the registry, so the provider is at once in the provider list,
  in the wire models[] and in the CLI's /provider -- the file a hand-written
  override would go into is the same one, so the GUI and an editor agree.
  The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
  name overrides that preset (a self-hosted endpoint, a proxy), everything
  else in the file (model rules) is kept, and a file that is not a JSON
  object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
  addProvider() / removeProvider() drive the two calls above.

Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
  max_tokens. 0 means the field is not sent at all -- an absent field is the
  provider's own default, which "temperature": 0 is not (deterministic
  decoding is a real choice), so cst_llm_build_body() only adds what was set.
  tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
  cst_settings_remember_params(); cst_settings_remember() carries them along
  with the provider choice, and cst_agent_create() reads them, so a CLI or
  TUI run sends what the window was set to. An all-zero config (the CLI)
  leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
  remembered parameters of the provider it lands on and a base URL that was
  edited in the dialog.

The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
  used to push the key field and the buttons out of the dialog. The model
  chips are filtered (a filter field appears past twelve) and capped at 40
  with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
  variable, default model, models; Save provider / Remove from models.json)
  and the model parameters (temperature, top-p, max tokens; empty = not
  sent). The fields follow the picked provider and are what gets saved, so a
  hand-typed edit is never lost to a stale property.

Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.

Version 0.8.15.

mode = coding
This commit is contained in:
WHC
2026-10-11 13:26:02 +08:00
parent 3fd039cb51
commit 82dee65942
14 changed files with 1202 additions and 184 deletions
+113
View File
@@ -570,6 +570,7 @@ QVariantList AgentCore::providers() const {
m["keyFrom"] = utf8(from);
m["keyEnv"] = utf8(p->key_env);
m["source"] = utf8(cst_provider_source(p));
m["custom"] = strcmp(cst_provider_source(p), "user") == 0;
m["current"] = (m_cfg.provider[0] && strcmp(p->name, m_cfg.provider) == 0);
out.append(m);
}
@@ -663,10 +664,122 @@ QString AgentCore::useProvider(const QString &provider, const QString &model,
snprintf(cfg->model, sizeof(cfg->model), "%s", mdl.constData());
cst_settings_remember(cfg);
}
/* A base URL edited in the advanced form (a custom provider, or a preset we
* override) takes effect without a trip through a different provider. */
const cst_provider_t *entry = cst_provider_find(name.constData());
if (entry && entry->api_base && strcmp(entry->api_base, cfg->api_base) != 0)
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", entry->api_base);
/* And the generation parameters remembered for whichever provider we ended
* on (cst_agent_create() does the same for the one it starts with). */
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, cfg->provider);
cfg->temperature = g ? g->temperature : 0;
cfg->top_p = g ? g->top_p : 0;
cfg->max_tokens = g ? g->max_tokens : 0;
}
syncConfig();
return QString();
}
QString AgentCore::addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv) {
const QByteArray n = name.trimmed().toUtf8();
const QByteArray b = base.trimmed().toUtf8();
const QByteArray ke = keyEnv.trimmed().toUtf8();
const QByteArray dm = defaultModel.trimmed().toUtf8();
/* "a, b, c" -> the model list the engine will offer for it. */
QVector<QByteArray> list;
for (const QString &part : modelsCsv.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
const QByteArray t = part.trimmed().toUtf8();
if (!t.isEmpty() && list.size() < 32)
list.append(t);
}
QVector<const char *> ptrs;
for (const QByteArray &p : list)
ptrs.append(p.constData()); /* list is complete: no reallocation after */
char err[512] = "";
if (cst_models_user_set_provider(n.constData(), b.constData(),
ke.isEmpty() ? nullptr : ke.constData(),
dm.isEmpty() ? nullptr : dm.constData(),
ptrs.isEmpty() ? nullptr : ptrs.constData(),
ptrs.size(), err, sizeof(err)) != 0)
return utf8(err[0] ? err : "the provider was refused");
/* Editing the live provider's own entry: its new base URL is live at once. */
if (m_agent && m_cfg.provider[0] && strcmp(m_cfg.provider, n.constData()) == 0) {
const cst_provider_t *p = cst_provider_find(n.constData());
if (p && p->api_base && strcmp(p->api_base, m_cfg.api_base) != 0) {
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
syncConfig();
}
}
return QString();
}
int AgentCore::removeProvider(const QString &name) {
const QByteArray n = name.trimmed().toUtf8();
if (n.isEmpty())
return -1;
char err[512] = "";
const int rc = cst_models_user_drop_provider(n.constData(), err, sizeof(err));
if (rc < 0)
emit statusChanged(QStringLiteral("%1: %2")
.arg(name, utf8(err[0] ? err : "not removed")));
return rc;
}
QVariantMap AgentCore::genParams(const QString &provider) const {
QVariantMap m;
m["temperature"] = 0.0;
m["top_p"] = 0.0;
m["max_tokens"] = 0;
const QByteArray name = provider.trimmed().toUtf8();
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, name.constData());
if (g) {
m["temperature"] = g->temperature;
m["top_p"] = g->top_p;
m["max_tokens"] = g->max_tokens;
}
}
/* The live provider's config is what actually goes out: show that, not what
* a file says about it. */
if (name.size() && m_cfg.provider[0] &&
strcmp(m_cfg.provider, name.constData()) == 0) {
m["temperature"] = m_cfg.temperature;
m["top_p"] = m_cfg.top_p;
m["max_tokens"] = m_cfg.max_tokens;
}
return m;
}
QString AgentCore::setGenParams(const QString &provider, double temperature,
double topP, int maxTokens) {
const QByteArray name = provider.trimmed().toUtf8();
if (name.isEmpty())
return QStringLiteral("pick a provider first");
cst_gen_params_t g;
memset(&g, 0, sizeof(g));
snprintf(g.provider, sizeof(g.provider), "%s", name.constData());
g.temperature = temperature > 0 ? temperature : 0;
g.top_p = topP > 0 ? topP : 0;
g.max_tokens = maxTokens > 0 ? maxTokens : 0;
if (cst_settings_remember_params(name.constData(), &g) != 0)
return QStringLiteral("the parameters were not saved");
if (m_agent && strcmp(m_cfg.provider, name.constData()) == 0) {
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
cfg->temperature = g.temperature;
cfg->top_p = g.top_p;
cfg->max_tokens = g.max_tokens;
syncConfig();
}
return QString();
}
QVariantMap AgentCore::usage() const {
QVariantMap m;
if (!m_agent)