v0.7.0: image input, model registry, prefix-cache savings

Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
This commit is contained in:
WHC
2026-10-08 20:34:09 +08:00
parent 0226f417ff
commit 895b47274c
32 changed files with 5825 additions and 429 deletions
+20 -130
View File
@@ -1,120 +1,32 @@
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MODEL_MAX_LEN 128
/* Provider and model data live in model_registry.c (cst_models.h). */
static const cst_provider_t PROVIDERS[] = {
{"minimax", "https://api.minimax.cn/v1", "MiniMax-M2.7", "MINIMAX_API_KEY"},
{"deepseek", "https://api.deepseek.com/v1", "deepseek-flash",
"DEEPSEEK_API_KEY"},
{"openai", "https://api.openai.com/v1", "gpt-4.1-mini", "OPENAI_API_KEY"},
/* Router: no fixed model; the first id from GET /models is picked. */
{"teamorouter", "https://api.teamorouter.com/v1", "",
"TEAMOROUTER_API_KEY"},
};
const cst_provider_t *cst_provider_find(const char *name) {
if (!name || !name[0])
return NULL;
for (size_t i = 0; i < sizeof(PROVIDERS) / sizeof(PROVIDERS[0]); i++) {
if (strcmp(PROVIDERS[i].name, name) == 0)
return &PROVIDERS[i];
}
return NULL;
}
const cst_provider_t *cst_provider_default(void) {
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
return p ? p : &PROVIDERS[0];
}
static int already(char out[][MODEL_MAX_LEN], int n, const char *name) {
for (int i = 0; i < n; i++) {
if (strcmp(out[i], name) == 0)
return 1;
}
return 0;
}
static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) {
if (!name || !name[0] || *n >= max)
int cst_price_is_peak(time_t when) {
time_t bj = when + 8 * 3600;
struct tm tmv;
#ifdef _WIN32
gmtime_s(&tmv, &bj);
#else
gmtime_r(&bj, &tmv);
#endif
if (tmv.tm_wday == 0 || tmv.tm_wday == 6)
return 0;
if (already(out, *n, name))
return 0;
snprintf(out[*n], MODEL_MAX_LEN, "%s", name);
(*n)++;
return 1;
return (tmv.tm_hour >= 9 && tmv.tm_hour < 12) ||
(tmv.tm_hour >= 14 && tmv.tm_hour < 18);
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
long out, time_t when) {
if (!p)
return 0;
int n = 0;
const char *env = getenv("CST_MODELS");
if (env && env[0]) {
char buf[1024];
snprintf(buf, sizeof(buf), "%s", env);
char *p = buf;
while (*p && n < max_out) {
while (*p == ':' || *p == ' ' || *p == ',')
p++;
if (!*p)
break;
char *start = p;
while (*p && *p != ':' && *p != ',')
p++;
char save = *p;
*p = 0;
char *end = start + strlen(start);
while (end > start && (end[-1] == ' ' || end[-1] == '\t'))
*--end = 0;
push(out, &n, max_out, start);
*p = save;
if (*p)
p++;
}
} else {
push(out, &n, max_out, "deepseek-flash");
push(out, &n, max_out, "deepseek-v4-pro");
push(out, &n, max_out, "MiniMax-M2.7");
push(out, &n, max_out, "MiniMax-M2.5");
push(out, &n, max_out, "gpt-4.1-mini");
push(out, &n, max_out, "gpt-4o");
}
if (current && current[0])
push(out, &n, max_out, current);
return n;
}
/* Context window in tokens (CST_CTX_TOKENS overrides). Prefix match. */
long cst_model_context_tokens(const char *model) {
const char *e = getenv("CST_CTX_TOKENS");
if (e && atol(e) > 0)
return atol(e);
static const struct {
const char *prefix;
long tokens;
} T[] = {{"deepseek", 128000}, {"MiniMax", 196608}, {"minimax", 196608},
{"gpt-4.1", 1047576}, {"gpt-4o", 128000}, {"gpt-5", 400000},
{"o3", 200000}, {"o4", 200000}, {"claude", 200000},
{"qwen", 131072}, {"glm", 128000}, {"kimi", 131072}};
if (model) {
/* routers name models "vendor/model" */
const char *slash = strrchr(model, '/');
if (slash && slash[1])
model = slash + 1;
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
if (strncmp(model, T[i].prefix, strlen(T[i].prefix)) == 0)
return T[i].tokens;
}
}
return 128000;
double c = (miss * p->miss + hit * p->hit + write * p->write +
out * p->out) / 1e6;
if (p->offpeak_half && !cst_price_is_peak(when))
c /= 2;
return c;
}
int cst_effort_valid(const char *effort) {
@@ -125,25 +37,3 @@ int cst_effort_valid(const char *effort) {
}
return 0;
}
const char *cst_effort_wire(const char *provider, const char *model,
const char *effort) {
if (!cst_effort_valid(effort) || !provider || !model)
return NULL;
if (strcmp(provider, "deepseek") == 0)
return effort;
/* OpenAI reasoning models take low | medium | high. */
if (strcmp(provider, "openai") == 0 &&
(strncmp(model, "o1", 2) == 0 || strncmp(model, "o3", 2) == 0 ||
strncmp(model, "o4", 2) == 0 || strncmp(model, "gpt-5", 5) == 0))
return strcmp(effort, "max") == 0 ? "high" : effort;
return NULL;
}
int cst_provider_count(void) {
return (int)(sizeof(PROVIDERS) / sizeof(PROVIDERS[0]));
}
const cst_provider_t *cst_provider_at(int i) {
return i >= 0 && i < cst_provider_count() ? &PROVIDERS[i] : NULL;
}