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
+37
View File
@@ -148,6 +148,43 @@ into the field reaches the engine when "Use this" is clicked, the note says so,
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
directory, so settings and keys of the real user are never rewritten. directory, so settings and keys of the real user are never rewritten.
## Custom providers, model parameters
The dialog's **Advanced options** covers the two things the presets cannot:
a provider of your own, and what the model should do.
- A custom provider is an entry the engine keeps in
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
`include/cst_models.h`) — the same file a hand-written override would go
into, so the GUI and a text editor are interchangeable:
```json
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
"key_env": "MYLLM_API_KEY",
"default_model": "m1",
"models": ["m1", "m2"] } } }
```
`AgentCore::addProvider()` / `removeProvider()` write it
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
reload the registry, so the provider is in the list, in `models[]` on the
wire and in `/provider` of the CLI at once. A name a preset uses overrides
that preset (a self-hosted endpoint, a proxy); the other content of the file
(model rules) is kept, and a file that is not a JSON object is never
overwritten. The key is still a key: it is verified against `GET /models` of
the new base URL before it is saved, and stored per provider name as before.
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
remembered per provider in `settings.json` (`"params": {...}`,
`cst_settings_remember_params()`), and applied to the engine's config by
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
An **empty field is not sent at all** — that is not the same as sending 0,
which is a real temperature; `cst_llm_build_body()` only adds a field that
was set (`tests/test_llm_body.c:test_gen_params`).
- A long model list (a router lists hundreds) is filtered and capped at 40
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
put and nothing can spill out of the frame.
## Attachments ## Attachments
The next prompt can carry files, and they arrive three ways: The next prompt can carry files, and they arrive three ways:
+7 -4
View File
@@ -180,8 +180,11 @@ ApplicationWindow {
settings.open() settings.open()
} }
function applySettings(provider, model, key) { function applySettings(provider, model, key, temperature, topP, maxTokens) {
const err = agent.useProvider(provider, model, key) /* The parameters are stored first: useProvider() then picks up what
* was remembered for whichever provider it ends on. */
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
const err = perr.length ? perr : agent.useProvider(provider, model, key)
settings.note = err.length ? err settings.note = err.length ? err
: "using " + agent.provider + " / " + agent.model : "using " + agent.provider + " / " + agent.model
settings.choices = agent.modelChoices(provider) settings.choices = agent.modelChoices(provider)
@@ -470,8 +473,8 @@ ApplicationWindow {
SettingsDialog { SettingsDialog {
id: settings id: settings
onApplyRequested: function (provider, model, key) { onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
win.applySettings(provider, model, key) win.applySettings(provider, model, key, temperature, topP, maxTokens)
} }
} }
+556 -178
View File
@@ -3,11 +3,18 @@ import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
// Provider, model and key: what the engine needs before it can talk to a model // Provider, model and key: what the engine needs before it can talk to a model
// at all. Everything here goes through AgentCore::useProvider(), which moves // at all. Everything here goes through AgentCore, which moves the engine's own
// the engine's own config (its session stays where it is) and remembers the // config (its session stays where it is) and remembers the choice. A key is
// choice. A key is verified against the provider before it is saved and is // verified against the provider before it is saved and is never read back into
// never read back into this window -- the dialog only ever says where the key // this window -- the dialog only ever says where the key it will use comes
// it will use comes from. // from.
//
// Three things live under "Advanced options", because they are for the odd
// provider or the odd model: the provider's own entry (name, base URL, key
// variable, models -- the engine keeps those in <config>/models.json, which is
// how a provider the presets do not know is added or an existing one is
// pointed somewhere else) and the generation parameters (temperature, top-p,
// max tokens), which are remembered per provider.
Dialog { Dialog {
id: dlg id: dlg
objectName: "settingsDialog" objectName: "settingsDialog"
@@ -21,15 +28,27 @@ Dialog {
property var choices: [] // AgentCore::modelChoices(picked) property var choices: [] // AgentCore::modelChoices(picked)
property string picked: "" property string picked: ""
property string model: "" property string model: ""
property string note: "" // what the last "Use this" answered property string note: "" // what the last action answered
property string keyEnv: "" property string keyEnv: ""
property string keyFrom: "" property string keyFrom: ""
property bool hasKey: false property bool hasKey: false
property bool advanced: false
property string modelFilter: "" // narrows a long model list
/* The advanced form: the picked provider's entry, editable. */
property string advName: ""
property string advBase: ""
property string advKeyEnv: ""
property string advModel: ""
property string advModels: ""
property string tempText: ""
property string topPText: ""
property string maxTokText: ""
signal applyRequested(string provider, string model, string key) signal applyRequested(string provider, string model, string key,
real temperature, real topP, int maxTokens)
width: Math.min(580, parent ? parent.width - 32 : 580) width: Math.min(660, parent ? parent.width - 32 : 660)
height: Math.min(560, parent ? parent.height - 60 : 560) height: Math.min(640, parent ? parent.height - 40 : 640)
/* The Basic style paints a light panel; this window is dark, so the /* The Basic style paints a light panel; this window is dark, so the
* dialog brings its own surface like every other one here. */ * dialog brings its own surface like every other one here. */
@@ -40,6 +59,35 @@ Dialog {
border.color: Theme.border border.color: Theme.border
} }
// A long provider list (a router lists hundreds) is a wall of chips: the
// filter narrows it and the rest is one line ("+N more").
readonly property int chipCap: 40
readonly property var chips: {
const f = modelFilter.trim().toLowerCase()
const out = []
for (let i = 0; i < choices.length; i++) {
const c = String(choices[i])
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
out.push(c)
}
return out
}
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
* which is not the same as sending 0). */
function num(text) {
const v = parseFloat(String(text).replace(",", "."))
return isFinite(v) && v > 0 ? v : 0
}
function pickedEntry() {
for (let i = 0; i < providers.length; i++)
if (providers[i].name === dlg.picked)
return providers[i]
return null
}
/* Fill it in: the presets, the provider the engine is on now, and the /* Fill it in: the presets, the provider the engine is on now, and the
* model that provider last used. */ * model that provider last used. */
function load(list, provider, currentModel) { function load(list, provider, currentModel) {
@@ -51,24 +99,33 @@ Dialog {
if (list[i].current === true) if (list[i].current === true)
picked = list[i].name picked = list[i].name
choices = picked.length ? agent.modelChoices(picked) : [] choices = picked.length ? agent.modelChoices(picked) : []
modelFilter = ""
syncPick() syncPick()
} }
/* Where the picked provider's key would come from. From the entries -- /* Where the picked provider's key would come from and what it is set to.
* a key itself never reaches the QML side. */ * From the entries -- a key itself never reaches the QML side. */
function syncPick() { function syncPick() {
hasKey = false hasKey = false
keyFrom = "" keyFrom = ""
keyEnv = "" keyEnv = ""
for (let i = 0; i < providers.length; i++) { const p = pickedEntry()
const p = providers[i] if (p) {
if (p.name !== picked)
continue
hasKey = p.hasKey === true hasKey = p.hasKey === true
keyFrom = p.keyFrom keyFrom = p.keyFrom
keyEnv = p.keyEnv keyEnv = p.keyEnv
return /* The advanced form describes the picked provider: edit it, or
* type another name to add one. */
advName = p.name
advBase = p.base
advKeyEnv = p.keyEnv
advModel = p.model
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
} }
const g = picked.length ? agent.genParams(picked) : null
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
} }
function keyState() { function keyState() {
@@ -90,6 +147,87 @@ Dialog {
return s return s
} }
/* Write the form's entry into the engine's user file and list it again. */
function saveProvider() {
/* The fields are the truth here, not the properties: a hand-typed
* edit is what the user wants saved. */
const name = advNameField.text.trim()
const err = agent.addProvider(name, advBaseField.text.trim(),
advKeyEnvField.text.trim(),
advModelField.text.trim(),
advModelsField.text)
if (err.length) {
note = err
return
}
providers = agent.providers()
picked = name
choices = agent.modelChoices(name)
syncPick()
note = "saved " + name + " to models.json — it is in the list above now"
}
function removeProvider() {
const typed = advNameField.text.trim()
const name = typed.length ? typed : picked
const rc = agent.removeProvider(name)
if (rc < 0) {
note = "could not remove " + name
return
}
providers = agent.providers()
note = rc === 0
? name + " is not in models.json (a preset of that name stays)"
: "removed " + name + " from models.json"
if (rc === 1 && providers.length > 0)
picked = providers[0].name
choices = picked.length ? agent.modelChoices(picked) : []
syncPick()
}
/* The form's fields follow the picked provider (and a user edit breaks
* the binding, so the push is imperative). */
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
// One labelled field of the advanced form.
component AdvField: ColumnLayout {
property string label: ""
property alias text: field.text
property alias placeholder: field.placeholderText
property int hintWidth: 0
Layout.fillWidth: true
spacing: 2
Text {
text: parent.label
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
TextField {
id: field
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: field.activeFocus ? Theme.accentDim : Theme.border
}
}
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
objectName: "settingsPanel" objectName: "settingsPanel"
spacing: Theme.gap spacing: Theme.gap
@@ -109,201 +247,427 @@ Dialog {
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
/* Everything scrolls, so the buttons stay where they are and a long
* model list cannot push them out of the dialog. */
Flickable { Flickable {
id: body
objectName: "settingsBody"
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.minimumHeight: 96 Layout.minimumHeight: 120
clip: true clip: true
contentWidth: width contentWidth: width
contentHeight: plist.implicitHeight contentHeight: bodyCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {} ScrollBar.vertical: ScrollBar {}
ColumnLayout { ColumnLayout {
id: plist id: bodyCol
width: parent.width width: parent.width
spacing: 2 spacing: Theme.gap
Repeater { ColumnLayout {
model: dlg.providers Layout.fillWidth: true
delegate: Rectangle { spacing: 2
id: prow
required property var modelData
readonly property bool selected: dlg.picked === modelData.name
objectName: "providerRow:" + modelData.name Repeater {
Layout.fillWidth: true model: dlg.providers
implicitHeight: prowCol.implicitHeight + 8 delegate: Rectangle {
radius: Theme.radiusSm id: prow
color: prow.selected ? Theme.accentDim required property var modelData
: prowHover.hovered ? Theme.surfaceAlt readonly property bool selected: dlg.picked === modelData.name
: "transparent"
border.width: 1
border.color: prow.selected ? Theme.accent : "transparent"
HoverHandler { id: prowHover } objectName: "providerRow:" + modelData.name
MouseArea { Layout.fillWidth: true
anchors.fill: parent implicitHeight: prowCol.implicitHeight + 8
onClicked: { radius: Theme.radiusSm
dlg.picked = prow.modelData.name color: prow.selected ? Theme.accentDim
modelField.text = prow.modelData.model : prowHover.hovered ? Theme.surfaceAlt
dlg.choices = agent.modelChoices(prow.modelData.name) : "transparent"
dlg.syncPick() border.width: 1
border.color: prow.selected ? Theme.accent : "transparent"
HoverHandler { id: prowHover }
MouseArea {
anchors.fill: parent
onClicked: {
dlg.picked = prow.modelData.name
modelField.text = prow.modelData.model
dlg.choices = agent.modelChoices(prow.modelData.name)
dlg.modelFilter = ""
dlg.syncPick()
}
} }
}
ColumnLayout { ColumnLayout {
id: prowCol id: prowCol
anchors { anchors {
left: parent.left left: parent.left
right: parent.right right: parent.right
top: parent.top top: parent.top
margins: 4 margins: 4
} }
spacing: 0 spacing: 0
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.gap spacing: Theme.gap
Text { Text {
text: prow.modelData.name text: prow.modelData.name
color: prow.selected ? Theme.accentText : Theme.textDim color: prow.selected ? Theme.accentText : Theme.textDim
font.pixelSize: Theme.smallSize font.pixelSize: Theme.smallSize
font.bold: prow.selected font.bold: prow.selected
}
Text {
visible: prow.modelData.current === true
text: "in use"
color: prow.selected ? Theme.okOnAccent : Theme.ok
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
Text {
text: prow.modelData.custom === true
? "custom" : prow.modelData.source
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
} }
Text { Text {
visible: prow.modelData.current === true Layout.fillWidth: true
text: "in use" elide: Text.ElideMiddle
color: prow.selected ? Theme.okOnAccent : Theme.ok text: prow.modelData.base + " · " +
font.pixelSize: Theme.smallSize (prow.modelData.hasKey === true
} ? (prow.modelData.keyFrom === "saved"
Item { Layout.fillWidth: true } ? "key saved"
Text { : "key from " + prow.modelData.keyFrom)
text: prow.modelData.source : (prow.modelData.keyEnv.length
? "no key (" + prow.modelData.keyEnv + ")"
: "no key"))
color: prow.selected ? Theme.accentTextDim : Theme.textFaint color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize font.pixelSize: Theme.smallSize
} }
} }
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: prow.modelData.base + " · " +
(prow.modelData.hasKey === true
? (prow.modelData.keyFrom === "saved"
? "key saved"
: "key from " + prow.modelData.keyFrom)
: (prow.modelData.keyEnv.length
? "no key (" + prow.modelData.keyEnv + ")"
: "no key"))
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
} }
} }
} }
}
}
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
text: "Model" text: "Model"
color: Theme.textDim color: Theme.textDim
font.pixelSize: Theme.smallSize font.pixelSize: Theme.smallSize
font.bold: true font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextField {
id: modelField
objectName: "settingsModel"
Layout.fillWidth: true
text: dlg.model
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderText: "model name"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: modelField.activeFocus ? Theme.accentDim
: Theme.border
} }
} RowLayout {
Text { Layout.fillWidth: true
Layout.preferredWidth: 90 spacing: Theme.gap
elide: Text.ElideRight TextField {
text: modelField.text.length ? agent.modelInfo(modelField.text) : "" id: modelField
color: Theme.textFaint objectName: "settingsModel"
font.pixelSize: Theme.smallSize Layout.fillWidth: true
} text: dlg.model
} implicitHeight: Theme.touchTarget
Flow { selectByMouse: true
Layout.fillWidth: true color: Theme.text
spacing: 6 placeholderText: "model name"
visible: dlg.choices.length > 0 placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
Repeater { inputMethodHints: Qt.ImhNoPredictiveText
model: dlg.choices background: Rectangle {
delegate: Rectangle { radius: Theme.radiusSm
id: chip color: Theme.bg
required property var modelData border.width: 1
readonly property string name: String(chip.modelData) border.color: modelField.activeFocus ? Theme.accentDim
: Theme.border
objectName: "modelChoice:" + chip.name }
implicitWidth: chipText.implicitWidth + 14 }
implicitHeight: 26
radius: Theme.radiusSm
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: modelField.text === chip.name ? Theme.accent
: Theme.border
HoverHandler { id: chipHover }
Text { Text {
id: chipText Layout.preferredWidth: 90
anchors.centerIn: parent elide: Text.ElideRight
text: chip.name text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
color: modelField.text === chip.name ? Theme.text color: Theme.textFaint
: Theme.textDim
font.pixelSize: Theme.smallSize font.pixelSize: Theme.smallSize
} }
TapHandler { onTapped: modelField.text = chip.name } }
/* A router can list hundreds of models: filter, cap, say how
* many are left instead of filling the dialog. */
RowLayout {
Layout.fillWidth: true
visible: dlg.choices.length > 12
spacing: Theme.gap
TextField {
id: filterField
objectName: "settingsFilter"
Layout.fillWidth: true
text: dlg.modelFilter
implicitHeight: Theme.segmentHeight
selectByMouse: true
color: Theme.text
placeholderText: "filter " + dlg.choices.length + " models"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
onTextChanged: dlg.modelFilter = text
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: filterField.activeFocus ? Theme.accentDim
: Theme.border
}
}
Text {
Layout.preferredWidth: 110
elide: Text.ElideRight
text: dlg.chips.length + " shown"
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Flow {
Layout.fillWidth: true
spacing: 6
visible: dlg.choices.length > 0
Repeater {
model: dlg.chips.slice(0, dlg.chipCap)
delegate: Rectangle {
id: chip
required property var modelData
readonly property string name: String(chip.modelData)
objectName: "modelChoice:" + chip.name
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 26
radius: Theme.radiusSm
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: modelField.text === chip.name ? Theme.accent
: Theme.border
HoverHandler { id: chipHover }
Text {
id: chipText
anchors.centerIn: parent
text: chip.name
color: modelField.text === chip.name ? Theme.text
: Theme.textDim
font.pixelSize: Theme.smallSize
}
TapHandler { onTapped: modelField.text = chip.name }
}
}
}
TextField {
id: keyField
objectName: "settingsKey"
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
echoMode: TextInput.Password
selectByMouse: true
color: Theme.text
placeholderText: "API key (leave empty to keep what is there)"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
onAccepted: dlg.apply()
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
}
}
Text {
Layout.fillWidth: true
text: dlg.keyState()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
/* --- advanced ------------------------------------------- */
ToolButton {
id: advToggle
objectName: "settingsAdvanced"
Layout.fillWidth: true
implicitHeight: Theme.segmentHeight
onClicked: dlg.advanced = !dlg.advanced
contentItem: RowLayout {
spacing: 6
ThemeIcon {
name: dlg.advanced ? "arrow-down" : "arrow-right"
glyph: dlg.advanced ? "▾" : "▸"
size: 14
color: Theme.textDim
}
Text {
text: "Advanced options"
color: advToggle.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
text: "custom provider · model parameters"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
background: Rectangle {
radius: Theme.radiusSm
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
}
}
ColumnLayout {
Layout.fillWidth: true
visible: dlg.advanced
spacing: 6
Text {
Layout.fillWidth: true
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
"A name the presets know is overridden, a new one is added."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: Theme.gap
rowSpacing: 4
AdvField {
id: advNameField
objectName: "advName"
label: "Name"
placeholder: "myllm"
Layout.preferredWidth: 180
}
AdvField {
id: advBaseField
objectName: "advBase"
label: "Base URL"
placeholder: "https://api.example.com/v1"
}
AdvField {
id: advKeyEnvField
objectName: "advKeyEnv"
label: "Key variable"
placeholder: "MYLLM_API_KEY"
}
AdvField {
id: advModelField
objectName: "advModel"
label: "Default model"
placeholder: "the model to start with"
}
AdvField {
id: advModelsField
objectName: "advModels"
label: "Models it offers (comma separated, optional)"
placeholder: "m1, m2"
Layout.columnSpan: 2
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Button {
id: saveProviderBtn
objectName: "settingsSaveProvider"
text: "Save provider"
implicitHeight: Theme.touchTarget
enabled: advNameField.text.trim().length > 0 &&
advBaseField.text.trim().length > 0
onClicked: dlg.saveProvider()
background: Rectangle {
radius: Theme.radiusSm
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: saveProviderBtn.text
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: removeProviderBtn
objectName: "settingsRemoveProvider"
text: "Remove from models.json"
implicitHeight: Theme.touchTarget
readonly property var entry: dlg.pickedEntry()
enabled: entry !== null && entry.custom === true
ToolTip.visible: hovered
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
onClicked: dlg.removeProvider()
background: Rectangle {
radius: Theme.radiusSm
color: removeProviderBtn.hovered && removeProviderBtn.enabled
? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: removeProviderBtn.text
color: removeProviderBtn.enabled ? Theme.textDim
: Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
text: "Model parameters — remembered for " +
(dlg.picked.length ? dlg.picked : "this provider") +
", sent with every request. Empty = the provider's own default."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
AdvField {
id: tempField
objectName: "advTemperature"
label: "Temperature"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: topPField
objectName: "advTopP"
label: "Top-p"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: maxTokField
objectName: "advMaxTokens"
label: "Max tokens"
placeholder: "not sent"
Layout.preferredWidth: 150
}
Item { Layout.fillWidth: true }
}
} }
} }
} }
TextField {
id: keyField
objectName: "settingsKey"
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
echoMode: TextInput.Password
selectByMouse: true
color: Theme.text
placeholderText: "API key (leave empty to keep what is there)"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
onAccepted: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
}
}
Text {
Layout.fillWidth: true
text: dlg.keyState()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Theme.gap spacing: Theme.gap
@@ -342,7 +706,7 @@ Dialog {
objectName: "settingsApply" objectName: "settingsApply"
text: "Use this" text: "Use this"
implicitHeight: Theme.touchTarget implicitHeight: Theme.touchTarget
onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text) onClicked: dlg.apply()
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
@@ -358,4 +722,18 @@ Dialog {
} }
} }
} }
/* The model parameters belong to the provider that is picked, so they are
* applied first: useProvider() then takes over what was remembered for
* whichever provider it ends on. */
function apply() {
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
dlg.num(tempField.text), dlg.num(topPField.text),
Math.round(dlg.num(maxTokField.text)))
}
onOpened: {
keyField.text = ""
modelField.forceActiveFocus()
}
} }
+113
View File
@@ -570,6 +570,7 @@ QVariantList AgentCore::providers() const {
m["keyFrom"] = utf8(from); m["keyFrom"] = utf8(from);
m["keyEnv"] = utf8(p->key_env); m["keyEnv"] = utf8(p->key_env);
m["source"] = utf8(cst_provider_source(p)); 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); m["current"] = (m_cfg.provider[0] && strcmp(p->name, m_cfg.provider) == 0);
out.append(m); 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()); snprintf(cfg->model, sizeof(cfg->model), "%s", mdl.constData());
cst_settings_remember(cfg); 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(); syncConfig();
return QString(); 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 AgentCore::usage() const {
QVariantMap m; QVariantMap m;
if (!m_agent) if (!m_agent)
+22 -1
View File
@@ -75,8 +75,29 @@ class AgentCore : public QObject {
/* The presets: name, base, model (remembered, else the preset's), hasKey /* The presets: name, base, model (remembered, else the preset's), hasKey
* and keyFrom ("saved", else the environment variable), keyEnv, source and * and keyFrom ("saved", else the environment variable), keyEnv, source and
* current. Never a key: the engine keeps those out of the UI's reach. */ * current, custom (the user's own provider, kept in the engine's user
* model file). Never a key: the engine keeps those out of the UI's reach. */
Q_INVOKABLE QVariantList providers() const; Q_INVOKABLE QVariantList providers() const;
/* A provider the engine's presets do not know: it goes into the user model
* file (<config>/cst-agent/models.json), which is where the registry keeps
* them, and the registry is reloaded, so the next providers() call lists
* it. `modelsCsv` is a comma-separated model list (may be empty).
* "" when it worked, else the reason. */
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv);
/* Drop a provider that came from that file (1 = it was there, 0 = it was
* not, -1 = with a reason). A preset of the same name stays. */
Q_INVOKABLE int removeProvider(const QString &name);
/* The generation parameters remembered for a provider (0 = not set, the
* field is not sent at all). */
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
* apply them to the engine when that provider is the live one.
* "" when it worked, else the reason. */
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
double topP, int maxTokens);
/* Model candidates for a provider: its own list, what was fetched into the /* Model candidates for a provider: its own list, what was fetched into the
* cache, and the remembered pick. No network. */ * cache, and the remembered pick. No network. */
Q_INVOKABLE QStringList modelChoices(const QString &provider) const; Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
+106
View File
@@ -338,6 +338,112 @@ int runUiTest(AgentCore &core, const QString &size) {
check(false, QStringLiteral("the dialog has a model field")); check(false, QStringLiteral("the dialog has a model field"));
} }
/* --- advanced: a custom provider, added and removed ------------------- */
/* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it),
* so the engine's user model file is this run's own. */
check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("no generation parameters are saved to begin with"));
check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(),
QStringLiteral("generation parameters are accepted"));
{
const QVariantMap g = core.genParams(QStringLiteral("deepseek"));
check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 &&
g.value(QStringLiteral("max_tokens")).toInt() == 4096,
QStringLiteral("they are remembered per provider (%1 / %2 / %3)")
.arg(g.value(QStringLiteral("temperature")).toDouble())
.arg(g.value(QStringLiteral("top_p")).toDouble())
.arg(g.value(QStringLiteral("max_tokens")).toInt()));
}
check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"),
QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"),
QStringLiteral("scratch-1, scratch-2"))
.isEmpty(),
QStringLiteral("a provider the presets do not know is added"));
{
bool found = false, custom = false;
QString base, models;
const QVariantList list = core.providers();
for (const QVariant &v : list) {
const QVariantMap p = v.toMap();
if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch"))
continue;
found = true;
custom = p.value(QStringLiteral("custom")).toBool();
base = p.value(QStringLiteral("base")).toString();
models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(','));
}
check(found && custom && base.contains(QLatin1String("api.example.com")),
QStringLiteral("it shows up in the provider list as a custom one"));
check(models.contains(QLatin1String("scratch-1")) &&
models.contains(QLatin1String("scratch-2")),
QStringLiteral("the models it lists are the picker's (%1)").arg(models));
}
check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"),
QString(), QString(), QString())
.isEmpty(),
QStringLiteral("a name that is not a provider name is refused"));
check(core.removeProvider(QStringLiteral("cst-scratch")) == 1,
QStringLiteral("the custom provider can be removed again"));
{
bool gone = true;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-scratch"))
gone = false;
check(gone, QStringLiteral("and it is gone from the list"));
}
check(core.removeProvider(QStringLiteral("deepseek")) == 0,
QStringLiteral("a preset cannot be removed (there is no user entry to drop)"));
check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() &&
core.genParams(QStringLiteral("deepseek"))
.value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("parameters can be cleared again"));
/* The dialog drives the same two engine calls: open it, fold the advanced
* section out and put a provider in through the form. */
clickMouse(win, QStringLiteral("settingsButton"));
if (panel && mfield) {
clickMouse(win, QStringLiteral("settingsAdvanced"));
QQuickItem *nfield = item(win, QStringLiteral("advName"));
QQuickItem *bfield = item(win, QStringLiteral("advBase"));
check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog"));
maybeShot(win, QStringLiteral("-advanced"));
if (nfield && bfield) {
nfield->setProperty("text", QStringLiteral("cst-form"));
bfield->setProperty("text", QStringLiteral("https://form.example.com/v1"));
/* The form sits at the end of the dialog's scrolling body: scroll it
* into view so the click lands on the button, not on the frame. */
QQuickItem *scroller = item(win, QStringLiteral("settingsBody"));
if (scroller) {
scroller->setProperty("contentY", scroller->property("contentHeight").toReal() -
scroller->height());
QTest::qWait(120); /* the flickable settles */
}
clickMouse(win, QStringLiteral("settingsSaveProvider"));
bool found = false;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-form"))
found = true;
check(found, QStringLiteral("the form in the dialog adds a provider"));
QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider"));
check(rm && rm->property("enabled").toBool(),
QStringLiteral("its entry can be removed from the form"));
clickMouse(win, QStringLiteral("settingsRemoveProvider"));
check(core.removeProvider(QStringLiteral("cst-form")) == 0,
QStringLiteral("the form's provider is gone again"));
} else {
check(false, QStringLiteral("the advanced form has name and base URL fields"));
}
clickMouse(win, QStringLiteral("settingsClose"));
} else {
check(false, QStringLiteral("the settings dialog can be reopened"));
}
/* --- attachments: a file goes next to the session -------------------- */ /* --- attachments: a file goes next to the session -------------------- */
const QString att = core.workspace() + QStringLiteral("/note.txt"); const QString att = core.workspace() + QStringLiteral("/note.txt");
{ {
+28
View File
@@ -89,6 +89,13 @@ typedef struct {
cst_protocol_t protocol; cst_protocol_t protocol;
int max_steps; /* model calls per user turn */ int max_steps; /* model calls per user turn */
size_t tool_out_cap; /* bytes of one tool output sent to the model */ size_t tool_out_cap; /* bytes of one tool output sent to the model */
/* What the model is asked to do (Chat Completions fields). 0 = the field
* is not sent at all and the provider's own default stands; that is what a
* config that was not told otherwise has, so a CLI run is unchanged.
* temperature and top_p are doubles, max_tokens the completion cap. */
double temperature;
double top_p;
int max_tokens;
int headless; int headless;
int confirm_shell; /* confirm run_shell */ int confirm_shell; /* confirm run_shell */
int confirm_write; /* confirm file-changing tools */ int confirm_write; /* confirm file-changing tools */
@@ -271,6 +278,9 @@ typedef struct {
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */ const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
int images_off; /* the model does not take images: send a note instead */ int images_off; /* the model does not take images: send a note instead */
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */ size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
double temperature; /* 0 = not sent */
double top_p; /* 0 = not sent */
int max_tokens; /* 0 = not sent */
const char *session_path; /* where stored images live; NULL = none */ const char *session_path; /* where stored images live; NULL = none */
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */ volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
volatile int *stop_flag; /* non-zero → stop reading, not an error */ volatile int *stop_flag; /* non-zero → stop reading, not an error */
@@ -422,6 +432,16 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
* <config>/settings.json. */ * <config>/settings.json. */
#define CST_SETTINGS_MAX_MODELS 16 #define CST_SETTINGS_MAX_MODELS 16
#define CST_SETTINGS_MAX_TRUSTED 32 #define CST_SETTINGS_MAX_TRUSTED 32
#define CST_SETTINGS_MAX_PARAMS 16
/* Generation parameters remembered per provider ("params" in settings.json):
* what the user set for that provider's models. 0 = not set, the field is
* not sent. */
typedef struct {
char provider[32];
double temperature;
double top_p;
int max_tokens;
} cst_gen_params_t;
typedef struct { typedef struct {
char provider[32]; char provider[32];
char effort[16]; char effort[16];
@@ -436,10 +456,18 @@ typedef struct {
char provider[32]; char provider[32];
char model[128]; char model[128];
} models[CST_SETTINGS_MAX_MODELS]; } models[CST_SETTINGS_MAX_MODELS];
int nparams;
cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS];
} cst_settings_t; } cst_settings_t;
int cst_settings_load(cst_settings_t *s); int cst_settings_load(cst_settings_t *s);
int cst_settings_save(const cst_settings_t *s); int cst_settings_save(const cst_settings_t *s);
const char *cst_settings_model(const cst_settings_t *s, const char *provider); const char *cst_settings_model(const cst_settings_t *s, const char *provider);
/* The remembered parameters of a provider, or NULL when it has none. */
const cst_gen_params_t *cst_settings_params(const cst_settings_t *s,
const char *provider);
/* Remember parameters for a provider; all-zero (or NULL) forgets them. */
int cst_settings_remember_params(const char *provider,
const cst_gen_params_t *p);
/* Record cfg's provider, model, effort and mode as the last used ones. */ /* Record cfg's provider, model, effort and mode as the last used ones. */
int cst_settings_remember(const cst_agent_config_t *cfg); int cst_settings_remember(const cst_agent_config_t *cfg);
/* Record an approval mode the user picked (CST_APPROVAL_*). */ /* Record an approval mode the user picked (CST_APPROVAL_*). */
+16
View File
@@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p);
/* The user file's path (it may not exist). */ /* The user file's path (it may not exist). */
int cst_models_user_path(char *out, size_t out_sz); int cst_models_user_path(char *out, size_t out_sz);
/* Add or replace a provider in the user file (layer 1 above) and reload the
* registry: this is how a provider the presets do not know becomes one.
* name is 1-31 characters of letters, digits, '.', '_' or '-'; api_base is
* required; key_env names the variable its key may come from; default_model
* and models[] are what the model picker offers (a fetched /models list still
* wins). An entry with a preset's name overrides that preset, everything else
* in the file (model rules) is kept. 0, or -1 with err filled. */
int cst_models_user_set_provider(const char *name, const char *api_base,
const char *key_env,
const char *default_model,
const char *const *models, int nmodels,
char *err, size_t err_sz);
/* Drop a provider from the user file (a preset of the same name stays).
* 1 = removed, 0 = it was not there, -1 with err. */
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz);
/* Drop everything and read the built-in data, the user file and the caches /* Drop everything and read the built-in data, the user file and the caches
* again. Provider pointers stay valid but may name another provider. */ * again. Provider pointers stay valid but may name another provider. */
void cst_models_reload(void); void cst_models_reload(void);
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #define CST_VERSION_MINOR 8
#define CST_VERSION_PATCH 14 #define CST_VERSION_PATCH 15
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+21
View File
@@ -1850,6 +1850,9 @@ static void fill_request(cst_agent_t *a, cst_llm_request_t *req,
req->session_path = cst_session_path(a->session); req->session_path = cst_session_path(a->session);
req->cancel_flag = &a->cancel; req->cancel_flag = &a->cancel;
req->tool_out_cap = a->cfg.tool_out_cap; req->tool_out_cap = a->cfg.tool_out_cap;
req->temperature = a->cfg.temperature;
req->top_p = a->cfg.top_p;
req->max_tokens = a->cfg.max_tokens;
if (cst_protocol_is_native(proto)) { if (cst_protocol_is_native(proto)) {
req->enable_tools = 1; req->enable_tools = 1;
req->tool_cfg = &a->cfg; req->tool_cfg = &a->cfg;
@@ -2538,6 +2541,24 @@ cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
a->cfg.max_steps = CST_DEFAULT_MAX_STEPS; a->cfg.max_steps = CST_DEFAULT_MAX_STEPS;
if (a->cfg.tool_out_cap == 0) if (a->cfg.tool_out_cap == 0)
a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP; a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
/* The generation parameters the user saved for this provider (Settings ->
* Advanced in the app, "params" in settings.json). They are read here so
* that the CLI and the TUI send what the GUI was set to; a caller that
* named one explicitly wins. */
{
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, a->cfg.provider);
if (g) {
if (a->cfg.temperature <= 0)
a->cfg.temperature = g->temperature;
if (a->cfg.top_p <= 0)
a->cfg.top_p = g->top_p;
if (a->cfg.max_tokens <= 0)
a->cfg.max_tokens = g->max_tokens;
}
}
}
a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
: "session.jsonl"); : "session.jsonl");
if (!a->session) { if (!a->session) {
+9
View File
@@ -444,6 +444,15 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *so = cJSON_AddObjectToObject(root, "stream_options"); cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
cJSON_AddTrueToObject(so, "include_usage"); cJSON_AddTrueToObject(so, "include_usage");
} }
/* The generation parameters the user set (Settings -> Advanced). A field
* that was not set is left out: an absent field is the provider's own
* default, which "temperature": 0 would not be. */
if (req->temperature > 0)
cJSON_AddNumberToObject(root, "temperature", req->temperature);
if (req->top_p > 0)
cJSON_AddNumberToObject(root, "top_p", req->top_p);
if (req->max_tokens > 0)
cJSON_AddNumberToObject(root, "max_tokens", req->max_tokens);
cJSON *msgs = cJSON_AddArrayToObject(root, "messages"); cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
size_t tcap = wire_tool_cap(req); size_t tcap = wire_tool_cap(req);
cJSON *prev_user = NULL; /* merge consecutive user messages */ cJSON *prev_user = NULL; /* merge consecutive user messages */
+158
View File
@@ -1,9 +1,11 @@
#include "cst_models.h" #include "cst_models.h"
#include "cst_util.h"
#include "cJSON.h" #include "cJSON.h"
#include "os_compat.h" #include "os_compat.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h>
#include <pthread.h> #include <pthread.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -326,6 +328,162 @@ int cst_models_user_path(char *out, size_t out_sz) {
return w > 0 && (size_t)w < out_sz ? 0 : -1; return w > 0 && (size_t)w < out_sz ? 0 : -1;
} }
/* --- the user file: providers the presets do not know -------------------- */
static int valid_provider_name(const char *name) {
if (!name || !name[0] || strlen(name) >= 32)
return 0;
for (const char *q = name; *q; q++)
if (!isalnum((unsigned char)*q) && *q != '.' && *q != '_' && *q != '-')
return 0;
return 1;
}
/* The user file as a JSON object: an empty one when there is no file, NULL
* when there is one that is not an object (never overwrite somebody else's
* file, their edit is not worth losing). */
static cJSON *read_user_doc(char *path, size_t path_sz) {
if (cst_models_user_path(path, path_sz) != 0)
return NULL;
char *text = read_file(path);
if (!text)
return cJSON_CreateObject();
cJSON *doc = cJSON_Parse(text + cst_bom_len(text, strlen(text)));
free(text);
if (!cJSON_IsObject(doc)) {
cJSON_Delete(doc);
return NULL;
}
return doc;
}
static int write_user_doc(cJSON *doc, const char *path, char *err,
size_t err_sz) {
char *text = cJSON_Print(doc);
if (!text) {
snprintf(err, err_sz, "out of memory");
return -1;
}
char tmp[1200];
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) {
cJSON_free(text);
snprintf(err, err_sz, "path too long");
return -1;
}
int rc = -1;
FILE *f = fopen(tmp, "wb");
if (!f) {
snprintf(err, err_sz, "%s: cannot write (%s)", tmp, strerror(errno));
} else {
const int ok = fputs(text, f) >= 0 && fputc('\n', f) != EOF;
const int closed = fclose(f);
if (ok && closed == 0 &&
#ifdef _WIN32
(remove(path), 1) &&
#endif
rename(tmp, path) == 0)
rc = 0;
else
snprintf(err, err_sz, "%s: cannot replace (%s)", path, strerror(errno));
if (rc != 0)
remove(tmp);
}
cJSON_free(text);
return rc;
}
int cst_models_user_set_provider(const char *name, const char *api_base,
const char *key_env, const char *default_model,
const char *const *models, int nmodels,
char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!valid_provider_name(name)) {
snprintf(err, err_sz,
"a provider name is 1-31 of letters, digits, '.', '_' or '-'");
return -1;
}
if (!api_base || !api_base[0]) {
snprintf(err, err_sz, "a base URL is needed");
return -1;
}
char path[1100];
pthread_mutex_lock(&g_mu);
cJSON *doc = read_user_doc(path, sizeof(path));
if (!doc) {
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
pthread_mutex_unlock(&g_mu);
return -1;
}
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
if (!cJSON_IsObject(providers))
providers = cJSON_AddObjectToObject(doc, "providers");
cJSON *entry = cJSON_CreateObject();
cJSON_AddStringToObject(entry, "api_base", api_base);
if (key_env && key_env[0])
cJSON_AddStringToObject(entry, "key_env", key_env);
if (default_model && default_model[0])
cJSON_AddStringToObject(entry, "default_model", default_model);
if (models && nmodels > 0) {
cJSON *arr = cJSON_AddArrayToObject(entry, "models");
for (int i = 0; i < nmodels; i++)
if (models[i] && models[i][0])
cJSON_AddItemToArray(arr, cJSON_CreateString(models[i]));
}
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
cJSON_AddItemToObject(providers, name, entry);
const int rc = write_user_doc(doc, path, err, err_sz);
cJSON_Delete(doc);
pthread_mutex_unlock(&g_mu);
if (rc == 0)
cst_models_reload(); /* the provider is there from now on */
return rc;
}
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!valid_provider_name(name)) {
snprintf(err, err_sz, "not a provider name");
return -1;
}
char path[1100];
if (cst_models_user_path(path, sizeof(path)) != 0) {
snprintf(err, err_sz, "no config directory");
return -1;
}
FILE *probe = fopen(path, "rb");
if (!probe)
return 0; /* no user file: nothing of ours to drop */
fclose(probe);
pthread_mutex_lock(&g_mu);
cJSON *doc = read_user_doc(path, sizeof(path));
if (!doc) {
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
pthread_mutex_unlock(&g_mu);
return -1;
}
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
const int found = cJSON_IsObject(providers) &&
cJSON_GetObjectItemCaseSensitive(providers, name) != NULL;
if (found)
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
const int rc = found ? write_user_doc(doc, path, err, err_sz) : 0;
cJSON_Delete(doc);
pthread_mutex_unlock(&g_mu);
if (rc == 0 && found)
cst_models_reload();
return rc == 0 ? found : -1;
}
static void load_default_locked(void) { static void load_default_locked(void) {
reset_locked(); reset_locked();
char e[128]; char e[128];
+93
View File
@@ -72,6 +72,25 @@ int cst_settings_load(cst_settings_t *s) {
m); m);
s->nmodels++; s->nmodels++;
} }
const cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params");
const cJSON *p;
cJSON_ArrayForEach(p, params) {
if (s->nparams >= CST_SETTINGS_MAX_PARAMS || !p->string || !cJSON_IsObject(p))
continue;
cst_gen_params_t *g = &s->params[s->nparams];
memset(g, 0, sizeof(*g));
snprintf(g->provider, sizeof(g->provider), "%s", p->string);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(p, "temperature");
if (cJSON_IsNumber(v))
g->temperature = v->valuedouble;
v = cJSON_GetObjectItemCaseSensitive(p, "top_p");
if (cJSON_IsNumber(v))
g->top_p = v->valuedouble;
v = cJSON_GetObjectItemCaseSensitive(p, "max_tokens");
if (cJSON_IsNumber(v))
g->max_tokens = (int)v->valuedouble;
s->nparams++;
}
cJSON_Delete(root); cJSON_Delete(root);
return 0; return 0;
} }
@@ -102,6 +121,21 @@ int cst_settings_save(const cst_settings_t *s) {
cJSON *models = cJSON_AddObjectToObject(root, "models"); cJSON *models = cJSON_AddObjectToObject(root, "models");
for (int i = 0; i < s->nmodels; i++) for (int i = 0; i < s->nmodels; i++)
cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model); cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model);
if (s->nparams) {
cJSON *params = cJSON_AddObjectToObject(root, "params");
for (int i = 0; i < s->nparams; i++) {
const cst_gen_params_t *g = &s->params[i];
if (!g->provider[0])
continue;
cJSON *o = cJSON_AddObjectToObject(params, g->provider);
if (g->temperature > 0)
cJSON_AddNumberToObject(o, "temperature", g->temperature);
if (g->top_p > 0)
cJSON_AddNumberToObject(o, "top_p", g->top_p);
if (g->max_tokens > 0)
cJSON_AddNumberToObject(o, "max_tokens", g->max_tokens);
}
}
char *text = cJSON_Print(root); char *text = cJSON_Print(root);
cJSON_Delete(root); cJSON_Delete(root);
if (!text) if (!text)
@@ -180,6 +214,65 @@ int cst_settings_remember(const cst_agent_config_t *cfg) {
snprintf(s.models[i].provider, sizeof(s.models[i].provider), "%s", snprintf(s.models[i].provider, sizeof(s.models[i].provider), "%s",
cfg->provider); cfg->provider);
snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model); snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model);
/* The generation parameters ride along with the provider choice. An
* all-zero config (the CLI, the TUI) leaves what the GUI saved alone. */
if (cfg->temperature > 0 || cfg->top_p > 0 || cfg->max_tokens > 0) {
int pi = 0;
while (pi < s.nparams && strcmp(s.params[pi].provider, cfg->provider) != 0)
pi++;
if (pi == s.nparams) {
if (s.nparams >= CST_SETTINGS_MAX_PARAMS)
return -1;
memset(&s.params[pi], 0, sizeof(s.params[pi]));
snprintf(s.params[pi].provider, sizeof(s.params[pi].provider), "%s",
cfg->provider);
s.nparams++;
}
s.params[pi].temperature = cfg->temperature;
s.params[pi].top_p = cfg->top_p;
s.params[pi].max_tokens = cfg->max_tokens;
}
return cst_settings_save(&s);
}
const cst_gen_params_t *cst_settings_params(const cst_settings_t *s,
const char *provider) {
for (int i = 0; s && provider && provider[0] && i < s->nparams; i++)
if (strcmp(s->params[i].provider, provider) == 0)
return &s->params[i];
return NULL;
}
int cst_settings_remember_params(const char *provider,
const cst_gen_params_t *p) {
if (!provider || !provider[0])
return -1;
cst_settings_t s;
cst_settings_load(&s);
int i = 0;
while (i < s.nparams && strcmp(s.params[i].provider, provider) != 0)
i++;
const int forget = !p || (p->temperature <= 0 && p->top_p <= 0 &&
p->max_tokens <= 0);
if (i == s.nparams) {
if (forget)
return 0; /* nothing remembered, nothing to forget */
if (s.nparams >= CST_SETTINGS_MAX_PARAMS)
return -1;
memset(&s.params[i], 0, sizeof(s.params[i]));
snprintf(s.params[i].provider, sizeof(s.params[i].provider), "%s",
provider);
s.nparams++;
}
if (forget) { /* keep the array compact when an entry goes away */
memmove(&s.params[i], &s.params[i + 1],
sizeof(s.params[i]) * (s.nparams - i - 1));
s.nparams--;
} else {
s.params[i].temperature = p->temperature;
s.params[i].top_p = p->top_p;
s.params[i].max_tokens = p->max_tokens;
}
return cst_settings_save(&s); return cst_settings_save(&s);
} }
+35
View File
@@ -75,6 +75,40 @@ static void test_escaping(void) {
cJSON_Delete(j); cJSON_Delete(j);
} }
/* Settings -> Advanced: the generation parameters go on the wire only when
* they were set. An absent field is the provider's own default, which
* "temperature": 0 would not be. */
static void test_gen_params(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "hi")};
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = m;
r.nmsgs = 1;
r.temperature = 0.2;
r.top_p = 0.9;
r.max_tokens = 4096;
char *body = cst_llm_build_body(&r);
EXPECT(body != NULL, "body with parameters");
cJSON *j = body ? cJSON_Parse(body) : NULL;
free(body);
const cJSON *t = cJSON_GetObjectItemCaseSensitive(j, "temperature");
const cJSON *p = cJSON_GetObjectItemCaseSensitive(j, "top_p");
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(j, "max_tokens");
EXPECT(cJSON_IsNumber(t) && t->valuedouble > 0.19 && t->valuedouble < 0.21,
"temperature is sent as a number");
EXPECT(cJSON_IsNumber(p) && cJSON_IsNumber(mt) && mt->valueint == 4096,
"top_p and max_tokens are sent");
cJSON_Delete(j);
/* unset: none of the three is in the body at all */
cJSON *plain = build(m, 1, 0, 0);
EXPECT(!cJSON_GetObjectItemCaseSensitive(plain, "temperature") &&
!cJSON_GetObjectItemCaseSensitive(plain, "top_p") &&
!cJSON_GetObjectItemCaseSensitive(plain, "max_tokens"),
"an unset parameter is not sent at all");
cJSON_Delete(plain);
}
static void test_invalid_utf8(void) { static void test_invalid_utf8(void) {
const char text[] = "ok \xff\xfe bad \xe4\xb8 trunc"; const char text[] = "ok \xff\xfe bad \xe4\xb8 trunc";
cst_message_t m[1] = {msg(CST_ROLE_USER, text)}; cst_message_t m[1] = {msg(CST_ROLE_USER, text)};
@@ -526,6 +560,7 @@ int main(void) {
test_reasoning_and_prefix(); test_reasoning_and_prefix();
test_cache_hints(); test_cache_hints();
test_tools_snapshot(); test_tools_snapshot();
test_gen_params();
test_escaping(); test_escaping();
test_invalid_utf8(); test_invalid_utf8();
test_tool_calls_and_null_content(); test_tool_calls_and_null_content();