v0.7.0: image input, model registry, prefix-cache savings
ci / linux (clang) (push) Successful in 4m41s
ci / linux (gcc) (push) Successful in 5m35s
ci / musl (push) Failing after 30s
ci / windows-cross (push) Successful in 9m59s

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
+1
View File
@@ -30,3 +30,4 @@ vendor/sashidown/*.o
vendor/sashidown/src/*.o
vendor/sashidown/libsashidown.a
vendor/sashidown/libsashidown-mingw.a
.gen/models_builtin.inc
+15 -2
View File
@@ -92,6 +92,8 @@ set(CST_CORE_SOURCES
src/plugins.c
src/mcp.c
src/subagent.c
src/attach.c
src/clipboard.c
src/session.c
src/tools/tools.c
src/tools/patch.c
@@ -110,8 +112,18 @@ set(CST_CORE_SOURCES
src/creds.c
src/mm_tool.c
src/models.c
src/model_registry.c
src/metrics.c
)
# Built-in model data as a C byte list (included by model_registry.c).
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
include_directories("${CST_GEN_DIR}")
if(WIN32)
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
include_directories(vendor/tre-regex)
@@ -257,9 +269,10 @@ foreach(t ${_CST_SMALL_TESTS})
endforeach()
# Tests that link the whole core (no TUI).
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch
test_session_roundtrip test_attach
test_shell test_diff test_tools_fs test_registry
test_undo test_config test_models test_sandbox
test_undo test_config test_models test_model_registry test_sandbox
test_keymap test_shell_compact test_skills
test_plugins test_mcp test_subagent test_lowil
test_win_utf8 test_os_dir test_tmpdir)
+14 -5
View File
@@ -46,7 +46,7 @@ endif
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
CPPFLAGS += -Iinclude -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
@@ -60,7 +60,7 @@ CORE_SRC = \
src/sandbox_exec.c \
src/keymap.c \
src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
src/session.c \
src/tools/tools.c \
src/tools/patch.c \
@@ -78,12 +78,13 @@ CORE_SRC = \
src/json_baseline.c \
src/creds.c \
src/mm_tool.c \
src/models.c \
src/models.c src/model_registry.c \
src/metrics.c
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
OBJ = $(SRC:.c=.o)
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
.gen/models_builtin.inc
TESTS = \
tests/test_ctl_frontier \
@@ -93,9 +94,11 @@ TESTS = \
tests/test_replay_real \
tests/test_mm_tool \
tests/test_llm_body \
tests/test_prefix_stable \
tests/test_sse_decode \
tests/test_apply_patch \
tests/test_session_roundtrip \
tests/test_attach \
tests/test_shell \
tests/test_diff \
tests/test_tools_fs \
@@ -103,6 +106,7 @@ TESTS = \
tests/test_undo \
tests/test_config \
tests/test_models \
tests/test_model_registry \
tests/test_sandbox \
tests/test_keymap \
tests/test_textsel \
@@ -144,6 +148,11 @@ FORCE:
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
# Built-in model data as a C byte list (included by model_registry.c).
.gen/models_builtin.inc: src/models_builtin.json
@mkdir -p .gen
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
%.o: %.c $(HDRS)
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
@@ -190,7 +199,7 @@ demo: cst-agent
./cst-agent --demo-ctl --workspace .
clean:
rm -f $(OBJ) cst-agent $(TESTS)
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc
install: cst-agent
install -d "$(DESTDIR)$(PREFIX)/bin"
+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);
+81
View File
@@ -0,0 +1,81 @@
#ifndef CST_ATTACH_H
#define CST_ATTACH_H
#include "cst_agent.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* --- image attachments ---------------------------------------------------
* An attachment is a validated copy of an image file, stored beside the
* session so the bytes a model is shown stay the bytes that were picked.
* Only the metadata travels in the JSONL; the bytes are encoded into the
* request when it is built. */
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
#define CST_ATTACH_MAX_COUNT 8 /* per message */
/* Magic-byte test: 0 and a canonical mime when data holds a supported
* image, -1 otherwise (an extension alone never decides). */
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz);
/* Pixel size from the header when it is readable; 0 for unknown. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h);
/* A stored file name as store() makes it ("<id>.<ext>"); rejects anything
* that could leave the attachment directory. */
int cst_attach_rel_ok(const char *rel);
/* Directory holding this session's stored images (created on demand). */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
/* Absolute path of a stored image (0 / -1). */
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz);
/* Read src_path, verify it is a supported image within limits, and copy it
* into the session's attachment directory. Returns a malloc'd entry the
* caller owns (cst_attach_free), or NULL with err filled. */
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz);
/* Store already-read bytes (clipboard, downloads); name is display-only. */
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz);
/* Validate and read back a stored image, or NULL when it is gone. */
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz);
/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
void cst_attach_free(cst_attachment_t *a);
/* Free a list of n entries allocated as separate blocks. */
void cst_attachments_free(cst_attachment_t *list, int n);
/* Deep copy (NULL on OOM or when n <= 0). */
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n);
/* Append src to *list (grows with realloc). 0 / -1. */
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src);
/* JSON array of n attachments for the session JSONL (malloc'd), or NULL. */
char *cst_attachments_to_json(const cst_attachment_t *list, int n);
/* Parse a JSON array; caller frees with cst_attachments_free. */
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out);
/* Base64 of n bytes (malloc'd, no line breaks). */
char *cst_base64_encode(const void *data, size_t n);
/* Standard or URL-safe base64 (whitespace ignored) to bytes; malloc'd,
* NULL on a bad character. */
char *cst_base64_decode(const char *s, size_t n, size_t *out_len);
#ifdef __cplusplus
}
#endif
#endif
+35
View File
@@ -0,0 +1,35 @@
#ifndef CST_CLIPBOARD_H
#define CST_CLIPBOARD_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Image bytes on the system clipboard (PNG, or BMP from a Windows DIB).
* Wayland: wl-paste; X11: xclip; macOS: pngpaste or osascript; Windows: the
* clipboard API. Returns 0 with *data malloc'd, or -1 with err filled in
* ("no image on the clipboard", "install wl-clipboard", ...). */
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
#define CST_PASTE_MAX_PATHS 8
#define CST_PASTE_PATH_MAX 1024
/* Split pasted text into file paths the way terminals paste dropped files:
* whitespace- or newline-separated, '...' / "..." quoting, file:// URIs
* (percent-decoded) and, outside Windows, backslash escapes. Returns the
* count, or -1 when the text is empty or holds more than max paths. Whether
* the paths exist is up to the caller. */
int cst_paste_paths(const char *text, size_t len,
char out[][CST_PASTE_PATH_MAX], int max);
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -64,6 +64,7 @@ typedef enum {
CST_KA_COMPACT,
CST_KA_COPY,
CST_KA_EXPORT,
CST_KA_PASTE_IMAGE,
/* model */
CST_KA_MODEL,
CST_KA_PROVIDER,
+92
View File
@@ -0,0 +1,92 @@
#ifndef CST_MODELS_H
#define CST_MODELS_H
/* Model registry: what cst-agent knows about providers and models.
*
* Three layers, highest first, merged field by field:
* 1. <config>/models.json user overrides and extra providers
* 2. <config>/models-cache/<provider>.json GET /models metadata, exact id
* 3. built-in data (src/models_builtin.json, embedded at build time)
* then defaults (128000 tokens, vision unknown). Model rules match the name
* without its "vendor/" prefix, case-insensitively: a plain "match" is a
* prefix, one with '*' is a glob, one with '/' is tested against the full
* id. Within a layer the most specific rule (most literal characters) that
* sets a field wins, so table order does not matter.
*
* CST_CTX_TOKENS, CST_PRICE, CST_MODELS and CST_NO_CACHE_HINTS still
* override everything. */
#include "cst_agent.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
long context; /* tokens */
int vision; /* 1 takes images, 0 text only, -1 unknown */
int cache_style; /* cst_cache_style_t */
int reasoning; /* takes OpenAI-style reasoning_effort */
int has_price;
cst_price_t price;
/* Layers that supplied anything, highest first: "user+api+built-in",
* "default", "env+built-in"... */
char source[48];
} cst_model_info_t;
/* provider may be NULL: then every provider's fetched cache is searched. */
void cst_model_info(const char *provider, const char *model,
cst_model_info_t *out);
/* Short picker label: "1M · vision", "200K · text only", "128K". */
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n);
/* One entry of GET /models, with the optional OpenRouter-style metadata. */
typedef struct {
char id[128];
long context; /* 0 = not given */
int vision; /* -1 = not given */
int has_price; /* prices in USD per 1M tokens */
double miss, hit, write, out;
} cst_model_meta_t;
/* Like cst_llm_list_models, keeping context_length,
* architecture.input_modalities (or modalities) and pricing when present. */
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
cst_model_meta_t *out, int max_out, char *err,
size_t err_sz);
#define CST_MODELS_CACHE_TTL (24L * 3600)
/* Store a fetched list as <config>/models-cache/<provider>.json and use it
* from now on. 0 / -1. */
int cst_models_cache_save(const char *provider, const char *api_base,
const cst_model_meta_t *list, int n);
/* The cached list for provider: count (up to max), or -1 when there is
* none. *age_s gets the seconds since it was fetched. */
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
long *age_s);
/* The "models" a provider entry lists (built-in or user file). */
int cst_provider_models(const cst_provider_t *p, char out[][128], int max);
/* Where a provider entry came from: "built-in" or "user". */
const char *cst_provider_source(const cst_provider_t *p);
/* The user file's path (it may not exist). */
int cst_models_user_path(char *out, size_t out_sz);
/* Drop everything and read the built-in data, the user file and the caches
* again. Provider pointers stay valid but may name another provider. */
void cst_models_reload(void);
/* Tests: load these documents instead of the built-in data and the user
* file (NULL = none). Caches still come from <config>. 0, or -1 with err. */
int cst_models_load_json(const char *builtin_json, const char *user_json,
char *err, size_t err_sz);
/* The embedded built-in document. */
const char *cst_models_builtin_json(void);
/* Problems found while loading (bad user file...), or "". */
const char *cst_models_load_error(void);
#ifdef __cplusplus
}
#endif
#endif
+2 -2
View File
@@ -2,8 +2,8 @@
#define CST_VERSION_H
#define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 6
#define CST_VERSION_PATCH 1
#define CST_VERSION_MINOR 7
#define CST_VERSION_PATCH 0
#define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+932 -219
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -288,7 +288,10 @@ int cst_agent_store_info(const char *workspace, const char *id,
cJSON *o = cJSON_Parse(line);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 &&
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
cJSON_IsString(c) && c->valuestring[0]) {
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
have_first = 1;
+646
View File
@@ -0,0 +1,646 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* --- base64 -------------------------------------------------------------- */
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *cst_base64_encode(const void *data, size_t n) {
const unsigned char *p = data;
size_t olen = ((n + 2) / 3) * 4;
char *out = malloc(olen + 1);
if (!out)
return NULL;
size_t o = 0;
for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned)p[i] << 16;
if (i + 1 < n)
v |= (unsigned)p[i + 1] << 8;
if (i + 2 < n)
v |= (unsigned)p[i + 2];
out[o++] = B64[(v >> 18) & 63];
out[o++] = B64[(v >> 12) & 63];
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
out[o++] = i + 2 < n ? B64[v & 63] : '=';
}
out[o] = 0;
return out;
}
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
if (c == '+' || c == '-')
return 62;
if (c == '/' || c == '_')
return 63;
return -1;
}
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
if (out_len)
*out_len = 0;
if (!s)
return NULL;
char *out = malloc(n / 4 * 3 + 4);
if (!out)
return NULL;
size_t o = 0;
unsigned acc = 0;
int bits = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '=')
break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
continue;
int v = b64_val(c);
if (v < 0) {
free(out);
return NULL;
}
acc = (acc << 6) | (unsigned)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (char)((acc >> bits) & 0xFF);
}
}
if (out_len)
*out_len = o;
return out;
}
/* --- format sniffing ----------------------------------------------------- */
static int be32(const unsigned char *p) {
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
}
static int le32(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
}
static int le16(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
}
static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "image/png") == 0)
return "png";
if (strcmp(mime, "image/jpeg") == 0)
return "jpg";
if (strcmp(mime, "image/gif") == 0)
return "gif";
if (strcmp(mime, "image/webp") == 0)
return "webp";
if (strcmp(mime, "image/bmp") == 0)
return "bmp";
return "bin";
}
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
static int jpeg_sof(int marker) {
switch (marker) {
case 0xC0:
case 0xC1:
case 0xC2:
case 0xC3:
case 0xC5:
case 0xC6:
case 0xC7:
case 0xC9:
case 0xCA:
case 0xCB:
case 0xCD:
case 0xCE:
case 0xCF:
return 1;
default:
return 0;
}
}
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
const unsigned char *p = data;
const char *m = NULL;
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
m = "image/png";
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
m = "image/jpeg";
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
m = "image/gif";
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
m = "image/gif";
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
m = "image/webp";
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
m = "image/bmp";
if (!m)
return -1;
snprintf(mime, mime_sz, "%s", m);
return 0;
}
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
* container flavours. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
const unsigned char *p = data;
*w = *h = 0;
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
return -1;
if (strcmp(mime, "image/png") == 0) {
/* 8 signature + 4 length + "IHDR" + w + h */
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
return -1;
*w = be32(p + 16);
*h = be32(p + 20);
return 0;
}
if (strcmp(mime, "image/gif") == 0) {
if (n < 10)
return -1;
*w = le16(p + 6);
*h = le16(p + 8);
return 0;
}
if (strcmp(mime, "image/bmp") == 0) {
if (n < 26)
return -1;
int wi = le32(p + 18);
int hi = le32(p + 22);
if (wi < 0)
wi = -wi; /* bottom-up rows are stored with a negative height */
if (hi < 0)
hi = -hi;
*w = wi;
*h = hi;
return 0;
}
if (strcmp(mime, "image/webp") == 0) {
if (n < 30)
return -1;
const unsigned char *c = p + 12;
if (memcmp(c, "VP8 ", 4) == 0) {
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
return -1;
*w = le16(c + 10) & 0x3FFF;
*h = le16(c + 12) & 0x3FFF;
return 0;
}
if (memcmp(c, "VP8L", 4) == 0) {
if (n < 25 || c[8] != 0x2F)
return -1;
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
*w = (int)(b & 0x3FFF) + 1;
*h = (int)((b >> 14) & 0x3FFF) + 1;
return 0;
}
if (memcmp(c, "VP8X", 4) == 0) {
if (n < 30)
return -1;
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
((unsigned)c[14] << 16)) + 1;
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
((unsigned)c[17] << 16)) + 1;
return 0;
}
return -1;
}
if (strcmp(mime, "image/jpeg") == 0) {
size_t i = 2;
while (i + 3 < n) {
if (p[i] != 0xFF)
return -1; /* lost sync */
unsigned marker = p[i + 1];
i += 2;
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
continue; /* standalone markers: no payload */
if (marker == 0xD9 || marker == 0x01)
continue;
if (i + 1 >= n)
return -1;
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
if (seg < 2 || i + seg > n)
return -1;
if (jpeg_sof((int)marker) && seg >= 7) {
*h = (p[i + 3] << 8) | p[i + 4];
*w = (p[i + 5] << 8) | p[i + 6];
return 0;
}
i += seg;
}
}
return -1;
}
/* --- storage ------------------------------------------------------------- */
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
* workspace keeps the two together. */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
if (!session_path || !session_path[0])
return -1;
char dir[OS_PATH_MAX];
snprintf(dir, sizeof(dir), "%s", session_path);
char *slash = strrchr(dir, '/');
#ifdef _WIN32
char *bslash = strrchr(dir, '\\');
if (bslash && (!slash || bslash > slash))
slash = bslash;
#endif
char leaf[256];
const char *base = slash ? slash + 1 : dir;
size_t bl = strlen(base);
if (bl >= sizeof(leaf))
bl = sizeof(leaf) - 1;
memcpy(leaf, base, bl);
leaf[bl] = 0;
if (slash)
*(slash + 1) = 0;
else {
dir[0] = '.';
dir[1] = 0;
}
char *dot = strstr(leaf, ".jsonl");
if (dot)
*dot = 0;
char rel[320];
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, rel) != 0)
return -1;
if (os_mkdir_p(full, 0700) != 0)
return -1;
snprintf(out, out_sz, "%s", full);
return 0;
}
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
* (hand-edited or crafted) could point outside the attachment directory. */
int cst_attach_rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '.')
return 0;
for (const char *p = rel; *p; p++) {
char c = *p;
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
return 0;
}
return strstr(rel, "..") == NULL;
}
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz) {
if (!a || !cst_attach_rel_ok(a->rel))
return -1;
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, out_sz, dir, a->rel);
}
static char *read_whole(const char *path, size_t *out_len, size_t max) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
cst_buf_t b = {0};
char chunk[16384];
size_t n;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
cst_buf_free(&b);
fclose(f);
return NULL;
}
}
fclose(f);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */
static void make_id(const char *name, const char *data, size_t n, char *out,
size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
}
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin,
char *err, size_t err_sz) {
if (!name || !name[0])
name = "image";
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory");
return NULL;
}
cst_attachment_t *a = calloc(1, sizeof(*a));
if (!a) {
snprintf(err, err_sz, "out of memory");
return NULL;
}
make_id(name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->bytes = (long long)n;
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment path is too long");
free(a);
return NULL;
}
FILE *f = fopen(full, "wb");
if (!f) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
size_t w = fwrite(data, 1, n, f);
if (fclose(f) != 0 || w != n) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
return a;
}
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the image in");
return NULL;
}
if (!data || !n) {
snprintf(err, err_sz, "image is empty");
return NULL;
}
return store(session_path, name, data, n, "user", err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
free(data);
return a;
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment has no stored file");
return NULL;
}
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session "
"store",
a->name);
return NULL;
}
char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
a->name);
free(data);
return NULL;
}
return data;
}
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
char err[256];
size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
if (!data)
return NULL;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64)
return NULL;
cst_buf_t b = {0};
cst_buf_printf(&b, "data:%s;base64,", mime);
cst_buf_puts(&b, b64);
free(b64);
return cst_buf_steal(&b);
}
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
if (!a) {
snprintf(out, out_sz, "(none)");
return;
}
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
char dim[32] = "";
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
out[0] = 0;
if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
(double)a->bytes / (1024 * 1024));
else
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
(double)a->bytes / 1024);
}
/* --- lifetime ------------------------------------------------------------ */
void cst_attach_free(cst_attachment_t *a) { free(a); }
/* Entries always live in one array (a 1-element array for a single import),
* so freeing the block releases them all. */
void cst_attachments_free(cst_attachment_t *list, int n) {
(void)n;
free(list);
}
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
if (!out)
return NULL;
for (int i = 0; i < n; i++)
out[i] = list[i];
return out;
}
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src) {
if (!list || !n || !cap || !src)
return -1;
if (*n == *cap) {
int nc = *cap ? *cap * 2 : 4;
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
if (!nv)
return -1;
*list = nv;
*cap = nc;
}
(*list)[(*n)++] = *src;
return 0;
}
/* --- JSONL --------------------------------------------------------------- */
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cJSON *arr = cJSON_CreateArray();
if (!arr)
return NULL;
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", a->id);
cJSON_AddStringToObject(o, "name", a->name);
cJSON_AddStringToObject(o, "mime", a->mime);
cJSON_AddStringToObject(o, "rel", a->rel);
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height);
}
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin);
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return s;
}
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
if (n_out)
*n_out = 0;
const cJSON *arr = array_json;
if (!cJSON_IsArray(arr))
return NULL;
int n = cJSON_GetArraySize(arr);
if (n <= 0)
return NULL;
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
if (!list)
return NULL;
int k = 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, arr) {
if (!cJSON_IsObject(it))
continue;
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
!cst_attach_rel_ok(jrel->valuestring))
continue;
cst_attachment_t *a = &list[k];
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
snprintf(a->name, sizeof(a->name), "%s",
cJSON_IsString(v) ? v->valuestring : a->id);
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
snprintf(a->mime, sizeof(a->mime), "%s",
cJSON_IsString(v) ? v->valuestring : "image/*");
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "width");
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto");
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : "");
k++;
}
if (k == 0) {
free(list);
return NULL;
}
if (n_out)
*n_out = k;
return list;
}
+482
View File
@@ -0,0 +1,482 @@
#include "cst_clipboard.h"
#include "cst_attach.h"
#include "os_compat.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#endif
static void set_err(char *err, size_t err_sz, const char *msg) {
if (err && err_sz)
snprintf(err, err_sz, "%s", msg);
}
static void put_le32(unsigned char *p, unsigned long v) {
p[0] = (unsigned char)(v & 0xff);
p[1] = (unsigned char)((v >> 8) & 0xff);
p[2] = (unsigned char)((v >> 16) & 0xff);
p[3] = (unsigned char)((v >> 24) & 0xff);
}
static void put_be32(unsigned char *p, unsigned long v) {
p[0] = (unsigned char)((v >> 24) & 0xff);
p[1] = (unsigned char)((v >> 16) & 0xff);
p[2] = (unsigned char)((v >> 8) & 0xff);
p[3] = (unsigned char)(v & 0xff);
}
static unsigned long get_le32(const unsigned char *p) {
return (unsigned long)p[0] | (unsigned long)p[1] << 8 |
(unsigned long)p[2] << 16 | (unsigned long)p[3] << 24;
}
static unsigned long crc32_update(unsigned long crc, const unsigned char *p,
size_t n) {
static unsigned long table[256];
static int ready;
if (!ready) {
for (unsigned long i = 0; i < 256; i++) {
unsigned long c = i;
for (int k = 0; k < 8; k++)
c = c & 1 ? 0xedb88320UL ^ (c >> 1) : c >> 1;
table[i] = c;
}
ready = 1;
}
crc ^= 0xffffffffUL;
for (size_t i = 0; i < n; i++)
crc = table[(crc ^ p[i]) & 0xff] ^ (crc >> 8);
return crc ^ 0xffffffffUL;
}
static unsigned char *png_chunk(unsigned char *o, const char *type,
const unsigned char *data, size_t n) {
put_be32(o, (unsigned long)n);
memcpy(o + 4, type, 4);
if (n)
memcpy(o + 8, data, n);
put_be32(o + 8 + n, crc32_update(0, o + 4, n + 4));
return o + 12 + n;
}
/* RGB rows (filter byte 0 each) in stored deflate blocks: no compressor in
* the tree, and clipboard screenshots stay well under the attach cap. */
static char *png_from_raw(const unsigned char *raw, size_t raw_n, long w,
long h, size_t *n) {
size_t blocks = raw_n / 65535 + 1;
size_t z_n = 2 + raw_n + blocks * 5 + 4;
size_t total = 8 + (12 + 13) + (12 + z_n) + 12;
unsigned char *out = malloc(total);
unsigned char *z = malloc(z_n);
if (!out || !z) {
free(out);
free(z);
return NULL;
}
unsigned char *q = z;
*q++ = 0x78;
*q++ = 0x01;
unsigned long a = 1, b = 0;
size_t left = raw_n, pos = 0;
do {
size_t len = left > 65535 ? 65535 : left;
*q++ = left == len ? 1 : 0;
q[0] = (unsigned char)(len & 0xff);
q[1] = (unsigned char)(len >> 8);
q[2] = (unsigned char)(~len & 0xff);
q[3] = (unsigned char)((~len >> 8) & 0xff);
q += 4;
memcpy(q, raw + pos, len);
for (size_t i = 0; i < len; i++) {
a = (a + raw[pos + i]) % 65521;
b = (b + a) % 65521;
}
q += len;
pos += len;
left -= len;
} while (left);
put_be32(q, b << 16 | a);
q += 4;
unsigned char ihdr[13];
put_be32(ihdr, (unsigned long)w);
put_be32(ihdr + 4, (unsigned long)h);
ihdr[8] = 8; /* bit depth */
ihdr[9] = 2; /* RGB */
ihdr[10] = ihdr[11] = ihdr[12] = 0;
unsigned char *o = out;
memcpy(o, "\x89PNG\r\n\x1a\n", 8);
o = png_chunk(o + 8, "IHDR", ihdr, sizeof(ihdr));
o = png_chunk(o, "IDAT", z, (size_t)(q - z));
o = png_chunk(o, "IEND", NULL, 0);
free(z);
*n = (size_t)(o - out);
return (char *)out;
}
/* CF_DIB is a BMP without its 14-byte file header; the pixel offset depends
* on the header size, BI_BITFIELDS masks and the color table. */
char *cst_dib_to_image(const void *dib_v, size_t sz, size_t *n) {
const unsigned char *dib = dib_v;
if (!dib || sz < 40)
return NULL;
unsigned long hsize = get_le32(dib);
long w = (long)(int)get_le32(dib + 4);
long hh = (long)(int)get_le32(dib + 8);
unsigned bits = (unsigned)(dib[14] | dib[15] << 8);
unsigned long comp = get_le32(dib + 16);
unsigned long used = get_le32(dib + 32);
if (hsize < 40 || hsize > sz)
return NULL;
size_t off = hsize;
if (hsize == 40 && comp == 3 /* BI_BITFIELDS */)
off += 12;
size_t colors = used;
if (!colors && bits <= 8)
colors = (size_t)1 << bits;
if (colors > 256)
return NULL;
off += colors * 4;
if (off > sz)
return NULL;
long h = hh < 0 ? -hh : hh;
if ((bits == 24 || bits == 32) && (comp == 0 || comp == 3) && w > 0 &&
h > 0 && w <= 32768 && h <= 32768) {
size_t stride = ((size_t)w * bits / 8 + 3) & ~(size_t)3;
size_t raw_n = (size_t)h * (1 + (size_t)w * 3);
if (stride * (size_t)h <= sz - off && raw_n < CST_ATTACH_MAX_BYTES) {
unsigned char *raw = malloc(raw_n);
if (!raw)
return NULL;
size_t bpp = bits / 8;
for (long y = 0; y < h; y++) {
long src_y = hh > 0 ? h - 1 - y : y; /* positive height = bottom-up */
const unsigned char *s = dib + off + (size_t)src_y * stride;
unsigned char *d = raw + (size_t)y * (1 + (size_t)w * 3);
*d++ = 0;
for (long x = 0; x < w; x++, s += bpp) {
*d++ = s[2];
*d++ = s[1];
*d++ = s[0];
}
}
char *png = png_from_raw(raw, raw_n, w, h, n);
free(raw);
return png;
}
}
char *out = malloc(14 + sz);
if (!out)
return NULL;
unsigned char *hd = (unsigned char *)out;
hd[0] = 'B';
hd[1] = 'M';
put_le32(hd + 2, (unsigned long)(14 + sz));
put_le32(hd + 6, 0);
put_le32(hd + 10, (unsigned long)(14 + off));
memcpy(out + 14, dib, sz);
*n = 14 + sz;
return out;
}
#ifdef _WIN32
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
UINT png = RegisterClipboardFormatA("PNG");
if (!OpenClipboard(NULL)) {
set_err(err, err_sz, "could not open the clipboard");
return -1;
}
HANDLE h = NULL;
int is_png = 0;
if (png && IsClipboardFormatAvailable(png)) {
h = GetClipboardData(png);
is_png = 1;
} else if (IsClipboardFormatAvailable(CF_DIB)) {
h = GetClipboardData(CF_DIB);
}
const unsigned char *p = h ? GlobalLock(h) : NULL;
if (!p) {
CloseClipboard();
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
size_t sz = (size_t)GlobalSize(h);
char *out = NULL;
if (sz > CST_ATTACH_MAX_BYTES) {
set_err(err, err_sz, "clipboard image is too large");
} else if (is_png) {
out = malloc(sz ? sz : 1);
if (out) {
memcpy(out, p, sz);
*n = sz;
}
} else {
out = cst_dib_to_image(p, sz, n);
if (!out)
set_err(err, err_sz, "clipboard bitmap is malformed");
}
GlobalUnlock(h);
CloseClipboard();
if (!out)
return -1;
*data = out;
return 0;
}
#else
/* Runs cmd (stderr already redirected by the caller); 127 = not installed. */
static int run_bytes(const char *cmd, char **data, size_t *n) {
os_run_result_t r;
memset(&r, 0, sizeof(r));
if (os_run_shell_c(NULL, cmd, CST_ATTACH_MAX_BYTES + 1, &r) != 0) {
os_run_result_free(&r);
return -1;
}
if (r.exit_code != 0 || r.out_len == 0) {
int code = r.exit_code == 127 ? 127 : -1;
os_run_result_free(&r);
return code;
}
*data = r.out;
*n = r.out_len;
return 0;
}
static int type_ok(const char *s, size_t n) {
if (n < 7 || n > 63 || strncmp(s, "image/", 6) != 0)
return 0;
for (size_t i = 0; i < n; i++)
if (!isalnum((unsigned char)s[i]) && !strchr("/.+-", s[i]))
return 0;
return 1;
}
/* Best image MIME type from a newline-separated list: PNG first. */
static int pick_type(const char *list, size_t len, char *out, size_t out_sz) {
out[0] = 0;
size_t i = 0;
while (i < len) {
size_t j = i;
while (j < len && list[j] != '\n' && list[j] != '\r')
j++;
if (type_ok(list + i, j - i)) {
int is_png = j - i == 9 && strncmp(list + i, "image/png", 9) == 0;
if (is_png || !out[0])
snprintf(out, out_sz, "%.*s", (int)(j - i), list + i);
if (is_png)
break;
}
i = j + 1;
}
return out[0] ? 0 : -1;
}
static int via_lister(const char *list_cmd, const char *get_fmt,
const char *tool, char **data, size_t *n, char *err,
size_t err_sz) {
char *types = NULL;
size_t tn = 0;
int rc = run_bytes(list_cmd, &types, &tn);
if (rc == 127) {
char msg[96];
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
set_err(err, err_sz, msg);
return -1;
}
char type[64];
int ok = rc == 0 && pick_type(types, tn, type, sizeof(type)) == 0;
free(types);
if (!ok) {
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
char cmd[256];
snprintf(cmd, sizeof(cmd), get_fmt, type);
if (run_bytes(cmd, data, n) != 0) {
set_err(err, err_sz, "could not read the clipboard image");
return -1;
}
return 0;
}
#ifdef __APPLE__
static int mac_image(char **data, size_t *n, char *err, size_t err_sz) {
int rc = run_bytes("pngpaste - 2>/dev/null", data, n);
if (rc == 0)
return 0;
char path[1024];
int fd = os_mkstemp(path, sizeof(path), "cst-clip");
if (fd < 0) {
set_err(err, err_sz, "could not create a temp file");
return -1;
}
close(fd);
char cmd[1600];
snprintf(cmd, sizeof(cmd),
"osascript -e 'set f to open for access (POSIX file \"%s\") with "
"write permission' -e 'set eof f to 0' -e 'write (the clipboard as "
"\xc2\xabclass PNGf\xc2\xbb) to f' -e 'close access f' "
">/dev/null 2>&1",
path);
os_run_result_t r;
memset(&r, 0, sizeof(r));
int ran = os_run_shell_c(NULL, cmd, 4096, &r);
int code = r.exit_code;
os_run_result_free(&r);
char *buf = NULL;
size_t len = 0;
if (ran == 0 && code == 0) {
FILE *f = fopen(path, "rb");
if (f) {
char chunk[65536];
size_t got;
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 &&
len <= CST_ATTACH_MAX_BYTES) {
char *nb = realloc(buf, len + got);
if (!nb)
break;
buf = nb;
memcpy(buf + len, chunk, got);
len += got;
}
fclose(f);
}
}
remove(path);
if (!buf || !len) {
free(buf);
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
*data = buf;
*n = len;
return 0;
}
#endif
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
#ifdef __APPLE__
return mac_image(data, n, err, err_sz);
#else
const char *wl = getenv("WAYLAND_DISPLAY");
if (wl && *wl)
return via_lister("wl-paste --list-types 2>/dev/null",
"wl-paste --no-newline --type '%s' 2>/dev/null",
"wl-clipboard (wl-paste)", data, n, err, err_sz);
const char *x = getenv("DISPLAY");
if (x && *x)
return via_lister("xclip -selection clipboard -t TARGETS -o 2>/dev/null",
"xclip -selection clipboard -t '%s' -o 2>/dev/null",
"xclip", data, n, err, err_sz);
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
#endif
}
#endif
static int hexval(int c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
/* file://[host]/path -> /path, percent-decoded; 0 on success. */
static int from_file_uri(char *s) {
const char *p = s + 7;
const char *slash = strchr(p, '/');
if (!slash)
return -1;
if (slash != p && !(slash - p == 9 && strncmp(p, "localhost", 9) == 0))
return -1;
char *o = s;
for (const char *q = slash; *q; q++) {
if (*q == '%' && hexval(q[1]) >= 0 && hexval(q[2]) >= 0) {
*o++ = (char)(hexval(q[1]) * 16 + hexval(q[2]));
q += 2;
} else {
*o++ = *q;
}
}
*o = 0;
#ifdef _WIN32
if (s[0] == '/' && isalpha((unsigned char)s[1]) && s[2] == ':')
memmove(s, s + 1, strlen(s));
#endif
return s[0] ? 0 : -1;
}
static int is_space(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
int cst_paste_paths(const char *text, size_t len,
char out[][CST_PASTE_PATH_MAX], int max) {
int n = 0;
size_t i = 0;
while (i < len) {
while (i < len && is_space(text[i]))
i++;
if (i >= len)
break;
if (n >= max)
return -1;
char *tok = out[n];
size_t k = 0;
char q = 0;
while (i < len) {
char c = text[i];
if (!c)
return -1;
if (q) {
if (c == q) {
q = 0;
i++;
continue;
}
} else {
if (c == '\'' || c == '"') {
q = c;
i++;
continue;
}
if (is_space(c))
break;
#ifndef _WIN32
if (c == '\\' && i + 1 < len)
c = text[++i];
#endif
}
if (k + 1 >= CST_PASTE_PATH_MAX)
return -1;
tok[k++] = c;
i++;
}
if (q || !k)
return -1;
tok[k] = 0;
if (strncmp(tok, "file://", 7) == 0 && from_file_uri(tok) != 0)
return -1;
n++;
}
return n > 0 ? n : -1;
}
+3
View File
@@ -48,6 +48,8 @@ static const cst_key_action_info_t INFO[CST_KA_COUNT] = {
[CST_KA_COPY] = {"copy", "Session", "copy the last reply", "/copy"},
[CST_KA_EXPORT] = {"export", "Session", "export the transcript",
"/export"},
[CST_KA_PASTE_IMAGE] = {"paste_image", "Session",
"attach the clipboard image", "/attach paste"},
[CST_KA_MODEL] = {"model", "Model", "pick the model", "/model"},
[CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"},
[CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"},
@@ -89,6 +91,7 @@ static const struct {
{CST_KA_COMPACT, "<leader> c"},
{CST_KA_COPY, "<leader> y"},
{CST_KA_EXPORT, "<leader> x"},
{CST_KA_PASTE_IMAGE, "ctrl+v"},
{CST_KA_MODEL, "<leader> m"},
{CST_KA_PROVIDER, "<leader> p"},
{CST_KA_EFFORT, "<leader> e"},
+430 -12
View File
@@ -61,6 +61,9 @@ static void llm_apply_ca_bundle(CURL *curl) {
#endif
}
#include "cst_attach.h"
#include "cst_models.h"
/* --- wire body ----------------------------------------------------------- */
static size_t wire_tool_cap(const cst_llm_request_t *req) {
@@ -116,6 +119,210 @@ static cJSON *clean_string(const char *s, size_t cap) {
return j;
}
/* --- multimodal content --------------------------------------------------
* OpenAI-compatible servers take a parts array where a plain string is also
* accepted, so text-only messages keep the string form and only those with
* images switch to parts. Images live in the session store and are read (and
* base64'd) here, once per request. */
/* The message's "content" as a parts array, converting a string in place. */
static cJSON *content_parts(cJSON *msg) {
cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsArray(c))
return c;
cJSON *arr = cJSON_CreateArray();
if (cJSON_IsString(c)) {
cJSON *t = cJSON_CreateObject();
cJSON_AddStringToObject(t, "type", "text");
cJSON_AddStringToObject(t, "text", c->valuestring);
cJSON_AddItemToArray(arr, t);
}
cJSON_ReplaceItemInObjectCaseSensitive(msg, "content", arr);
return arr;
}
/* Append text, joining it onto a trailing text part when there is one.
* Takes ownership of txt. */
static void parts_add_text(cJSON *parts, cJSON *txt) {
if (!txt)
txt = cJSON_CreateString("");
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
const cJSON *lt = last ? cJSON_GetObjectItemCaseSensitive(last, "type") : NULL;
if (cJSON_IsString(lt) && strcmp(lt->valuestring, "text") == 0) {
const cJSON *old = cJSON_GetObjectItemCaseSensitive(last, "text");
cst_buf_t b = {0};
if (cJSON_IsString(old))
cst_buf_puts(&b, old->valuestring);
if (b.len)
cst_buf_puts(&b, "\n\n");
cst_buf_puts(&b, txt->valuestring ? txt->valuestring : "");
cJSON_ReplaceItemInObjectCaseSensitive(
last, "text", cJSON_CreateString(b.buf ? b.buf : ""));
cst_buf_free(&b);
cJSON_Delete(txt);
return;
}
cJSON_AddItemToArray(parts, txt);
}
/* One stored image as an image_url part, or NULL with why filled in. */
static cJSON *image_part(const cst_llm_request_t *req,
const cst_attachment_t *a, size_t *budget, char *why,
size_t why_sz) {
why[0] = 0;
if (!req->session_path || !req->session_path[0]) {
snprintf(why, why_sz, "there is no session store to read it from");
return NULL;
}
char err[256];
size_t n = 0;
char *data = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
if (!data) {
snprintf(why, why_sz, "%s", err);
return NULL;
}
if (n > *budget) {
snprintf(why, why_sz, "%d KB left in the %d MB image budget",
(int)(*budget / 1024), (int)(CST_ATTACH_MAX_TOTAL >> 20));
free(data);
return NULL;
}
*budget -= n;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* read verified it */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64) {
snprintf(why, why_sz, "out of memory");
return NULL;
}
cst_buf_t b = {0};
cst_buf_puts(&b, "data:");
cst_buf_puts(&b, mime);
cst_buf_puts(&b, ";base64,");
cst_buf_puts(&b, b64);
free(b64);
if (!b.buf) {
snprintf(why, why_sz, "out of memory");
return NULL;
}
cJSON *p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "type", "image_url");
cJSON *iu = cJSON_AddObjectToObject(p, "image_url");
cJSON_AddStringToObject(iu, "url", b.buf);
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(iu, "detail", a->detail);
cst_buf_free(&b);
return p;
}
/* Append every attachment of m as image parts (or a note saying why not). */
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget) {
int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) {
char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
n);
parts_add_text(parts, cJSON_CreateString(note));
n = CST_ATTACH_MAX_COUNT;
}
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i];
char why[256];
cJSON *p = image_part(req, a, budget, why, sizeof(why));
if (p) {
cJSON_AddItemToArray(parts, p);
continue;
}
char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
a->name, why);
parts_add_text(parts, cJSON_CreateString(note));
}
}
/* Images a tool result produced are deferred: an assistant tool_call must be
* answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */
static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON **pending, size_t *budget) {
cJSON *p = *pending;
if (!p) {
p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "role", "user");
cJSON_AddArrayToObject(p, "content");
*pending = p;
}
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget);
}
/* m's text plus one line per image it would have carried (malloc'd). */
static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) {
char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
b.len ? "\n" : "", m->attach[i].name, d, why);
}
return cst_buf_steal(&b);
}
/* --- cache breakpoints (Anthropic-style) ----------------------------------
* At most 4 per request. The system prompt one also covers tools[]; the
* newest message one moves forward every step; the newest real user message
* keeps a hit when a long tool run pushes the previous one out of the
* provider's lookback. */
static void mark_message(cJSON *msg, const char *ttl) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsString(c) ? !c->valuestring[0] : !cJSON_IsArray(c))
return;
cJSON *parts = content_parts(msg);
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
if (!cJSON_IsObject(last) ||
cJSON_GetObjectItemCaseSensitive(last, "cache_control"))
return;
cJSON *cc = cJSON_AddObjectToObject(last, "cache_control");
cJSON_AddStringToObject(cc, "type", "ephemeral");
if (ttl && ttl[0])
cJSON_AddStringToObject(cc, "ttl", ttl);
}
static int msg_role_is(const cJSON *msg, const char *role) {
const cJSON *r = cJSON_GetObjectItemCaseSensitive(msg, "role");
return cJSON_IsString(r) && strcmp(r->valuestring, role) == 0;
}
static int msg_has_content(const cJSON *msg) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
return cJSON_IsString(c) ? c->valuestring[0] != 0 : cJSON_IsArray(c);
}
static void cache_marks(cJSON *msgs, const char *ttl) {
int n = cJSON_GetArraySize(msgs);
cJSON *first = cJSON_GetArrayItem(msgs, 0);
if (first && msg_role_is(first, "system"))
mark_message(first, ttl);
int last = -1;
for (int i = n - 1; i > 0 && last < 0; i--)
if (msg_has_content(cJSON_GetArrayItem(msgs, i)))
last = i;
if (last < 0)
return;
mark_message(cJSON_GetArrayItem(msgs, last), ttl);
for (int i = last - 1; i > 0; i--) {
cJSON *m = cJSON_GetArrayItem(msgs, i);
if (msg_role_is(m, "user") && msg_has_content(m)) {
mark_message(m, ttl);
break;
}
}
}
static const char *role_api(cst_role_t r) {
switch (r) {
case CST_ROLE_SYSTEM:
@@ -146,19 +353,48 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
size_t tcap = wire_tool_cap(req);
cJSON *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL;
const char *noimg = getenv("CST_NO_IMAGES");
const char *img_off =
noimg && noimg[0] && noimg[0] != '0'
? "image sending is disabled (CST_NO_IMAGES)"
: req->images_off ? "this model does not take images"
: NULL;
for (int i = 0; i < req->nmsgs; i++) {
const cst_message_t *m = &req->msgs[i];
cst_message_t mm = req->msgs[i];
const cst_message_t *m = &mm;
cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER;
int has_img = m->attach && m->nattach > 0;
char *noted = NULL;
if (has_img && img_off) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
has_img = 0;
}
/* Every tool_call must be answered by tool messages, so tool-produced
* images only leave the queue when the run of tool results is over. */
if (role != CST_ROLE_TOOL && pending) {
cJSON_AddItemToArray(msgs, pending);
prev_user = pending;
pending = NULL;
}
if (role == CST_ROLE_USER) {
/* Text-protocol result frames are capped by the agent before framing
* (their byte counts must stay exact), so never cap user text here. */
cJSON *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL;
if (!has_img && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0);
if (prev_user) {
cJSON *pc = cJSON_GetObjectItemCaseSensitive(prev_user, "content");
cst_buf_t b = {0};
cst_buf_puts(&b, pc->valuestring);
cst_buf_puts(&b, "\n\n");
@@ -167,6 +403,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_CreateString(b.buf));
cst_buf_free(&b);
cJSON_Delete(c);
free(noted);
continue;
}
cJSON *o = cJSON_CreateObject();
@@ -174,6 +411,22 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddItemToObject(o, "content", c);
cJSON_AddItemToArray(msgs, o);
prev_user = o;
free(noted);
continue;
}
cJSON *o = prev_user;
if (!o) {
o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "role", "user");
cJSON_AddArrayToObject(o, "content");
cJSON_AddItemToArray(msgs, o);
prev_user = o;
}
cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0));
if (has_img)
parts_add_images(req, parts, m, &img_budget);
free(noted);
continue;
}
prev_user = NULL;
@@ -198,17 +451,39 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
} else {
cJSON_AddItemToObject(o, "content", clean_string(m->content, 0));
}
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_ASSISTANT) {
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");
}
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img)
defer_images(req, m, &pending, &img_budget);
free(noted);
}
if (pending)
cJSON_AddItemToArray(msgs, pending);
if (req->enable_tools) {
cJSON *tools = cst_tools_schema_cfg(req->tool_cfg);
cJSON *tools = req->tools_json ? cJSON_Parse(req->tools_json)
: cst_tools_schema_cfg(req->tool_cfg);
if (tools) {
cJSON_AddItemToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice",
req->tool_choice_none ? "none" : "auto");
}
}
if (req->cache_style == CST_CACHE_OPENAI && req->cache_key &&
req->cache_key[0]) {
cJSON_AddStringToObject(root, "prompt_cache_key", req->cache_key);
const char *ret = getenv("CST_CACHE_RETENTION");
if (ret && ret[0])
cJSON_AddStringToObject(root, "prompt_cache_retention", ret);
} else if (req->cache_style == CST_CACHE_ANTHROPIC) {
cache_marks(msgs, req->cache_ttl);
}
char *out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
@@ -371,11 +646,22 @@ static void handle_payload(sse_t *s, const char *payload) {
const cJSON *pt = cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens");
const cJSON *ct =
cJSON_GetObjectItemCaseSensitive(usage, "completion_tokens");
/* DeepSeek: prompt_cache_hit/miss_tokens. OpenAI and routers:
* prompt_tokens_details.cached_tokens (+ cache_write_tokens).
* Anthropic fields passed through: cache_read/creation_input_tokens. */
const cJSON *hit =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens");
const cJSON *miss =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_miss_tokens");
const cJSON *det =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details");
const cJSON *cached = cJSON_GetObjectItemCaseSensitive(det, "cached_tokens");
const cJSON *dwrite =
cJSON_GetObjectItemCaseSensitive(det, "cache_write_tokens");
const cJSON *aread =
cJSON_GetObjectItemCaseSensitive(usage, "cache_read_input_tokens");
const cJSON *awrite =
cJSON_GetObjectItemCaseSensitive(usage, "cache_creation_input_tokens");
if (cJSON_IsNumber(pt))
s->st->prompt_tokens = (long)pt->valuedouble;
if (cJSON_IsNumber(ct))
@@ -384,6 +670,19 @@ static void handle_payload(sse_t *s, const char *payload) {
s->st->cached_tokens = (long)hit->valuedouble;
else if (cJSON_IsNumber(cached))
s->st->cached_tokens = (long)cached->valuedouble;
else if (cJSON_IsNumber(aread))
s->st->cached_tokens = (long)aread->valuedouble;
if (cJSON_IsNumber(dwrite))
s->st->cache_write_tokens = (long)dwrite->valuedouble;
else if (cJSON_IsNumber(awrite))
s->st->cache_write_tokens = (long)awrite->valuedouble;
if (cJSON_IsNumber(miss)) {
s->st->cache_miss_tokens = (long)miss->valuedouble;
} else {
long m = s->st->prompt_tokens - s->st->cached_tokens -
s->st->cache_write_tokens;
s->st->cache_miss_tokens = m > 0 ? m : 0;
}
}
const cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
const cJSON *ch = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0)
@@ -526,15 +825,37 @@ static void trace_path(const char *dir, int seq, const char *ext, char *out,
snprintf(out, n, "%s/%04d.%s", dir, seq, ext);
}
static void format_http_error(sse_t *s, long http, char *err, size_t err_sz);
/* NNNN.sse is fed as the stream; with an NNNN.status other than 200 it is
* the error body of that HTTP status instead. */
static int replay_run(sse_t *s, const char *dir, int seq, char *err,
size_t err_sz) {
char path[OS_PATH_MAX];
trace_path(dir, seq, "status", path, sizeof(path));
FILE *sf = fopen(path, "rb");
long status = 200;
if (sf) {
if (fscanf(sf, "%ld", &status) != 1)
status = 200;
fclose(sf);
}
trace_path(dir, seq, "sse", path, sizeof(path));
FILE *fp = fopen(path, "rb");
if (!fp) {
snprintf(err, err_sz, "replay: missing %s", path);
return -1;
}
if (status != 200) {
char eb[4096];
size_t got = fread(eb, 1, sizeof(eb), fp);
fclose(fp);
cst_buf_append(&s->errbody, eb, got);
if (s->st)
s->st->http_status = status;
format_http_error(s, status, err, err_sz);
return -1;
}
size_t chunk = 16;
const char *e = getenv("CST_REPLAY_CHUNK");
if (e && atoi(e) > 0)
@@ -742,7 +1063,7 @@ finish:
/* --- model listing -------------------------------------------------------- */
#define MODELS_BODY_MAX (2 * 1024 * 1024)
#define MODELS_BODY_MAX (8 * 1024 * 1024)
static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
cst_buf_t *b = ud;
@@ -752,18 +1073,97 @@ static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
return n;
}
static int add_model(char out[][128], int n, int max_out, const char *id) {
if (!id || !id[0] || n >= max_out || strlen(id) >= 128)
/* A number, or a numeric string (OpenRouter prices are strings). */
static int json_num(const cJSON *v, double *out) {
if (cJSON_IsNumber(v)) {
*out = v->valuedouble;
return 0;
}
if (cJSON_IsString(v) && v->valuestring[0]) {
char *end;
double d = strtod(v->valuestring, &end);
if (end != v->valuestring && !*end) {
*out = d;
return 0;
}
}
return -1;
}
static int has_image(const cJSON *arr) {
const cJSON *x;
cJSON_ArrayForEach(x, arr) {
if (cJSON_IsString(x) && strcmp(x->valuestring, "image") == 0)
return 1;
}
return 0;
}
/* Optional OpenRouter-style metadata: context_length (or context_window /
* max_model_len), architecture.input_modalities, modalities, and pricing in
* USD per token. */
static void model_meta(const cJSON *it, cst_model_meta_t *m) {
static const char *const ctx_keys[] = {"context_length", "context_window",
"max_model_len"};
for (size_t k = 0; k < sizeof(ctx_keys) / sizeof(ctx_keys[0]); k++) {
double d;
if (json_num(cJSON_GetObjectItemCaseSensitive(it, ctx_keys[k]), &d) == 0 &&
d > 0) {
m->context = (long)d;
break;
}
}
const cJSON *arch = cJSON_GetObjectItemCaseSensitive(it, "architecture");
const cJSON *in = cJSON_GetObjectItemCaseSensitive(arch, "input_modalities");
const cJSON *mods = cJSON_GetObjectItemCaseSensitive(it, "modalities");
if (cJSON_IsObject(mods))
mods = cJSON_GetObjectItemCaseSensitive(mods, "input");
const cJSON *modality = cJSON_GetObjectItemCaseSensitive(arch, "modality");
if (cJSON_IsArray(in))
m->vision = has_image(in);
else if (cJSON_IsArray(mods))
m->vision = has_image(mods);
else if (cJSON_IsString(modality)) {
const char *arrow = strstr(modality->valuestring, "->");
size_t l = arrow ? (size_t)(arrow - modality->valuestring)
: strlen(modality->valuestring);
char left[64];
snprintf(left, sizeof(left), "%.*s", (int)l, modality->valuestring);
m->vision = strstr(left, "image") != NULL;
}
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(it, "pricing");
double miss, out;
if (json_num(cJSON_GetObjectItemCaseSensitive(pr, "prompt"), &miss) == 0 &&
json_num(cJSON_GetObjectItemCaseSensitive(pr, "completion"), &out) == 0 &&
miss >= 0 && out >= 0) {
double hit = miss, write = miss;
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_read"), &hit);
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_write"), &write);
m->has_price = 1;
m->miss = miss * 1e6;
m->out = out * 1e6;
m->hit = (hit >= 0 ? hit : miss) * 1e6;
m->write = (write >= 0 ? write : miss) * 1e6;
}
}
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;
for (int i = 0; i < n; i++)
if (strcmp(out[i], id) == 0)
if (strcmp(out[i].id, id) == 0)
return n;
snprintf(out[n], 128, "%s", id);
memset(&out[n], 0, sizeof(out[n]));
out[n].vision = -1;
snprintf(out[n].id, sizeof(out[n].id), "%s", id);
if (cJSON_IsObject(it))
model_meta(it, &out[n]);
return n + 1;
}
/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */
static int parse_models(const char *json, char out[][128], int max_out) {
static int parse_models(const char *json, cst_model_meta_t *out, int max_out) {
cJSON *root = cJSON_Parse(json);
if (!root)
return -1;
@@ -781,9 +1181,9 @@ static int parse_models(const char *json, char out[][128], int max_out) {
if (!cJSON_IsString(id))
id = cJSON_GetObjectItemCaseSensitive(it, "name");
if (cJSON_IsString(id))
n = add_model(out, n, max_out, id->valuestring);
n = add_model(out, n, max_out, id->valuestring, it);
else if (cJSON_IsString(it))
n = add_model(out, n, max_out, it->valuestring);
n = add_model(out, n, max_out, it->valuestring, NULL);
}
}
cJSON_Delete(root);
@@ -793,6 +1193,24 @@ static int parse_models(const char *json, char out[][128], int max_out) {
int cst_llm_list_models(const char *api_base, const char *api_key,
char out[][128], int max_out, char *err,
size_t err_sz) {
if (!out || max_out <= 0)
return cst_llm_list_models_meta(api_base, api_key, NULL, 0, err, err_sz);
cst_model_meta_t *m = calloc((size_t)max_out, sizeof(*m));
if (!m) {
if (err && err_sz)
snprintf(err, err_sz, "out of memory");
return -1;
}
int n = cst_llm_list_models_meta(api_base, api_key, m, max_out, err, err_sz);
for (int i = 0; i < n; i++)
snprintf(out[i], 128, "%s", m[i].id);
free(m);
return n;
}
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
cst_model_meta_t *out, int max_out, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
+82 -4
View File
@@ -1,5 +1,6 @@
#include "cst_agent.h"
#include "cst_ext.h"
#include "cst_models.h"
#include "cst_tools.h"
#include "cst_version.h"
#include "os_compat.h"
@@ -29,6 +30,8 @@ static void usage(const char *argv0) {
" -N, --new start a new session\n"
" -R, --resume ID open a session by id or unique prefix\n"
" -L, --list-sessions list sessions and exit\n"
" --list-models [P] what is known about each provider's models\n"
" (built-in, <config>/models.json, GET /models)\n"
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
" the sandbox or by safe rules) | yolo\n"
" -y, --yolo same as --approval yolo (this run only)\n"
@@ -81,6 +84,62 @@ static int parse_confirm(const char *s, cst_agent_config_t *cfg) {
return 0;
}
static void list_one_model(const char *provider, const char *id,
const char *where) {
cst_model_info_t mi;
cst_model_info(provider, id, &mi);
char tag[48], price[128] = "";
cst_model_info_label(&mi, tag, sizeof(tag));
if (mi.has_price)
snprintf(price, sizeof(price), " %s%g/%g/%g per 1M (miss/hit/out)",
mi.price.currency, mi.price.miss, mi.price.hit, mi.price.out);
printf(" %-40s %-18s %-8s%s [%s]\n", id, tag, where, price, mi.source);
}
/* --list-models [provider]: the merged registry, to check models.json. */
static int list_models(const char *only) {
char user[1100] = "";
cst_models_user_path(user, sizeof(user));
FILE *uf = user[0] ? fopen(user, "rb") : NULL;
printf("user file: %s%s\n", user[0] ? user : "(no config dir)",
uf ? "" : " (none)");
if (uf)
fclose(uf);
if (cst_models_load_error()[0])
printf("warning: %s\n", cst_models_load_error());
int shown = 0;
static cst_model_meta_t cached[1024];
for (int i = 0; i < cst_provider_count(); i++) {
const cst_provider_t *p = cst_provider_at(i);
if (only && only[0] && strcmp(p->name, only) != 0)
continue;
shown++;
printf("\n%s %s (%s; key %s)\n", p->name, p->api_base,
cst_provider_source(p), p->key_env[0] ? p->key_env : "-");
char listed[16][128];
int nl = cst_provider_models(p, listed, 16);
for (int k = 0; k < nl; k++)
list_one_model(p->name, listed[k],
strcmp(listed[k], p->model) == 0 ? "default" : "listed");
long age = 0;
int nc = cst_models_cache_load(p->name, cached, 1024, &age);
if (nc > 0) {
printf(" -- %d from GET /models, fetched %ldh ago%s\n", nc,
age / 3600, age >= CST_MODELS_CACHE_TTL ? " (stale)" : "");
for (int k = 0; k < nc; k++)
list_one_model(p->name, cached[k].id, "api");
} else if (!nl) {
printf(" (no models listed; the first one GET /models returns is "
"used)\n");
}
}
if (only && only[0] && !shown) {
fprintf(stderr, "unknown provider: %s\n", only);
return 1;
}
return 0;
}
static int list_sessions(const char *ws) {
static char ids[200][128];
int n = cst_agent_store_list(ws, ids, 200);
@@ -286,6 +345,7 @@ int main(int argc, char **argv) {
{"effort", required_argument, 0, 'e'},
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
{"list-models", optional_argument, 0, 0x100},
{0, 0, 0, 0}};
int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
@@ -385,6 +445,14 @@ int main(int argc, char **argv) {
usage(argv[0]);
curl_global_cleanup();
return 0;
case 0x100: {
const char *only = optarg;
if (!only && optind < argc && argv[optind][0] != '-')
only = argv[optind++];
int rc = list_models(only);
curl_global_cleanup();
return rc;
}
case 'V':
printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup();
@@ -543,10 +611,20 @@ int main(int argc, char **argv) {
/* Router presets have no fixed model: take the first one the API lists
* (the TUI does the same after its /models probe). */
if (!cfg.model[0] && cfg.headless) {
char ids[1][128], err[256] = "";
if (cst_llm_list_models(cfg.api_base, cfg.api_key, ids, 1, err,
sizeof(err)) > 0) {
snprintf(cfg.model, sizeof(cfg.model), "%s", ids[0]);
static cst_model_meta_t list[512];
char err[256] = "";
long age = 0;
int n = cst_models_cache_load(cfg.provider, list, 1, &age);
if (n <= 0 || age >= CST_MODELS_CACHE_TTL) {
int r = cst_llm_list_models_meta(cfg.api_base, cfg.api_key, list, 512,
err, sizeof(err));
if (r >= 0)
cst_models_cache_save(cfg.provider, cfg.api_base, list, r);
if (r >= 0 || n <= 0)
n = r;
}
if (n > 0) {
snprintf(cfg.model, sizeof(cfg.model), "%s", list[0].id);
} else {
fprintf(stderr, "no model for %s: pass --model (%s)\n", cfg.provider,
err[0] ? err : "the API listed none");
+61 -3
View File
@@ -6,6 +6,8 @@
#endif
#include "cst_mcp.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include <pthread.h>
#include <stdatomic.h>
@@ -602,7 +604,56 @@ static int auto_approved(const mcp_srv_t *s, const char *tool) {
return 0;
}
static void put_content(cst_buf_t *b, const cJSON *item) {
/* Images a tool call returned, stored beside the session. */
typedef struct {
const char *session;
const char *server;
cst_attachment_t *list;
int n, cap;
} mcp_imgs_t;
/* An image content item goes into the session store and rides along with the
* tool result; the text keeps a one-line description. 0 when stored. */
static int put_image(cst_buf_t *b, const cJSON *item, mcp_imgs_t *im) {
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
if (!im || !cJSON_IsString(d))
return -1;
char err[256] = "no session to store the image in";
if (im->n >= CST_ATTACH_MAX_COUNT) {
snprintf(err, sizeof(err), "more than %d images", CST_ATTACH_MAX_COUNT);
} else if (im->session && im->session[0]) {
size_t n = 0;
char *raw = cst_base64_decode(d->valuestring, strlen(d->valuestring), &n);
char name[96];
snprintf(name, sizeof(name), "%s-%d", im->server, im->n + 1);
cst_attachment_t *a =
raw ? cst_attach_import_bytes(im->session, name, raw, n, err,
sizeof(err))
: NULL;
if (!raw)
snprintf(err, sizeof(err), "bad base64");
free(raw);
if (a) {
snprintf(a->origin, sizeof(a->origin), "mcp");
int pushed = cst_attachments_push(&im->list, &im->n, &im->cap, a) == 0;
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_attach_free(a);
if (pushed) {
cst_buf_printf(b, "[image %s (shown to you as an image)]", desc);
return 0;
}
snprintf(err, sizeof(err), "out of memory");
}
}
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
cst_buf_printf(b, "[image %s, %zu bytes base64, not shown: %s]",
cJSON_IsString(mt) ? mt->valuestring : "",
strlen(d->valuestring), err);
return -1;
}
static void put_content(cst_buf_t *b, const cJSON *item, mcp_imgs_t *im) {
const cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type");
const char *t = cJSON_IsString(type) ? type->valuestring : "";
if (b->len && b->buf[b->len - 1] != '\n')
@@ -611,6 +662,9 @@ static void put_content(cst_buf_t *b, const cJSON *item) {
const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "text");
if (cJSON_IsString(x))
cst_buf_puts(b, x->valuestring);
} else if (strcmp(t, "image") == 0 &&
cJSON_IsString(cJSON_GetObjectItemCaseSensitive(item, "data"))) {
put_image(b, item, im);
} else if (strcmp(t, "image") == 0 || strcmp(t, "audio") == 0) {
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
@@ -660,7 +714,6 @@ static int set_out(cst_tool_result_t *r, int code, const char *fmt, ...) {
static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r, void *ud) {
(void)ctx;
mtool_t *mt = ud;
mcp_srv_t *s = mt->srv;
pthread_mutex_lock(&s->mu);
@@ -690,9 +743,12 @@ static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
const cJSON *res = cJSON_GetObjectItemCaseSensitive(resp, "result");
const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content");
cst_buf_t b = {0};
mcp_imgs_t im = {0};
im.session = ctx && ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
im.server = s->name;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, content) {
put_content(&b, it);
put_content(&b, it, &im);
if (b.len > MCP_OUT_MAX)
break;
}
@@ -716,6 +772,8 @@ static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cJSON_Delete(resp);
r->out_len = b.len;
r->out = cst_buf_steal(&b);
r->attach = im.list; /* one block, freed by cst_tool_result_free */
r->nattach = im.n;
return 0;
}
+818
View File
@@ -0,0 +1,818 @@
#include "cst_models.h"
#include "cJSON.h"
#include "os_compat.h"
#include <ctype.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static const char BUILTIN[] = {
#include "models_builtin.inc"
0};
const char *cst_models_builtin_json(void) { return BUILTIN; }
#define MAX_PROV 32
#define MAX_PMODELS 16
#define MAX_RULES 256
typedef struct {
cst_provider_t pub; /* points into the buffers below */
char name[64];
char api_base[256];
char model[128];
char key_env[64];
int passback, prefix;
char effort[16]; /* "", "passthrough", "openai" */
char models[MAX_PMODELS][128];
int nmodels;
const char *source;
} prov_t;
enum { F_CONTEXT = 1, F_VISION = 2, F_CACHE = 4, F_REASONING = 8, F_PRICE = 16 };
typedef struct {
char match[128]; /* lowercase */
int spec; /* literal characters: higher is more specific */
unsigned has; /* F_* set by this rule */
long context;
int vision, cache, reasoning;
cst_price_t price;
} rule_t;
typedef struct {
char provider[64];
long fetched_at;
cst_model_meta_t m;
} cached_t;
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static struct {
int loaded;
prov_t prov[MAX_PROV];
int nprov;
rule_t builtin[MAX_RULES];
int nbuiltin;
rule_t user[MAX_RULES];
int nuser;
cached_t *cache;
int ncache;
char err[1400];
} R;
/* --- parsing ------------------------------------------------------------- */
static void lower_copy(char *out, size_t n, const char *s) {
size_t i = 0;
for (; s && s[i] && i + 1 < n; i++)
out[i] = (char)tolower((unsigned char)s[i]);
out[i] = 0;
}
static const char *jstr(const cJSON *o, const char *k) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
return cJSON_IsString(v) ? v->valuestring : NULL;
}
static void fix_pub(prov_t *p) {
p->pub.name = p->name;
p->pub.api_base = p->api_base;
p->pub.model = p->model;
p->pub.key_env = p->key_env;
}
static prov_t *prov_by_name(const char *name) {
for (int i = 0; i < R.nprov; i++)
if (strcmp(R.prov[i].name, name) == 0)
return &R.prov[i];
return NULL;
}
static void parse_providers(const cJSON *obj, const char *source) {
const cJSON *it = NULL;
cJSON_ArrayForEach(it, obj) {
if (!cJSON_IsObject(it) || !it->string || !it->string[0])
continue;
prov_t *p = prov_by_name(it->string);
if (!p) {
if (R.nprov >= MAX_PROV)
continue;
p = &R.prov[R.nprov++];
memset(p, 0, sizeof(*p));
snprintf(p->name, sizeof(p->name), "%s", it->string);
fix_pub(p);
}
p->source = source;
const char *s;
if ((s = jstr(it, "api_base")))
snprintf(p->api_base, sizeof(p->api_base), "%s", s);
if ((s = jstr(it, "key_env")))
snprintf(p->key_env, sizeof(p->key_env), "%s", s);
if ((s = jstr(it, "default_model")))
snprintf(p->model, sizeof(p->model), "%s", s);
if ((s = jstr(it, "effort")))
snprintf(p->effort, sizeof(p->effort), "%s", s);
const cJSON *b = cJSON_GetObjectItemCaseSensitive(it, "reasoning_passback");
if (cJSON_IsBool(b))
p->passback = cJSON_IsTrue(b);
b = cJSON_GetObjectItemCaseSensitive(it, "prefix_completion");
if (cJSON_IsBool(b))
p->prefix = cJSON_IsTrue(b);
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(it, "models");
if (cJSON_IsArray(ms)) {
p->nmodels = 0;
const cJSON *m = NULL;
cJSON_ArrayForEach(m, ms) {
if (cJSON_IsString(m) && m->valuestring[0] && p->nmodels < MAX_PMODELS)
snprintf(p->models[p->nmodels++], 128, "%s", m->valuestring);
}
}
}
}
static int parse_price(const cJSON *o, cst_price_t *pr) {
const cJSON *miss = cJSON_GetObjectItemCaseSensitive(o, "miss");
const cJSON *out = cJSON_GetObjectItemCaseSensitive(o, "out");
if (!cJSON_IsNumber(miss) || !cJSON_IsNumber(out))
return -1;
memset(pr, 0, sizeof(*pr));
pr->miss = miss->valuedouble;
pr->out = out->valuedouble;
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "hit");
pr->hit = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
v = cJSON_GetObjectItemCaseSensitive(o, "write");
pr->write = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
const char *c = jstr(o, "currency");
snprintf(pr->currency, sizeof(pr->currency), "%s", c ? c : "$");
pr->offpeak_half =
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(o, "offpeak_half"));
return 0;
}
static int parse_rule(const cJSON *o, rule_t *r) {
const char *m = jstr(o, "match");
if (!m || !m[0])
return -1;
memset(r, 0, sizeof(*r));
lower_copy(r->match, sizeof(r->match), m);
for (const char *q = r->match; *q; q++)
if (*q != '*')
r->spec++;
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
if (cJSON_IsNumber(v) && v->valuedouble > 0) {
r->context = (long)v->valuedouble;
r->has |= F_CONTEXT;
}
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
if (cJSON_IsBool(v)) {
r->vision = cJSON_IsTrue(v);
r->has |= F_VISION;
}
v = cJSON_GetObjectItemCaseSensitive(o, "reasoning");
if (cJSON_IsBool(v)) {
r->reasoning = cJSON_IsTrue(v);
r->has |= F_REASONING;
}
const char *c = jstr(o, "cache");
if (c) {
if (strcmp(c, "openai") == 0)
r->cache = CST_CACHE_OPENAI;
else if (strcmp(c, "anthropic") == 0)
r->cache = CST_CACHE_ANTHROPIC;
else
r->cache = CST_CACHE_AUTO;
r->has |= F_CACHE;
}
v = cJSON_GetObjectItemCaseSensitive(o, "price");
if (cJSON_IsObject(v) && parse_price(v, &r->price) == 0)
r->has |= F_PRICE;
return 0;
}
static int parse_doc(const char *json, int user, char *err, size_t err_sz) {
cJSON *doc = cJSON_Parse(json);
if (!cJSON_IsObject(doc)) {
cJSON_Delete(doc);
snprintf(err, err_sz, "not a JSON object");
return -1;
}
parse_providers(cJSON_GetObjectItemCaseSensitive(doc, "providers"),
user ? "user" : "built-in");
rule_t *rules = user ? R.user : R.builtin;
int *n = user ? &R.nuser : &R.nbuiltin;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
if (*n < MAX_RULES && parse_rule(it, &rules[*n]) == 0)
(*n)++;
}
cJSON_Delete(doc);
return 0;
}
static char *read_file(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
char *buf = NULL;
size_t len = 0;
char chunk[8192];
size_t got;
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 && len < (8u << 20)) {
char *nb = realloc(buf, len + got + 1);
if (!nb)
break;
buf = nb;
memcpy(buf + len, chunk, got);
len += got;
}
fclose(f);
if (buf)
buf[len] = 0;
return buf;
}
static int cache_path(const char *provider, char *out, size_t n) {
char dir[1024];
if (!provider || !provider[0] || os_config_dir(dir, sizeof(dir)) != 0)
return -1;
for (const char *q = provider; *q; q++)
if (!isalnum((unsigned char)*q) && !strchr("._-", *q))
return -1;
int w = snprintf(out, n, "%s/models-cache/%s.json", dir, provider);
return w > 0 && (size_t)w < n ? 0 : -1;
}
static void meta_from_json(const cJSON *o, cst_model_meta_t *m) {
memset(m, 0, sizeof(*m));
m->vision = -1;
const char *id = jstr(o, "id");
snprintf(m->id, sizeof(m->id), "%s", id ? id : "");
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
if (cJSON_IsNumber(v) && v->valuedouble > 0)
m->context = (long)v->valuedouble;
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
if (cJSON_IsBool(v))
m->vision = cJSON_IsTrue(v);
v = cJSON_GetObjectItemCaseSensitive(o, "price");
cst_price_t pr;
if (cJSON_IsObject(v) && parse_price(v, &pr) == 0) {
m->has_price = 1;
m->miss = pr.miss;
m->hit = pr.hit;
m->write = pr.write;
m->out = pr.out;
}
}
/* Appends provider's cache file to R.cache (caller holds the lock). */
static void load_cache(const char *provider) {
char path[1100];
if (cache_path(provider, path, sizeof(path)) != 0)
return;
char *txt = read_file(path);
cJSON *doc = txt ? cJSON_Parse(txt) : NULL;
free(txt);
const cJSON *fa = cJSON_GetObjectItemCaseSensitive(doc, "fetched_at");
long at = cJSON_IsNumber(fa) ? (long)fa->valuedouble : 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
cst_model_meta_t m;
meta_from_json(it, &m);
if (!m.id[0])
continue;
cached_t *nc = realloc(R.cache, (size_t)(R.ncache + 1) * sizeof(*nc));
if (!nc)
break;
R.cache = nc;
cached_t *c = &R.cache[R.ncache++];
snprintf(c->provider, sizeof(c->provider), "%s", provider);
c->fetched_at = at;
c->m = m;
}
cJSON_Delete(doc);
}
static void drop_cache(const char *provider) {
int w = 0;
for (int i = 0; i < R.ncache; i++)
if (provider && strcmp(R.cache[i].provider, provider) != 0)
R.cache[w++] = R.cache[i];
R.ncache = w;
}
static void reset_locked(void) {
R.nprov = 0;
R.nbuiltin = 0;
R.nuser = 0;
drop_cache(NULL);
R.err[0] = 0;
}
static void load_caches_locked(void) {
for (int i = 0; i < R.nprov; i++)
load_cache(R.prov[i].name);
}
int cst_models_user_path(char *out, size_t out_sz) {
char dir[1024];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
int w = snprintf(out, out_sz, "%s/models.json", dir);
return w > 0 && (size_t)w < out_sz ? 0 : -1;
}
static void load_default_locked(void) {
reset_locked();
char e[128];
if (parse_doc(BUILTIN, 0, e, sizeof(e)) != 0)
snprintf(R.err, sizeof(R.err), "built-in model data: %s", e);
char path[1100];
char *user = cst_models_user_path(path, sizeof(path)) == 0
? read_file(path)
: NULL;
if (user && parse_doc(user, 1, e, sizeof(e)) != 0)
snprintf(R.err, sizeof(R.err), "%s: %s", path, e);
free(user);
load_caches_locked();
R.loaded = 1;
}
static void ensure_locked(void) {
if (!R.loaded)
load_default_locked();
}
void cst_models_reload(void) {
pthread_mutex_lock(&g_mu);
load_default_locked();
pthread_mutex_unlock(&g_mu);
}
int cst_models_load_json(const char *builtin_json, const char *user_json,
char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
pthread_mutex_lock(&g_mu);
reset_locked();
int rc = 0;
if (builtin_json && parse_doc(builtin_json, 0, err, err_sz) != 0)
rc = -1;
if (rc == 0 && user_json && parse_doc(user_json, 1, err, err_sz) != 0)
rc = -1;
load_caches_locked();
R.loaded = 1;
pthread_mutex_unlock(&g_mu);
return rc;
}
const char *cst_models_load_error(void) {
pthread_mutex_lock(&g_mu);
ensure_locked();
pthread_mutex_unlock(&g_mu);
return R.err;
}
/* --- providers ----------------------------------------------------------- */
const cst_provider_t *cst_provider_find(const char *name) {
if (!name || !name[0])
return NULL;
pthread_mutex_lock(&g_mu);
ensure_locked();
prov_t *p = prov_by_name(name);
pthread_mutex_unlock(&g_mu);
return p ? &p->pub : NULL;
}
const cst_provider_t *cst_provider_default(void) {
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
return p ? p : cst_provider_at(0);
}
int cst_provider_count(void) {
pthread_mutex_lock(&g_mu);
ensure_locked();
int n = R.nprov;
pthread_mutex_unlock(&g_mu);
return n;
}
const cst_provider_t *cst_provider_at(int i) {
pthread_mutex_lock(&g_mu);
ensure_locked();
const cst_provider_t *p = i >= 0 && i < R.nprov ? &R.prov[i].pub : NULL;
pthread_mutex_unlock(&g_mu);
return p;
}
const char *cst_provider_source(const cst_provider_t *p) {
for (int i = 0; p && i < MAX_PROV; i++)
if (p == &R.prov[i].pub)
return R.prov[i].source ? R.prov[i].source : "built-in";
return "";
}
int cst_provider_models(const cst_provider_t *p, char out[][128], int max) {
int n = 0;
pthread_mutex_lock(&g_mu);
for (int i = 0; p && i < R.nprov; i++) {
if (p != &R.prov[i].pub)
continue;
for (int k = 0; k < R.prov[i].nmodels && n < max; k++)
snprintf(out[n++], 128, "%s", R.prov[i].models[k]);
}
pthread_mutex_unlock(&g_mu);
return n;
}
static int push_name(char out[][128], int *n, int max, const char *name) {
if (!name || !name[0] || *n >= max)
return 0;
for (int i = 0; i < *n; i++)
if (strcmp(out[i], name) == 0)
return 0;
snprintf(out[(*n)++], 128, "%s", name);
return 1;
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
return 0;
int n = 0;
const char *env = getenv("CST_MODELS");
if (env && env[0]) {
char buf[1024];
snprintf(buf, sizeof(buf), "%s", env);
for (char *tok = buf; *tok;) {
while (*tok == ':' || *tok == ',' || *tok == ' ')
tok++;
char *end = tok;
while (*end && *end != ':' && *end != ',')
end++;
char save = *end;
*end = 0;
char *t = end;
while (t > tok && (t[-1] == ' ' || t[-1] == '\t'))
*--t = 0;
push_name(out, &n, max_out, tok);
*end = save;
tok = *end ? end + 1 : end;
}
} else {
pthread_mutex_lock(&g_mu);
ensure_locked();
for (int i = 0; i < R.nprov; i++)
for (int k = 0; k < R.prov[i].nmodels; k++)
push_name(out, &n, max_out, R.prov[i].models[k]);
pthread_mutex_unlock(&g_mu);
}
push_name(out, &n, max_out, current);
return n;
}
/* --- model lookup -------------------------------------------------------- */
/* '*' matches any run; without one the pattern is a prefix. */
static int glob(const char *p, const char *s) {
if (!strchr(p, '*'))
return strncmp(s, p, strlen(p)) == 0;
while (*p) {
if (*p == '*') {
while (*p == '*')
p++;
if (!*p)
return 1;
for (; *s; s++)
if (glob(p, s))
return 1;
return 0;
}
if (*p != *s)
return 0;
p++;
s++;
}
return !*s;
}
static void set_field(cst_model_info_t *o, unsigned f, const rule_t *r) {
switch (f) {
case F_CONTEXT:
o->context = r->context;
break;
case F_VISION:
o->vision = r->vision;
break;
case F_CACHE:
o->cache_style = r->cache;
break;
case F_REASONING:
o->reasoning = r->reasoning;
break;
case F_PRICE:
o->price = r->price;
o->has_price = 1;
break;
}
}
static void add_source(cst_model_info_t *o, const char *src) {
if (strstr(o->source, src))
return;
size_t l = strlen(o->source);
snprintf(o->source + l, sizeof(o->source) - l, "%s%s", l ? "+" : "", src);
}
static void apply_rules(const rule_t *rules, int n, const char *full,
const char *bare, unsigned *have, cst_model_info_t *o,
const char *src) {
for (unsigned f = 1; f <= F_PRICE; f <<= 1) {
if (*have & f)
continue;
const rule_t *best = NULL;
for (int i = 0; i < n; i++) {
const rule_t *r = &rules[i];
if (!(r->has & f) || (best && r->spec <= best->spec))
continue;
if (glob(r->match, strchr(r->match, '/') ? full : bare))
best = r;
}
if (best) {
set_field(o, f, best);
*have |= f;
add_source(o, src);
}
}
}
static void apply_cache(const char *provider, const char *model,
unsigned *have, cst_model_info_t *o) {
for (int i = 0; i < R.ncache; i++) {
const cached_t *c = &R.cache[i];
if ((provider && strcmp(c->provider, provider) != 0) ||
strcmp(c->m.id, model) != 0)
continue;
unsigned before = *have;
if (!(*have & F_CONTEXT) && c->m.context > 0) {
o->context = c->m.context;
*have |= F_CONTEXT;
}
if (!(*have & F_VISION) && c->m.vision >= 0) {
o->vision = c->m.vision;
*have |= F_VISION;
}
if (!(*have & F_PRICE) && c->m.has_price) {
memset(&o->price, 0, sizeof(o->price));
snprintf(o->price.currency, sizeof(o->price.currency), "$");
o->price.miss = c->m.miss;
o->price.hit = c->m.hit;
o->price.write = c->m.write;
o->price.out = c->m.out;
o->has_price = 1;
*have |= F_PRICE;
}
if (*have != before)
add_source(o, "api");
return;
}
}
static int env_price(cst_price_t *out) {
const char *e = getenv("CST_PRICE");
if (!e || !e[0])
return -1;
memset(out, 0, sizeof(*out));
char cur[8] = "";
int n = sscanf(e, "%lf,%lf,%lf,%lf,%7s", &out->miss, &out->hit, &out->out,
&out->write, cur);
if (n < 3)
return -1;
if (n < 4)
out->write = out->miss;
snprintf(out->currency, sizeof(out->currency), "%s", cur);
return 0;
}
void cst_model_info(const char *provider, const char *model,
cst_model_info_t *out) {
memset(out, 0, sizeof(*out));
out->vision = -1;
unsigned have = 0;
const char *e = getenv("CST_CTX_TOKENS");
if (e && atol(e) > 0) {
out->context = atol(e);
have |= F_CONTEXT;
add_source(out, "env");
}
if (env_price(&out->price) == 0) {
out->has_price = 1;
have |= F_PRICE;
add_source(out, "env");
}
if (model && model[0]) {
char full[256], bare[256];
lower_copy(full, sizeof(full), model);
const char *slash = strrchr(full, '/');
snprintf(bare, sizeof(bare), "%s", slash && slash[1] ? slash + 1 : full);
pthread_mutex_lock(&g_mu);
ensure_locked();
apply_rules(R.user, R.nuser, full, bare, &have, out, "user");
if (provider && !prov_by_name(provider))
provider = NULL;
apply_cache(provider, model, &have, out);
apply_rules(R.builtin, R.nbuiltin, full, bare, &have, out, "built-in");
pthread_mutex_unlock(&g_mu);
}
if (!(have & F_CONTEXT)) {
out->context = 128000;
add_source(out, "default");
}
}
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n) {
char ctx[32];
if (mi->context >= 1000000)
snprintf(ctx, sizeof(ctx), "%.0fM", (double)mi->context / 1e6);
else
snprintf(ctx, sizeof(ctx), "%ldK", (mi->context + 500) / 1000);
snprintf(out, n, "%s%s", ctx,
mi->vision == 1 ? " · vision"
: mi->vision == 0 ? " · text only"
: "");
}
/* --- wrappers kept for the rest of the code ------------------------------ */
long cst_model_context_tokens(const char *model) {
cst_model_info_t i;
cst_model_info(NULL, model, &i);
return i.context;
}
int cst_model_price(const char *model, cst_price_t *out) {
cst_model_info_t i;
cst_model_info(NULL, model, &i);
*out = i.price;
if (!i.has_price)
memset(out, 0, sizeof(*out));
return i.has_price ? 0 : -1;
}
/* "https://api.deepseek.com/v1" -> "api.deepseek.com" */
static void host_of(const char *url, char *out, size_t n) {
const char *s = strstr(url, "://");
s = s ? s + 3 : url;
size_t i = 0;
for (; s[i] && s[i] != '/' && i + 1 < n; i++)
out[i] = s[i];
out[i] = 0;
}
void cst_model_caps(const char *provider, const char *api_base,
const char *model, cst_model_caps_t *out) {
memset(out, 0, sizeof(*out));
pthread_mutex_lock(&g_mu);
ensure_locked();
/* provider extensions follow the provider, or its host behind any name */
for (int i = 0; i < R.nprov; i++) {
const prov_t *p = &R.prov[i];
if (!p->passback && !p->prefix)
continue;
char host[256];
host_of(p->api_base, host, sizeof(host));
if ((provider && strcmp(provider, p->name) == 0) ||
(api_base && host[0] && strstr(api_base, host))) {
out->reasoning_passback |= p->passback;
out->prefix_completion |= p->prefix;
}
}
pthread_mutex_unlock(&g_mu);
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
out->vision = mi.vision;
const char *e = getenv("CST_NO_CACHE_HINTS");
if ((e && e[0] == '1') || !model)
return;
out->cache_style = mi.cache_style;
}
const char *cst_effort_wire(const char *provider, const char *model,
const char *effort) {
if (!cst_effort_valid(effort) || !provider || !model)
return NULL;
char mode[16] = "";
pthread_mutex_lock(&g_mu);
ensure_locked();
const prov_t *p = prov_by_name(provider);
if (p)
snprintf(mode, sizeof(mode), "%s", p->effort);
pthread_mutex_unlock(&g_mu);
if (strcmp(mode, "passthrough") == 0)
return effort;
if (strcmp(mode, "openai") == 0) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
if (mi.reasoning)
return strcmp(effort, "max") == 0 ? "high" : effort;
}
return NULL;
}
/* --- fetched cache ------------------------------------------------------- */
int cst_models_cache_save(const char *provider, const char *api_base,
const cst_model_meta_t *list, int n) {
char path[1100];
if (!list || n < 0 || cache_path(provider, path, sizeof(path)) != 0)
return -1;
char dir[1100];
snprintf(dir, sizeof(dir), "%s", path);
char *sl = strrchr(dir, '/');
if (sl)
*sl = 0;
os_mkdir_p(dir, 0700);
long now = (long)time(NULL);
cJSON *doc = cJSON_CreateObject();
cJSON_AddNumberToObject(doc, "fetched_at", (double)now);
if (api_base)
cJSON_AddStringToObject(doc, "api_base", api_base);
cJSON *arr = cJSON_AddArrayToObject(doc, "models");
for (int i = 0; i < n; i++) {
const cst_model_meta_t *m = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", m->id);
if (m->context > 0)
cJSON_AddNumberToObject(o, "context", (double)m->context);
if (m->vision >= 0)
cJSON_AddBoolToObject(o, "vision", m->vision);
if (m->has_price) {
cJSON *p = cJSON_AddObjectToObject(o, "price");
cJSON_AddStringToObject(p, "currency", "$");
cJSON_AddNumberToObject(p, "miss", m->miss);
cJSON_AddNumberToObject(p, "hit", m->hit);
cJSON_AddNumberToObject(p, "write", m->write);
cJSON_AddNumberToObject(p, "out", m->out);
}
cJSON_AddItemToArray(arr, o);
}
char *txt = cJSON_Print(doc);
cJSON_Delete(doc);
if (!txt)
return -1;
char tmp[1200];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE *f = fopen(tmp, "wb");
int rc = -1;
if (f) {
size_t len = strlen(txt);
rc = fwrite(txt, 1, len, f) == len ? 0 : -1;
if (fclose(f) != 0)
rc = -1;
if (rc == 0)
rc = os_rename_replace(tmp, path);
if (rc != 0)
remove(tmp);
}
free(txt);
if (rc == 0) {
pthread_mutex_lock(&g_mu);
ensure_locked();
drop_cache(provider);
load_cache(provider);
pthread_mutex_unlock(&g_mu);
}
return rc;
}
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
long *age_s) {
if (age_s)
*age_s = 0;
if (!provider)
return -1;
pthread_mutex_lock(&g_mu);
ensure_locked();
int n = 0, found = 0;
for (int i = 0; i < R.ncache; i++) {
const cached_t *c = &R.cache[i];
if (strcmp(c->provider, provider) != 0)
continue;
if (!found && age_s)
*age_s = (long)time(NULL) - c->fetched_at;
found = 1;
if (out && n < max)
out[n++] = c->m;
}
pthread_mutex_unlock(&g_mu);
return found ? n : -1;
}
+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;
}
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;
return (tmv.tm_hour >= 9 && tmv.tm_hour < 12) ||
(tmv.tm_hour >= 14 && tmv.tm_hour < 18);
}
static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) {
if (!name || !name[0] || *n >= max)
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
long out, time_t when) {
if (!p)
return 0;
if (already(out, *n, name))
return 0;
snprintf(out[*n], MODEL_MAX_LEN, "%s", name);
(*n)++;
return 1;
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
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;
}
+70
View File
@@ -0,0 +1,70 @@
{
"providers": {
"minimax": {
"api_base": "https://api.minimax.cn/v1",
"key_env": "MINIMAX_API_KEY",
"default_model": "MiniMax-M2.7",
"models": ["MiniMax-M2.7", "MiniMax-M2.5"]
},
"deepseek": {
"api_base": "https://api.deepseek.com/v1",
"key_env": "DEEPSEEK_API_KEY",
"default_model": "deepseek-flash",
"models": ["deepseek-flash", "deepseek-v4-pro"],
"reasoning_passback": true,
"prefix_completion": true,
"effort": "passthrough"
},
"openai": {
"api_base": "https://api.openai.com/v1",
"key_env": "OPENAI_API_KEY",
"default_model": "gpt-4.1-mini",
"models": ["gpt-4.1-mini", "gpt-4o"],
"effort": "openai"
},
"teamorouter": {
"api_base": "https://api.teamorouter.com/v1",
"key_env": "TEAMOROUTER_API_KEY",
"default_model": ""
}
},
"models": [
{"match": "deepseek", "context": 1000000, "vision": false,
"price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}},
{"match": "deepseek-v4-pro",
"price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}},
{"match": "deepseek-vl", "vision": true},
{"match": "minimax", "context": 196608, "vision": false},
{"match": "gpt-", "cache": "openai"},
{"match": "gpt-4.1", "context": 1047576, "vision": true,
"price": {"currency": "$", "miss": 2, "hit": 0.5, "out": 8}},
{"match": "gpt-4.1-mini",
"price": {"currency": "$", "miss": 0.4, "hit": 0.1, "out": 1.6}},
{"match": "gpt-4o", "context": 128000, "vision": true,
"price": {"currency": "$", "miss": 2.5, "hit": 1.25, "out": 10}},
{"match": "gpt-4o-mini",
"price": {"currency": "$", "miss": 0.15, "hit": 0.075, "out": 0.6}},
{"match": "gpt-5", "context": 400000, "vision": true, "reasoning": true},
{"match": "o1", "vision": true, "cache": "openai", "reasoning": true},
{"match": "o3", "context": 200000, "vision": true, "cache": "openai", "reasoning": true},
{"match": "o4", "context": 200000, "vision": true, "cache": "openai", "reasoning": true},
{"match": "anthropic/", "cache": "anthropic"},
{"match": "claude", "context": 200000, "vision": true, "cache": "anthropic"},
{"match": "claude-sonnet",
"price": {"currency": "$", "miss": 3, "hit": 0.3, "write": 3.75, "out": 15}},
{"match": "gemini", "context": 1048576, "vision": true},
{"match": "grok", "context": 256000},
{"match": "grok-4", "vision": true},
{"match": "qwen", "context": 131072},
{"match": "glm", "context": 128000},
{"match": "glm-*v*", "vision": true},
{"match": "kimi", "context": 131072},
{"match": "pixtral", "vision": true},
{"match": "llava", "vision": true},
{"match": "*-vl*", "vision": true},
{"match": "*vision*", "vision": true}
]
}
+54 -1
View File
@@ -7,6 +7,7 @@
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include <pthread.h>
#include <stdio.h>
@@ -97,7 +98,11 @@ static void msg_free(cst_message_t *m) {
free(m->content);
free(m->tool_calls_json);
free(m->meta_json);
m->content = m->tool_calls_json = m->meta_json = NULL;
free(m->reasoning);
cst_attachments_free(m->attach, m->nattach);
m->content = m->tool_calls_json = m->meta_json = m->reasoning = NULL;
m->attach = NULL;
m->nattach = 0;
}
static int session_grow(cst_session_t *s) {
@@ -127,6 +132,16 @@ static int session_push_mem(cst_session_t *s, const cst_message_t *src) {
d->tool_calls_json = xstrdup(src->tool_calls_json);
if (src->meta_json && src->meta_json[0])
d->meta_json = xstrdup(src->meta_json);
if (src->reasoning && src->reasoning[0])
d->reasoning = xstrdup(src->reasoning);
if (src->attach && src->nattach > 0) {
d->attach = cst_attachments_dup(src->attach, src->nattach);
if (!d->attach) {
msg_free(d);
return -1;
}
d->nattach = src->nattach;
}
s->nmsgs++;
return 0;
}
@@ -174,9 +189,14 @@ static void load_existing(cst_session_t *s) {
if (cJSON_IsObject(mo))
meta = cJSON_PrintUnformatted(mo);
m.meta_json = meta;
m.reasoning = (char *)jstr(o, "reasoning");
const cJSON *at = cJSON_GetObjectItemCaseSensitive(o, "attachments");
if (cJSON_IsArray(at))
m.attach = cst_attachments_from_json(at, &m.nattach);
session_push_mem(s, &m);
free(tcj);
free(meta);
cst_attachments_free(m.attach, m.nattach);
cJSON_Delete(o);
}
free(line);
@@ -249,6 +269,8 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
cJSON_AddStringToObject(o, "tool_calls_json", msg->tool_calls_json);
}
}
if (msg->reasoning && msg->reasoning[0])
cJSON_AddStringToObject(o, "reasoning", msg->reasoning);
if (msg->meta_json && msg->meta_json[0]) {
cJSON *mo = cJSON_Parse(msg->meta_json);
if (cJSON_IsObject(mo))
@@ -256,6 +278,28 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
else
cJSON_Delete(mo);
}
if (msg->attach && msg->nattach > 0) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < msg->nattach; i++) {
const cst_attachment_t *a = &msg->attach[i];
cJSON *it = cJSON_CreateObject();
cJSON_AddStringToObject(it, "id", a->id);
cJSON_AddStringToObject(it, "name", a->name);
cJSON_AddStringToObject(it, "mime", a->mime);
cJSON_AddStringToObject(it, "rel", a->rel);
cJSON_AddNumberToObject(it, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(it, "width", a->width);
cJSON_AddNumberToObject(it, "height", a->height);
}
if (a->detail[0])
cJSON_AddStringToObject(it, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(it, "origin", a->origin);
cJSON_AddItemToArray(arr, it);
}
cJSON_AddItemToObject(o, "attachments", arr);
}
char *txt = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
if (!txt)
@@ -310,6 +354,7 @@ int cst_session_clear(cst_session_t *s, int keep_system) {
if (s->msgs[i].role == CST_ROLE_SYSTEM) {
sys.role = CST_ROLE_SYSTEM;
sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : "");
sys.meta_json = xstrdup(s->msgs[i].meta_json);
have_sys = sys.content != NULL;
break;
}
@@ -322,6 +367,7 @@ int cst_session_clear(cst_session_t *s, int keep_system) {
write_line(s->fp, &sys);
}
free(sys.content);
free(sys.meta_json);
pthread_mutex_unlock(&s->mu);
return rc;
}
@@ -339,6 +385,11 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
copy[i].tool_calls_json =
msgs[i].tool_calls_json ? strdup(msgs[i].tool_calls_json) : NULL;
copy[i].meta_json = msgs[i].meta_json ? strdup(msgs[i].meta_json) : NULL;
copy[i].reasoning = msgs[i].reasoning ? strdup(msgs[i].reasoning) : NULL;
copy[i].attach = msgs[i].attach && msgs[i].nattach > 0
? cst_attachments_dup(msgs[i].attach, msgs[i].nattach)
: NULL;
copy[i].nattach = copy[i].attach ? msgs[i].nattach : 0;
}
pthread_mutex_lock(&s->mu);
free_all(s);
@@ -352,6 +403,8 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
free(copy[i].content);
free(copy[i].tool_calls_json);
free(copy[i].meta_json);
free(copy[i].reasoning);
cst_attachments_free(copy[i].attach, copy[i].nattach);
}
free(copy);
return rc;
+42
View File
@@ -14,6 +14,7 @@
#include <regex.h>
#include "cst_attach.h"
#include "cst_tools.h"
#include "cst_util.h"
#include "os_compat.h"
@@ -241,6 +242,47 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return tool_take_buf(r, &b, 0);
}
/* --- view_image ---------------------------------------------------------- */
/* Show an image to the model: the bytes are copied into the session's
* attachment store and travel as a multimodal content part of the result. */
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
const char *path = tool_arg(c, "path");
if (!path || !path[0])
return tool_set_result(r, "error: missing path", 1);
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
if (!session || !session[0])
return tool_set_result(r, "error: no session to store the image in", 1);
char full[OS_PATH_MAX];
if (resolve(ctx, path, full, sizeof(full)) != 0)
return tool_set_resultf(r, 1,
"error: path outside workspace or missing parent: "
"%.200s",
path);
if (is_dir(full))
return tool_set_resultf(r, 1, "error: %.200s is a directory", path);
char err[256];
cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err));
if (!a)
return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err);
snprintf(a->origin, sizeof(a->origin), "view_image");
const char *d = tool_arg(c, "detail");
if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0))
snprintf(a->detail, sizeof(a->detail), "%s", d);
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_buf_t out = {0};
cst_buf_printf(&out, "view_image %.200s: %s\n(shown to you as an image)\n",
path, desc);
int rc = tool_take_buf(r, &out, 0);
r->attach = a; /* one entry; freed as a block by cst_tool_result_free */
r->nattach = 1;
return rc;
}
/* --- edit_file ----------------------------------------------------------- */
static size_t count_occ(const char *hay, size_t hn, const char *needle,
+42 -4
View File
@@ -56,6 +56,15 @@ static const cst_tool_def_t TOOLS[] = {
{"limit", I, 0, "maximum number of lines"},
{"line_numbers", B, 0, "`N|` line prefixes (default true)"}},
CST_TP_READ, 1, run_read_file, NULL, NO_DYN},
{"view_image",
"Show a local image to yourself: PNG, JPEG, GIF, WebP or BMP, checked by "
"content. The file is copied into the session store and comes back as an "
"image you can actually see (not as text). Use it for screenshots, "
"diagrams and UI checks; path resolves like read_file's.",
NULL,
{{"path", S, 1, "image file path relative to the workspace"},
{"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN},
{"run_shell",
"Run a non-interactive shell command in the workspace root for builds, "
"tests, git and other CLIs (stdin is empty; long runs are killed). "
@@ -333,8 +342,8 @@ int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t) {
return 1;
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
return 0;
if ((cfg->plan_mode || cfg->read_only) &&
(t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
/* plan_mode is enforced when a call runs, so tools[] stays the same */
if (cfg->read_only && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
return 0;
if (cfg->depth > 0 && strcmp(t->name, "task") == 0)
return 0;
@@ -413,16 +422,44 @@ static int is_minimal(const cst_agent_config_t *cfg) {
return cfg && cfg->tool_profile == CST_TOOLS_MINIMAL;
}
static int is_external(const cst_tool_def_t *t) {
return t->owner && strncmp(t->owner, "builtin:", 8) != 0;
}
static int by_name(const void *x, const void *y) {
const cst_tool_def_t *a = *(const cst_tool_def_t *const *)x;
const cst_tool_def_t *b = *(const cst_tool_def_t *const *)y;
return strcmp(a->name, b->name);
}
void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) {
cJSON *arr = cJSON_CreateArray();
if (!arr)
return NULL;
int legacy = is_minimal(cfg);
int n = cst_tool_count();
/* Built-ins in registry order, then plugin/MCP tools by name: servers
* finish starting in any order, and tools[] is part of the cached prefix. */
const cst_tool_def_t **order = calloc((size_t)(n ? n : 1), sizeof(*order));
if (!order) {
cJSON_Delete(arr);
return NULL;
}
int nb = 0, no = 0;
for (int i = 0; i < n; i++) {
const cst_tool_def_t *t = cst_tool_at(i);
if (!t || !cst_tool_active(cfg, t))
continue;
if (t && cst_tool_active(cfg, t) && !is_external(t))
order[no++] = t;
}
nb = no;
for (int i = 0; i < n; i++) {
const cst_tool_def_t *t = cst_tool_at(i);
if (t && cst_tool_active(cfg, t) && is_external(t))
order[no++] = t;
}
qsort(order + nb, (size_t)(no - nb), sizeof(*order), by_name);
for (int i = 0; i < no; i++) {
const cst_tool_def_t *t = order[i];
cJSON *tool = cJSON_CreateObject();
cJSON_AddStringToObject(tool, "type", "function");
cJSON *fn = cJSON_AddObjectToObject(tool, "function");
@@ -457,6 +494,7 @@ void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) {
}
cJSON_AddItemToArray(arr, tool);
}
free(order);
return arr;
}
+4
View File
@@ -10,6 +10,7 @@
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_sandbox.h"
#include "cst_util.h"
#include "os_compat.h"
@@ -26,6 +27,9 @@ void cst_tool_result_free(cst_tool_result_t *r) {
free(r->out);
r->out = NULL;
r->out_len = 0;
cst_attachments_free(r->attach, r->nattach);
r->attach = NULL;
r->nattach = 0;
}
void cst_tool_call_clear(ctl_tool_call_t *c) {
+2
View File
@@ -48,6 +48,8 @@ int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
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_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_preview_write_file(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out);
+324 -21
View File
@@ -4,9 +4,12 @@
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_clipboard.h"
#include "cst_keymap.h"
#include "cst_ext.h"
#include "cst_mcp.h"
#include "cst_models.h"
#include "cst_plugins.h"
#include "cst_skills.h"
#include "cst_subagent.h"
@@ -340,6 +343,7 @@ typedef struct {
size_t cur; /* cursor byte offset into inbuf */
int in_paste;
int paste_cr; /* last pasted byte was CR (fold CRLF) */
size_t paste_at, paste_len0; /* cursor and length when the paste began */
char status_msg[512];
char title[128]; /* session title or first prompt, for the header */
struct timespec ctrlc_at;
@@ -1981,6 +1985,12 @@ static int ctx_percent(const tui_t *t, long *used) {
return win > 0 ? (int)(*used * 100 / win) : 0;
}
static const char *money_sym(const char *cur) {
if (g_ascii && strcmp(cur, "¥") == 0)
return "CNY ";
return cur;
}
static int approval_mode(const tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg)
@@ -2019,8 +2029,20 @@ static void draw_status(tui_t *t) {
int pct = ctx_percent(t, &used);
char tok[32];
fmt_tokens(used, tok, sizeof(tok));
snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands",
tok, pct, dot_sep());
char usage[64] = "";
cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL);
if (u.requests > 0 && u.prompt > 0) {
int n = snprintf(usage, sizeof(usage), "%shit %ld%%", dot_sep(),
u.cached * 100 / u.prompt);
if (u.priced && n > 0 && (size_t)n < sizeof(usage))
snprintf(usage + n, sizeof(usage) - (size_t)n, "%s%s%s%.2f",
dot_sep(), g_ascii ? "~" : "≈", money_sym(u.currency),
u.cost);
}
snprintf(right, sizeof(right),
"%s (%d%%)%s%s\033[97mctrl+p\033[39m commands", tok, pct, usage,
dot_sep());
rw = vis_cols(right);
if (rw > w)
rw = w;
@@ -2078,7 +2100,14 @@ static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) {
x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0);
if (eff) {
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
x += draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
}
int npick = cst_agent_pending_count(t->agent);
if (npick > 0) {
char mark[32];
snprintf(mark, sizeof(mark), "%d image%s", npick, npick > 1 ? "s" : "");
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
draw_ansi_at(w, y, x, room - x + 2, mark, CP_BOX_PRIMARY, 0, 0);
}
if (rw)
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
@@ -2789,11 +2818,23 @@ static void reload_hist_from_session(tui_t *t) {
else
b->expanded = 0;
t->last_tool_idx = t->hist.n - 1;
} else if (cst_message_is_note(m)) {
b->kind = BLK_SYSTEM;
const char *c = m->content ? m->content : "";
const char *nl = strchr(c, '\n');
block_set_body(b, c, nl ? (size_t)(nl - c) : strlen(c));
} else if (m->role == CST_ROLE_USER) {
b->kind = BLK_USER;
block_set_title(b, "You");
block_set_body(b, m->content ? m->content : "",
m->content ? strlen(m->content) : 0);
cst_buf_t ub = {0};
cst_buf_puts(&ub, m->content ? m->content : "");
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&ub, "\n[image %s · %s]", m->attach[k].name, d);
}
block_set_body(b, ub.buf ? ub.buf : "", ub.len);
cst_buf_free(&ub);
b->expanded = 1;
} else if (m->role == CST_ROLE_ASSISTANT) {
b->kind = BLK_ASSISTANT;
@@ -3839,6 +3880,7 @@ typedef struct {
char provider[32];
char base[512];
char key[512];
int have_cache; /* a cached list is showing: keep it if the fetch fails */
} probe_arg_t;
static void am_store(const char *provider, int r, char ids[][128],
@@ -3855,11 +3897,48 @@ static void am_store(const char *provider, int r, char ids[][128],
pthread_mutex_unlock(&g_am_mu);
}
/* GET /models; a good answer also refreshes models-cache/<provider>.json. */
static int fetch_models(const char *provider, const char *base,
const char *key, char ids[][128], int max, char *err,
size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m) {
snprintf(err, err_sz, "out of memory");
return -1;
}
int r = cst_llm_list_models_meta(base, key, m, max, err, err_sz);
if (r >= 0)
cst_models_cache_save(provider, base, m, r);
for (int i = 0; i < r; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
return r;
}
/* The cached list for provider into ids: count, or -1; *fresh when it is
* younger than CST_MODELS_CACHE_TTL. */
static int cached_models(const char *provider, char ids[][128], int max,
int *fresh) {
*fresh = 0;
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m)
return -1;
long age = 0;
int n = cst_models_cache_load(provider, m, max, &age);
for (int i = 0; i < n; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
return n > 0 ? n : -1;
}
static void *probe_thread(void *arg) {
probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256];
int r = cst_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err));
int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err,
sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err);
memset(a->key, 0, sizeof(a->key));
free(a);
@@ -3883,16 +3962,28 @@ static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return;
static char ids[AM_MAX][128];
int fresh = 0;
int nc = cached_models(cfg->provider, ids, AM_MAX, &fresh);
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
if (nc > 0)
am_store(cfg->provider, nc, ids, "");
if (fresh)
return;
probe_arg_t *a = calloc(1, sizeof(*a));
if (!a)
return;
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
a->have_cache = nc > 0;
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
if (nc <= 0) {
g_am.state = AM_RUNNING;
g_am.n = 0;
}
g_am_threads++;
pthread_mutex_unlock(&g_am_mu);
pthread_t th;
@@ -3973,7 +4064,7 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
draw_status(t);
static char ids[AM_MAX][128];
char err[256];
int r = cst_llm_list_models(base, out, ids, AM_MAX, err, sizeof(err));
int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err));
if (r == CST_MODELS_AUTH) {
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one",
@@ -4202,6 +4293,16 @@ static void cmd_delete(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(deleted session %s)", victim);
}
/* "name 1M · vision (api)" for the /model picker. */
static void model_label(const char *provider, const char *name, int api,
char *out, size_t n) {
cst_model_info_t mi;
cst_model_info(provider, name, &mi);
char tag[40];
cst_model_info_label(&mi, tag, sizeof(tag));
snprintf(out, n, "%-40.72s %.32s%s", name, tag, api ? " (api)" : "");
}
static void cmd_model(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
@@ -4212,8 +4313,8 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
set_status(t, "fetching the model list…");
draw_status(t);
char err[256];
na = cst_llm_list_models(cfg->api_base, cfg->api_key, names, AM_MAX, err,
sizeof(err));
na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names,
AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
@@ -4226,7 +4327,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
if (na < 0)
na = 0;
for (int i = 0; i < na; i++)
snprintf(labels[i], 128, "%.100s (api)", names[i]);
model_label(cfg->provider, names[i], 1, labels[i], 128);
char builtin[32][128];
int nb = cst_models_list(cfg->model, builtin, 32);
int n = na;
@@ -4237,7 +4338,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
if (dup)
continue;
memcpy(names[n], builtin[i], 128);
memcpy(labels[n], builtin[i], 128);
model_label(cfg->provider, builtin[i], 0, labels[n], 128);
n++;
}
int cur_idx = -1;
@@ -4252,8 +4353,13 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
}
cst_settings_remember(cfg);
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
cst_model_context_tokens(cfg->model));
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
push_system_line(t, "(model → %s, context %ld tokens%s)", cfg->model,
mi.context,
mi.vision == 1 ? ", takes images"
: mi.vision == 0 ? ", text only"
: "");
}
static void cmd_key(tui_t *t, char *arg, int *quit) {
@@ -4320,16 +4426,18 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) {
}
} else {
int n = cst_provider_count();
char items[16][128];
char items[32][128];
int cur_idx = 0;
for (int i = 0; i < n && i < 16; i++) {
for (int i = 0; i < n && i < 32; i++) {
const cst_provider_t *q = cst_provider_at(i);
snprintf(items[i], 128, "%-11s %s (%s)", q->name, q->api_base,
q->model[0] ? q->model : "models from API");
snprintf(items[i], 128, "%-11.24s %.48s (%.24s%s)", q->name,
q->api_base, q->model[0] ? q->model : "models from API",
strcmp(cst_provider_source(q), "user") == 0 ? " · models.json"
: "");
if (strcmp(q->name, cfg->provider) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Provider", items, n < 16 ? n : 16, cur_idx);
int pick = picker_run(t, "Provider", items, n < 32 ? n : 32, cur_idx);
if (pick < 0)
return;
p = cst_provider_at(pick);
@@ -4568,6 +4676,15 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
cfg->model, used, win, win ? used * 100 / win : 0);
{
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
char tag[48];
cst_model_info_label(&mi, tag, sizeof(tag));
push_system_line(t, " model info %s · from %s%s%s", tag, mi.source,
cst_models_load_error()[0] ? " · " : "",
cst_models_load_error());
}
push_system_line(t, " effort %s%s", cfg->effort,
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
? ""
@@ -4596,6 +4713,57 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
push_system_line(t, " undo %d turn(s) can be undone",
cst_agent_undo_available(t->agent));
cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL);
if (u.requests > 0 && u.prompt > 0)
push_system_line(t, " cache %ld%% of %ld prompt tokens hit (/cost)",
u.cached * 100 / u.prompt, u.prompt);
}
static void push_usage(tui_t *t, const char *label, const cst_usage_t *u) {
if (u->requests == 0) {
push_system_line(t, " %-9s no requests yet", label);
return;
}
long hitp = u->prompt > 0 ? u->cached * 100 / u->prompt : 0;
push_system_line(t,
" %-9s %d request(s) · prompt %ld (hit %ld = %ld%%, miss "
"%ld) · output %ld",
label, u->requests, u->prompt, u->cached, hitp, u->miss,
u->out);
if (u->write)
push_system_line(t, " %-9s cache write %ld tokens", "", u->write);
if (u->priced)
push_system_line(t, " %-9s %s %s%.4f", "", g_ascii ? "~" : "≈",
money_sym(u->currency), u->cost);
}
static void cmd_cost(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
cst_usage_t s, last;
cst_agent_usage(t->agent, &s, &last);
push_system_line(t, "Token use and cost (%s):", cfg->model);
push_usage(t, "session", &s);
push_usage(t, "last turn", &last);
cst_price_t p;
if (cst_model_price(cfg->model, &p) != 0) {
push_system_line(t, " price unknown for this model; set "
"CST_PRICE=miss,hit,out[,write[,currency]] (per 1M "
"tokens)");
return;
}
const char *c = money_sym(p.currency);
push_system_line(t,
" price per 1M tokens: miss %s%.2f · hit %s%.2f · "
"output %s%.2f",
c, p.miss, c, p.hit, c, p.out);
if (p.offpeak_half)
push_system_line(t,
" now %s (off-peak is half price: outside "
"Mon–Fri 9–12 / 14–18 Beijing time)",
cst_price_is_peak(time(NULL)) ? "peak" : "off-peak");
}
static void cmd_todo(tui_t *t, char *arg, int *quit) {
@@ -4707,8 +4875,17 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
if (msg_is_tool_result(m)) {
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
md_fence(fp, c, 4000);
} else if (cst_message_is_note(m)) {
fprintf(fp, "> %.*s\n\n", (int)strcspn(c, "\n"), c);
} else if (m->role == CST_ROLE_USER) {
fprintf(fp, "## User\n\n%s\n\n", c);
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
fprintf(fp, "- image `%s` (%s)\n", m->attach[k].name, d);
}
if (m->nattach)
fputc('\n', fp);
} else {
fprintf(fp, "## Assistant\n\n%s\n\n", c);
if (m->tool_calls_json && m->tool_calls_json[0]) {
@@ -4722,6 +4899,116 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(exported to %s)", path);
}
/* The newest `added` picks, and a warning when the model would not see them. */
static void report_attached(tui_t *t, int added) {
int n = cst_agent_pending_count(t->agent);
for (int i = n - added; i < n; i++) {
char line[256];
if (i >= 0 &&
cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, "Attached %s — it goes with your next message.",
line);
}
if (added > 0 && cst_agent_sees_images(t->agent) == 0)
push_system_line(t, "Note: %s does not take images; they are sent as a "
"text note until you /model to one that does.",
cst_agent_config(t->agent)->model);
}
/* /attach: stage images for the next user message. The bytes are copied into
* the session's attachment directory now, so a picture picked from /tmp keeps
* working after the file is gone. */
static void cmd_attach(tui_t *t, char *arg, int *quit) {
(void)quit;
int n = cst_agent_pending_count(t->agent);
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
if (n == 0) {
push_system_line(t, "No images picked. Usage: /attach <path>");
return;
}
push_system_line(t, "%d image%s with the next message (sent once):", n,
n > 1 ? "s" : "");
for (int i = 0; i < n; i++) {
char line[256];
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, " %d. %s", i + 1, line);
}
push_system_line(t, "(/attach clear drops them)");
return;
}
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
cst_agent_pending_clear(t->agent);
push_system_line(t, "Dropped the picked images.");
return;
}
char err[256];
if (strcmp(arg, "paste") == 0) {
char *img = NULL;
size_t len = 0;
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
return;
}
char mime[32], name[32];
const char *ext = cst_attach_sniff(img, len, mime, sizeof(mime)) == 0 &&
strchr(mime, '/')
? strchr(mime, '/') + 1
: "png";
snprintf(name, sizeof(name), "clipboard.%s", ext);
int rc = cst_agent_attach_bytes(t->agent, name, img, len, err, sizeof(err));
free(img);
if (rc != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
return;
}
if (cst_agent_attach_file(t->agent, arg, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
}
static int file_is_image(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return 0;
unsigned char head[16];
size_t got = fread(head, 1, sizeof(head), f);
fclose(f);
char mime[32];
return cst_attach_sniff(head, got, mime, sizeof(mime)) == 0;
}
/* A paste that is nothing but image file paths (a file dropped on the
* terminal) attaches those files instead of typing the paths. */
static void paste_image_paths(tui_t *t) {
size_t from = t->paste_at, to = t->cur;
if (to <= from || to - from != t->in_len - t->paste_len0 ||
to - from > CST_PASTE_MAX_PATHS * CST_PASTE_PATH_MAX)
return;
static char paths[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
int n = cst_paste_paths(t->inbuf + from, to - from, paths,
CST_PASTE_MAX_PATHS);
if (n <= 0)
return;
for (int i = 0; i < n; i++)
if (!file_is_image(paths[i]))
return;
input_erase(t, from, to);
int added = 0;
for (int i = 0; i < n; i++) {
char err[256];
if (cst_agent_attach_file(t->agent, paths[i], err, sizeof(err)) == 0)
added++;
else
push_system_line(t, "Error: %s", err);
}
report_attached(t, added);
}
#ifdef _WIN32
#define COPY_VIA "clipboard"
#else
@@ -5015,7 +5302,7 @@ static void show_subagent(tui_t *t, const char *pid,
const char *final = NULL;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m->content)
if (!m->content || cst_message_is_note(m))
continue;
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
push_system_line(t, " prompt: %.300s", m->content);
@@ -5118,6 +5405,8 @@ static const slash_cmd_t SLASH[] = {
cmd_mouse},
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
{"status", "", "", "session, model, context and settings", 1, cmd_status},
{"cost", "usage", "", "tokens, cache hit rate and estimated cost", 1,
cmd_cost},
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
@@ -5127,6 +5416,9 @@ static const slash_cmd_t SLASH[] = {
{"export", "", "[file]", "write the transcript as Markdown", 1,
cmd_export},
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
{"attach", "image", "[path|paste|clear]",
"send image(s) with the next message",
1, cmd_attach},
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
@@ -5430,7 +5722,15 @@ static int begin_turn(tui_t *t, char *prompt) {
if (ub) {
ub->kind = BLK_USER;
block_set_title(ub, "You");
block_set_body(ub, prompt, strlen(prompt));
cst_buf_t body = {0};
cst_buf_puts(&body, prompt);
for (int k = 0; k < cst_agent_pending_count(t->agent); k++) {
char d[128];
if (cst_agent_pending_describe(t->agent, k, d, sizeof(d)) == 0)
cst_buf_printf(&body, "\n[image %s]", d);
}
block_set_body(ub, body.buf ? body.buf : "", body.len);
cst_buf_free(&body);
ub->expanded = 1;
}
t->think_idx = -1;
@@ -6646,10 +6946,13 @@ int cst_tui_run(cst_agent_t *agent) {
if (wch == KEY_PASTE_BEGIN) {
t.in_paste = 1;
t.paste_cr = 0;
t.paste_at = t.cur;
t.paste_len0 = t.in_len;
continue;
}
if (wch == KEY_PASTE_END) {
t.in_paste = 0;
paste_image_paths(&t);
input_changed(&t);
after_edit(&t);
continue;
+543
View File
@@ -0,0 +1,543 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_agent.h"
#include "cst_clipboard.h"
#include "cst_test_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static void put_be32(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v >> 24);
p[1] = (unsigned char)(v >> 16);
p[2] = (unsigned char)(v >> 8);
p[3] = (unsigned char)v;
}
static void put_le16(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
}
/* 8 signature + 4 length + "IHDR" + w + h (what cst_attach_dimensions reads). */
static size_t png_hdr(unsigned char *b, unsigned w, unsigned h) {
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
memcpy(b, sig, 8);
b[8] = b[9] = b[10] = 0;
b[11] = 13;
memcpy(b + 12, "IHDR", 4);
put_be32(b + 16, w);
put_be32(b + 20, h);
return 32;
}
static size_t gif_hdr(unsigned char *b, unsigned w, unsigned h) {
memcpy(b, "GIF89a", 6);
put_le16(b + 6, w);
put_le16(b + 8, h);
return 16;
}
static void put_le32(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
p[2] = (unsigned char)((v >> 16) & 0xFF);
p[3] = (unsigned char)((v >> 24) & 0xFF);
}
static size_t bmp_hdr(unsigned char *b, int w, int h) {
memset(b, 0, 64);
b[0] = 'B';
b[1] = 'M';
put_le32(b + 18, (unsigned)w);
put_le32(b + 22, (unsigned)h);
return 64;
}
/* FFD8 + a filler APP0 segment + SOF0 carrying the frame size. */
static size_t jpeg_hdr(unsigned char *b, unsigned w, unsigned h) {
size_t i = 0;
b[i++] = 0xFF;
b[i++] = 0xD8;
b[i++] = 0xFF;
b[i++] = 0xE0;
b[i++] = 0x00;
b[i++] = 0x04;
b[i++] = 0x00;
b[i++] = 0x00;
b[i++] = 0xFF;
b[i++] = 0xC0;
b[i++] = 0x00;
b[i++] = 0x0B;
b[i++] = 0x08;
b[i++] = (unsigned char)(h >> 8);
b[i++] = (unsigned char)h;
b[i++] = (unsigned char)(w >> 8);
b[i++] = (unsigned char)w;
b[i++] = 0x01; /* one component */
b[i++] = 0x01; /* component id */
b[i++] = 0x11; /* sampling */
b[i++] = 0x00; /* quant table */
return i;
}
static void test_sniff(void) {
unsigned char b[64];
char mime[32];
size_t n;
n = png_hdr(b, 32, 24);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/png") == 0, "PNG sniffed");
n = jpeg_hdr(b, 20, 10);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/jpeg") == 0, "JPEG sniffed");
n = gif_hdr(b, 3, 5);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/gif") == 0, "GIF sniffed");
memcpy(b, "RIFF\0\0\0\0WEBPVP8 ", 16);
EXPECT(cst_attach_sniff(b, 30, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/webp") == 0, "WebP sniffed");
n = bmp_hdr(b, 7, 9);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/bmp") == 0, "BMP sniffed");
/* an extension never decides: text and a PDF are not images */
EXPECT(cst_attach_sniff("hello, world", 12, mime, sizeof(mime)) != 0,
"text rejected");
EXPECT(cst_attach_sniff("%PDF-1.7\n", 9, mime, sizeof(mime)) != 0,
"PDF rejected");
EXPECT(cst_attach_sniff("\x89PNG", 4, mime, sizeof(mime)) != 0,
"truncated PNG rejected");
EXPECT(cst_attach_sniff("", 0, mime, sizeof(mime)) != 0, "empty rejected");
}
static void test_dimensions(void) {
unsigned char b[64];
int w = 0, h = 0;
size_t n;
n = png_hdr(b, 1280, 720);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 1280 && h == 720,
"PNG dimensions");
n = gif_hdr(b, 16, 40);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 16 && h == 40,
"GIF dimensions");
n = bmp_hdr(b, 7, 9);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 7 && h == 9,
"BMP dimensions");
n = jpeg_hdr(b, 640, 480);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 640 && h == 480,
"JPEG dimensions");
w = h = -1;
EXPECT(cst_attach_dimensions("nope", 4, &w, &h) != 0 && w == 0 && h == 0,
"unknown format gives 0x0");
}
static void test_base64(void) {
char *s = cst_base64_encode("Man", 3);
EXPECT(s && strcmp(s, "TWFu") == 0, "base64 <Man>");
free(s);
s = cst_base64_encode("Ma", 2);
EXPECT(s && strcmp(s, "TWE=") == 0, "base64 padding 1");
free(s);
s = cst_base64_encode("M", 1);
EXPECT(s && strcmp(s, "TQ==") == 0, "base64 padding 2");
free(s);
s = cst_base64_encode("\x00\xff\xfe", 3);
EXPECT(s && strcmp(s, "AP/+") == 0, "base64 binary");
free(s);
size_t n = 0;
char *d = cst_base64_decode("TWFu", 4, &n);
EXPECT(d && n == 3 && memcmp(d, "Man", 3) == 0, "decode <Man>");
free(d);
d = cst_base64_decode("TQ==", 4, &n);
EXPECT(d && n == 1 && d[0] == 'M', "decode with padding");
free(d);
d = cst_base64_decode("AP/+\n", 5, &n);
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0,
"decode binary, newline ignored");
free(d);
d = cst_base64_decode("AP_-", 4, &n);
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0, "URL-safe alphabet");
free(d);
EXPECT(cst_base64_decode("A*B=", 4, &n) == NULL, "bad character refused");
unsigned char raw[200];
for (int i = 0; i < 200; i++)
raw[i] = (unsigned char)(i * 37);
for (size_t len = 0; len < 7; len++) {
char *e = cst_base64_encode(raw, 190 + len);
d = e ? cst_base64_decode(e, strlen(e), &n) : NULL;
EXPECT(d && n == 190 + len && memcmp(d, raw, n) == 0, "round trip");
free(e);
free(d);
}
}
static void test_rel_ok(const char *root) {
EXPECT(cst_attach_rel_ok("img-0a1b2c3d.png"), "normal name accepted");
EXPECT(!cst_attach_rel_ok("../x.png"), "parent dir refused");
EXPECT(!cst_attach_rel_ok("a/../../b.png"), "slash refused");
EXPECT(!cst_attach_rel_ok("/etc/passwd"), "absolute refused");
EXPECT(!cst_attach_rel_ok("a\\b.png"), "backslash refused");
EXPECT(!cst_attach_rel_ok(".hidden"), "dot file refused");
EXPECT(!cst_attach_rel_ok(""), "empty refused");
cJSON *arr = cJSON_Parse("[{\"id\":\"x\",\"rel\":\"../../secret.png\"},"
"{\"id\":\"y\",\"rel\":\"img-1.png\"}]");
int n = 0;
cst_attachment_t *back = cst_attachments_from_json(arr, &n);
EXPECT(n == 1 && back && strcmp(back[0].id, "y") == 0,
"escaping rel dropped on load");
cst_attachments_free(back, n);
cJSON_Delete(arr);
/* a stored file swapped for non-image bytes is not sent */
char session[1024], full[1024], err[256];
snprintf(session, sizeof(session), "%.900s/sessions/r.jsonl", root);
unsigned char b[64];
size_t bn = png_hdr(b, 4, 4);
cst_attachment_t *a =
cst_attach_import_bytes(session, "x.png", (const char *)b, bn, err,
sizeof(err));
EXPECT(a != NULL, "stored");
if (!a)
return;
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0, "path");
FILE *f = fopen(full, "wb");
if (f) {
fputs("plain text now", f);
fclose(f);
}
size_t rn = 0;
EXPECT(cst_attach_read(session, a, &rn, err, sizeof(err)) == NULL &&
strstr(err, "no longer an image"),
"read re-checks the bytes");
EXPECT(cst_attach_data_url(session, a) == NULL, "no data url for it");
snprintf(a->rel, sizeof(a->rel), "../r.jsonl");
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) != 0,
"full path refuses an escaping rel");
cst_attach_free(a);
}
static void test_store(const char *root) {
char session[1024], dir[1024];
snprintf(session, sizeof(session), "%.900s/sessions/a.jsonl", root);
unsigned char b[64];
size_t n = png_hdr(b, 32, 24);
char err[256];
cst_attachment_t *a = cst_attach_import_bytes(session, "shot.png",
(const char *)b, n, err,
sizeof(err));
EXPECT(a != NULL, "import stored");
if (!a) {
fprintf(stderr, " err: %s\n", err);
return;
}
EXPECT(strcmp(a->mime, "image/png") == 0, "stored mime");
EXPECT(a->width == 32 && a->height == 24, "stored dimensions");
EXPECT(a->bytes == (long long)n, "stored size");
EXPECT(strcmp(a->name, "shot.png") == 0, "stored name");
EXPECT(strcmp(a->origin, "user") == 0, "origin=user");
EXPECT(strcmp(a->rel, "img-") != 0 && strstr(a->rel, ".png") != NULL,
"rel has a .png extension");
EXPECT(cst_attach_dir(session, dir, sizeof(dir)) == 0, "attach dir");
char full[1024];
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0 &&
strstr(full, "attachments/") != NULL,
"full path inside attachments/");
FILE *f = fopen(full, "rb");
EXPECT(f != NULL, "stored file exists");
if (f)
fclose(f);
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
EXPECT(strstr(desc, "32x24") && strstr(desc, "png"), "describe has size");
size_t rn = 0;
char *data = cst_attach_read(session, a, &rn, err, sizeof(err));
EXPECT(data && rn == n && memcmp(data, b, n) == 0, "read back exact bytes");
free(data);
char *url = cst_attach_data_url(session, a);
EXPECT(url && strncmp(url, "data:image/png;base64,", 22) == 0,
"data url prefix");
if (url)
EXPECT(strlen(url) == 22 + ((n + 2) / 3) * 4, "data url length");
free(url);
cst_attach_free(a);
/* a text file named .png is still refused */
EXPECT(cst_attach_import_bytes(session, "fake.png", "not an image", 12, err,
sizeof(err)) == NULL,
"content decides, not the name");
EXPECT(strstr(err, "not a supported image") != NULL, "err explains why");
EXPECT(cst_attach_import_bytes(session, "empty.png", "", 0, err,
sizeof(err)) == NULL,
"empty data refused");
}
static void test_import_file(const char *root) {
char session[1024], src[1024];
snprintf(session, sizeof(session), "%.900s/sessions/b.jsonl", root);
snprintf(src, sizeof(src), "%.900s/logo.gif", root);
unsigned char b[64];
size_t n = gif_hdr(b, 8, 6);
FILE *f = fopen(src, "wb");
EXPECT(f != NULL, "write source image");
if (f) {
fwrite(b, 1, n, f);
fclose(f);
}
char err[256];
cst_attachment_t *a =
cst_attach_import_file(session, src, err, sizeof(err));
EXPECT(a != NULL, "file imported");
if (a) {
EXPECT(strcmp(a->mime, "image/gif") == 0, "imported mime");
EXPECT(strcmp(a->name, "logo.gif") == 0, "imported keeps the leaf name");
EXPECT(a->width == 8 && a->height == 6, "imported dimensions");
cst_attach_free(a);
}
EXPECT(cst_attach_import_file(session, "/nonexistent/x.png", err,
sizeof(err)) == NULL,
"missing file refused");
}
static void test_json_roundtrip(void) {
cst_attachment_t list[3];
memset(list, 0, sizeof(list));
snprintf(list[0].id, sizeof(list[0].id), "img-1");
snprintf(list[0].name, sizeof(list[0].name), "a.png");
snprintf(list[0].mime, sizeof(list[0].mime), "image/png");
snprintf(list[0].rel, sizeof(list[0].rel), "img-1.png");
list[0].bytes = 12345;
list[0].width = 640;
list[0].height = 480;
snprintf(list[0].origin, sizeof(list[0].origin), "user");
snprintf(list[1].id, sizeof(list[1].id), "img-2");
snprintf(list[1].name, sizeof(list[1].name), "b.jpg");
snprintf(list[1].mime, sizeof(list[1].mime), "image/jpeg");
snprintf(list[1].rel, sizeof(list[1].rel), "img-2.jpg");
list[1].bytes = 4096;
snprintf(list[1].detail, sizeof(list[1].detail), "high");
snprintf(list[1].origin, sizeof(list[1].origin), "view_image");
snprintf(list[2].id, sizeof(list[2].id), "img-3");
snprintf(list[2].name, sizeof(list[2].name), "c.webp");
snprintf(list[2].mime, sizeof(list[2].mime), "image/webp");
snprintf(list[2].rel, sizeof(list[2].rel), "img-3.webp");
list[2].bytes = 7;
char *json = cst_attachments_to_json(list, 3);
EXPECT(json != NULL, "to_json");
int n = 0;
cJSON *parsed = cJSON_Parse(json);
cst_attachment_t *back = cst_attachments_from_json(parsed, &n);
cJSON_Delete(parsed);
EXPECT(back != NULL && n == 3, "from_json count");
if (back && n == 3) {
EXPECT(strcmp(back[0].id, "img-1") == 0 && back[0].bytes == 12345 &&
back[0].width == 640 && back[0].height == 480,
"first entry survives");
EXPECT(strcmp(back[1].detail, "high") == 0 &&
strcmp(back[1].origin, "view_image") == 0,
"detail and origin survive");
EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
"entry without dimensions");
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
cst_attachments_free(back, n);
}
free(json);
/* entries without rel/id are dropped; a non-array is NULL */
cJSON *arr = cJSON_Parse("[{\"id\":\"x\"},{\"id\":\"y\",\"rel\":\"y.png\"}]");
back = cst_attachments_from_json(arr, &n);
EXPECT(back != NULL && n == 1 && strcmp(back[0].id, "y") == 0,
"incomplete entries skipped");
cst_attachments_free(back, n);
cJSON_Delete(arr);
cJSON *obj = cJSON_Parse("{}");
n = 7;
EXPECT(cst_attachments_from_json(obj, &n) == NULL && n == 0,
"object is not an attachment array");
cJSON_Delete(obj);
EXPECT(cst_attachments_to_json(NULL, 0) == NULL, "empty list is NULL");
}
static void test_lists(void) {
cst_attachment_t src;
memset(&src, 0, sizeof(src));
snprintf(src.id, sizeof(src.id), "img-x");
snprintf(src.mime, sizeof(src.mime), "image/png");
cst_attachment_t *list = NULL;
int n = 0, cap = 0;
for (int i = 0; i < 10; i++)
EXPECT(cst_attachments_push(&list, &n, &cap, &src) == 0, "push");
EXPECT(n == 10 && cap >= 10, "list grew");
EXPECT(strcmp(list[9].id, "img-x") == 0, "last entry copied");
cst_attachment_t *dup = cst_attachments_dup(list, n);
EXPECT(dup && strcmp(dup[3].mime, "image/png") == 0, "dup copies fields");
dup[3].id[0] = 'Z';
EXPECT(list[3].id[0] == 'i', "dup is a deep copy");
cst_attachments_free(dup, n);
EXPECT(cst_attachments_dup(NULL, 0) == NULL, "dup of nothing is NULL");
cst_attachments_free(list, n);
}
static void test_agent_pending(const char *root) {
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/sessions/c.jsonl",
root);
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "agent created");
if (!a)
return;
char err[256], line[128];
EXPECT(cst_agent_pending_count(a) == 0, "no pending at start");
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == -1,
"describe out of range");
unsigned char b[32];
size_t n = png_hdr(b, 4, 2);
char src[1024];
snprintf(src, sizeof(src), "%.900s/pic.png", root);
FILE *f = fopen(src, "wb");
EXPECT(f != NULL, "write the source png");
if (f) {
fwrite(b, 1, n, f);
fclose(f);
}
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0,
"attach through the agent");
EXPECT(cst_agent_pending_count(a) == 1, "one pending");
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == 0 &&
strstr(line, "4x2") != NULL,
"pending describe shows the size");
EXPECT(cst_agent_attach_file(a, "/nonexistent/x.png", err, sizeof(err)) == -1,
"missing file refused");
EXPECT(cst_agent_pending_count(a) == 1, "a failure keeps the queue");
for (int i = 1; i < CST_ATTACH_MAX_COUNT; i++)
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0, "attach more");
EXPECT(cst_agent_pending_count(a) == CST_ATTACH_MAX_COUNT, "queue full");
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == -1 &&
strstr(err, "at most") != NULL,
"one over the limit is refused");
cst_agent_pending_clear(a);
EXPECT(cst_agent_pending_count(a) == 0, "cleared");
cst_agent_destroy(a);
}
static void test_paste_paths(void) {
static char p[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
const char *s = "/tmp/a.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
strcmp(p[0], "/tmp/a.png") == 0,
"single path");
s = " '/home/u/my shot.png' \"/x/y z.jpg\"\n/plain.gif\r\n";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 3, "quoted paths count");
EXPECT(strcmp(p[0], "/home/u/my shot.png") == 0, "single quotes");
EXPECT(strcmp(p[1], "/x/y z.jpg") == 0, "double quotes");
EXPECT(strcmp(p[2], "/plain.gif") == 0, "newline separated");
s = "file:///home/u/a%20b.png file://localhost/c.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 2, "file uris");
EXPECT(strcmp(p[0], "/home/u/a b.png") == 0, "percent decoded");
EXPECT(strcmp(p[1], "/c.png") == 0, "localhost host");
s = "file://evil.example/c.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "remote host refused");
#ifndef _WIN32
s = "/home/u/my\\ shot.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
strcmp(p[0], "/home/u/my shot.png") == 0,
"backslash escape");
#endif
s = "'/unterminated.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "unbalanced quote");
s = " \n ";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "blank");
s = "a b c";
EXPECT(cst_paste_paths(s, strlen(s), p, 2) == -1, "too many");
}
static void test_dib(void) {
/* 3x2, 24-bit, bottom-up: stride 12 (9 bytes + pad). */
unsigned char dib[40 + 24];
memset(dib, 0, sizeof(dib));
put_le32(dib, 40);
put_le32(dib + 4, 3);
put_le32(dib + 8, 2);
put_le16(dib + 12, 1);
put_le16(dib + 14, 24);
dib[40] = 0xff; /* bottom-left pixel blue (BGR) */
size_t n = 0;
char *img = cst_dib_to_image(dib, sizeof(dib), &n);
char mime[32];
int w = 0, h = 0;
EXPECT(img && cst_attach_sniff(img, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/png") == 0,
"24-bit DIB becomes a PNG");
EXPECT(img && cst_attach_dimensions(img, n, &w, &h) == 0 && w == 3 && h == 2,
"PNG keeps the size");
free(img);
put_le16(dib + 14, 8); /* palette needs 256 entries: past the end */
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "short DIB refused");
put_le32(dib, 4);
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "bad header size");
}
int main(void) {
char root[1024];
if (test_tmpdir(root, sizeof(root), "cst-attach") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
test_sniff();
test_dimensions();
test_base64();
test_rel_ok(root);
test_paste_paths();
test_dib();
test_store(root);
test_import_file(root);
test_json_roundtrip();
test_lists();
test_agent_pending(root);
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_attach: %d failed\n", nfail);
return 1;
}
printf("test_attach: OK\n");
return 0;
}
+170
View File
@@ -159,7 +159,177 @@ static void test_tool_cap_head_tail(void) {
free(big);
}
static cJSON *build_req(const cst_llm_request_t *r) {
char *body = cst_llm_build_body(r);
EXPECT(body != NULL, "body built");
cJSON *j = body ? cJSON_Parse(body) : NULL;
EXPECT(j != NULL, "body is valid JSON");
free(body);
return j;
}
static const cJSON *msg_at(const cJSON *root, int i) {
return cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"),
i);
}
static const cJSON *cache_mark(const cJSON *m) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
return NULL;
const cJSON *last = cJSON_GetArrayItem(c, cJSON_GetArraySize(c) - 1);
return cJSON_GetObjectItemCaseSensitive(last, "cache_control");
}
static void test_reasoning_and_prefix(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "go");
m[2] = msg(CST_ROLE_ASSISTANT, "partial answer");
m[2].reasoning = "thinking it over";
m[3] = msg(CST_ROLE_ASSISTANT, "cut off here");
m[3].prefix = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
cJSON *j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content"),
"reasoning only when asked for");
const cJSON *p = cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "prefix");
EXPECT(cJSON_IsTrue(p), "prefix:true on the continued message");
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"),
"no prefix on history");
cJSON_Delete(j);
r.send_reasoning = 1;
j = build_req(&r);
const cJSON *rc =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content");
EXPECT(cJSON_IsString(rc) && strcmp(rc->valuestring, "thinking it over") == 0,
"reasoning_content passed back");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no cache key without a cache style");
cJSON_Delete(j);
}
static void test_cache_hints(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "first");
m[2] = msg(CST_ROLE_ASSISTANT, "reply");
m[3] = msg(CST_ROLE_USER, "second");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "gpt-4.1";
r.msgs = m;
r.nmsgs = 4;
r.cache_style = CST_CACHE_OPENAI;
r.cache_key = "sess-1";
cJSON *j = build_req(&r);
const cJSON *k = cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key");
EXPECT(cJSON_IsString(k) && strcmp(k->valuestring, "sess-1") == 0,
"prompt_cache_key");
EXPECT(content_at(j, 3) && !cache_mark(msg_at(j, 3)),
"OpenAI style leaves content as strings");
cJSON_Delete(j);
r.model = "anthropic/claude-sonnet-4";
r.cache_style = CST_CACHE_ANTHROPIC;
r.cache_ttl = "1h";
j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no prompt_cache_key for Anthropic");
const cJSON *cs = cache_mark(msg_at(j, 0));
const cJSON *cl = cache_mark(msg_at(j, 3));
const cJSON *cu = cache_mark(msg_at(j, 1));
EXPECT(cs && cl && cu, "breakpoints on system, newest, previous user");
EXPECT(!cache_mark(msg_at(j, 2)), "no breakpoint on the assistant reply");
const cJSON *ttl = cJSON_GetObjectItemCaseSensitive(cl, "ttl");
EXPECT(cJSON_IsString(ttl) && strcmp(ttl->valuestring, "1h") == 0, "ttl");
const cJSON *parts =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "content");
const cJSON *txt =
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(parts, 0), "text");
EXPECT(cJSON_IsString(txt) && strcmp(txt->valuestring, "second") == 0,
"marked content keeps its text");
cJSON_Delete(j);
}
static void test_tools_snapshot(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = m;
r.nmsgs = 1;
r.enable_tools = 1;
r.tools_json = "[{\"type\":\"function\",\"function\":{\"name\":\"only_me\","
"\"parameters\":{\"type\":\"object\"}}}]";
cJSON *j = build_req(&r);
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) == 1,
"tools[] taken from the snapshot");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(tools, 0),
"function"),
"name");
EXPECT(cJSON_IsString(nm) && strcmp(nm->valuestring, "only_me") == 0,
"snapshot tool name");
cJSON_Delete(j);
}
static void test_images_off(void) {
cst_attachment_t img;
memset(&img, 0, sizeof(img));
snprintf(img.id, sizeof(img.id), "img-1");
snprintf(img.rel, sizeof(img.rel), "img-1.png");
snprintf(img.name, sizeof(img.name), "shot.png");
snprintf(img.mime, sizeof(img.mime), "image/png");
img.width = 640;
img.height = 480;
img.bytes = 2048;
cst_message_t m[4];
m[0] = msg(CST_ROLE_USER, "what is this");
m[0].attach = &img;
m[0].nattach = 1;
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"view_image\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "view_image shot.png");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
m[2].attach = &img;
m[2].nattach = 1;
m[3] = msg(CST_ROLE_USER, "and now?");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
r.images_off = 1;
r.session_path = "/nonexistent/s.jsonl";
cJSON *j = build_req(&r);
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && !strstr(s, "image_url"), "no image parts when images are off");
free(s);
const char *c0 = content_at(j, 0);
EXPECT(c0 && strstr(c0, "what is this") &&
strstr(c0, "[image \"shot.png\" (640x480") &&
strstr(c0, "not sent: this model does not take images"),
"user image becomes a plain-text note");
const char *c2 = content_at(j, 2);
EXPECT(c2 && strstr(c2, "view_image shot.png") && strstr(c2, "not sent"),
"tool image noted inside the tool result");
EXPECT(nmsgs(j) == 4, "no extra user message for deferred tool images");
cJSON_Delete(j);
}
int main(void) {
test_images_off();
test_reasoning_and_prefix();
test_cache_hints();
test_tools_snapshot();
test_escaping();
test_invalid_utf8();
test_tool_calls_and_null_content();
+54 -2
View File
@@ -5,6 +5,8 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_mcp.h"
#include "cst_tools.h"
#include "os_compat.h"
@@ -18,6 +20,7 @@
static int nfail;
static char g_tmp[OS_PATH_MAX];
static cst_agent_t *g_agent;
#define CHECK(cond, msg) \
do { \
@@ -99,6 +102,7 @@ static int server(void) {
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
cJSON_AddItemToArray(tools, tool("crash", "Exits."));
cJSON_AddItemToArray(tools, tool("pic", "Returns images."));
}
reply(id, r);
} else if (strcmp(mt, "tools/call") == 0) {
@@ -136,6 +140,21 @@ static int server(void) {
cJSON_AddStringToObject(rr, "text", "resource text");
cJSON_AddItemToArray(c, res);
reply(id, r);
} else if (strcmp(name, "pic") == 0) {
cJSON *r = text_result("a chart", 0);
cJSON *c = cJSON_GetObjectItemCaseSensitive(r, "content");
const char *datas[] = {
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAC"
"hwGA60e6kgAAAABJRU5ErkJggg==",
"AAAA"};
for (int i = 0; i < 2; i++) {
cJSON *img = cJSON_CreateObject();
cJSON_AddStringToObject(img, "type", "image");
cJSON_AddStringToObject(img, "mimeType", "image/png");
cJSON_AddStringToObject(img, "data", datas[i]);
cJSON_AddItemToArray(c, img);
}
reply(id, r);
} else if (strcmp(name, "crash") == 0) {
fprintf(stderr, "fatal: fake crash\n");
exit(3);
@@ -163,6 +182,7 @@ static int call(const char *tool, const char *k, const char *v,
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = g_tmp;
ctx.agent = g_agent;
memset(r, 0, sizeof(*r));
return cst_tool_run(&ctx, &c, r);
}
@@ -217,8 +237,8 @@ int main(int argc, char **argv) {
cst_mcp_info_t mi[CST_MCP_MAX];
int n = cst_mcp_info(mi, CST_MCP_MAX);
const cst_mcp_info_t *f = info_of("fake", mi, n);
CHECK(f && f->state == CST_MCP_READY && f->ntools == 6,
"fake ready with 6 tools over two pages");
CHECK(f && f->state == CST_MCP_READY && f->ntools == 7,
"fake ready with 7 tools over two pages");
const cst_mcp_info_t *rm = info_of("remote", mi, n);
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
"url servers unsupported");
@@ -255,8 +275,40 @@ int main(int argc, char **argv) {
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") &&
strstr(r.out, "resource text"),
"content items mapped to text");
CHECK(r.nattach == 0, "no session: images stay text");
cst_tool_result_free(&r);
cst_agent_config_t acfg;
memset(&acfg, 0, sizeof(acfg));
snprintf(acfg.workspace_root, sizeof(acfg.workspace_root), "%s", g_tmp);
char sdir[1024];
snprintf(sdir, sizeof(sdir), "%.900s/cst-mcp-test-%d", g_tmp, (int)getpid());
os_mkdir_p(sdir, 0755);
snprintf(acfg.session_path, sizeof(acfg.session_path), "%s/s.jsonl", sdir);
g_agent = cst_agent_create(&acfg);
CHECK(g_agent != NULL, "agent for the session store");
call("mcp__fake__pic", NULL, NULL, &r);
CHECK(r.exit_code == 0 && r.nattach == 1 && r.attach &&
strcmp(r.attach[0].mime, "image/png") == 0 &&
strcmp(r.attach[0].origin, "mcp") == 0 &&
r.attach[0].width == 1 && r.attach[0].height == 1,
"image content stored and attached");
CHECK(r.out && strstr(r.out, "a chart\n[image ") &&
strstr(r.out, "shown to you as an image") &&
strstr(r.out, "not shown:"),
"one line per image, bad data falls back to text");
if (r.nattach == 1) {
char full[OS_PATH_MAX];
CHECK(cst_attach_full_path(acfg.session_path, &r.attach[0], full,
sizeof(full)) == 0 &&
access(full, R_OK) == 0,
"stored file exists");
}
cst_tool_result_free(&r);
cst_agent_destroy(g_agent);
g_agent = NULL;
test_rmrf(sdir);
volatile int cancel = 1;
os_set_cancel_flag(&cancel);
double t0 = os_now_ms();
+223
View File
@@ -0,0 +1,223 @@
/* Model registry: built-in data, longest/most specific match, the user file,
* the fetched cache and the env overrides. */
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_models.h"
#include "cst_test_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static void test_builtin(void) {
cJSON *doc = cJSON_Parse(cst_models_builtin_json());
EXPECT(cJSON_IsObject(doc), "embedded built-in JSON parses");
cJSON_Delete(doc);
EXPECT(cst_models_load_error()[0] == 0, "no load error");
EXPECT(cst_provider_count() >= 4, "built-in providers");
const cst_provider_t *p = cst_provider_at(0);
EXPECT(p && strcmp(p->name, "minimax") == 0, "file order kept");
p = cst_provider_find("deepseek");
EXPECT(p && strcmp(p->model, "deepseek-flash") == 0 &&
strcmp(p->key_env, "DEEPSEEK_API_KEY") == 0 &&
strcmp(cst_provider_source(p), "built-in") == 0,
"deepseek preset");
cst_model_info_t mi;
/* most specific rule per field: price from deepseek-v4-pro, context from
* deepseek */
cst_model_info("deepseek", "deepseek-v4-pro", &mi);
EXPECT(mi.context == 1000000 && mi.vision == 0, "context inherited");
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
"longer match wins the price");
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
"gpt-4o-mini over gpt-4o");
cst_model_info(NULL, "Qwen/Qwen2.5-VL-72B-Instruct", &mi);
EXPECT(mi.vision == 1 && mi.context == 131072, "glob *-vl* + qwen prefix");
cst_model_info(NULL, "zhipu/glm-4.5v", &mi);
EXPECT(mi.vision == 1 && mi.context == 128000, "glm-*v*");
cst_model_info(NULL, "glm-4-plus", &mi);
EXPECT(mi.vision == -1, "glm without v: unknown");
cst_model_info(NULL, "anthropic/some-new-model", &mi);
EXPECT(mi.cache_style == CST_CACHE_ANTHROPIC, "vendor rule on the full id");
cst_model_info(NULL, "totally-unknown", &mi);
EXPECT(mi.context == 128000 && mi.vision == -1 && !mi.has_price &&
strcmp(mi.source, "default") == 0,
"defaults");
char tag[48];
cst_model_info(NULL, "gpt-4.1", &mi);
cst_model_info_label(&mi, tag, sizeof(tag));
EXPECT(strcmp(tag, "1M · vision") == 0, "label");
}
static const char *USER_JSON =
"{\"providers\": {"
" \"deepseek\": {\"api_base\": \"https://proxy.example/v1\"},"
" \"local\": {\"api_base\": \"http://127.0.0.1:8000/v1\","
" \"key_env\": \"LOCAL_KEY\", \"default_model\": \"qwen3-32b\","
" \"models\": [\"qwen3-32b\", \"my-vl\"]}"
"},"
"\"models\": ["
" {\"match\": \"qwen3\", \"context\": 32768},"
" {\"match\": \"my-vl\", \"vision\": true, \"price\": {\"miss\": 1,"
" \"out\": 2, \"currency\": \"¥\"}}"
"]}";
static void test_user(void) {
char err[256];
EXPECT(cst_models_load_json(cst_models_builtin_json(), USER_JSON, err,
sizeof(err)) == 0,
"load with user file");
const cst_provider_t *ds = cst_provider_find("deepseek");
EXPECT(ds && strcmp(ds->api_base, "https://proxy.example/v1") == 0 &&
strcmp(ds->model, "deepseek-flash") == 0 &&
strcmp(cst_provider_source(ds), "user") == 0,
"user overrides one field, keeps the rest");
const cst_provider_t *lp = cst_provider_find("local");
EXPECT(lp && strcmp(lp->model, "qwen3-32b") == 0 &&
strcmp(lp->key_env, "LOCAL_KEY") == 0,
"user adds a provider");
EXPECT(cst_provider_at(cst_provider_count() - 1) == lp, "appended last");
cst_model_info_t mi;
cst_model_info("local", "qwen3-32b", &mi);
EXPECT(mi.context == 32768 && strcmp(mi.source, "user") == 0,
"user rule beats built-in qwen");
cst_model_info("local", "my-vl", &mi);
EXPECT(mi.vision == 1 && mi.has_price && mi.price.hit == 1 &&
mi.price.write == 1 && strcmp(mi.price.currency, "¥") == 0,
"user price, hit/write default to miss");
/* passback follows the provider name even behind a proxy base */
cst_model_caps_t caps;
cst_model_caps("deepseek", "https://proxy.example/v1", "deepseek-flash",
&caps);
EXPECT(caps.reasoning_passback && caps.prefix_completion, "caps by name");
char names[32][128];
int n = cst_models_list(NULL, names, 32);
int seen = 0;
for (int i = 0; i < n; i++)
seen += strcmp(names[i], "my-vl") == 0;
EXPECT(seen == 1, "picker list includes user models");
EXPECT(cst_models_load_json(cst_models_builtin_json(), "{not json", err,
sizeof(err)) != 0 &&
err[0],
"bad user file reported");
}
static void test_cache(void) {
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
cst_model_meta_t m[3];
memset(m, 0, sizeof(m));
snprintf(m[0].id, sizeof(m[0].id), "anthropic/claude-sonnet-4");
m[0].context = 1000000;
m[0].vision = -1;
snprintf(m[1].id, sizeof(m[1].id), "deepseek-flash");
m[1].vision = 1; /* API claims vision */
m[1].has_price = 1;
m[1].miss = 0.5;
m[1].hit = 0.1;
m[1].write = 0.5;
m[1].out = 1;
snprintf(m[2].id, sizeof(m[2].id), "brand-new");
m[2].vision = -1;
EXPECT(cst_models_cache_save("teamorouter", "https://x/v1", m, 3) == 0,
"cache saved");
cst_model_info_t mi;
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
EXPECT(mi.context == 1000000, "API context beats built-in");
EXPECT(mi.vision == 1 && mi.cache_style == CST_CACHE_ANTHROPIC,
"missing API fields fall through to built-in");
EXPECT(strcmp(mi.source, "api+built-in") == 0, "source lists both");
cst_model_info("teamorouter", "deepseek-flash", &mi);
EXPECT(mi.vision == 1 && mi.price.miss == 0.5 &&
strcmp(mi.price.currency, "$") == 0,
"API vision and USD price");
cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.vision == 0 && mi.price.miss == 2.0,
"another provider's cache is not used");
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
"no provider: any cache, exact id");
cst_model_meta_t back[8];
long age = -1;
int n = cst_models_cache_load("teamorouter", back, 8, &age);
EXPECT(n == 3 && age >= 0 && age < 60 &&
strcmp(back[2].id, "brand-new") == 0,
"cache reads back");
EXPECT(cst_models_cache_load("openai", back, 8, &age) == -1, "no cache");
EXPECT(cst_models_cache_save("../evil", NULL, m, 1) != 0,
"provider name cannot leave the cache dir");
/* a fresh process (reload) sees the file */
cst_models_reload();
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
EXPECT(mi.context == 1000000, "cache survives a reload");
}
static void test_env(void) {
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
setenv("CST_CTX_TOKENS", "4242", 1);
setenv("CST_PRICE", "1,0.5,3", 1);
cst_model_info_t mi;
cst_model_info(NULL, "deepseek-flash", &mi);
EXPECT(mi.context == 4242 && mi.price.miss == 1 && mi.price.write == 1 &&
strstr(mi.source, "env"),
"env overrides");
unsetenv("CST_CTX_TOKENS");
unsetenv("CST_PRICE");
setenv("CST_NO_CACHE_HINTS", "1", 1);
cst_model_caps_t caps;
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
EXPECT(caps.cache_style == CST_CACHE_AUTO && caps.vision == 1,
"no cache hints, vision kept");
unsetenv("CST_NO_CACHE_HINTS");
setenv("CST_MODELS", "a, b:c", 1);
char names[8][128];
int n = cst_models_list("cur", names, 8);
EXPECT(n == 4 && strcmp(names[1], "b") == 0 && strcmp(names[3], "cur") == 0,
"CST_MODELS list");
unsetenv("CST_MODELS");
}
int main(void) {
char root[1024];
if (test_tmpdir(root, sizeof(root), "cst-models") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
unsetenv("CST_CTX_TOKENS");
unsetenv("CST_PRICE");
unsetenv("CST_MODELS");
unsetenv("CST_NO_CACHE_HINTS");
test_builtin();
test_user();
test_cache();
test_env();
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_model_registry: %d failed\n", nfail);
return 1;
}
printf("test_model_registry: OK\n");
return 0;
}
+70 -2
View File
@@ -3,6 +3,8 @@
#endif
#include "cst_agent.h"
#include "cst_models.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
@@ -72,11 +74,51 @@ static int start_server(void) {
"Connection: close\r\n\r\n" body
int main(void) {
char cfgdir[1024];
CHECK(test_tmpdir(cfgdir, sizeof(cfgdir), "cst-models-cfg") == 0);
setenv("XDG_CONFIG_HOME", cfgdir, 1); /* ignore a real models-cache */
const cst_provider_t *tr = cst_provider_find("teamorouter");
CHECK(tr && strcmp(tr->api_base, "https://api.teamorouter.com/v1") == 0);
CHECK(tr->model[0] == 0); /* picked from GET /models */
CHECK(cst_model_context_tokens("anthropic/claude-sonnet-4") == 200000);
CHECK(cst_model_context_tokens("deepseek-flash") == 128000);
CHECK(cst_model_context_tokens("deepseek-flash") == 1000000);
CHECK(cst_model_context_tokens("deepseek/deepseek-v4-pro") == 1000000);
CHECK(cst_model_context_tokens("google/gemini-2.5-pro") == 1048576);
CHECK(cst_model_context_tokens("unknown-model") == 128000);
cst_model_caps_t caps;
cst_model_caps("deepseek", "https://api.deepseek.com/v1", "deepseek-flash",
&caps);
CHECK(caps.reasoning_passback && caps.prefix_completion);
CHECK(caps.cache_style == CST_CACHE_AUTO);
cst_model_caps("teamorouter", "https://api.teamorouter.com/v1",
"deepseek-flash", &caps);
CHECK(!caps.reasoning_passback && !caps.prefix_completion);
cst_model_caps("teamorouter", NULL, "anthropic/claude-sonnet-4", &caps);
CHECK(caps.cache_style == CST_CACHE_ANTHROPIC);
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
CHECK(caps.cache_style == CST_CACHE_OPENAI);
cst_model_caps("openai", NULL, "o3-mini", &caps);
CHECK(caps.cache_style == CST_CACHE_OPENAI);
cst_model_caps("minimax", NULL, "MiniMax-M2", &caps);
CHECK(caps.cache_style == CST_CACHE_AUTO);
/* Beijing time: Thu 10:00 peak, Thu 13:00 and Sat 10:00 off-peak */
CHECK(cst_price_is_peak((time_t)1791424800));
CHECK(!cst_price_is_peak((time_t)1791435600));
CHECK(!cst_price_is_peak((time_t)1791597600));
cst_price_t pr;
CHECK(cst_model_price("deepseek-flash", &pr) == 0);
CHECK(pr.offpeak_half && pr.hit < pr.miss);
double peak = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
(time_t)1791424800);
CHECK(peak > 9.99 && peak < 10.05); /* 2 + 0.04 + 8 */
double off = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
(time_t)1791435600);
CHECK(off > peak / 2 - 0.001 && off < peak / 2 + 0.001);
CHECK(cst_model_price("anthropic/claude-sonnet-4", &pr) == 0);
CHECK(pr.write > pr.miss && strcmp(pr.currency, "$") == 0);
CHECK(cst_model_price("MiniMax-M2", &pr) != 0);
g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":["
"{\"id\":\"deepseek-flash\"},"
@@ -87,7 +129,21 @@ int main(void) {
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
"{\"name\":\"m2\"}]}");
g_responses[4] = RESP("500 Internal Server Error", "{}");
g_nresp = 5;
/* OpenRouter-style metadata; prices are USD per token, as strings */
g_responses[5] = RESP(
"200 OK",
"{\"data\":[{\"id\":\"anthropic/claude-sonnet-4\","
"\"context_length\":1000000,"
"\"architecture\":{\"input_modalities\":[\"text\",\"image\"]},"
"\"pricing\":{\"prompt\":\"0.000003\",\"completion\":\"0.000015\","
"\"input_cache_read\":\"0.0000003\"}},"
"{\"id\":\"old/text-model\","
"\"architecture\":{\"modality\":\"text->text\"},"
"\"pricing\":{\"prompt\":\"-1\",\"completion\":\"-1\"}},"
"{\"id\":\"vllm-model\",\"max_model_len\":32768,"
"\"modalities\":{\"input\":[\"text\"]}},"
"{\"id\":\"plain\"}]}");
g_nresp = 6;
int port = start_server();
pthread_t th;
CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
@@ -118,6 +174,17 @@ int main(void) {
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1 && strstr(err, "500") != NULL);
cst_model_meta_t meta[8];
n = cst_llm_list_models_meta(base, "sk-test", meta, 8, err, sizeof(err));
CHECK(n == 4);
CHECK(meta[0].context == 1000000 && meta[0].vision == 1);
CHECK(meta[0].has_price && meta[0].miss > 2.99 && meta[0].miss < 3.01 &&
meta[0].out > 14.99 && meta[0].out < 15.01 && meta[0].hit < 0.31 &&
meta[0].write == meta[0].miss);
CHECK(meta[1].vision == 0 && !meta[1].has_price && meta[1].context == 0);
CHECK(meta[2].context == 32768 && meta[2].vision == 0);
CHECK(meta[3].vision == -1 && meta[3].context == 0 && !meta[3].has_price);
pthread_join(th, NULL);
test_sock_close(g_listen);
@@ -125,6 +192,7 @@ int main(void) {
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1);
test_rmrf(cfgdir);
puts("test_models: ok");
return 0;
}
+392
View File
@@ -0,0 +1,392 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cst_test_util.h"
/* Runs a few replayed turns (tool call, Plan mode on, approval change, a
* reply cut off by max tokens) and checks the recorded request bodies: every
* request must extend the previous one byte for byte, so the provider's
* prefix cache keeps hitting. */
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
#define USAGE \
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000," \
"\"completion_tokens\":20,\"prompt_cache_hit_tokens\":900," \
"\"prompt_cache_miss_tokens\":100}}\n\n" \
"data: [DONE]\n\n"
#define TOOL(id, name, args) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," \
"\"id\":\"" id "\",\"type\":\"function\",\"function\":{\"name\":\"" name \
"\",\"arguments\":\"" args "\"}}]},\"finish_reason\":null}]}\n\n" \
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":" \
"\"tool_calls\"}]}\n\n" USAGE
#undef TEXT /* <windows.h> has one */
#define TEXT(s, finish) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" s "\"}," \
"\"finish_reason\":null}]}\n\n" \
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"" finish \
"\"}]}\n\n" USAGE
#define THINK(s) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"" s \
"\"},\"finish_reason\":null}]}\n\n"
static const char *FIXTURES[] = {
/* turn 1 */
THINK("look at the file") TOOL("c1", "read_file", "{\\\"path\\\":\\\"a.txt\\\"}"),
TEXT("done one", "stop"),
/* turn 2, Plan mode */
TOOL("c2", "write_file",
"{\\\"path\\\":\\\"b.txt\\\",\\\"content\\\":\\\"x\\\"}"),
TEXT("plan reply", "stop"),
/* turn 3, Build mode + yolo; cut off, then continued */
TEXT("part one", "length"),
TEXT(" part two", "stop"),
/* turn 4 */
TEXT("bye", "stop"),
};
#define NFIX ((int)(sizeof(FIXTURES) / sizeof(FIXTURES[0])))
static void write_file(const char *dir, const char *name, const char *body) {
char p[2048];
snprintf(p, sizeof(p), "%s/%s", dir, name);
FILE *fp = fopen(p, "wb");
EXPECT(fp != NULL, "write fixture");
if (fp) {
fputs(body, fp);
fclose(fp);
}
}
static cJSON *load_req(const char *dir, int seq) {
char p[2048];
snprintf(p, sizeof(p), "%s/%04d.req.json", dir, seq);
FILE *fp = fopen(p, "rb");
if (!fp)
return NULL;
cst_buf_t b = {0};
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0)
cst_buf_append(&b, buf, n);
fclose(fp);
cJSON *j = b.buf ? cJSON_Parse(b.buf) : NULL;
cst_buf_free(&b);
return j;
}
static const cJSON *msgs_of(const cJSON *req) {
return cJSON_GetObjectItemCaseSensitive(req, "messages");
}
static int is_prefix_msg(const cJSON *m) {
return cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(m, "prefix"));
}
static const char *str_of(const cJSON *o, const char *key) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
return cJSON_IsString(v) ? v->valuestring : NULL;
}
static char *print(const cJSON *j) { return j ? cJSON_PrintUnformatted(j) : NULL; }
/* messages of `a` (minus a request-only prefix message) start `b` */
static int extends(const cJSON *a, const cJSON *b) {
const cJSON *ma = msgs_of(a), *mb = msgs_of(b);
int na = cJSON_GetArraySize(ma), nb = cJSON_GetArraySize(mb);
if (na > 0 && is_prefix_msg(cJSON_GetArrayItem(ma, na - 1)))
na--;
if (na > nb)
return 0;
for (int i = 0; i < na; i++) {
char *x = print(cJSON_GetArrayItem(ma, i));
char *y = print(cJSON_GetArrayItem(mb, i));
int same = x && y && strcmp(x, y) == 0;
if (!same)
fprintf(stderr, " message %d differs:\n %.300s\n %.300s\n", i,
x ? x : "(null)", y ? y : "(null)");
free(x);
free(y);
if (!same)
return 0;
}
return 1;
}
static const cJSON *last_msg(const cJSON *req) {
const cJSON *m = msgs_of(req);
return cJSON_GetArrayItem(m, cJSON_GetArraySize(m) - 1);
}
static int any_msg_has(const cJSON *req, const char *key, const char *needle) {
const cJSON *m;
cJSON_ArrayForEach(m, msgs_of(req)) {
const char *s = str_of(m, key);
if (s && strstr(s, needle))
return 1;
}
return 0;
}
static int run(cst_agent_t *a, const char *text) {
char err[512];
int rc = cst_agent_run_turn(a, text, err, sizeof(err));
if (rc != 0)
fprintf(stderr, " turn \"%s\": %s\n", text, err);
return rc;
}
/* 8-byte PNG signature + IHDR header: enough for the attachment sniffer. */
static size_t tiny_png(unsigned char *b) {
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n'};
memset(b, 0, 32);
memcpy(b, sig, 8);
b[11] = 13;
memcpy(b + 12, "IHDR", 4);
b[19] = 2; /* 2 x 2 */
b[23] = 2;
return 32;
}
static int tools_have(const cJSON *req, const char *name) {
char *t = print(cJSON_GetObjectItemCaseSensitive(req, "tools"));
int has = t && strstr(t, name) != NULL;
free(t);
return has;
}
static int has_image_url(const cJSON *req) {
char *t = print(msgs_of(req));
int has = t && strstr(t, "image_url") != NULL;
free(t);
return has;
}
/* A text-only model never sees images or view_image; a model of unknown
* vision that answers 400 to an image is retried with notes, and stays
* that way so later requests extend the retried one. */
static void test_vision(const char *root) {
char dir[1100], sse[1200], rec[1200];
snprintf(dir, sizeof(dir), "%s/vision", root);
snprintf(sse, sizeof(sse), "%s/sse", dir);
snprintf(rec, sizeof(rec), "%s/rec", dir);
os_mkdir_p(sse, 0755);
write_file(sse, "0000.sse", TEXT("blind reply", "stop"));
write_file(sse, "0001.sse", "{\"error\":{\"message\":\"image input is not "
"supported\"}}");
write_file(sse, "0001.status", "400\n");
write_file(sse, "0002.sse", TEXT("noted reply", "stop"));
write_file(sse, "0003.sse", TEXT("later reply", "stop"));
setenv("CST_REPLAY_SSE", sse, 1);
setenv("CST_RECORD_SSE", rec, 1);
cst_llm_trace_reset();
unsigned char png[32];
size_t pn = tiny_png(png);
char err[256];
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir);
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 4;
cfg.headless = 1;
cfg.approval = CST_APPROVAL_AUTO;
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "blind agent");
if (!a)
return;
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0,
"attach to a blind model");
EXPECT(run(a, "what is in the picture?") == 0, "blind turn");
cst_agent_destroy(a);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/unknown.jsonl",
dir);
snprintf(cfg.model, sizeof(cfg.model), "mystery-model-9");
snprintf(cfg.provider, sizeof(cfg.provider), "teamorouter");
a = cst_agent_create(&cfg);
EXPECT(a != NULL, "unknown-vision agent");
if (!a)
return;
EXPECT(cst_agent_sees_images(a) != 0, "unknown vision: images are tried");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0,
"attach to an unknown model");
EXPECT(run(a, "describe it") == 0, "400 retried without images");
EXPECT(run(a, "and now?") == 0, "next turn");
cst_agent_destroy(a);
cJSON *r[4];
for (int i = 0; i < 4; i++) {
r[i] = load_req(rec, i);
EXPECT(r[i] != NULL, "vision request recorded");
}
if (r[0] && r[1] && r[2] && r[3]) {
EXPECT(!tools_have(r[0], "view_image"), "blind: no view_image tool");
EXPECT(!has_image_url(r[0]), "blind: no image part");
const char *c = str_of(last_msg(r[0]), "content");
EXPECT(c && strstr(c, "what is in the picture?") &&
strstr(c, "not sent: this model does not take images"),
"blind: the image becomes a note");
EXPECT(tools_have(r[1], "view_image") && has_image_url(r[1]),
"unknown: first try sends the image");
EXPECT(!tools_have(r[2], "view_image") && !has_image_url(r[2]),
"retry: no image, no view_image");
c = str_of(last_msg(r[2]), "content");
EXPECT(c && strstr(c, "not sent"), "retry: note instead");
EXPECT(!has_image_url(r[3]) && extends(r[2], r[3]),
"images stay off; the next request extends the retry");
}
for (int i = 0; i < 4; i++)
cJSON_Delete(r[i]);
}
int main(void) {
char root[1024], sse[1100], rec[1100];
if (test_tmpdir(root, sizeof(root), "cst-prefix") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
snprintf(sse, sizeof(sse), "%s/sse", root);
snprintf(rec, sizeof(rec), "%s/rec", root);
os_mkdir_p(sse, 0755);
for (int i = 0; i < NFIX; i++) {
char name[32];
snprintf(name, sizeof(name), "%04d.sse", i);
write_file(sse, name, FIXTURES[i]);
}
write_file(root, "a.txt", "hello\n");
setenv("CST_REPLAY_SSE", sse, 1);
setenv("CST_RECORD_SSE", rec, 1);
setenv("CST_MCP_WAIT_MS", "0", 1);
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
unsetenv("CST_CTX_WINDOW");
unsetenv("CST_NO_CACHE_HINTS");
unsetenv("CST_PRICE");
cst_llm_trace_reset();
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", root);
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 8;
cfg.headless = 1;
cfg.approval = CST_APPROVAL_AUTO;
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "agent created");
if (!a)
return 1;
EXPECT(run(a, "read a.txt") == 0, "turn 1");
cst_agent_config_mut(a)->plan_mode = 1;
EXPECT(run(a, "now write b.txt") == 0, "turn 2 (plan)");
cst_agent_config_mut(a)->plan_mode = 0;
cst_agent_config_mut(a)->approval = CST_APPROVAL_YOLO;
EXPECT(run(a, "a long answer please") == 0, "turn 3 (continued)");
EXPECT(run(a, "bye") == 0, "turn 4");
char bpath[1100];
snprintf(bpath, sizeof(bpath), "%s/b.txt", root);
EXPECT(access(bpath, F_OK) != 0, "Plan mode refused write_file");
cJSON *req[NFIX];
for (int i = 0; i < NFIX; i++) {
req[i] = load_req(rec, i);
EXPECT(req[i] != NULL, "request recorded");
}
if (req[NFIX - 1]) {
char *tools0 = print(cJSON_GetObjectItemCaseSensitive(req[0], "tools"));
char *sys0 = print(cJSON_GetArrayItem(msgs_of(req[0]), 0));
EXPECT(tools0 && strstr(tools0, "write_file"),
"write tools stay in the schema");
for (int i = 0; i < NFIX; i++) {
char *tools = print(cJSON_GetObjectItemCaseSensitive(req[i], "tools"));
char *sys = print(cJSON_GetArrayItem(msgs_of(req[i]), 0));
EXPECT(tools && tools0 && strcmp(tools, tools0) == 0,
"tools[] identical in every request");
EXPECT(sys && sys0 && strcmp(sys, sys0) == 0,
"system prompt identical in every request");
const char *tc = str_of(req[i], "tool_choice");
EXPECT(tc && strcmp(tc, "auto") == 0, "tool_choice stays auto");
free(tools);
free(sys);
if (i + 1 < NFIX && !extends(req[i], req[i + 1])) {
fprintf(stderr, " request %d does not extend request %d\n", i + 1, i);
nfail++;
}
}
free(tools0);
free(sys0);
EXPECT(any_msg_has(req[1], "reasoning_content", "look at the file"),
"DeepSeek reasoning passed back");
EXPECT(strstr(str_of(last_msg(req[2]), "content"), "Plan mode is now ON"),
"Plan mode announced by a note");
EXPECT(any_msg_has(req[3], "content", "Plan mode is on"),
"write_file denied with a Plan mode error");
const char *t3 = str_of(last_msg(req[4]), "content");
EXPECT(t3 && strstr(t3, "Plan mode is now OFF") &&
strstr(t3, "Approval mode is now yolo"),
"Build mode and approval change announced");
const cJSON *pm = last_msg(req[5]);
EXPECT(is_prefix_msg(pm) && strcmp(str_of(pm, "role"), "assistant") == 0 &&
strcmp(str_of(pm, "content"), "part one") == 0,
"cut-off reply continued through an assistant prefix");
EXPECT(any_msg_has(req[6], "content", "part one part two"),
"continued reply stored as one message");
}
for (int i = 0; i < NFIX; i++)
cJSON_Delete(req[i]);
cst_usage_t s, turn;
cst_agent_usage(a, &s, &turn);
EXPECT(s.requests == NFIX, "every response counted");
EXPECT(s.prompt == 1000L * NFIX && s.cached == 900L * NFIX &&
s.miss == 100L * NFIX,
"hit and miss tokens summed");
EXPECT(s.priced && s.cost > 0 && strcmp(s.currency, "¥") == 0,
"DeepSeek cost estimated");
EXPECT(turn.requests == 1, "last turn usage");
cst_agent_destroy(a);
test_vision(root);
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_prefix_stable: %d failed\n", nfail);
return 1;
}
printf("test_prefix_stable: OK\n");
return 0;
}