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
+97 -2
View File
@@ -4,6 +4,7 @@
#include "ctl.h"
#include <stddef.h>
#include <time.h>
#ifdef __cplusplus
extern "C" {
@@ -11,7 +12,6 @@ extern "C" {
#define CST_AGENT_MAX_MSG 256
#define CST_AGENT_MAX_CONTENT (512 * 1024)
#define CST_AGENT_CTX_WINDOW 80 /* max non-system msgs sent to LLM */
#define CST_DEFAULT_MAX_STEPS 30
#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024)
@@ -47,6 +47,19 @@ int cst_protocol_is_text(cst_protocol_t p);
/* Eager protocols execute a call during the stream, at confirmation time. */
int cst_protocol_is_eager(cst_protocol_t p);
/* One image carried by a message (see cst_attach.h). The bytes live beside
* the session; this is the metadata plus the name of the stored copy. */
typedef struct {
char id[32]; /* unique inside the session ("img-1a2b3c4d") */
char name[256]; /* display name (original file name) */
char mime[32]; /* image/png, image/jpeg, image/gif, image/webp, image/bmp */
char rel[256]; /* file name inside the session's attachment directory */
long long bytes; /* stored size */
int width, height; /* pixels, 0 when the header could not be read */
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
char origin[32]; /* user | view_image | mcp (for display only) */
} cst_attachment_t;
typedef struct {
cst_role_t role;
char *content;
@@ -55,6 +68,10 @@ typedef struct {
char tool_name[CTL_MAX_NAME];
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
char *meta_json; /* optional JSON object (kind, protocol, ...) */
cst_attachment_t *attach; /* images shown with this message, or NULL */
int nattach;
char *reasoning; /* assistant: reasoning_content as streamed, or NULL */
int prefix; /* wire only: assistant prefix the model continues */
} cst_message_t;
typedef struct {
@@ -149,6 +166,10 @@ typedef struct {
char *out;
size_t out_len;
int exit_code;
/* Images the tool produced for the model (view_image, image-returning MCP
* tools). cst_tool_result_free releases them. */
cst_attachment_t *attach;
int nattach;
} cst_tool_result_t;
void cst_tool_result_free(cst_tool_result_t *r);
@@ -200,7 +221,9 @@ typedef struct {
char finish_reason[32];
long prompt_tokens;
long completion_tokens;
long cached_tokens;
long cached_tokens; /* prompt tokens read from the provider's cache */
long cache_miss_tokens; /* prompt tokens billed at the full input price */
long cache_write_tokens; /* prompt tokens written to the cache (Anthropic) */
double t_first_byte_ms; /* relative to request start; -1 if none */
double t_end_ms;
int client_stopped; /* stream ended by stop_flag (not an error) */
@@ -222,8 +245,15 @@ typedef struct {
void *userdata;
int enable_tools; /* send tools[] schema */
const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */
const char *tools_json; /* tools[] sent verbatim (frozen); NULL = tool_cfg */
int tool_choice_none; /* "tool_choice":"none" */
int send_reasoning; /* assistant reasoning goes back as reasoning_content */
int cache_style; /* CST_CACHE_* hints to add */
const char *cache_key; /* prompt_cache_key (CST_CACHE_OPENAI) */
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
int images_off; /* the model does not take images: send a note instead */
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
const char *session_path; /* where stored images live; NULL = none */
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
volatile int *stop_flag; /* non-zero → stop reading, not an error */
cst_llm_stats_t *stats; /* optional */
@@ -294,6 +324,25 @@ int cst_agent_switch_session(cst_agent_t *a, const char *id);
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
/* Bare id of the current session (file name without .jsonl). */
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
/* Transcript path of the current session (images are stored beside it). */
const char *cst_agent_session_path(const cst_agent_t *a);
/* Images picked for the next user message (/attach). They ride along with the
* next cst_agent_run_turn*() call and are then part of the session.
* cst_agent_attach_file copies the file into the session store; *err gets a
* short reason on failure (-1). */
int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err,
size_t err_sz);
/* Same for bytes already in memory (clipboard); name is display-only. */
int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
size_t n, char *err, size_t err_sz);
/* Whether the current model is shown images: 1 yes, -1 unknown (tried; a
* 400 turns sending off), 0 no (images go as text notes). */
int cst_agent_sees_images(const cst_agent_t *a);
int cst_agent_pending_count(const cst_agent_t *a);
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
size_t out_sz);
void cst_agent_pending_clear(cst_agent_t *a);
/* Latest todo_write list of this session, or NULL. */
const char *cst_agent_todo(const cst_agent_t *a);
/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */
@@ -311,6 +360,19 @@ void cst_agent_undo_key(const cst_agent_t *a, char *sid, size_t sid_sz,
char *tag, size_t tag_sz);
/* Last prompt_tokens reported by the model (0 if unknown). */
long cst_agent_last_prompt_tokens(const cst_agent_t *a);
/* Token use since the session was opened, and of the latest turn. */
typedef struct {
int requests;
long prompt, cached, miss, write, out;
double cost; /* in currency; valid when priced */
int priced; /* every request had a known price */
char currency[8];
} cst_usage_t;
void cst_agent_usage(const cst_agent_t *a, cst_usage_t *session,
cst_usage_t *turn);
/* "[cst] ..." notes the agent appends when Plan mode, approval, branch,
* date or AGENTS.md change mid-session (the system prompt stays frozen). */
int cst_message_is_note(const cst_message_t *m);
const char *cst_agent_system_prompt(void);
const char *cst_agent_system_prompt_for(cst_protocol_t p);
@@ -384,6 +446,39 @@ const cst_provider_t *cst_provider_at(int i);
/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */
long cst_model_context_tokens(const char *model);
/* How a provider caches prompt prefixes.
* auto : implicit prefix cache, nothing to send (DeepSeek, MiniMax...)
* openai : implicit, routed by "prompt_cache_key"
* anthropic : explicit "cache_control" breakpoints on content parts */
typedef enum {
CST_CACHE_AUTO = 0,
CST_CACHE_OPENAI,
CST_CACHE_ANTHROPIC
} cst_cache_style_t;
typedef struct {
int cache_style; /* cst_cache_style_t */
int reasoning_passback; /* history keeps reasoning_content (DeepSeek) */
int prefix_completion; /* assistant "prefix": true via <base>/beta */
int vision; /* 1 takes images, 0 text only, -1 unknown */
} cst_model_caps_t;
/* CST_NO_CACHE_HINTS=1 forces CST_CACHE_AUTO. */
void cst_model_caps(const char *provider, const char *api_base,
const char *model, cst_model_caps_t *out);
/* Prices per million tokens. CST_PRICE="miss,hit,out[,write[,currency]]"
* overrides the built-in table. */
typedef struct {
char currency[8]; /* "¥", "$" */
double miss, hit, write, out;
int offpeak_half; /* DeepSeek: half price outside Beijing peak hours */
} cst_price_t;
/* 0 when a price is known for model, -1 otherwise. */
int cst_model_price(const char *model, cst_price_t *out);
/* DeepSeek peak hours: Beijing time Mon-Fri 9:00-12:00 and 14:00-18:00. */
int cst_price_is_peak(time_t when);
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
long out, time_t when);
/* Reasoning effort: low | medium | high | max. */
#define CST_DEFAULT_EFFORT "medium"
int cst_effort_valid(const char *effort);