Skip to content

Commit 5957e43

Browse files
committed
Add per-model sampling parameters
Saved models can carry an optional sampling object (temperature, top_p, top_k, min_p). Unset keys are never sent, so profiles without sampling keep byte-identical requests. Chat Completions sends all four (top_k and min_p are vLLM/SGLang extensions), Responses only temperature and top_p, Anthropic rejects min_p and caps temperature at 1, and managed Copilot/Grok reject sampling. A value that would be silently dropped is a validation error. Settings > Models > Advanced shows only the fields the selected provider and protocol support. Sampling is part of the provider construction fingerprint, so running sessions pick up an edit on their next turn. Motivated by the intranet wizard-ai gateway: its GLM model publishes recommended values (top_p 0.95, top_k 20, temperature 1, min_p 0) that the gateway's own agent sends and ACECode never did.
1 parent f4dd8e7 commit 5957e43

24 files changed

Lines changed: 1082 additions & 11 deletions

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,8 @@ The existing region detector still probes DuckDuckGo once at startup through `Pr
897897
`saved_models` is a named registry; `default_model_name` points into it. Each entry needs `name` (must NOT start with `(` — reserved for synthesized `(session:<id>)`), `provider`, `model`. OpenAI entries also need `base_url` + `api_key`. On load, array order is oldest-to-newest: for duplicate names, every occurrence except the last is atomically persisted as `<name>-<6-lowercase-hex-hash>`, while the last keeps the original name. Duplicate-repair write failures remain fatal. A non-empty dangling `default_model_name` is instead replaced with the first saved-model name before validation and atomically written back; a write failure is logged but startup continues with the in-memory fallback and retries on the next load. If the main logger is not initialized yet, this repair silently opens `~/.acecode/logs/config-<date>.log`; an active logger is never redirected. This repair does not rewrite an empty default and emits no UI message. Successful full loads and saves refresh a restricted `config-backups/last-good/config.json` snapshot. Later JSON parse/type/range/semantic failures archive the exact invalid bytes under `config-backups/invalid`, atomically restore and validate that snapshot, and persist a one-time Web/Desktop warning; recovery logs contain paths and categories only. Without a valid prior snapshot, startup keeps the invalid active file and follows the existing fatal boundary.
898898
`acecode configure` upserts one named `saved_models` entry from the selected provider/model and sets it as `default_model_name`; normal startup no longer derives a selectable model from top-level provider fields.
899899

900+
Each saved model may carry an optional `sampling` object (`temperature` [0,2], `top_p` (0,1], `top_k` ≥ 1, `min_p` [0,1]). Unset keys are never sent, so a profile without sampling produces byte-identical requests. Support is per provider/protocol and validated in `validate_model_sampling_support` (`saved_models.cpp`): Chat Completions sends all four (`top_k`/`min_p` are vLLM/SGLang extensions the official OpenAI API rejects), Responses only `temperature`/`top_p`, Anthropic rejects `min_p` and caps `temperature` at 1, managed Copilot/Grok reject sampling entirely — a value that would be silently dropped is a validation error, not a no-op. Edits replace the whole object (`null`/`{}` clears; a provider switch clears it). Sampling is part of the provider construction fingerprint, so running sessions rebuild on the next turn after an edit. Settings > Models > 高级设置 shows only the fields the selected provider/protocol supports (`modelSamplingFieldSupport` in `web/src/lib/modelSettings.js`). Added for the intranet wizard-ai gateway, whose GLM model ships recommended values (top_p 0.95 / top_k 20 / temperature 1 / min_p 0) that ACECode previously never sent.
901+
900902
### Model profile resolution
901903

902904
At startup and on every `--resume` / `/resume`, `src/adapters/provider/model_resolver.cpp` resolves the effective `ModelEntry` in three layers. CLI `-r` is not a `--resume` short alias; it starts the TUI normally, runs the startup hook path, then opens the same `/resume` picker immediately.

‎docs/daemon-api.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3019,7 +3019,7 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`.
30193019
原值和 `has_api_key` 布尔值,供编辑表单默认遮罩回填;调用方必须把整个响应视为
30203020
敏感数据,日志与错误消息仍不得包含密钥。高级字段有值时会原样返回,包括
30213021
`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning`、
3022-
`request_headers`、`context_window` 与 `stream_timeout_ms`。
3022+
`sampling`、`request_headers`、`context_window` 与 `stream_timeout_ms`。
30233023

30243024
### `POST /api/models`
30253025

@@ -3048,10 +3048,22 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`.
30483048
"default_effort": "medium",
30493049
"effort": "high",
30503050
"supports_max_tokens": false
3051-
}
3051+
},
3052+
"sampling": {"temperature": 1, "top_p": 0.95, "top_k": 20, "min_p": 0}
30523053
}
30533054
```
30543055

3056+
`sampling` 是可选的采样参数对象,四个键都可省略或为 `null`(视为未设置):
3057+
`temperature` ∈ [0, 2]、`top_p` ∈ (0, 1]、`top_k` 为正整数、`min_p` ∈ [0, 1]。
3058+
未设置的键不会出现在发往模型服务的请求体里,沿用服务端默认值;整个对象省略或
3059+
没有任何已设置的键时,请求体与不支持该功能的版本完全一致。支持范围按 Provider /
3060+
API 协议区分:OpenAI Chat Completions 四个都发送(`top_k` / `min_p` 是 vLLM、
3061+
SGLang 等推理服务的扩展字段,官方 OpenAI 接口会拒绝);Responses 只支持
3062+
`temperature` / `top_p`;Anthropic 支持 `temperature`(上限 1)、`top_p`、`top_k`,
3063+
不支持 `min_p`;Copilot / Grok 托管 Provider 不接受采样参数。越界值或当前 Provider /
3064+
协议不支持的键返回 `INVALID_SAMPLING`,托管 Provider 返回 `UNSUPPORTED_MODEL_OPTION`。
3065+
响应只返回已设置的键。
3066+
30553067
`api_protocol` 仅适用于 `provider:"openai"`,接受 `chat_completions` 或
30563068
`responses`;省略时,目录身份 `models_dev_provider_id:"acemodel"` 使用 Responses,
30573069
其他 OpenAI 配置继续使用 Chat Completions。模型设置的 **API 协议** 下拉框可
@@ -3075,9 +3087,10 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`.
30753087
路径仍区分大小写。
30763088

30773089
省略 `base_url`、`context_window`、`stream_timeout_ms`、`capabilities`、
3078-
`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning` 或
3079-
`request_headers` 会保留原值。显式空 `request_headers` 会清空请求头;对应高级
3080-
字段传 `null` 时按各字段合同清除。外部登录器留下的 legacy `readonly:true` 只是
3090+
`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning`、
3091+
`sampling` 或 `request_headers` 会保留原值。显式空 `request_headers` 会清空请求头;
3092+
`sampling` 传入时整体替换(旧值中未出现的键会被清掉),传 `null` 或 `{}` 清除全部
3093+
采样参数,切换到其他 Provider 时同样清除;其余高级字段传 `null` 时按各字段合同清除。外部登录器留下的 legacy `readonly:true` 只是
30813094
兼容元数据,不阻止编辑。响应包含 `api_key` 原值与 `has_api_key`,并仅允许经
30823095
Web 认证的调用方读取。
30833096

‎src/adapters/provider/anthropic_provider.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,11 @@ nlohmann::json AnthropicProvider::build_request_body(
516516
body["max_tokens"] = request_options_.max_output_tokens.value_or(
517517
kDefaultMaxTokens);
518518
if (stream) body["stream"] = true;
519+
// 采样参数只在配置了时发送。Anthropic 不支持 min_p,配置校验已拒绝。
520+
const auto& sampling = request_options_.sampling;
521+
if (sampling.temperature.has_value()) body["temperature"] = *sampling.temperature;
522+
if (sampling.top_p.has_value()) body["top_p"] = *sampling.top_p;
523+
if (sampling.top_k.has_value()) body["top_k"] = *sampling.top_k;
519524

520525
if (request_options_.reasoning_protocol == ReasoningWireProtocol::Anthropic &&
521526
request_options_.reasoning.has_value() &&

‎src/adapters/provider/openai_provider.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,14 @@ nlohmann::json OpenAiCompatProvider::build_request_body(
898898
if (request_options_.max_output_tokens.has_value()) {
899899
body["max_tokens"] = *request_options_.max_output_tokens;
900900
}
901+
// 采样参数只在用户为这个模型配置了时发送;未配置时请求体与旧版本一致。
902+
// top_k / min_p 不是 OpenAI 官方字段(vLLM / SGLang 支持),配置校验已保证
903+
// Responses 协议不会带上它们(Responses 转换也只保留 temperature / top_p)。
904+
const auto& sampling = request_options_.sampling;
905+
if (sampling.temperature.has_value()) body["temperature"] = *sampling.temperature;
906+
if (sampling.top_p.has_value()) body["top_p"] = *sampling.top_p;
907+
if (sampling.top_k.has_value()) body["top_k"] = *sampling.top_k;
908+
if (sampling.min_p.has_value()) body["min_p"] = *sampling.min_p;
901909
if (request_options_.reasoning_protocol == ReasoningWireProtocol::OpenRouter &&
902910
request_options_.reasoning.has_value() &&
903911
request_options_.reasoning->supported) {

‎src/adapters/provider/provider_factory.cpp‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include <algorithm>
1515
#include <cctype>
1616
#include <cstdint>
17+
#include <cstring>
1718
#include <map>
1819
#include <memory>
1920
#include <random>
@@ -52,6 +53,7 @@ ProviderRequestOptions request_options_from_entry(const ModelProfile& entry) {
5253
options.api_protocol = model_profile_api_protocol(entry);
5354
options.max_output_tokens = entry.max_output_tokens;
5455
options.reasoning = entry.reasoning;
56+
options.sampling = entry.sampling;
5557
if (entry.provider == "anthropic" && entry.reasoning.has_value()) {
5658
options.reasoning_protocol = ReasoningWireProtocol::Anthropic;
5759
} else if (entry.provider == "openai" && entry.reasoning.has_value() &&
@@ -88,6 +90,24 @@ void append_optional_int(std::string& out, const std::optional<T>& value) {
8890
}
8991
}
9092

93+
void append_optional_double(std::string& out,
94+
const std::optional<double>& value) {
95+
append_bool(out, value.has_value());
96+
if (!value.has_value()) return;
97+
// 按位编码:0.95 与 0.9500000001 必须得到不同指纹,不能先格式化成文本。
98+
std::uint64_t bits = 0;
99+
static_assert(sizeof(bits) == sizeof(double), "double must be 64-bit");
100+
std::memcpy(&bits, &*value, sizeof(bits));
101+
append_u64(out, bits);
102+
}
103+
104+
void append_sampling(std::string& out, const ModelSamplingOptions& sampling) {
105+
append_optional_double(out, sampling.temperature);
106+
append_optional_double(out, sampling.top_p);
107+
append_optional_int(out, sampling.top_k);
108+
append_optional_double(out, sampling.min_p);
109+
}
110+
91111
void append_optional_bool(std::string& out,
92112
const std::optional<bool>& value) {
93113
append_bool(out, value.has_value());
@@ -142,6 +162,7 @@ std::string canonical_plan_bytes(const EffectiveProviderBuildPlan& plan) {
142162
append_u64(out, static_cast<std::uint64_t>(
143163
plan.request_options.reasoning_protocol));
144164
append_reasoning(out, plan.request_options.reasoning);
165+
append_sampling(out, plan.request_options.sampling);
145166
}
146167
append_bool(out, plan.applies_vision_routing);
147168
if (plan.applies_vision_routing) {
@@ -209,18 +230,20 @@ std::optional<EffectiveProviderBuildPlan> effective_plan_from_entry(
209230
} else if (entry.provider == "copilot") {
210231
if (!entry.base_url.empty() || !entry.api_key.empty() ||
211232
!entry.request_headers.empty() || entry.endpoint_mode.has_value() ||
212-
entry.max_output_tokens.has_value() || entry.reasoning.has_value()) {
233+
entry.max_output_tokens.has_value() || entry.reasoning.has_value() ||
234+
!entry.sampling.empty()) {
213235
LOG_WARN("[provider_factory] refusing unsupported Copilot endpoint, "
214-
"credential, output, or reasoning customization");
236+
"credential, output, reasoning, or sampling customization");
215237
return std::nullopt;
216238
}
217239
} else if (entry.provider == "grok") {
218240
if (!entry.base_url.empty() || !entry.api_key.empty() ||
219241
!entry.request_headers.empty() || entry.endpoint_mode.has_value() ||
220242
entry.max_output_tokens.has_value() || entry.reasoning.has_value() ||
221-
entry.stream_timeout_ms.has_value()) {
243+
entry.stream_timeout_ms.has_value() || !entry.sampling.empty()) {
222244
LOG_WARN("[provider_factory] refusing unsupported Grok endpoint, "
223-
"credential, timeout, output, or reasoning customization");
245+
"credential, timeout, output, reasoning, or sampling "
246+
"customization");
224247
return std::nullopt;
225248
}
226249
} else if (entry.provider == "codex") {

‎src/adapters/provider/provider_request_options.hpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ struct ProviderRequestOptions {
2222
std::optional<int> max_output_tokens;
2323
std::optional<ModelReasoningOptions> reasoning;
2424
ReasoningWireProtocol reasoning_protocol = ReasoningWireProtocol::None;
25+
// 空 = 一个采样字段都不发,请求体与旧版本逐字节一致。
26+
ModelSamplingOptions sampling;
2527
};
2628

2729
} // namespace acecode

‎src/apps/web/handlers/models_handler.cpp‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ nlohmann::json entry_to_json(const ModelProfile& entry) {
9090
if (entry.reasoning.has_value()) {
9191
o["reasoning"] = model_reasoning_options_to_json(*entry.reasoning);
9292
}
93+
if (!entry.sampling.empty()) {
94+
o["sampling"] = model_sampling_options_to_json(entry.sampling);
95+
}
9396
if (!entry.request_headers.empty()) {
9497
o["request_headers"] = entry.request_headers;
9598
}
@@ -388,6 +391,22 @@ std::optional<SavedModelDraft> parse_model_draft(const nlohmann::json& body,
388391
}
389392
}
390393
}
394+
if (body.contains("sampling")) {
395+
// null / {} 都表示清除;对象整体替换已保存的采样参数。
396+
d.sampling_supplied = true;
397+
if (body["sampling"].is_null()) {
398+
d.sampling.reset();
399+
} else {
400+
std::string sampling_error;
401+
auto sampling = parse_model_sampling_options(body["sampling"],
402+
sampling_error);
403+
if (!sampling.has_value()) {
404+
err = "field 'sampling' is invalid: " + sampling_error;
405+
} else {
406+
d.sampling = *sampling;
407+
}
408+
}
409+
}
391410
if (body.contains("request_headers")) {
392411
d.request_headers_supplied = true;
393412
auto parsed = parse_request_headers_json(body["request_headers"],

‎src/base/config/config.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2693,6 +2693,9 @@ nlohmann::json build_config_json(const AppConfig& cfg) {
26932693
if (e.reasoning.has_value()) {
26942694
ej["reasoning"] = model_reasoning_options_to_json(*e.reasoning);
26952695
}
2696+
if (!e.sampling.empty()) {
2697+
ej["sampling"] = model_sampling_options_to_json(e.sampling);
2698+
}
26962699
if (!e.request_headers.empty()) {
26972700
nlohmann::json headers = nlohmann::json::object();
26982701
for (const auto& [k, v] : e.request_headers) headers[k] = v;

0 commit comments

Comments
 (0)