v0.8.3: WebUI models, providers and keys; garbled thinking fixed

Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
This commit is contained in:
WHC
2026-10-09 15:54:53 +08:00
parent e238f5f37d
commit 2d1052fd5f
18 changed files with 1553 additions and 182 deletions
+395 -39
View File
@@ -15,6 +15,9 @@
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_http.h"
#include "cst_models.h"
#include "cst_provider_switch.h"
#include "cst_tools.h"
#include "cst_util.h"
#include "cst_web_md.h"
#include "cst_webui.h"
@@ -82,6 +85,10 @@ typedef struct wui {
uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */
char ask_ans[WUI_ASK_MAX];
/* turn thread only: the tail of a UTF-8 sequence split across deltas,
* [0] think_delta, [1] assistant_delta */
cst_utf8_hold_t carry[2];
volatile int stopping;
} wui_t;
@@ -157,8 +164,79 @@ static void wui_emit_text(wui_t *w, const char *type, const char *text,
wui_emit(w, type, o);
}
/* A JSON string with half a character turns into U+FFFD in the browser:
* hold an incomplete trailing sequence until the next delta completes it. */
static void wui_emit_stream(wui_t *w, int k, const char *type,
const char *text, size_t len) {
size_t n = 0;
char *s = cst_utf8_hold_feed(&w->carry[k], text, len, &n);
if (s && n)
wui_emit_text(w, type, s, n);
free(s);
}
static void wui_reset_carry(wui_t *w) {
w->carry[0].n = w->carry[1].n = 0;
}
/* --- state --------------------------------------------------------------- */
/* Whether provider has a key and where it comes from: "saved", the env
* variable's name, or "session" (given for this run only). The key itself
* never leaves this function. */
static int key_source(const cst_agent_config_t *cfg, const char *provider,
char *from, size_t from_sz) {
char k[512];
const char *src = NULL;
int found = cst_creds_resolve(provider, k, sizeof(k), &src) == 0;
int cur = cfg && strcmp(cfg->provider, provider) == 0;
int has = cur ? cfg->api_key[0] != 0 : found;
if (!has)
from[0] = 0;
else if (cur && (!found || strcmp(k, cfg->api_key) != 0))
snprintf(from, from_sz, "session");
else
snprintf(from, from_sz, "%s", src ? src : "saved");
memset(k, 0, sizeof(k));
return has;
}
static void model_info_json(cJSON *o, const char *provider,
const char *model) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
char label[48];
cst_model_info_label(&mi, label, sizeof(label));
cJSON_AddStringToObject(o, "label", label);
cJSON_AddNumberToObject(o, "context", (double)mi.context);
if (mi.vision < 0)
cJSON_AddNullToObject(o, "vision");
else
cJSON_AddBoolToObject(o, "vision", mi.vision == 1);
cJSON_AddBoolToObject(o, "reasoning", mi.reasoning != 0);
cJSON_AddStringToObject(o, "source", mi.source);
if (mi.has_price) {
cJSON *p = cJSON_AddObjectToObject(o, "price");
if (p) {
cJSON_AddStringToObject(p, "currency", mi.price.currency);
cJSON_AddNumberToObject(p, "miss", mi.price.miss);
cJSON_AddNumberToObject(p, "hit", mi.price.hit);
cJSON_AddNumberToObject(p, "out", mi.price.out);
}
}
}
static void usage_json(cJSON *u, const cst_usage_t *s) {
cJSON_AddNumberToObject(u, "requests", s->requests);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)s->prompt);
cJSON_AddNumberToObject(u, "completion_tokens", (double)s->out);
cJSON_AddNumberToObject(u, "cached_tokens", (double)s->cached);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)s->miss);
cJSON_AddNumberToObject(u, "cost", s->cost);
cJSON_AddBoolToObject(u, "priced", s->priced != 0);
cJSON_AddStringToObject(u, "currency", s->currency);
}
static cJSON *wui_state_new(wui_t *w) {
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
@@ -188,16 +266,29 @@ static cJSON *wui_state_new(wui_t *w) {
cJSON_AddBoolToObject(o, "undo_available",
cst_agent_undo_available(w->agent) != 0);
cJSON *u = cJSON_AddObjectToObject(o, "usage");
if (u) {
cJSON_AddNumberToObject(u, "requests", sess.requests);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt);
cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out);
cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss);
cJSON_AddNumberToObject(u, "cost", sess.cost);
cJSON_AddBoolToObject(u, "priced", sess.priced != 0);
cJSON_AddStringToObject(u, "currency", sess.currency);
}
if (u)
usage_json(u, &sess);
u = cJSON_AddObjectToObject(o, "turn_usage");
if (u)
usage_json(u, &turn);
cJSON_AddStringToObject(o, "api_base", cfg->api_base);
char from[64];
cJSON_AddBoolToObject(o, "key_set",
key_source(cfg, cfg->provider, from, sizeof(from)));
cJSON_AddStringToObject(o, "key_from", from);
cJSON *mi = cJSON_AddObjectToObject(o, "model_info");
if (mi)
model_info_json(mi, cfg->provider, cfg->model);
cJSON_AddBoolToObject(o, "effort_applies",
cst_effort_wire(cfg->provider, cfg->model,
cfg->effort) != NULL);
/* 1 yes, 0 no, -1 not known */
cJSON_AddNumberToObject(o, "sees_images", cst_agent_sees_images(w->agent));
cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps);
cJSON_AddStringToObject(o, "tool_profile",
cst_tool_profile_name(cfg->tool_profile));
cJSON_AddNumberToObject(o, "perm_timeout",
w->perm_timeout_s > 0 ? w->perm_timeout_s : 600);
cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images");
int n = cst_agent_pending_count(w->agent);
for (int i = 0; i < n && imgs; i++) {
@@ -309,11 +400,11 @@ static void on_assistant_delta(const char *text, size_t len, void *ud) {
w->md = cst_web_md_new(wui_md_emit, w);
if (w->md)
cst_web_md_feed(w->md, text, len);
wui_emit_text(w, "assistant_delta", text, len);
wui_emit_stream(w, 1, "assistant_delta", text, len);
}
static void on_think_delta(const char *text, size_t len, void *ud) {
wui_emit_text((wui_t *)ud, "think_delta", text, len);
wui_emit_stream((wui_t *)ud, 0, "think_delta", text, len);
}
static cJSON *call_json(const ctl_tool_call_t *call) {
@@ -380,6 +471,10 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu);
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
if (!has_client)
timeout = 3;
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddNumberToObject(o, "id", (double)id);
@@ -390,12 +485,9 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
strncmp(detail, CST_PERM_SANDBOX,
strlen(CST_PERM_SANDBOX)) ==
0);
cJSON_AddNumberToObject(o, "timeout", timeout);
wui_set_pending(w, wui_emit(w, "permission", o));
}
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
if (!has_client)
timeout = 3;
int answer = CST_PERM_DENY;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
@@ -765,7 +857,9 @@ static void *wui_turn_main(void *arg) {
hooks.userdata = w;
char err[512];
err[0] = 0;
wui_reset_carry(w);
int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err));
wui_reset_carry(w); /* a dangling partial character is dropped */
wui_end_turn(w, rc, err);
free(t->prompt);
free(t);
@@ -863,6 +957,46 @@ static void wui_release(wui_t *w) {
pthread_mutex_unlock(&w->mu);
}
/* A copy of the config for a slow read (a model list fetch). Changes only
* write it after claiming under mu, so none is half done while mu is held
* and op_busy is clear; a running turn does not write these fields. */
static int wui_cfg_snapshot(wui_t *w, cst_agent_config_t *out) {
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
*out = *cst_agent_config(w->agent);
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
static int wui_send_obj(cst_http_conn_t *c, int status, cJSON *o) {
char *json = o ? cJSON_PrintUnformatted(o) : NULL;
cJSON_Delete(o);
if (!json)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_json(c, status, json);
free(json);
return rc;
}
static cJSON *wui_body(cst_http_conn_t *c) {
const cst_http_req_t *req = cst_http_request(c);
return req->body_len ? cJSON_ParseWithLength(req->body, req->body_len)
: NULL;
}
static int wui_model_ok(const cJSON *m) {
if (!cJSON_IsString(m))
return 0;
size_t n = strlen(m->valuestring);
if (n == 0 || n > 127)
return 0;
for (size_t i = 0; i < n; i++)
if ((unsigned char)m->valuestring[i] < 0x21)
return 0;
return 1;
}
#define WUI_EXCLUSIVE(name) \
static int name(cst_http_conn_t *c, void *ud) { \
wui_t *w = ud; \
@@ -1053,40 +1187,256 @@ static int h_config(cst_http_conn_t *c, void *ud) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object");
}
/* everything is checked before anything changes */
const cJSON *model = cJSON_GetObjectItem(j, "model");
const cJSON *effort = cJSON_GetObjectItem(j, "effort");
const cJSON *approval = cJSON_GetObjectItem(j, "approval");
const cJSON *plan = cJSON_GetObjectItem(j, "plan_mode");
const char *bad = NULL;
if (cJSON_GetObjectItem(j, "provider"))
bad = "切换提供商请用 POST /api/provider";
else if (model && !wui_model_ok(model))
bad = "模型名无效(1-127 个可见字符)";
else if (effort && (!cJSON_IsString(effort) ||
!cst_effort_valid(effort->valuestring)))
bad = "推理强度只能是 low / medium / high / max";
int a = 0;
if (!bad && approval && (!cJSON_IsString(approval) ||
cst_approval_parse(approval->valuestring, &a) != 0))
bad = "审批模式只能是 ask / auto / yolo";
if (!bad && plan && !cJSON_IsBool(plan))
bad = "plan_mode 必须是 true 或 false";
if (bad) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, bad);
}
/* the strings are read by the turn as it builds each request; approval
* and plan mode may change mid-turn (say, to yolo at a pending gate) */
int claimed = cJSON_GetObjectItem(j, "model") ||
cJSON_GetObjectItem(j, "provider") ||
cJSON_GetObjectItem(j, "effort");
int claimed = model || effort;
if (claimed && wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "busy: wait for the running turn");
return cst_http_send_err(c, 409, "正在运行,等这一轮结束再改模型");
}
const cJSON *item;
if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item))
snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "provider")) && cJSON_IsString(item))
snprintf(cfg->provider, sizeof(cfg->provider), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "effort")) && cJSON_IsString(item) &&
cst_effort_valid(item->valuestring))
snprintf(cfg->effort, sizeof(cfg->effort), "%s", item->valuestring);
if ((item = cJSON_GetObjectItem(j, "approval")) && cJSON_IsString(item)) {
int a = 0;
if (cst_approval_parse(item->valuestring, &a) == 0) {
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
}
if (model)
snprintf(cfg->model, sizeof(cfg->model), "%s", model->valuestring);
if (effort)
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort->valuestring);
if (claimed)
cst_settings_remember(cfg);
if (approval) {
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this run only */
cst_settings_remember_approval(a);
}
if ((item = cJSON_GetObjectItem(j, "plan_mode")) && cJSON_IsBool(item)) {
cfg->plan_mode = cJSON_IsTrue(item) ? 1 : 0;
cfg->read_only = cfg->plan_mode;
if (plan)
cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0;
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
if (effort && !cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
cJSON_AddStringToObject(o, "warning", "当前模型不接受推理强度参数:已保存,"
"换到支持的模型时生效");
}
cJSON_Delete(j);
if (claimed)
wui_release(w);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
return wui_send_obj(c, 200, o);
}
/* GET /api/providers: the presets, whether each has a key and from where.
* Never the key itself. */
static int h_providers(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = malloc(sizeof(*cfg));
if (!cfg)
return cst_http_send_err(c, 500, "out of memory");
if (wui_cfg_snapshot(w, cfg) != 0) {
free(cfg);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
cJSON *o = cJSON_CreateObject();
if (!o) {
free(cfg);
return cst_http_send_err(c, 500, "out of memory");
}
cJSON_AddStringToObject(o, "current", cfg->provider);
cJSON *arr = cJSON_AddArrayToObject(o, "providers");
int n = cst_provider_count();
for (int i = 0; arr && i < n; i++) {
const cst_provider_t *p = cst_provider_at(i);
cJSON *e = cJSON_CreateObject();
if (!p || !e) {
cJSON_Delete(e);
continue;
}
int cur = strcmp(p->name, cfg->provider) == 0;
cJSON_AddStringToObject(e, "name", p->name);
cJSON_AddStringToObject(e, "api_base", cur ? cfg->api_base : p->api_base);
cJSON_AddStringToObject(e, "model", cur ? cfg->model
: (p->model ? p->model : ""));
char from[64];
cJSON_AddBoolToObject(e, "has_key",
key_source(cfg, p->name, from, sizeof(from)));
cJSON_AddStringToObject(e, "key_from", from);
cJSON_AddStringToObject(e, "key_env", p->key_env ? p->key_env : "");
cJSON_AddStringToObject(e, "source", cst_provider_source(p));
cJSON_AddBoolToObject(e, "current", cur);
cJSON_AddItemToArray(arr, e);
}
memset(cfg->api_key, 0, sizeof(cfg->api_key));
free(cfg);
return wui_send_obj(c, 200, o);
}
/* GET /api/models[?refresh=1]: the picker list for the current provider.
* A rejected key is a 401 that still carries the built-in names. */
static int h_models(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
char rf[8] = "";
cst_http_query(req, "refresh", rf, sizeof(rf));
cst_agent_config_t *snap = malloc(sizeof(*snap));
int max = CST_PS_MAX_MODELS + 32;
cst_ps_model_t *list = calloc((size_t)max, sizeof(*list));
if (!snap || !list) {
free(snap);
free(list);
return cst_http_send_err(c, 500, "out of memory");
}
if (wui_cfg_snapshot(w, snap) != 0) {
free(snap);
free(list);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
int api_rc = -1;
char err[256] = "";
int n = cst_ps_models(snap, rf[0] == '1', list, max, &api_rc, err,
sizeof(err));
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddStringToObject(o, "provider", snap->provider);
cJSON_AddStringToObject(o, "current", snap->model);
cJSON_AddStringToObject(o, "api_base", snap->api_base);
cJSON_AddBoolToObject(o, "key_set", snap->api_key[0] != 0);
cJSON_AddNumberToObject(o, "api_count", api_rc >= 0 ? api_rc : -1);
if (api_rc == CST_MODELS_AUTH) {
char msg[400];
snprintf(msg, sizeof(msg), "%s 拒绝了当前 API key(%.200s),请换一个",
snap->provider, err);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "auth", 1);
} else if (api_rc < 0 && err[0]) {
char msg[400];
snprintf(msg, sizeof(msg), "拉取模型列表失败:%.300s", err);
cJSON_AddStringToObject(o, "api_error", msg);
}
cJSON *arr = cJSON_AddArrayToObject(o, "models");
for (int i = 0; arr && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "id", list[i].id);
cJSON_AddBoolToObject(e, "api", list[i].api);
model_info_json(e, snap->provider, list[i].id);
cJSON_AddItemToArray(arr, e);
}
}
memset(snap->api_key, 0, sizeof(snap->api_key));
free(snap);
free(list);
return wui_send_obj(c, api_rc == CST_MODELS_AUTH ? 401 : 200, o);
}
/* The answer to a key check / provider switch, in the browser's words. */
static int ps_reply(wui_t *w, cst_http_conn_t *c, int r, const char *who,
const char *err) {
char msg[512];
if (r == CST_PS_UNKNOWN) {
snprintf(msg, sizeof(msg), "没有这个提供商:%.64s", who);
return cst_http_send_err(c, 404, msg);
}
if (r == CST_MODELS_AUTH) {
snprintf(msg, sizeof(msg), "%.64s 拒绝了这个 API key(%.300s)", who, err);
return cst_http_send_err(c, 401, msg);
}
if (r < 0) {
cJSON *o = cJSON_CreateObject();
if (o) {
snprintf(msg, sizeof(msg), "%.64s 需要 API key", who);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "need_key", 1);
}
return wui_send_obj(c, 400, o);
}
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model);
msg[0] = 0;
if (r & CST_PS_UNSAVED)
snprintf(msg, sizeof(msg), "key 没能写进 credentials 文件,只在本次运行有效");
else if (r & CST_PS_UNVERIFIED)
snprintf(msg, sizeof(msg), "没能验证 key(%.300s),已先保存", err);
if (msg[0])
cJSON_AddStringToObject(o, "warning", msg);
}
wui_emit_state(w);
return wui_send_obj(c, 200, o);
}
/* Wipe a secret out of a parsed body before it is freed. */
static void wipe_str(const cJSON *s) {
if (cJSON_IsString(s) && s->valuestring)
memset(s->valuestring, 0, strlen(s->valuestring));
}
/* POST /api/provider {"name": "...", "key": "..."?} */
static int h_provider_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL;
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(name) || (key && !cJSON_IsString(key))) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"name\": \"提供商\", \"key\": 可选}");
}
char who[64], err[256] = "";
snprintf(who, sizeof(who), "%s", name->valuestring);
int r = cst_ps_switch_provider(cfg, who, key ? key->valuestring : NULL, err,
sizeof(err));
wipe_str(key);
cJSON_Delete(j);
if (r >= 0) {
char line[256];
snprintf(line, sizeof(line), "提供商 → %s · 模型 %s", cfg->provider,
cfg->model[0] ? cfg->model : "(未选)");
wui_emit_str(w, "status", "message", line);
}
return ps_reply(w, c, r, who, err);
}
/* POST /api/key {"key": "..."}: replace the current provider's key. */
static int h_key_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(key) || !key->valuestring[0]) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"key\": \"...\"}");
}
char err[256] = "";
int r = cst_ps_set_key(cfg, key->valuestring, err, sizeof(err));
wipe_str(key);
cJSON_Delete(j);
return ps_reply(w, c, r, cfg->provider, err);
}
static int h_undo_x(cst_http_conn_t *c, void *ud) {
@@ -1140,6 +1490,8 @@ WUI_EXCLUSIVE(h_undo)
WUI_EXCLUSIVE(h_compact)
WUI_EXCLUSIVE(h_attach_upload)
WUI_EXCLUSIVE(h_attach_clear)
WUI_EXCLUSIVE(h_provider)
WUI_EXCLUSIVE(h_key)
static int h_quit(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
@@ -1360,6 +1712,10 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
{"POST", "/api/permission", h_permission, NULL, 0, 0},
{"POST", "/api/ask_user", h_ask_user, NULL, 0, 0},
{"POST", "/api/config", h_config, NULL, 0, 0},
{"GET", "/api/providers", h_providers, NULL, 0, 0},
{"GET", "/api/models", h_models, NULL, 0, 0},
{"POST", "/api/provider", h_provider, NULL, 0, 0},
{"POST", "/api/key", h_key, NULL, 0, 0},
{"POST", "/api/undo", h_undo, NULL, 0, 0},
{"POST", "/api/compact", h_compact, NULL, 0, 0},
{"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u},