Files
ligent/src/config.c
T
2026-10-03 14:56:56 +08:00

556 lines
20 KiB
C

/* config.c - 配置文件 / 环境变量加载 */
#include "config.h"
#include "http.h"
#include "i18n.h"
#include "json.h"
#include "util.h"
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <limits.h>
#include <sys/stat.h>
#define DEFAULT_BASE_URL "https://api.deepseek.com/v1"
#define DEFAULT_MODEL "deepseek-flash"
void config_defaults(LigentConfig *c)
{
memset(c, 0, sizeof *c);
c->base_url = xstrdup(DEFAULT_BASE_URL);
c->api_key = xstrdup("");
c->api_key_env = NULL; /* 密钥来源, 供诊断展示 (见 config.h) */
c->model = xstrdup(DEFAULT_MODEL);
c->system = NULL;
c->temperature = -1; /* 不发送, 用服务端默认 */
c->max_tokens = 0; /* 不发送 */
c->thinking = THINKING_AUTO; /* 不发送 thinking, 由服务端决定 */
c->reasoning_effort = NULL; /* 不发送 */
c->timeout = 300;
c->stream = 1;
/* max_steps <= 0 = 不限制单轮往返次数 (默认). 这是**默认**值: 模型陷进
* "调工具 → 看结果 → 再调" 的循环时不会再被我们自己掐断, 由用户 Ctrl-C
* (或模型自己收尾)来结束. 要恢复上限就设个正数. */
c->max_steps = 0;
c->use_tools = 1;
c->retries = 2;
c->confirm = 1;
c->auto_save = 1;
c->probe = 1;
c->config_path = NULL;
}
static void set_str(char **dst, const char *val)
{
free(*dst);
*dst = val ? xstrdup(val) : NULL;
}
/* 服务端认识的思考级别. DeepSeek 会把它们映射到 low/high/max 三档
* (minimal/low→low, medium/high/xhigh→high, max/ultra→max),
* OpenAI 那边则用 minimal/low/medium/high. 这里只做白名单校验,
* 不替用户改写 —— 原样发过去, 由服务端决定怎么映射. */
static const char *const EFFORTS[] = {
"minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL
};
int config_effort_valid(const char *s)
{
if (!s || !*s) return 0;
for (size_t i = 0; EFFORTS[i]; i++)
if (strcmp(s, EFFORTS[i]) == 0) return 1;
return 0;
}
const char *config_effort_list(void)
{
static char buf[128];
if (!buf[0]) {
size_t n = 0;
for (size_t i = 0; EFFORTS[i]; i++)
n += (size_t)snprintf(buf + n, sizeof buf - n, "%s%s",
i ? "/" : "", EFFORTS[i]);
}
return buf;
}
const char *config_thinking_name(int thinking)
{
if (thinking == THINKING_ON) return t("cfg.thinking.on");
if (thinking == THINKING_OFF) return t("cfg.thinking.off");
return t("cfg.thinking.auto");
}
int config_parse_thinking(const char *s, int *out)
{
if (!s) return -1;
if (!*s || strcmp(s, "auto") == 0) { *out = THINKING_AUTO; return 0; }
if (strcmp(s, "on") == 0 || strcmp(s, "true") == 0 ||
strcmp(s, "enabled") == 0) { *out = THINKING_ON; return 0; }
if (strcmp(s, "off") == 0 || strcmp(s, "false") == 0 ||
strcmp(s, "disabled") == 0) { *out = THINKING_OFF; return 0; }
return -1;
}
/* 配置文件里 thinking 既可以是布尔也可以是字符串:
* `"thinking": true` 比 `"thinking": "on"` 自然, 两种都收. */
static int load_thinking(LigentConfig *c, const JsonValue *v,
const char *path, char *errbuf, size_t errlen)
{
if (v->type == JSON_BOOL) {
c->thinking = v->u.boolean ? THINKING_ON : THINKING_OFF;
return 0;
}
const char *s = json_string_value(v);
if (s && config_parse_thinking(s, &c->thinking) == 0) return 0;
snprintf(errbuf, errlen, "%s", tf("cfg.err.thinking_type", "path", path, NULL));
return -1;
}
/* ------------------------------------------------------------------ */
/* 配置文件 */
/* ------------------------------------------------------------------ */
/* ---- 定位二进制所在目录 ----
*
* 关键点: 路径要"跟着用户看到的那个文件走". 如果 ligent 是个软链接,
* 用户是把它当 ./bin/ligent 用的, 配置就该在 ./bin/ 旁边, 而不是被
* 悄悄挪到源文件所在的目录去.
*
* 所以这里优先用 argv[0] (不解析最后一段的软链接), 只在拿不到时
* 才退回 /proc/self/exe —— 那个会解析软链接. */
/* 确认 cand 确实指向我们自己的可执行文件.
* 用 inode 比对, 避免 $PATH 里恰好有个同名的别的东西把目录带偏.
* 读不到 /proc/self/exe 时不做检查 (非 Linux 平台). */
static int is_self(const char *cand)
{
struct stat a, b;
if (!cand || stat(cand, &a) != 0) return 0; /* stat 会跟随软链接 */
if (stat("/proc/self/exe", &b) != 0) return 1; /* 无法比对, 放行 */
return a.st_dev == b.st_dev && a.st_ino == b.st_ino;
}
/* 在 $PATH 里查找可执行且确实是我们自己的文件, 返回完整路径 (malloc)或 NULL */
static char *search_path(const char *name)
{
const char *path = getenv("PATH");
if (!path || !*path) return NULL;
char *copy = xstrdup(path);
char *save = NULL;
char *dir = strtok_r(copy, ":", &save);
char *found = NULL;
while (dir) {
char *cand = xasprintf("%s/%s", *dir ? dir : ".", name);
if (access(cand, X_OK) == 0 && is_self(cand)) { found = cand; break; }
free(cand);
dir = strtok_r(NULL, ":", &save);
}
free(copy);
return found;
}
/* 取路径中最后一段之前的部分; 没有 '/' 时返回 NULL */
static char *dir_of(const char *path)
{
const char *slash = path ? strrchr(path, '/') : NULL;
if (!slash) return NULL;
if (slash == path) return xstrdup("/");
return xstrndup(path, (size_t)(slash - path));
}
/* 取所在目录并规范化 (解析目录部分里的软链接、. 和 ..),
* 但刻意不解析最后一段 —— 那正是"软链接所在目录"的关键 */
static char *dir_of_canonical(const char *path)
{
char *dir = dir_of(path);
if (!dir) return NULL;
char *canon = realpath(dir, NULL); /* 成功时由 malloc 分配 */
if (canon) { free(dir); return canon; }
/* 目录不存在等情况下退回未规范化的结果, 但要保证是绝对路径 */
if (dir[0] == '/') return dir;
char cwd[PATH_MAX];
if (getcwd(cwd, sizeof cwd)) {
char *full = xasprintf("%s/%s", cwd, dir);
free(dir);
return full;
}
return dir;
}
char *config_binary_dir(const char *argv0)
{
/* 优先 argv[0]: 它反映用户实际调用的那个路径 */
if (argv0 && *argv0) {
if (strchr(argv0, '/')) {
if (is_self(argv0)) {
char *d = dir_of_canonical(argv0);
if (d) return d;
}
} else {
/* 只是个名字: 去 $PATH 里找 (返回的仍是软链接本身) */
char *full = search_path(argv0);
if (full) {
char *d = dir_of_canonical(full);
free(full);
if (d) return d;
}
}
}
/* 兜底: 问内核可执行文件在哪. 注意这会解析软链接,
* 只在 argv[0] 给不出答案时使用 */
char buf[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", buf, sizeof buf - 1);
if (n > 0 && (size_t)n < sizeof buf) {
buf[n] = '\0';
char *d = dir_of_canonical(buf);
if (d) return d;
}
/* 都失败就认当前目录, 至少不会崩 */
return xstrdup(".");
}
char *config_default_path(const char *argv0)
{
char *dir = config_binary_dir(argv0);
char *path = xasprintf("%s/ligent.json", dir ? dir : ".");
free(dir);
return path;
}
/* ---- 查找要加载的配置文件 ---- */
/* 只认两个来源: 命令行的显式指定, 以及二进制目录下的 ligent.json.
* 不做用户级 / 系统级 / 当前工作目录的查找. */
static char *find_config_file(const char *explicit_path, const char *argv0)
{
if (explicit_path && *explicit_path) {
/* 显式指定的路径不做存在性判断, 读不到会明确报错 */
return xstrdup(explicit_path);
}
const char *env = getenv("LIGENT_CONFIG");
if (env && *env) return xstrdup(env);
char *path = config_default_path(argv0);
if (access(path, R_OK) == 0) return path;
free(path); /* 不存在就表示"用内置默认值" */
return NULL;
}
int config_load_file(LigentConfig *c, const char *path,
char *errbuf, size_t errlen)
{
size_t len = 0;
char *text = read_entire_file(path, &len);
if (!text) {
snprintf(errbuf, errlen, "%s",
tf("cfg.err.read_failed", "path", path, NULL));
return -1;
}
char jerr[256];
JsonValue *root = json_parse_ui(text, jerr, sizeof jerr);
free(text);
if (!root) {
snprintf(errbuf, errlen, "%s",
tf("cfg.err.bad_json", "path", path, "error", jerr, NULL));
return -1;
}
if (root->type != JSON_OBJECT) {
json_free(root);
snprintf(errbuf, errlen, "%s",
tf("cfg.err.not_object", "path", path, NULL));
return -1;
}
const char *s;
if ((s = json_get_string(root, "base_url"))) set_str(&c->base_url, s);
if ((s = json_get_string(root, "api_key"))) {
char *expanded = expand_env(s);
if (*expanded) c->api_key_from_file = 1;
set_str(&c->api_key, expanded);
free(expanded);
}
if ((s = json_get_string(root, "model"))) set_str(&c->model, s);
/* 空字符串视为"没写": 内置提示词里带着工具使用规范,
* 静默丢掉会让 agent 行为明显变差; 而且下发一条空的 system 消息
* 既浪费又没有意义. */
if ((s = json_get_string(root, "system"))) {
if (*s) set_str(&c->system, s);
}
JsonValue *v;
if ((v = json_object_get(root, "temperature")) && v->type == JSON_NUMBER)
c->temperature = v->u.number;
if ((v = json_object_get(root, "max_tokens")) && v->type == JSON_NUMBER)
c->max_tokens = (long)v->u.number;
if ((v = json_object_get(root, "thinking"))) {
if (load_thinking(c, v, path, errbuf, errlen) != 0) {
json_free(root);
return -1;
}
}
if ((s = json_get_string(root, "reasoning_effort")) && *s) {
if (!config_effort_valid(s)) {
snprintf(errbuf, errlen, "%s",
tf("cfg.err.bad_effort", "path", path,
"list", config_effort_list(), NULL));
json_free(root);
return -1;
}
set_str(&c->reasoning_effort, s);
}
if ((v = json_object_get(root, "timeout")) && v->type == JSON_NUMBER)
c->timeout = (long)v->u.number;
if ((v = json_object_get(root, "stream")))
c->stream = json_bool_value(v, c->stream);
if ((v = json_object_get(root, "max_steps")) && v->type == JSON_NUMBER)
c->max_steps = (int)v->u.number;
if ((v = json_object_get(root, "tools")))
c->use_tools = json_bool_value(v, c->use_tools);
if ((v = json_object_get(root, "retries")) && v->type == JSON_NUMBER)
c->retries = (int)v->u.number;
if ((v = json_object_get(root, "confirm")))
c->confirm = json_bool_value(v, c->confirm);
if ((v = json_object_get(root, "auto_save")))
c->auto_save = json_bool_value(v, c->auto_save);
if ((v = json_object_get(root, "probe")))
c->probe = json_bool_value(v, c->probe);
json_free(root);
free(c->config_path);
c->config_path = xstrdup(path);
return 0;
}
/* ------------------------------------------------------------------ */
/* 环境变量 */
/* ------------------------------------------------------------------ */
static void env_str(char **dst, const char *name)
{
const char *v = getenv(name);
if (v && *v) set_str(dst, v);
}
void config_apply_env(LigentConfig *c)
{
env_str(&c->base_url, "LIGENT_BASE_URL");
env_str(&c->model, "LIGENT_MODEL");
{
const char *v = getenv("LIGENT_SYSTEM");
if (v && *v) set_str(&c->system, v);
}
/* 厂商专属的环境变量只在"确实在用这家"时才读.
*
* 否则会出这种事: 配置文件里写的是 OpenRouter 的 key,
* 而 shell 里残留的 DEEPSEEK_API_KEY 把它悄悄顶掉,
* 结果 A 家的密钥被发给了 B 家 —— 对方只会回一个含糊的 401.
* 用户明明配对了却怎么都调不通, 排查方向还会被带偏. */
const char *url = c->base_url ? c->base_url : "";
int use_deepseek = strstr(url, "deepseek") != NULL;
int use_openai = strstr(url, "api.openai.com") != NULL;
/* api_key 优先级:
* LIGENT_API_KEY (显式覆盖) > 配置文件 > 对应厂商的环境变量兜底 */
const char *key = getenv("LIGENT_API_KEY");
if (key && *key) {
set_str(&c->api_key, key);
c->api_key_env = "LIGENT_API_KEY";
} else if (!c->api_key || !c->api_key[0]) {
const char *k = NULL;
const char *src = NULL;
if (use_deepseek) { k = getenv("DEEPSEEK_API_KEY"); src = "DEEPSEEK_API_KEY"; }
else if (use_openai) { k = getenv("OPENAI_API_KEY"); src = "OPENAI_API_KEY"; }
if (k && *k) { set_str(&c->api_key, k); c->api_key_env = src; }
}
/* OPENAI_BASE_URL 同理, 只在 base_url 还是内置默认值 (用户没显式配)时兜底 */
if (use_openai || strcmp(url, DEFAULT_BASE_URL) == 0) {
const char *bu = getenv("OPENAI_BASE_URL");
if (bu && *bu) set_str(&c->base_url, bu);
}
const char *v;
if ((v = getenv("LIGENT_TEMPERATURE")) && *v) c->temperature = atof(v);
if ((v = getenv("LIGENT_MAX_TOKENS")) && *v) c->max_tokens = atol(v);
if ((v = getenv("LIGENT_TIMEOUT")) && *v) c->timeout = atol(v);
if ((v = getenv("LIGENT_MAX_STEPS")) && *v) c->max_steps = atoi(v);
if ((v = getenv("LIGENT_STREAM")) && *v)
c->stream = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
if ((v = getenv("LIGENT_TOOLS")) && *v)
c->use_tools = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
if ((v = getenv("LIGENT_RETRIES")) && *v) c->retries = atoi(v);
if ((v = getenv("LIGENT_CONFIRM")) && *v)
c->confirm = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
if ((v = getenv("LIGENT_AUTO_SAVE")) && *v)
c->auto_save = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
if ((v = getenv("LIGENT_PROBE")) && *v)
c->probe = !(strcmp(v, "0") == 0 || strcmp(v, "false") == 0);
/* 环境变量里写错了不报错、直接忽略: 环境变量是临时开关,
* 为它把整个程序拦下来太粗暴. 配置文件和命令行则严格校验. */
if ((v = getenv("LIGENT_THINKING")) && *v) {
int t;
if (config_parse_thinking(v, &t) == 0) c->thinking = t;
}
if ((v = getenv("LIGENT_REASONING_EFFORT")) && *v) {
if (config_effort_valid(v)) set_str(&c->reasoning_effort, v);
}
}
int config_init(LigentConfig *c, const char *explicit_path, const char *argv0,
char *errbuf, size_t errlen)
{
config_defaults(c);
if (errbuf && errlen) errbuf[0] = '\0';
/* 记下二进制目录: 配置和以后任何数据都只放这里 */
free(c->config_dir);
c->config_dir = config_binary_dir(argv0);
char *path = find_config_file(explicit_path, argv0);
if (path) {
if (config_load_file(c, path, errbuf, errlen) != 0) {
if (explicit_path && *explicit_path) { free(path); return -1; }
/* 约定位置的配置坏了, 也不该直接拦住用户 */
if (errbuf && errlen)
fprintf(stderr, "%s\n",
tf("cfg.err.parse_ignored", "error", errbuf, NULL));
if (errbuf && errlen) errbuf[0] = '\0';
}
free(path);
}
config_apply_env(c);
return 0;
}
void config_free(LigentConfig *c)
{
if (!c) return;
free(c->base_url);
free(c->api_key);
free(c->model);
free(c->system);
free(c->reasoning_effort);
free(c->config_dir);
free(c->config_path);
memset(c, 0, sizeof *c);
}
/* ------------------------------------------------------------------ */
/* 展示 */
/* ------------------------------------------------------------------ */
static void mask_key(const char *key, char *out, size_t outlen)
{
if (!key || !*key) { snprintf(out, outlen, "%s", t("cfg.val.unset")); return; }
size_t n = strlen(key);
if (n <= 8) { snprintf(out, outlen, "%s", t("cfg.val.empty_key")); return; }
char num[32];
snprintf(num, sizeof num, "%zu", n);
snprintf(out, outlen, "%.4s...%.4s %s", key, key + n - 4,
tf("cfg.val.chars", "n", num, NULL));
}
/* 字段名按显示宽度补齐. 中文标签一个字占 2 列, 用 %-13s 会整体顶歪,
* 而英文标签没有这个问题 —— 所以宽度得算, 不能写死空格 (同 doctor 的 label). */
static void dump_field(const char *lbl, const char *fmt, ...)
{
size_t w = display_width(lbl, strlen(lbl));
fputs(lbl, stdout);
while (w < 13) { putchar(' '); w++; }
fputs(": ", stdout);
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
putchar('\n');
}
void config_dump(const LigentConfig *c)
{
char masked[128], num[32];
mask_key(c->api_key, masked, sizeof masked);
dump_field("base_url", "%s", c->base_url ? c->base_url : "");
dump_field("model", "%s", c->model ? c->model : "");
dump_field("api_key", "%s", masked);
if (c->temperature < 0) {
dump_field("temperature", "%s", t("cfg.val.server_default"));
} else {
snprintf(num, sizeof num, "%.2f", c->temperature);
dump_field("temperature", "%s", num);
}
if (c->max_tokens > 0) {
snprintf(num, sizeof num, "%ld", c->max_tokens);
dump_field("max_tokens", "%s", num);
} else {
dump_field("max_tokens", "%s", t("cfg.val.server_default"));
}
dump_field("thinking", "%s", config_thinking_name(c->thinking));
dump_field(t("cfg.dump.label.effort"), "%s",
(c->reasoning_effort && *c->reasoning_effort)
? c->reasoning_effort : t("cfg.val.server_default"));
snprintf(num, sizeof num, "%ld", c->timeout);
dump_field("timeout", "%s", tf("cfg.val.seconds", "n", num, NULL));
dump_field("stream", "%s", t(c->stream ? "cfg.val.yes" : "cfg.val.no"));
if (c->max_steps > 0) {
snprintf(num, sizeof num, "%d", c->max_steps);
dump_field("max_steps", "%s", num);
} else {
dump_field("max_steps", "%s", t("cfg.val.unlimited"));
}
dump_field("tools", "%s",
t(c->use_tools ? "cfg.val.enabled" : "cfg.val.disabled"));
snprintf(num, sizeof num, "%d", c->retries);
dump_field("retries", "%s", tf("cfg.val.times", "n", num, NULL));
dump_field("confirm", "%s",
t(c->confirm ? "cfg.val.confirm_on" : "cfg.val.disabled"));
dump_field("auto_save", "%s",
t(c->auto_save ? "cfg.val.auto_save_on" : "cfg.val.disabled"));
dump_field("probe", "%s",
t(c->probe ? "cfg.val.probe_on" : "cfg.val.disabled"));
snprintf(num, sizeof num, "%zu", http_log_size());
dump_field(t("cfg.dump.label.log"), "%s", tf("cfg.val.log", "n", num, NULL));
dump_field("system", "%s",
c->system ? c->system : t("cfg.val.builtin_system"));
dump_field("language", "%s", i18n_lang_name());
if (c->config_path) {
dump_field("config_file", "%s", c->config_path);
} else if (c->config_dir) {
char *p = xasprintf("%s/ligent.json", c->config_dir);
dump_field("config_file", "%s",
tf("cfg.dump.config_file_missing", "path", p, NULL));
free(p);
} else {
dump_field("config_file", "%s", t("cfg.dump.config_file_none"));
}
}
/* 密钥来源的可读标签 (诊断用). 诊断代码不该去 strstr() 中文文案
* 判断来源 —— 界面语言一变那个判断就悄悄失效了. */
const char *config_api_key_source(const LigentConfig *c)
{
if (c->api_key_env) return tf("cfg.src.env", "name", c->api_key_env, NULL);
if (c->api_key_from_cli) return t("cfg.src.cli");
if (c->api_key_from_file) return t("cfg.src.file");
return t("cfg.val.unknown");
}