diff --git a/docs/env_openai.md b/docs/env_openai.md index 977bd34..84fcf8e 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -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` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_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` / 下表中的环境变量优先于预设。 @@ -76,6 +77,19 @@ cst-agent --provider openai 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 diff --git a/src/creds.c b/src/creds.c index 6121499..c2e4d48 100644 --- a/src/creds.c +++ b/src/creds.c @@ -149,12 +149,23 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz, return 0; } const cst_provider_t *p = cst_provider_find(provider); - const char *env = p ? getenv(p->key_env) : NULL; - if (env && env[0]) { - snprintf(out, out_sz, "%s", env); - if (from) - *from = p->key_env; - return 0; + if (p && p->key_env && p->key_env[0]) { + char alt[96]; + const char *env = NULL; + /* HERMIT_ then bare KEY_ENV then CST_. */ + 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]) { + snprintf(out, out_sz, "%s", env); + if (from) + *from = p->key_env; + return 0; + } } out[0] = 0; return -1; diff --git a/src/llm_openai.c b/src/llm_openai.c index 01f3c5f..0c93226 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -9,6 +9,7 @@ #include "os_compat.h" #include "cst_hermit_glyph.h" +#include #include #include #include @@ -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, const char *id, const cJSON *it) { if (!id || !id[0] || n >= max_out || strlen(id) >= sizeof(out->id)) 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++) if (strcmp(out[i].id, id) == 0) return n; diff --git a/src/main.c b/src/main.c index b2776d1..bea83e9 100644 --- a/src/main.c +++ b/src/main.c @@ -41,8 +41,9 @@ static void cst_curl_cleanup(void) { static void usage(const char *argv0) { fprintf(stderr, "Usage: %s [options] (works in the current directory)\n" - " -P, --provider NAME minimax | deepseek | openai | teamorouter\n" - " (last used)\n" + " -P, --provider NAME minimax | deepseek | kimi | kimi-code |\n" + " openai | teamorouter | tokenhub (tencent)\n" + " (last used; tokenhub auto-fetches /models)\n" " -m, --model NAME model (last used for the provider)\n" " -e, --effort LEVEL low | medium | high | max (last used)\n" " -M, --mode NAME coding (default, code framed) | general\n" diff --git a/src/model_registry.c b/src/model_registry.c index 68a6de5..343a8bd 100644 --- a/src/model_registry.c +++ b/src/model_registry.c @@ -545,6 +545,11 @@ const char *cst_models_load_error(void) { const cst_provider_t *cst_provider_find(const char *name) { if (!name || !name[0]) 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); ensure_locked(); prov_t *p = prov_by_name(name); diff --git a/src/models_builtin.json b/src/models_builtin.json index 0a0eeea..8083df5 100644 --- a/src/models_builtin.json +++ b/src/models_builtin.json @@ -43,6 +43,21 @@ "api_base": "https://api.teamorouter.com/v1", "key_env": "TEAMOROUTER_API_KEY", "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": [ @@ -95,6 +110,18 @@ {"match": "pixtral", "vision": true}, {"match": "llava", "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} ] } diff --git a/src/provider_switch.c b/src/provider_switch.c index 6cf22fb..eba1ade 100644 --- a/src/provider_switch.c +++ b/src/provider_switch.c @@ -4,6 +4,60 @@ #include #include +/* 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, 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)); @@ -160,20 +214,20 @@ int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name, } cst_ps_apply_provider(cfg, p, k); memset(k, 0, sizeof(k)); - if (!cfg->model[0] && ids) { - /* a router preset: the first model the API lists */ + /* Always try to activate against a live (or cached) /models list so + * TokenHub and similar gateways only expose models the key can call. */ + if (ids) { int fresh = 0; if (nids <= 0) nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh); - if (nids <= 0) { + if (nids <= 0 || !fresh) { char e2[256]; - nids = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids, - CST_PS_MAX_MODELS, e2, sizeof(e2)); - } - if (nids > 0) { - snprintf(cfg->model, sizeof(cfg->model), "%s", ids[0]); - cst_settings_remember(cfg); + int r = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids, + CST_PS_MAX_MODELS, e2, sizeof(e2)); + if (r >= 0) + nids = r; } + activate_model(cfg, p, ids, nids > 0 ? nids : 0); } free(ids); 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"); return CST_PS_NEED_KEY; } - int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, NULL, 0, NULL, - err, err_sz); - if (r >= 0) + char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids)); + int nids = -1; + 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); + if (ids && nids > 0) { + const cst_provider_t *p = cst_provider_find(cfg->provider); + activate_model(cfg, p, ids, nids); + } + } + free(ids); return r; } diff --git a/tests/test_models.c b/tests/test_models.c index eff36de..4e11456 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -143,7 +143,15 @@ int main(void) { "{\"id\":\"vllm-model\",\"max_model_len\":32768," "\"modalities\":{\"input\":[\"text\"]}}," "{\"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(); pthread_t th; 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[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); test_sock_close(g_listen);