/* capability.c - 模型能力探测: 基线 -> 合并 -> 单独拆开 */ #include "capability.h" #include "http.h" #include "i18n.h" #include "json.h" #include "util.h" #include #include #include /* 一张 1x1 的 PNG. 探测只需要"一个合法的图片字节流", 越小越好: * 服务端会把它缩放后按图计费, 尺寸再大也换不来更多信息. */ static const char PROBE_PNG_B64[] = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1" "PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLv" "AAAAAElFTkSuQmCC"; /* 缓存: 按 base_url + model 索引. 同一个进程里换模型会自然重测, * 切回来则命中. 条数很少 (人手切的模型个数). */ #define CAP_CACHE_MAX 8 typedef struct { char *base_url; char *model; Capabilities caps; } CapEntry; static CapEntry g_cache[CAP_CACHE_MAX]; static size_t g_cache_n; void cap_cache_clear(void) { for (size_t i = 0; i < g_cache_n; i++) { free(g_cache[i].base_url); free(g_cache[i].model); g_cache[i].base_url = g_cache[i].model = NULL; } g_cache_n = 0; } const char *cap_name(int v) { switch (v) { case CAP_YES: return "支持"; case CAP_NO: return "不支持"; default: return "未测"; } } /* 给人看的标签 (--verbose 的探测记录). cap_name() 是内部/测试用的中文常量, * 不随界面语言变, 所以这里另走一遍界面文案表. */ static const char *cap_label(int v, int is_tools) { if (v == CAP_YES) return t("agent.caps.yes"); if (v == CAP_NO) return t(is_tools ? "agent.caps.no.tools" : "agent.caps.no.vision"); return t("agent.caps.unknown"); } /* https://host/v1 + /chat/completions (base_url 末尾可能带斜杠) */ static char *chat_url(const char *base_url) { const char *bu = base_url ? base_url : ""; size_t bl = strlen(bu); while (bl > 0 && bu[bl - 1] == '/') bl--; return xasprintf("%.*s/chat/completions", (int)bl, bu); } /* 一个试探请求的结论 */ /* 注意别叫 R_OK —— 里那个是 access() 的权限位, 会撞名 */ typedef enum { PROBE_OK = 0, /* 2xx: 接受 */ PROBE_REJECT = 1, /* 400: 明确拒绝 (只有在基线通过时才有意义) */ PROBE_UNKNOWN = 2 /* 网络、401/403/404/429/5xx……: 什么也说明不了 */ } ProbeResult; static ProbeResult probe(const LlmConfig *cfg, const char *url, const JsonValue *messages, int with_tools, int verbose, long *out_status, char *errbuf, size_t errlen) { JsonValue *body = json_new_object(); json_object_set_string(body, "model", cfg->model); json_object_set(body, "messages", json_clone(messages)); /* 只让它回一个 token: 探测要的是"收不收", 不是回复内容. * 不设这个的话, 默认开着思考的模型会为了回答"1"生成一大段推理. */ json_object_set_number(body, "max_tokens", 1); json_object_set_bool(body, "stream", 0); if (with_tools) { JsonValue *tools = json_new_array(); JsonValue *t = json_new_object(); json_object_set_string(t, "type", "function"); JsonValue *fn = json_new_object(); json_object_set_string(fn, "name", "ligent_probe"); json_object_set_string(fn, "description", "能力探测用, 永远不该被调用"); JsonValue *params = json_new_object(); json_object_set_string(params, "type", "object"); json_object_set(fn, "parameters", params); json_object_set(t, "function", fn); json_array_push(tools, t); json_object_set(body, "tools", tools); } char *text = json_dump(body); json_free(body); char *resp_body = NULL; long status = 0; char local_err[256]; local_err[0] = '\0'; /* 探测不重试: 它只是一次判断, 服务端抖一下还有兜底 (一律按支持处理), * 不值得为此让每个进程启动都多等几个退避周期. */ HttpRetry no_retry = HTTP_RETRY_NONE; int rc = http_post(url, cfg->api_key, text, cfg->timeout, verbose, &no_retry, &resp_body, &status, NULL, local_err, sizeof local_err); free(text); free(resp_body); if (out_status) *out_status = status; if (rc == 0) return PROBE_OK; if (status == 400) return PROBE_REJECT; if (errbuf && errlen && !errbuf[0] && local_err[0]) snprintf(errbuf, errlen, "%s", local_err); return PROBE_UNKNOWN; } /* 探测用的消息: 一句 "1", 可选带一张图 */ static JsonValue *probe_messages(int with_image) { JsonValue *msgs = json_new_array(); JsonValue *m = json_new_object(); json_object_set_string(m, "role", "user"); if (!with_image) { json_object_set_string(m, "content", "1"); json_array_push(msgs, m); return msgs; } JsonValue *content = json_new_array(); JsonValue *tp = json_new_object(); json_object_set_string(tp, "type", "text"); json_object_set_string(tp, "text", "1"); json_array_push(content, tp); JsonValue *ip = json_new_object(); json_object_set_string(ip, "type", "image_url"); JsonValue *iu = json_new_object(); char *data_url = xasprintf("data:image/png;base64,%s", PROBE_PNG_B64); json_object_set_string(iu, "url", data_url); free(data_url); json_object_set(ip, "image_url", iu); json_array_push(content, ip); json_object_set(m, "content", content); json_array_push(msgs, m); return msgs; } static void run_sequence(const LlmConfig *cfg, Capabilities *out, int verbose, char *errbuf, size_t errlen) { out->vision = out->tools = CAP_UNKNOWN; out->probed = 1; out->conclusive = 0; char *url = chat_url(cfg->base_url); long status = 0; /* --- 1. 基线 --- */ JsonValue *plain = probe_messages(0); ProbeResult base = probe(cfg, url, plain, 0, verbose, &status, errbuf, errlen); json_free(plain); if (base != PROBE_OK) { /* 连基线都不通, 后面的否定结论一概不作数 */ if (verbose) fprintf(stderr, "%s", tf("capability.note.baseline_failed", "status", i18n_num(status), NULL)); free(url); return; } out->conclusive = 1; /* --- 2. 合并试探: 图片 + tools --- */ JsonValue *both = probe_messages(1); ProbeResult combined = probe(cfg, url, both, 1, verbose, &status, errbuf, errlen); json_free(both); if (combined == PROBE_OK) { /* 常见情况: 两样都支持, 两个请求收工 */ out->vision = out->tools = CAP_YES; if (verbose) fprintf(stderr, "%s", t("capability.note.both_ok")); free(url); return; } /* --- 3. 拆开单独试: 才知道是哪一个不行 --- */ JsonValue *img = probe_messages(1); ProbeResult vr = probe(cfg, url, img, 0, verbose, &status, errbuf, errlen); json_free(img); out->vision = (vr == PROBE_OK) ? CAP_YES : (vr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); JsonValue *txt = probe_messages(0); ProbeResult tr = probe(cfg, url, txt, 1, verbose, &status, errbuf, errlen); json_free(txt); out->tools = (tr == PROBE_OK) ? CAP_YES : (tr == PROBE_REJECT ? CAP_NO : CAP_UNKNOWN); if (verbose) /* 用界面语言的标签, 不用 cap_name() —— 后者是给日志和断言用的中文常量 */ fprintf(stderr, "%s", tf("capability.note.split", "vision", cap_label(out->vision, 0), "tools", cap_label(out->tools, 1), NULL)); free(url); } int cap_cached(const LlmConfig *cfg, Capabilities *out) { const char *model = (cfg && cfg->model) ? cfg->model : ""; const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; for (size_t i = 0; i < g_cache_n; i++) { if (strcmp(g_cache[i].model, model) == 0 && strcmp(g_cache[i].base_url, base) == 0) { if (out) *out = g_cache[i].caps; return 1; } } return 0; } int cap_get(const LlmConfig *cfg, int enable, Capabilities *out, int verbose, char *errbuf, size_t errlen) { if (!out) return 0; if (errbuf && errlen) errbuf[0] = '\0'; if (!cfg) { /* 没有配置就什么都测不了, 按"未测"处理 */ out->vision = out->tools = CAP_UNKNOWN; out->probed = 0; out->conclusive = 0; return 0; } const char *model = (cfg && cfg->model) ? cfg->model : ""; const char *base = (cfg && cfg->base_url) ? cfg->base_url : ""; if (!enable) { /* 探测被关掉: 不下任何结论, 一切都按"支持" */ out->vision = out->tools = CAP_UNKNOWN; out->probed = 0; out->conclusive = 0; return 0; } for (size_t i = 0; i < g_cache_n; i++) { if (strcmp(g_cache[i].model, model) == 0 && strcmp(g_cache[i].base_url, base) == 0) { *out = g_cache[i].caps; return 0; } } run_sequence(cfg, out, verbose, errbuf, errlen); /* 记进缓存; 满了就丢掉最老的一条 */ if (g_cache_n == CAP_CACHE_MAX) { free(g_cache[0].base_url); free(g_cache[0].model); memmove(&g_cache[0], &g_cache[1], sizeof g_cache[0] * (CAP_CACHE_MAX - 1)); g_cache_n--; } g_cache[g_cache_n].base_url = xstrdup(base); g_cache[g_cache_n].model = xstrdup(model); g_cache[g_cache_n].caps = *out; g_cache_n++; return 1; }