feat: add Tencent Cloud TokenHub provider with auto model sync
ci / tcc-check (push) Successful in 1m5s
ci / linux (aarch64, 0) (push) Failing after 48s
ci / linux (x86, 1) (push) Failing after 1h0m2s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 51s

Fetch GET /v1/models on provider/key switch, activate online chat models,
and accept HERMIT_/CST_ key env aliases plus tencent* provider names.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-11-03 21:18:39 +08:00
co-authored by Cursor
parent 5538ed3412
commit 88feddd794
8 changed files with 187 additions and 22 deletions
+14
View File
@@ -16,6 +16,7 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` | | `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` | | `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
| `tokenhub`(别名 `tencent` / `tencentcloud`) | `https://tokenhub.tencentmaas.com/v1`(广州;新加坡可 `--api-base https://tokenhub-intl.tencentmaas.com/v1`) | `hy3`(保存 key / 切换 provider 时自动 `GET /v1/models`,只激活 `online`/`pre-offline` 对话模型;当前模型不在列表则回落到可用默认) | `TOKENHUB_API_KEY`(亦认 `HERMIT_TOKENHUB_API_KEY`) |
显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。 显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。
@@ -76,6 +77,19 @@ cst-agent --provider openai
cst-agent --provider minimax cst-agent --provider minimax
``` ```
### 腾讯云 TokenHub
```bash
export TOKENHUB_API_KEY='...' # 或 HERMIT_TOKENHUB_API_KEY
hermit --provider tokenhub # 别名: tencent / tencentcloud
# 切换或 /key 保存后会自动 GET /v1/models,只激活 online 对话模型,
# 并把当前模型校正到可用列表(默认偏好 hy3)
hermit --provider tokenhub --model hy4-preview
# 新加坡站点:
hermit --provider tokenhub \
--api-base https://tokenhub-intl.tencentmaas.com/v1
```
### 任意兼容网关 ### 任意兼容网关
```bash ```bash
+12 -1
View File
@@ -149,13 +149,24 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
return 0; return 0;
} }
const cst_provider_t *p = cst_provider_find(provider); const cst_provider_t *p = cst_provider_find(provider);
const char *env = p ? getenv(p->key_env) : NULL; if (p && p->key_env && p->key_env[0]) {
char alt[96];
const char *env = NULL;
/* HERMIT_<KEY_ENV> then bare KEY_ENV then CST_<KEY_ENV>. */
if (snprintf(alt, sizeof(alt), "HERMIT_%s", p->key_env) < (int)sizeof(alt))
env = getenv(alt);
if ((!env || !env[0]))
env = getenv(p->key_env);
if ((!env || !env[0]) &&
snprintf(alt, sizeof(alt), "CST_%s", p->key_env) < (int)sizeof(alt))
env = getenv(alt);
if (env && env[0]) { if (env && env[0]) {
snprintf(out, out_sz, "%s", env); snprintf(out, out_sz, "%s", env);
if (from) if (from)
*from = p->key_env; *from = p->key_env;
return 0; return 0;
} }
}
out[0] = 0; out[0] = 0;
return -1; return -1;
} }
+32
View File
@@ -9,6 +9,7 @@
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h" #include "cst_hermit_glyph.h"
#include <ctype.h>
#include <curl/curl.h> #include <curl/curl.h>
#include <stdatomic.h> #include <stdatomic.h>
#include <stdio.h> #include <stdio.h>
@@ -1336,10 +1337,41 @@ static void model_meta(const cJSON *it, cst_model_meta_t *m) {
} }
} }
/* Skip vectors / speech / rerankers: the agent picker is for chat models.
* TokenHub and similar gateways mix them into GET /models. */
static int model_id_is_chat(const char *id) {
char low[160];
size_t i = 0;
if (!id || !id[0])
return 0;
for (; id[i] && i + 1 < sizeof(low); i++)
low[i] = (char)tolower((unsigned char)id[i]);
low[i] = 0;
if (strstr(low, "embed") || strstr(low, "tts") || strstr(low, "asr") ||
strstr(low, "whisper") || strstr(low, "rerank") ||
strstr(low, "speech-to") || strstr(low, "text-to-speech") ||
strstr(low, "tenlai"))
return 0;
return 1;
}
/* TokenHub (and similar) mark availability with status; OpenAI omits it. */
static int model_status_active(const cJSON *it) {
const cJSON *st = cJSON_GetObjectItemCaseSensitive(it, "status");
if (!cJSON_IsString(st) || !st->valuestring || !st->valuestring[0])
return 1;
return strcmp(st->valuestring, "online") == 0 ||
strcmp(st->valuestring, "pre-offline") == 0;
}
static int add_model(cst_model_meta_t *out, int n, int max_out, static int add_model(cst_model_meta_t *out, int n, int max_out,
const char *id, const cJSON *it) { const char *id, const cJSON *it) {
if (!id || !id[0] || n >= max_out || strlen(id) >= sizeof(out->id)) if (!id || !id[0] || n >= max_out || strlen(id) >= sizeof(out->id))
return n; return n;
if (!model_id_is_chat(id))
return n;
if (cJSON_IsObject(it) && !model_status_active(it))
return n;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
if (strcmp(out[i].id, id) == 0) if (strcmp(out[i].id, id) == 0)
return n; return n;
+3 -2
View File
@@ -41,8 +41,9 @@ static void cst_curl_cleanup(void) {
static void usage(const char *argv0) { static void usage(const char *argv0) {
fprintf(stderr, fprintf(stderr,
"Usage: %s [options] (works in the current directory)\n" "Usage: %s [options] (works in the current directory)\n"
" -P, --provider NAME minimax | deepseek | openai | teamorouter\n" " -P, --provider NAME minimax | deepseek | kimi | kimi-code |\n"
" (last used)\n" " openai | teamorouter | tokenhub (tencent)\n"
" (last used; tokenhub auto-fetches /models)\n"
" -m, --model NAME model (last used for the provider)\n" " -m, --model NAME model (last used for the provider)\n"
" -e, --effort LEVEL low | medium | high | max (last used)\n" " -e, --effort LEVEL low | medium | high | max (last used)\n"
" -M, --mode NAME coding (default, code framed) | general\n" " -M, --mode NAME coding (default, code framed) | general\n"
+5
View File
@@ -545,6 +545,11 @@ const char *cst_models_load_error(void) {
const cst_provider_t *cst_provider_find(const char *name) { const cst_provider_t *cst_provider_find(const char *name) {
if (!name || !name[0]) if (!name || !name[0])
return NULL; return NULL;
/* Friendly aliases for 腾讯云 TokenHub. */
if (strcmp(name, "tencent") == 0 || strcmp(name, "tencentcloud") == 0 ||
strcmp(name, "token-hub") == 0 || strcmp(name, "腾讯") == 0 ||
strcmp(name, "腾讯云") == 0)
name = "tokenhub";
pthread_mutex_lock(&g_mu); pthread_mutex_lock(&g_mu);
ensure_locked(); ensure_locked();
prov_t *p = prov_by_name(name); prov_t *p = prov_by_name(name);
+28 -1
View File
@@ -43,6 +43,21 @@
"api_base": "https://api.teamorouter.com/v1", "api_base": "https://api.teamorouter.com/v1",
"key_env": "TEAMOROUTER_API_KEY", "key_env": "TEAMOROUTER_API_KEY",
"default_model": "" "default_model": ""
},
"tokenhub": {
"api_base": "https://tokenhub.tencentmaas.com/v1",
"key_env": "TOKENHUB_API_KEY",
"default_model": "hy3",
"models": [
"hy4-preview",
"hy3",
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-v3",
"glm-5.3-flash",
"kimi-k3",
"minimax-m2.7"
]
} }
}, },
"models": [ "models": [
@@ -95,6 +110,18 @@
{"match": "pixtral", "vision": true}, {"match": "pixtral", "vision": true},
{"match": "llava", "vision": true}, {"match": "llava", "vision": true},
{"match": "*-vl*", "vision": true}, {"match": "*-vl*", "vision": true},
{"match": "*vision*", "vision": true} {"match": "*vision*", "vision": true},
{"match": "hunyuan", "context": 256000},
{"match": "hy3", "context": 262144, "reasoning": true},
{"match": "hy4", "context": 1048576, "reasoning": true},
{"match": "hy-role", "context": 32768},
{"match": "hy-mt2", "context": 8192},
{"match": "deepseek-v4-flash", "context": 1048576, "vision": true,
"reasoning": true},
{"match": "glm-5", "context": 1048576, "reasoning": true},
{"match": "glm-5*flash*", "vision": true},
{"match": "minimax-m", "context": 1048576, "reasoning": true},
{"match": "minimax-m2.7", "context": 204800},
{"match": "kinfra-text-embedding", "context": 32000}
] ]
} }
+73 -11
View File
@@ -4,6 +4,60 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
/* Prefer the preset default when it is live; else a TokenHub-friendly
* shortlist; else the first activated id. */
static const char *pick_active_model(const char *prefer, char ids[][128],
int n) {
static const char *const fallbacks[] = {
"hy4-preview", "hy3", "deepseek-v4-flash",
"deepseek-v4-pro", "deepseek-v3", "glm-5.3-flash",
"kimi-k3", "minimax-m2.7", NULL};
if (n <= 0)
return NULL;
if (prefer && prefer[0]) {
for (int i = 0; i < n; i++)
if (strcmp(ids[i], prefer) == 0)
return ids[i];
}
for (int f = 0; fallbacks[f]; f++) {
for (int i = 0; i < n; i++)
if (strcmp(ids[i], fallbacks[f]) == 0)
return ids[i];
/* TokenHub sometimes prefixes vendor paths: deepseek/deepseek-flash */
for (int i = 0; i < n; i++) {
const char *slash = strrchr(ids[i], '/');
if (slash && strcmp(slash + 1, fallbacks[f]) == 0)
return ids[i];
}
}
return ids[0];
}
static int id_in_list(char ids[][128], int n, const char *id) {
if (!id || !id[0])
return 0;
for (int i = 0; i < n; i++)
if (strcmp(ids[i], id) == 0)
return 1;
return 0;
}
/* After a live /models fetch: if cfg has no model, or it is not among the
* activated ids, pick one and remember it. */
static void activate_model(cst_agent_config_t *cfg, const cst_provider_t *p,
char ids[][128], int n) {
if (!cfg || n <= 0)
return;
if (id_in_list(ids, n, cfg->model))
return;
const char *prefer = (p && p->model[0]) ? p->model : NULL;
const char *pick = pick_active_model(prefer, ids, n);
if (!pick)
return;
snprintf(cfg->model, sizeof(cfg->model), "%s", pick);
cst_settings_remember(cfg);
}
int cst_ps_fetch(const char *provider, const char *api_base, const char *key, int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
char ids[][128], int max, char *err, size_t err_sz) { char ids[][128], int max, char *err, size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m)); cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m));
@@ -160,20 +214,20 @@ int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
} }
cst_ps_apply_provider(cfg, p, k); cst_ps_apply_provider(cfg, p, k);
memset(k, 0, sizeof(k)); memset(k, 0, sizeof(k));
if (!cfg->model[0] && ids) { /* Always try to activate against a live (or cached) /models list so
/* a router preset: the first model the API lists */ * TokenHub and similar gateways only expose models the key can call. */
if (ids) {
int fresh = 0; int fresh = 0;
if (nids <= 0) if (nids <= 0)
nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh); nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh);
if (nids <= 0) { if (nids <= 0 || !fresh) {
char e2[256]; char e2[256];
nids = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids, int r = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids,
CST_PS_MAX_MODELS, e2, sizeof(e2)); CST_PS_MAX_MODELS, e2, sizeof(e2));
if (r >= 0)
nids = r;
} }
if (nids > 0) { activate_model(cfg, p, ids, nids > 0 ? nids : 0);
snprintf(cfg->model, sizeof(cfg->model), "%s", ids[0]);
cst_settings_remember(cfg);
}
} }
free(ids); free(ids);
return flags; return flags;
@@ -185,9 +239,17 @@ int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
snprintf(err, err_sz, "empty key"); snprintf(err, err_sz, "empty key");
return CST_PS_NEED_KEY; return CST_PS_NEED_KEY;
} }
int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, NULL, 0, NULL, char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids));
err, err_sz); int nids = -1;
if (r >= 0) int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, ids,
ids ? CST_PS_MAX_MODELS : 0, &nids, err, err_sz);
if (r >= 0) {
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key); snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
if (ids && nids > 0) {
const cst_provider_t *p = cst_provider_find(cfg->provider);
activate_model(cfg, p, ids, nids);
}
}
free(ids);
return r; return r;
} }
+14 -1
View File
@@ -143,7 +143,15 @@ int main(void) {
"{\"id\":\"vllm-model\",\"max_model_len\":32768," "{\"id\":\"vllm-model\",\"max_model_len\":32768,"
"\"modalities\":{\"input\":[\"text\"]}}," "\"modalities\":{\"input\":[\"text\"]}},"
"{\"id\":\"plain\"}]}"); "{\"id\":\"plain\"}]}");
g_nresp = 6; /* TokenHub-style: status + non-chat ids */
g_responses[6] = RESP(
"200 OK",
"{\"object\":\"list\",\"data\":["
"{\"id\":\"hy3\",\"status\":\"online\"},"
"{\"id\":\"gone\",\"status\":\"offline\"},"
"{\"id\":\"text-embedding-v1\",\"status\":\"online\"},"
"{\"id\":\"deepseek-v4-pro\",\"status\":\"pre-offline\"}]}");
g_nresp = 7;
int port = start_server(); int port = start_server();
pthread_t th; pthread_t th;
CHECK(pthread_create(&th, NULL, serve, NULL) == 0); CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
@@ -185,6 +193,11 @@ int main(void) {
CHECK(meta[2].context == 32768 && meta[2].vision == 0); CHECK(meta[2].context == 32768 && meta[2].vision == 0);
CHECK(meta[3].vision == -1 && meta[3].context == 0 && !meta[3].has_price); CHECK(meta[3].vision == -1 && meta[3].context == 0 && !meta[3].has_price);
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == 2);
CHECK(strcmp(ids[0], "hy3") == 0);
CHECK(strcmp(ids[1], "deepseek-v4-pro") == 0);
pthread_join(th, NULL); pthread_join(th, NULL);
test_sock_close(g_listen); test_sock_close(g_listen);