/* llm.c - OpenAI 兼容接口: 请求构造、SSE 流式解析、tool_calls 增量拼接 */ #include "llm.h" #include "config.h" /* THINKING_AUTO / _ON / _OFF */ #include "http.h" #include "i18n.h" #include "util.h" #include #include #include /* ------------------------------------------------------------------ */ /* URL / 请求体 */ /* ------------------------------------------------------------------ */ char *llm_endpoint_url(const char *base_url) { const char *base = (base_url && *base_url) ? base_url : "https://api.openai.com/v1"; size_t n = strlen(base); while (n > 0 && base[n - 1] == '/') n--; /* 允许用户直接给完整的 completions 地址 */ if (str_ends_with(base_url, "/chat/completions")) return xstrndup(base, n); return xasprintf("%.*s/chat/completions", (int)n, base); } JsonValue *llm_build_body(const LlmConfig *cfg, const JsonValue *messages, const JsonValue *tools, int stream) { JsonValue *body = json_new_object(); json_object_set_string(body, "model", cfg->model); json_object_set(body, "messages", json_clone(messages)); if (cfg->temperature >= 0) json_object_set_number(body, "temperature", cfg->temperature); if (cfg->max_tokens > 0) json_object_set_number(body, "max_tokens", (double)cfg->max_tokens); /* 思考模式开关. AUTO 时整个字段都不发, 让服务端用自己的默认值 * (DeepSeek 默认是开启). 注意这些是请求体的顶层字段, 不是 * OpenAI SDK 里的 extra_body 包装. */ if (cfg->thinking != THINKING_AUTO) { JsonValue *t = json_new_object(); json_object_set_string( t, "type", cfg->thinking == THINKING_ON ? "enabled" : "disabled"); json_object_set(body, "thinking", t); } if (cfg->reasoning_effort && *cfg->reasoning_effort) json_object_set_string(body, "reasoning_effort", cfg->reasoning_effort); if (tools && json_array_size(tools) > 0) { json_object_set(body, "tools", json_clone(tools)); json_object_set_string(body, "tool_choice", "auto"); } json_object_set_bool(body, "stream", stream ? 1 : 0); return body; } /* 单行 SSE 上限. 正常的 data: 行只有几十字节; * 出现超长"行"说明对端根本不是 SSE (例如忽略 stream 直接返回整包 JSON, * 而 JSON 里没有换行), 必须丢弃, 否则内存会被撑爆. */ #define SSE_MAX_LINE (256u * 1024) /* 单条回复累积内容上限 */ #define SSE_MAX_CONTENT (32u * 1024 * 1024) /* 单个工具调用参数上限. 正常人写不出 8MB 的参数, * 超过基本可以断定是对端异常或恶意, 必须截住 */ #define SSE_MAX_TOOL_ARGS (8u * 1024 * 1024) /* ------------------------------------------------------------------ */ /* 错误响应的可读化 */ /* ------------------------------------------------------------------ */ /* 很多服务端把 message 写成 \uXXXX 转义, 直接打印很难看. * 这里尝试解析出 error.message, 失败则回退到原始文本. */ static char *extract_error_message(const char *body) { if (!body || !*body) return NULL; JsonValue *root = json_parse(body, NULL, 0); if (!root) return NULL; char *msg = NULL; JsonValue *err = json_object_get(root, "error"); if (err && err->type == JSON_OBJECT) { const char *m = json_get_string(err, "message"); if (m && *m) msg = xstrdup(m); } else if (err && err->type == JSON_STRING) { msg = xstrdup(err->u.string); } if (!msg) { const char *m = json_get_string(root, "message"); if (m && *m) msg = xstrdup(m); } json_free(root); return msg; } /* 401/403 时补一句提示. 服务端的原文经常把人引向错误的方向: * OpenRouter 对"格式不对的 key"回的是 "Missing Authentication header", * 听起来像是请求里没带认证头, 实际上带了, 只是 key 不属于这家. */ static void append_auth_hint(char *errbuf, size_t errlen, const LlmConfig *cfg, long status) { if (status != 401 && status != 403) return; const char *key = cfg->api_key ? cfg->api_key : ""; size_t n = strlen(key); char masked[80]; if (n == 0) { snprintf(masked, sizeof masked, "%s", t("llm.mask.empty")); } else { /* 只露前几个字符: 够认出是哪一把, 又不至于把密钥打进日志 */ char head[8]; snprintf(head, sizeof head, "%.*s", n <= 10 ? 4 : 7, key); snprintf(masked, sizeof masked, "%s", tf("llm.mask.partial", "head", head, "n", i18n_num((long long)n), NULL)); } size_t cur = strlen(errbuf); if (cur + 4 >= errlen) return; snprintf(errbuf + cur, errlen - cur, "%s", tf("llm.err.auth_hint", "masked", masked, "url", cfg->base_url && *cfg->base_url ? cfg->base_url : t("llm.val.no_base_url"), NULL)); } static void set_error_with_detail(char *errbuf, size_t errlen, const char *prefix, const char *body) { snprintf(errbuf, errlen, "%s", prefix); if (!body || !*body) return; size_t cur = strlen(errbuf); if (cur + 4 >= errlen) return; char *msg = extract_error_message(body); if (msg) { snprintf(errbuf + cur, errlen - cur, " — %s", msg); free(msg); } else { snprintf(errbuf + cur, errlen - cur, " — %.400s", body); } } /* ------------------------------------------------------------------ */ /* 流式累积器 */ /* ------------------------------------------------------------------ */ typedef struct { char *id; char *name; StrBuf args; int started; } ToolAcc; typedef struct { StrBuf raw; /* 原始字节, 用于 "服务端不支持流式" 时的回退 */ StrBuf content; StrBuf reasoning; /* reasoning_content 累积 (思考过程, 不显示) */ ToolAcc *accs; size_t acc_count; size_t acc_cap; char *finish_reason; LlmDeltaFn on_delta; void *ud; LlmThinkFn on_think; void *think_ud; int got_event; int overflow; /* 内容超过上限 */ int raw_truncated; char *error; /* 服务端返回的 error.message */ JsonValue *usage; StrBuf linebuf; } SseCtx; static ToolAcc *acc_at(SseCtx *c, size_t index) { if (index >= c->acc_cap) { size_t cap = c->acc_cap ? c->acc_cap : 4; while (cap <= index) cap *= 2; c->accs = xrealloc(c->accs, cap * sizeof(ToolAcc)); memset(c->accs + c->acc_cap, 0, (cap - c->acc_cap) * sizeof(ToolAcc)); c->acc_cap = cap; } ToolAcc *a = &c->accs[index]; if (!a->started) { a->started = 1; sb_init(&a->args); if (index + 1 > c->acc_count) c->acc_count = index + 1; } return a; } static void sse_ctx_free(SseCtx *c) { sb_free(&c->raw); sb_free(&c->content); sb_free(&c->reasoning); sb_free(&c->linebuf); for (size_t i = 0; i < c->acc_count; i++) { if (!c->accs[i].started) continue; free(c->accs[i].id); free(c->accs[i].name); sb_free(&c->accs[i].args); } free(c->accs); free(c->finish_reason); free(c->error); json_free(c->usage); } /* 处理一个 SSE data 负载 (不含 "data:" 前缀) */ static void handle_payload(SseCtx *c, const char *payload, size_t len) { if (len == 0) return; if (len == 6 && memcmp(payload, "[DONE]", 6) == 0) return; char *js = xstrndup(payload, len); char err[256]; JsonValue *root = json_parse(js, err, sizeof err); free(js); if (!root) return; c->got_event = 1; JsonValue *errv = json_object_get(root, "error"); if (errv && errv->type == JSON_OBJECT) { const char *m = json_get_string(errv, "message"); if (m && !c->error) c->error = xstrdup(m); json_free(root); return; } JsonValue *usage = json_object_get(root, "usage"); if (usage && usage->type == JSON_OBJECT && !c->usage) c->usage = json_clone(usage); JsonValue *choices = json_object_get(root, "choices"); JsonValue *ch0 = json_array_get(choices, 0); if (!ch0) { json_free(root); return; } const char *fr = json_get_string(ch0, "finish_reason"); if (fr && *fr && !c->finish_reason) c->finish_reason = xstrdup(fr); /* OpenAI 流式用 delta, 少数实现直接给 message */ JsonValue *d = json_object_get(ch0, "delta"); if (!d) d = json_object_get(ch0, "message"); if (!d || d->type != JSON_OBJECT) { json_free(root); return; } const char *piece = json_get_string(d, "content"); if (piece && *piece) { if (c->content.len + strlen(piece) > SSE_MAX_CONTENT) { c->overflow = 1; } else { sb_puts(&c->content, piece); if (c->on_delta) c->on_delta(c->ud, piece); } } /* 思考过程. DeepSeek 用 reasoning_content, 有些 OpenAI 兼容实现用 * reasoning. 只累积、不回调 —— 用户明确要求不显示, 但它必须随 * assistant 消息一起回传: 请求带 tools 时服务端要求这么做, * 漏了会在下一轮直接 400. */ const char *think = json_get_string(d, "reasoning_content"); if (!think) think = json_get_string(d, "reasoning"); if (think && *think) { if (c->reasoning.len + strlen(think) > SSE_MAX_CONTENT) { c->overflow = 1; } else { sb_puts(&c->reasoning, think); /* 不往正文回调里塞 —— 思维链不该被渲染. 单独走一路, * 让上层知道"模型开始推理了". */ if (c->on_think) c->on_think(c->think_ud, think); } } JsonValue *tcs = json_object_get(d, "tool_calls"); if (tcs && tcs->type == JSON_ARRAY) { for (size_t i = 0; i < json_array_size(tcs); i++) { JsonValue *tc = json_array_get(tcs, i); if (!tc) continue; size_t idx = (size_t)json_get_number(tc, "index", (double)i); ToolAcc *a = acc_at(c, idx); const char *id = json_get_string(tc, "id"); if (id && *id && !a->id) a->id = xstrdup(id); JsonValue *fn = json_object_get(tc, "function"); if (fn && fn->type == JSON_OBJECT) { const char *name = json_get_string(fn, "name"); if (name && *name) { if (!a->name) a->name = xstrdup(name); else if (strcmp(a->name, name) != 0) { /* 有些服务端会拆着发名字, 追加处理 */ char *joined = xasprintf("%s%s", a->name, name); free(a->name); a->name = joined; } } const char *args = json_get_string(fn, "arguments"); if (args) { if (a->args.len + strlen(args) > SSE_MAX_TOOL_ARGS) c->overflow = 1; else sb_puts(&a->args, args); } } } } json_free(root); } /* 按行切分, 逐条处理 "data:" */ static void feed(SseCtx *c, const char *data, size_t len) { sb_append(&c->linebuf, data, len); char *buf = c->linebuf.data; size_t start = 0; for (size_t i = 0; i < c->linebuf.len; i++) { if (buf[i] != '\n') continue; size_t line_len = i - start; if (line_len > 0 && buf[start + line_len - 1] == '\r') line_len--; const char *line = buf + start; if (line_len > 5 && memcmp(line, "data:", 5) == 0) { const char *p = line + 5; size_t n = line_len - 5; while (n > 0 && (*p == ' ' || *p == '\t')) { p++; n--; } handle_payload(c, p, n); } start = i + 1; } if (start > 0) { memmove(buf, buf + start, c->linebuf.len - start); c->linebuf.len -= start; buf[c->linebuf.len] = '\0'; } /* 残行过长: 丢弃, 保证内存有界 */ if (c->linebuf.len > SSE_MAX_LINE) sb_clear(&c->linebuf); } static size_t sse_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { SseCtx *c = ud; size_t n = size * nmemb; if (c->overflow) return 0; /* 超限, 中止传输 */ if (c->raw.len < 65536) { /* 仅保留开头用于回退判断 */ size_t take = n < 65536 - c->raw.len ? n : 65536 - c->raw.len; sb_append(&c->raw, ptr, take); if (take < n) c->raw_truncated = 1; } else { c->raw_truncated = 1; } feed(c, ptr, n); if (c->overflow) return 0; return n; } /* ------------------------------------------------------------------ */ /* 从整包 JSON 响应里抽出 assistant 消息 */ /* ------------------------------------------------------------------ */ static LlmResponse *from_full_response(JsonValue *root, size_t raw_len) { (void)raw_len; JsonValue *errv = json_object_get(root, "error"); if (errv && errv->type == JSON_OBJECT) return NULL; JsonValue *ch0 = json_array_get(json_object_get(root, "choices"), 0); if (!ch0) return NULL; JsonValue *msg = json_object_get(ch0, "message"); if (!msg || msg->type != JSON_OBJECT) return NULL; LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); r->message = json_clone(msg); JsonValue *u = json_object_get(root, "usage"); if (u && u->type == JSON_OBJECT) r->usage = json_clone(u); const char *fr = json_get_string(ch0, "finish_reason"); if (fr) r->finish_reason = xstrdup(fr); return r; } /* 把累积结构整理成 LlmResponse */ static LlmResponse *from_sse(SseCtx *c) { LlmResponse *r = xcalloc(1, sizeof(LlmResponse)); r->message = json_new_object(); json_object_set_string(r->message, "role", "assistant"); int has_tools = 0; for (size_t i = 0; i < c->acc_count; i++) if (c->accs[i].started) { has_tools = 1; break; } if (c->content.len > 0 || !has_tools) json_object_set_string(r->message, "content", c->content.data ? c->content.data : ""); else json_object_set_null(r->message, "content"); /* 思考过程跟着 assistant 消息一起进历史. 用户看不到它, 但它必须原样回传: * 只要请求里带 tools, 服务端就要求把历史里的 reasoning_content 全带上, * 否则下一轮直接 400. */ if (c->reasoning.len > 0) json_object_set_string(r->message, "reasoning_content", c->reasoning.data ? c->reasoning.data : ""); if (has_tools) { JsonValue *arr = json_new_array(); for (size_t i = 0; i < c->acc_count; i++) { ToolAcc *a = &c->accs[i]; if (!a->started) continue; JsonValue *tc = json_new_object(); if (a->id) { json_object_set_string(tc, "id", a->id); } else { char *gen = xasprintf("call_%zu", i); json_object_set_string(tc, "id", gen); free(gen); } json_object_set_string(tc, "type", "function"); JsonValue *fn = json_new_object(); json_object_set_string(fn, "name", a->name ? a->name : ""); json_object_set_string(fn, "arguments", a->args.data ? a->args.data : "{}"); json_object_set(tc, "function", fn); json_array_push(arr, tc); } json_object_set(r->message, "tool_calls", arr); } if (c->finish_reason) r->finish_reason = xstrdup(c->finish_reason); if (c->usage) r->usage = json_clone(c->usage); return r; } /* ------------------------------------------------------------------ */ /* 主入口 */ /* ------------------------------------------------------------------ */ LlmResponse *llm_chat(const LlmConfig *cfg, const JsonValue *messages, const JsonValue *tools, LlmDeltaFn on_delta, void *ud, LlmThinkFn on_think, void *think_ud, char *errbuf, size_t errlen) { static char errsink[1]; if (!errbuf || !errlen) { errbuf = errsink; errlen = 1; } errbuf[0] = '\0'; if (!cfg->api_key || !*cfg->api_key) { snprintf(errbuf, errlen, "%s", t("llm.err.no_api_key")); return NULL; } char *url = llm_endpoint_url(cfg->base_url); JsonValue *body = llm_build_body(cfg, messages, tools, cfg->stream); char *payload = json_dump(body); json_free(body); if (cfg->verbose) fprintf(stderr, "[ligent] POST %s\n[ligent] body: %.2000s\n", url, payload); LlmResponse *result = NULL; char http_err[512]; long status = 0; if (cfg->stream) { SseCtx ctx; memset(&ctx, 0, sizeof ctx); sb_init(&ctx.raw); sb_init(&ctx.content); sb_init(&ctx.reasoning); sb_init(&ctx.linebuf); ctx.on_delta = on_delta; ctx.ud = ud; ctx.on_think = on_think; ctx.think_ud = think_ud; char *errbody = NULL; size_t delivered = 0; HttpRetry rt = HTTP_RETRY_DEFAULT; rt.max_retries = cfg->retries; int rc = http_post_stream(url, cfg->api_key, payload, cfg->timeout, cfg->verbose, &rt, sse_write_cb, &ctx, &errbody, &status, NULL, &delivered, http_err, sizeof http_err); if (g_ligent_interrupt) { result = xcalloc(1, sizeof(LlmResponse)); result->interrupted = 1; result->message = json_new_object(); json_object_set_string(result->message, "role", "assistant"); json_object_set_string(result->message, "content", ctx.content.data ? ctx.content.data : ""); if (ctx.reasoning.len > 0) json_object_set_string(result->message, "reasoning_content", ctx.reasoning.data ? ctx.reasoning.data : ""); } else if (ctx.overflow) { snprintf(errbuf, errlen, "%s", tf("llm.err.overflow", "content", i18n_num((long long)SSE_MAX_CONTENT), "args", i18n_num((long long)SSE_MAX_TOOL_ARGS), NULL)); } else if (rc != 0 && (ctx.content.len > 0 || ctx.got_event)) { /* 传输断了, 但已经收到并显示了部分内容. * 这些内容用户已经看到, 丢掉会让上下文和屏幕对不上; * 所以保留成一条"不完整"的消息, 交给上层提示. */ result = from_sse(&ctx); result->incomplete = 1; result->incomplete_reason = xstrdup(http_err); } else if (rc != 0) { if (errbody && *errbody) { set_error_with_detail(errbuf, errlen, http_err, errbody); } else if (ctx.error) { size_t cur = (size_t)snprintf(errbuf, errlen, "%s", http_err); if (cur + 4 < errlen) snprintf(errbuf + cur, errlen - cur, " — %s", ctx.error); } else { snprintf(errbuf, errlen, "%s", http_err); } append_auth_hint(errbuf, errlen, cfg, status); } else if (ctx.error) { snprintf(errbuf, errlen, "%s", tf("llm.err.server", "error", ctx.error, NULL)); } else if (ctx.got_event) { result = from_sse(&ctx); } else { /* 服务端忽略了 stream 参数, 直接返回整包 JSON */ char *raw = xstrndup(ctx.raw.data ? ctx.raw.data : "", ctx.raw.len); JsonValue *root = json_parse_ui(raw, http_err, sizeof http_err); if (root) { result = from_full_response(root, 0); json_free(root); } free(raw); if (!result && !errbuf[0]) { if (status >= 400) snprintf(errbuf, errlen, "HTTP %ld", status); else snprintf(errbuf, errlen, "%s", t("llm.err.unparsable_response")); } } free(errbody); sse_ctx_free(&ctx); } else { char *resp = NULL; HttpRetry rt = HTTP_RETRY_DEFAULT; rt.max_retries = cfg->retries; int rc = http_post(url, cfg->api_key, payload, cfg->timeout, cfg->verbose, &rt, &resp, &status, NULL, http_err, sizeof http_err); if (rc != 0) { if (resp && *resp) set_error_with_detail(errbuf, errlen, http_err, resp); else snprintf(errbuf, errlen, "%s", http_err); append_auth_hint(errbuf, errlen, cfg, status); } else { JsonValue *root = json_parse_ui(resp ? resp : "", http_err, sizeof http_err); if (root) { JsonValue *errv = json_object_get(root, "error"); if (errv && errv->type == JSON_OBJECT) { const char *m = json_get_string(errv, "message"); snprintf(errbuf, errlen, "%s", tf("llm.err.server", "error", m ? m : t("llm.val.unknown"), NULL)); } else { result = from_full_response(root, 0); if (!result) snprintf(errbuf, errlen, "%s", t("llm.err.no_choices")); } json_free(root); } else { char head[201]; snprintf(head, sizeof head, "%.200s", http_err); snprintf(errbuf, errlen, "%s", tf("llm.err.bad_json", "error", head, NULL)); } } free(resp); } free(payload); free(url); return result; } void llm_response_free(LlmResponse *r) { if (!r) return; json_free(r->message); json_free(r->usage); free(r->finish_reason); free(r->incomplete_reason); free(r); }