feat: add Kimi Code and web APIs

This commit is contained in:
WHC
2026-10-19 10:41:00 +08:00
parent 60047ca5ce
commit 235652604d
21 changed files with 1189 additions and 49 deletions
+47 -3
View File
@@ -22,6 +22,25 @@
#define ERRBODY_MAX 4096
#define LLM_MAX_RETRIES 3
static int api_host_has(const char *base, const char *needle) {
return base && needle && strstr(base, needle) != NULL;
}
static int is_kimi_api(const char *base) {
return api_host_has(base, "api.moonshot.ai") ||
api_host_has(base, "api.moonshot.cn") ||
api_host_has(base, "api.kimi.ai") ||
api_host_has(base, "api.kimi.com");
}
static int is_kimi_k2(const char *model) {
return model && strncmp(model, "kimi-k2", 7) == 0;
}
static int is_kimi_k27(const char *model) {
return model && strncmp(model, "kimi-k2.7-code", 14) == 0;
}
static int ca_file_ok(const char *path) {
if (!path || !path[0])
return 0;
@@ -452,8 +471,31 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
return NULL;
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "model", req->model);
if (req->reasoning_effort && req->reasoning_effort[0])
const int kimi = is_kimi_api(req->api_base);
const int kimi_k2 = kimi && is_kimi_k2(req->model);
if (req->reasoning_effort && req->reasoning_effort[0] && !kimi_k2)
cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort);
if (kimi_k2) {
/* K2.6/K2.7 do not understand K3's top-level reasoning_effort. They
* expose thinking as an object and require preserved reasoning to be
* sent back on the next turn. K2.7-code is always enabled/kept. */
cJSON *thinking = cJSON_AddObjectToObject(root, "thinking");
cJSON_AddStringToObject(thinking, "type", "enabled");
if (req->send_reasoning || is_kimi_k27(req->model))
cJSON_AddStringToObject(thinking, "keep", "all");
}
if (kimi && (!getenv("CST_NO_CACHE_HINTS") ||
!getenv("CST_NO_CACHE_HINTS")[0] ||
getenv("CST_NO_CACHE_HINTS")[0] == '0')) {
/* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause
* longer than that, so opt into the 1h tier; users can select 5m/1h. */
const char *ttl = getenv("CST_KIMI_CACHE_TTL");
if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0))
ttl = "1h";
cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options");
cJSON_AddStringToObject(cache, "mode", "implicit");
cJSON_AddStringToObject(cache, "ttl", ttl);
}
cJSON_AddTrueToObject(root, "stream");
const char *nousage = getenv("CST_NO_STREAM_USAGE");
if (!nousage || !nousage[0] || nousage[0] == '0') {
@@ -586,8 +628,10 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
if (req->send_reasoning && m->reasoning && m->reasoning[0])
cJSON_AddItemToObject(o, "reasoning_content",
clean_string(m->reasoning, 0));
if (m->prefix)
cJSON_AddTrueToObject(o, "prefix");
if (m->prefix) {
/* Kimi calls the OpenAI-style assistant prefill flag `partial`. */
cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix");
}
}
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && n_att)
+5
View File
@@ -876,6 +876,11 @@ const char *cst_effort_wire(const char *provider, const char *model,
pthread_mutex_unlock(&g_mu);
if (strcmp(mode, "passthrough") == 0)
return effort;
if (strcmp(mode, "kimi") == 0) {
/* Kimi K3 accepts low/high/max; the older K2 thinking models use the
* separate `thinking` object and only need a valid effort selection. */
return strcmp(effort, "medium") == 0 ? "low" : effort;
}
if (strcmp(mode, "openai") == 0) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
+27
View File
@@ -15,6 +15,23 @@
"prefix_completion": true,
"effort": "passthrough"
},
"kimi": {
"api_base": "https://api.moonshot.ai/v1",
"key_env": "MOONSHOT_API_KEY",
"default_model": "kimi-k3",
"models": ["kimi-k3", "kimi-k2.6", "kimi-k2.7-code"],
"reasoning_passback": true,
"effort": "kimi"
},
"kimi-code": {
"api_base": "https://api.kimi.ai/coding/v1",
"key_env": "KIMI_API_KEY",
"default_model": "k3",
"models": ["k3", "k3-256k", "kimi-for-coding",
"kimi-for-coding-highspeed"],
"reasoning_passback": true,
"effort": "kimi"
},
"openai": {
"api_base": "https://api.openai.com/v1",
"key_env": "OPENAI_API_KEY",
@@ -65,6 +82,16 @@
{"match": "glm", "context": 128000},
{"match": "glm-*v*", "vision": true},
{"match": "kimi", "context": 131072},
{"match": "kimi-k3", "context": 1048576, "vision": true,
"reasoning": true},
{"match": "kimi-k2.6", "context": 262144, "vision": true,
"reasoning": true},
{"match": "kimi-k2.7-code", "context": 262144, "reasoning": true},
{"match": "k3", "context": 1048576, "vision": true, "reasoning": true},
{"match": "k3-256k", "context": 262144, "reasoning": true},
{"match": "kimi-for-coding", "context": 1048576, "reasoning": true},
{"match": "kimi-for-coding-highspeed", "context": 262144,
"reasoning": true},
{"match": "pixtral", "vision": true},
{"match": "llava", "vision": true},
{"match": "*-vl*", "vision": true},
+8
View File
@@ -159,6 +159,14 @@ static const cst_tool_def_t TOOLS[] = {
NULL,
{{"todos", S, 1, "the whole list, one item per line"}},
CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META},
{"web_search",
"Search the live web through the current Kimi or DeepSeek provider and "
"return titles, URLs and snippets. Use web_fetch to read a result.",
NULL,
{{"query", S, 1, "search query"},
{"count", I, 0, "number of results (default 5, max 20)"},
{"include_content", B, 0, "include provider-returned page content"}},
CST_TP_NET, 0, cst_tool_web_search, NULL, NO_DYN, NO_META},
{"web_fetch",
"Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML "
"is reduced to readable text.",
+2
View File
@@ -55,6 +55,8 @@ int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out);
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
+209
View File
@@ -6,6 +6,7 @@
#include <curl/curl.h>
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_tools.h"
@@ -23,6 +24,211 @@ typedef struct {
int overflow;
} fetch_t;
static size_t on_body(char *p, size_t sz, size_t n, void *ud);
static const cst_agent_config_t *web_cfg(const cst_tool_ctx_t *ctx) {
return ctx && ctx->agent ? cst_agent_config(ctx->agent) : NULL;
}
static int provider_api(const cst_tool_ctx_t *ctx, const char **kind,
const char **base, const char **key) {
const cst_agent_config_t *cfg = web_cfg(ctx);
if (!cfg || !cfg->api_key[0])
return 0;
if (strcmp(cfg->provider, "deepseek") == 0 ||
(cfg->api_base[0] && strstr(cfg->api_base, "api.deepseek.com"))) {
*kind = "deepseek";
} else if (strcmp(cfg->provider, "kimi") == 0 ||
(cfg->api_base[0] && strstr(cfg->api_base, "moonshot"))) {
*kind = "kimi";
} else {
return 0;
}
*base = cfg->api_base;
*key = cfg->api_key;
return 1;
}
static int api_post_json(const char *url, const char *key, const char *body,
cst_buf_t *out, long *http) {
CURL *curl = curl_easy_init();
if (!curl)
return -1;
char auth[700];
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", key);
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "Accept: application/json");
hdrs = curl_slist_append(hdrs, auth);
fetch_t f = {0};
cst_curl_apply_ca(curl);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 90L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &f);
CURLcode rc = curl_easy_perform(curl);
*http = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http);
if (f.body.buf)
cst_buf_append(out, f.body.buf, f.body.len);
cst_buf_free(&f.body);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
return rc == CURLE_OK ? 0 : -1;
}
static void api_path(char *out, size_t n, const char *base, const char *path) {
size_t len = strlen(base);
while (len && base[len - 1] == '/')
len--;
snprintf(out, n, "%.*s/%s", (int)len, base, path);
}
static int api_error(cst_tool_result_t *r, long http, const cst_buf_t *raw) {
cJSON *j = cJSON_Parse(raw->buf ? raw->buf : "");
const cJSON *e = j ? cJSON_GetObjectItemCaseSensitive(j, "error") : NULL;
const cJSON *m = e ? cJSON_GetObjectItemCaseSensitive(e, "message") : NULL;
int rc;
if (cJSON_IsString(m))
rc = tool_set_resultf(r, 1, "error: HTTP %ld: %s", http, m->valuestring);
else
rc = tool_set_resultf(r, 1, "error: HTTP %ld: %.400s", http,
raw->buf ? raw->buf : "no response");
cJSON_Delete(j);
return rc;
}
static int online_fetch(const cst_tool_ctx_t *ctx, const char *url,
long max_out, cst_tool_result_t *r) {
const char *kind, *base, *key;
if (!provider_api(ctx, &kind, &base, &key))
return 0;
if (max_out <= 0)
max_out = FETCH_DEFAULT_OUT;
if (max_out > FETCH_MAX_OUT)
max_out = FETCH_MAX_OUT;
char endpoint[1024];
api_path(endpoint, sizeof(endpoint), base, "tools/fetch");
cJSON *body = cJSON_CreateObject();
cJSON_AddStringToObject(body, "url", url);
char *json = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
cst_buf_t raw = {0};
long http = 0;
int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1;
free(json);
if (rc != 0) {
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch request failed", 1);
}
if (http >= 400) {
int e = api_error(r, http, &raw);
cst_buf_free(&raw);
return e;
}
cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : "");
if (!doc) {
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch returned invalid JSON", 1);
}
const cJSON *text = cJSON_GetObjectItemCaseSensitive(
doc, strcmp(kind, "kimi") == 0 ? "markdown" : "content");
const cJSON *title = cJSON_GetObjectItemCaseSensitive(doc, "title");
if (!cJSON_IsString(text)) {
cJSON_Delete(doc);
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch returned no readable content", 1);
}
cst_buf_t out = {0};
cst_buf_printf(&out, "%s\n\n%s", cJSON_IsString(title) ? title->valuestring : url,
cJSON_IsString(text) ? text->valuestring : "");
size_t keep = out.len > (size_t)max_out ? cst_utf8_floor(out.buf, (size_t)max_out)
: out.len;
cst_buf_t clipped = {0};
cst_buf_append(&clipped, out.buf ? out.buf : "", keep);
if (keep < out.len)
cst_buf_puts(&clipped, "\n...(truncated)\n");
int result = tool_take_buf(r, &clipped, 0);
cst_buf_free(&out);
cJSON_Delete(doc);
cst_buf_free(&raw);
return result;
}
int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
const char *query = tool_arg(c, "query");
const char *kind, *base, *key;
if (!query || !query[0])
return tool_set_result(r, "error: missing query", 1);
if (!provider_api(ctx, &kind, &base, &key))
return tool_set_result(
r, "error: web_search needs the kimi or deepseek provider", 1);
long count = tool_arg_long(c, "count", 5);
if (count < 1) count = 1;
if (count > 20) count = 20;
int content = tool_arg_bool(c, "include_content", 0);
cJSON *body = cJSON_CreateObject();
cJSON_AddStringToObject(body, strcmp(kind, "kimi") == 0 ? "text_query" : "query", query);
cJSON_AddNumberToObject(body, strcmp(kind, "kimi") == 0 ? "limit" : "count", count);
if (strcmp(kind, "kimi") == 0)
cJSON_AddBoolToObject(body, "include_content", content);
else
cJSON_AddBoolToObject(body, "need_content", content);
char *json = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
char endpoint[1024];
api_path(endpoint, sizeof(endpoint), base, "tools/search");
cst_buf_t raw = {0};
long http = 0;
int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1;
free(json);
if (rc != 0) {
cst_buf_free(&raw);
return tool_set_result(r, "error: web search request failed", 1);
}
if (http >= 400) {
int e = api_error(r, http, &raw);
cst_buf_free(&raw);
return e;
}
cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : "");
if (!doc) {
cst_buf_free(&raw);
return tool_set_result(r, "error: web search returned invalid JSON", 1);
}
const cJSON *arr = cJSON_GetObjectItemCaseSensitive(
doc, strcmp(kind, "kimi") == 0 ? "search_results" : "results");
if (!cJSON_IsArray(arr)) {
cJSON_Delete(doc);
cst_buf_free(&raw);
return tool_set_result(r, "error: web search returned no result list", 1);
}
cst_buf_t out = {0};
int n = cJSON_GetArraySize(arr);
cst_buf_printf(&out, "web search (%s): %d result%s\n", kind, n, n == 1 ? "" : "s");
for (int i = 0; i < n; i++) {
const cJSON *item = cJSON_GetArrayItem(arr, i);
const cJSON *title = cJSON_GetObjectItemCaseSensitive(item, "title");
const cJSON *url = cJSON_GetObjectItemCaseSensitive(item, "url");
const cJSON *snippet = cJSON_GetObjectItemCaseSensitive(
item, strcmp(kind, "kimi") == 0 ? (content ? "text" : "snippet") :
(content ? "summary" : "snippet"));
cst_buf_printf(&out, "\n[%d] %s\n%s\n%s\n", i + 1,
cJSON_IsString(title) ? title->valuestring : "(untitled)",
cJSON_IsString(url) ? url->valuestring : "",
cJSON_IsString(snippet) ? snippet->valuestring : "");
}
int result = tool_take_buf(r, &out, 0);
cJSON_Delete(doc);
cst_buf_free(&raw);
return result;
}
static size_t on_body(char *p, size_t sz, size_t n, void *ud) {
fetch_t *f = ud;
size_t len = sz * n;
@@ -206,6 +412,9 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
if (strncasecmp(url, "http://", 7) != 0 &&
strncasecmp(url, "https://", 8) != 0)
return tool_set_result(r, "error: only http:// and https:// URLs", 1);
const char *online_kind, *online_base, *online_key;
if (provider_api(ctx, &online_kind, &online_base, &online_key))
return online_fetch(ctx, url, tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT), r);
long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT);
if (max_out <= 0)
max_out = FETCH_DEFAULT_OUT;