diff --git a/app/README.md b/app/README.md index 7190d7e..7341467 100644 --- a/app/README.md +++ b/app/README.md @@ -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 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 + `/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 The next prompt can carry files, and they arrive three ways: diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 3166e95..1f87de9 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -180,8 +180,11 @@ ApplicationWindow { settings.open() } - function applySettings(provider, model, key) { - const err = agent.useProvider(provider, model, key) + function applySettings(provider, model, key, temperature, topP, maxTokens) { + /* 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 : "using " + agent.provider + " / " + agent.model settings.choices = agent.modelChoices(provider) @@ -470,8 +473,8 @@ ApplicationWindow { SettingsDialog { id: settings - onApplyRequested: function (provider, model, key) { - win.applySettings(provider, model, key) + onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) { + win.applySettings(provider, model, key, temperature, topP, maxTokens) } } diff --git a/app/qml/SettingsDialog.qml b/app/qml/SettingsDialog.qml index 1bf202e..5310ed8 100644 --- a/app/qml/SettingsDialog.qml +++ b/app/qml/SettingsDialog.qml @@ -3,11 +3,18 @@ import QtQuick.Controls import QtQuick.Layouts // 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 -// the engine's own config (its session stays where it is) and remembers the -// choice. A key is verified against the provider before it is saved and is -// never read back into this window -- the dialog only ever says where the key -// it will use comes from. +// at all. Everything here goes through AgentCore, which moves the engine's own +// config (its session stays where it is) and remembers the choice. A key is +// verified against the provider before it is saved and is never read back into +// this window -- the dialog only ever says where the key it will use comes +// 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 /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 { id: dlg objectName: "settingsDialog" @@ -21,15 +28,27 @@ Dialog { property var choices: [] // AgentCore::modelChoices(picked) property string picked: "" 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 keyFrom: "" 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) - height: Math.min(560, parent ? parent.height - 60 : 560) + width: Math.min(660, parent ? parent.width - 32 : 660) + height: Math.min(640, parent ? parent.height - 40 : 640) /* The Basic style paints a light panel; this window is dark, so the * dialog brings its own surface like every other one here. */ @@ -40,6 +59,35 @@ Dialog { 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 * model that provider last used. */ function load(list, provider, currentModel) { @@ -51,24 +99,33 @@ Dialog { if (list[i].current === true) picked = list[i].name choices = picked.length ? agent.modelChoices(picked) : [] + modelFilter = "" syncPick() } - /* Where the picked provider's key would come from. From the entries -- - * a key itself never reaches the QML side. */ + /* Where the picked provider's key would come from and what it is set to. + * From the entries -- a key itself never reaches the QML side. */ function syncPick() { hasKey = false keyFrom = "" keyEnv = "" - for (let i = 0; i < providers.length; i++) { - const p = providers[i] - if (p.name !== picked) - continue + const p = pickedEntry() + if (p) { hasKey = p.hasKey === true keyFrom = p.keyFrom 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() { @@ -90,6 +147,87 @@ Dialog { 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 { objectName: "settingsPanel" spacing: Theme.gap @@ -109,201 +247,427 @@ Dialog { 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 { + id: body + objectName: "settingsBody" Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumHeight: 96 + Layout.minimumHeight: 120 clip: true contentWidth: width - contentHeight: plist.implicitHeight + contentHeight: bodyCol.implicitHeight boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar {} ColumnLayout { - id: plist + id: bodyCol width: parent.width - spacing: 2 + spacing: Theme.gap - Repeater { - model: dlg.providers - delegate: Rectangle { - id: prow - required property var modelData - readonly property bool selected: dlg.picked === modelData.name + ColumnLayout { + Layout.fillWidth: true + spacing: 2 - objectName: "providerRow:" + modelData.name - Layout.fillWidth: true - implicitHeight: prowCol.implicitHeight + 8 - radius: Theme.radiusSm - color: prow.selected ? Theme.accentDim - : prowHover.hovered ? Theme.surfaceAlt - : "transparent" - border.width: 1 - border.color: prow.selected ? Theme.accent : "transparent" + Repeater { + model: dlg.providers + delegate: Rectangle { + id: prow + required property var modelData + readonly property bool selected: dlg.picked === modelData.name - 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.syncPick() + objectName: "providerRow:" + modelData.name + Layout.fillWidth: true + implicitHeight: prowCol.implicitHeight + 8 + radius: Theme.radiusSm + color: prow.selected ? Theme.accentDim + : prowHover.hovered ? Theme.surfaceAlt + : "transparent" + 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 { - id: prowCol - anchors { - left: parent.left - right: parent.right - top: parent.top - margins: 4 - } - spacing: 0 + ColumnLayout { + id: prowCol + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 4 + } + spacing: 0 - RowLayout { - Layout.fillWidth: true - spacing: Theme.gap - Text { - text: prow.modelData.name - color: prow.selected ? Theme.accentText : Theme.textDim - font.pixelSize: Theme.smallSize - font.bold: prow.selected + RowLayout { + Layout.fillWidth: true + spacing: Theme.gap + Text { + text: prow.modelData.name + color: prow.selected ? Theme.accentText : Theme.textDim + font.pixelSize: Theme.smallSize + 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 { - 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.source + 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 { - 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 { - Layout.fillWidth: true - text: "Model" - color: Theme.textDim - font.pixelSize: Theme.smallSize - 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 + Text { + Layout.fillWidth: true + text: "Model" + color: Theme.textDim + font.pixelSize: Theme.smallSize + font.bold: true } - } - Text { - Layout.preferredWidth: 90 - elide: Text.ElideRight - text: modelField.text.length ? agent.modelInfo(modelField.text) : "" - color: Theme.textFaint - font.pixelSize: Theme.smallSize - } - } - Flow { - Layout.fillWidth: true - spacing: 6 - visible: dlg.choices.length > 0 - - Repeater { - model: dlg.choices - 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 } + 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 + } + } Text { - id: chipText - anchors.centerIn: parent - text: chip.name - color: modelField.text === chip.name ? Theme.text - : Theme.textDim + Layout.preferredWidth: 90 + elide: Text.ElideRight + text: modelField.text.length ? agent.modelInfo(modelField.text) : "" + color: Theme.textFaint 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 /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 { Layout.fillWidth: true spacing: Theme.gap @@ -342,7 +706,7 @@ Dialog { objectName: "settingsApply" text: "Use this" implicitHeight: Theme.touchTarget - onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text) + onClicked: dlg.apply() background: Rectangle { radius: Theme.radiusSm 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() + } } diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 364c6a7..49aaaee 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -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 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 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) diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index f1179fd..b5f7e21 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -75,8 +75,29 @@ class AgentCore : public QObject { /* The presets: name, base, model (remembered, else the preset's), hasKey * 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; + /* A provider the engine's presets do not know: it goes into the user model + * file (/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 * cache, and the remembered pick. No network. */ Q_INVOKABLE QStringList modelChoices(const QString &provider) const; diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 820016e..240f43f 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -338,6 +338,112 @@ int runUiTest(AgentCore &core, const QString &size) { 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 -------------------- */ const QString att = core.workspace() + QStringLiteral("/note.txt"); { diff --git a/include/cst_agent.h b/include/cst_agent.h index 66c7feb..baec867 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -89,6 +89,13 @@ typedef struct { cst_protocol_t protocol; int max_steps; /* model calls per user turn */ 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 confirm_shell; /* confirm run_shell */ 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 */ 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 */ + 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 */ volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */ 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); * /settings.json. */ #define CST_SETTINGS_MAX_MODELS 16 #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 { char provider[32]; char effort[16]; @@ -436,10 +456,18 @@ typedef struct { char provider[32]; char model[128]; } models[CST_SETTINGS_MAX_MODELS]; + int nparams; + cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS]; } cst_settings_t; int cst_settings_load(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); +/* 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. */ int cst_settings_remember(const cst_agent_config_t *cfg); /* Record an approval mode the user picked (CST_APPROVAL_*). */ diff --git a/include/cst_models.h b/include/cst_models.h index 1c1d945..9ccb35b 100644 --- a/include/cst_models.h +++ b/include/cst_models.h @@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p); /* The user file's path (it may not exist). */ 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 * again. Provider pointers stay valid but may name another provider. */ void cst_models_reload(void); diff --git a/include/cst_version.h b/include/cst_version.h index 54077fc..807fdc1 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #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) CST_VERSION_STR_(x) diff --git a/src/agent_loop.c b/src/agent_loop.c index dfaecaa..40eb0d3 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -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->cancel_flag = &a->cancel; 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)) { req->enable_tools = 1; 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; if (a->cfg.tool_out_cap == 0) 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 : "session.jsonl"); if (!a->session) { diff --git a/src/llm_openai.c b/src/llm_openai.c index 52bde6f..d65d6ba 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -444,6 +444,15 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { cJSON *so = cJSON_AddObjectToObject(root, "stream_options"); 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"); size_t tcap = wire_tool_cap(req); cJSON *prev_user = NULL; /* merge consecutive user messages */ diff --git a/src/model_registry.c b/src/model_registry.c index f562556..e48823f 100644 --- a/src/model_registry.c +++ b/src/model_registry.c @@ -1,9 +1,11 @@ #include "cst_models.h" +#include "cst_util.h" #include "cJSON.h" #include "os_compat.h" #include +#include #include #include #include @@ -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; } +/* --- 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) { reset_locked(); char e[128]; diff --git a/src/settings.c b/src/settings.c index f198630..3710fce 100644 --- a/src/settings.c +++ b/src/settings.c @@ -72,6 +72,25 @@ int cst_settings_load(cst_settings_t *s) { m); 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); return 0; } @@ -102,6 +121,21 @@ int cst_settings_save(const cst_settings_t *s) { cJSON *models = cJSON_AddObjectToObject(root, "models"); for (int i = 0; i < s->nmodels; i++) 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); cJSON_Delete(root); 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", cfg->provider); 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); } diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index d8decec..afcb11d 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -75,6 +75,40 @@ static void test_escaping(void) { 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) { const char text[] = "ok \xff\xfe bad \xe4\xb8 trunc"; cst_message_t m[1] = {msg(CST_ROLE_USER, text)}; @@ -526,6 +560,7 @@ int main(void) { test_reasoning_and_prefix(); test_cache_hints(); test_tools_snapshot(); + test_gen_params(); test_escaping(); test_invalid_utf8(); test_tool_calls_and_null_content();