diff --git a/.gitignore b/.gitignore index 3f78207..3567efb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ vendor/sashidown/*.o vendor/sashidown/src/*.o vendor/sashidown/libsashidown.a vendor/sashidown/libsashidown-mingw.a +.gen/models_builtin.inc diff --git a/CMakeLists.txt b/CMakeLists.txt index 16f61bc..dca5f97 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 : 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) diff --git a/Makefile b/Makefile index 8da5385..035611b 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/include/cst_agent.h b/include/cst_agent.h index 09893df..ebc8d9b 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -4,6 +4,7 @@ #include "ctl.h" #include +#include #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 " (, 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 /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); diff --git a/include/cst_attach.h b/include/cst_attach.h new file mode 100644 index 0000000..30e7454 --- /dev/null +++ b/include/cst_attach.h @@ -0,0 +1,81 @@ +#ifndef CST_ATTACH_H +#define CST_ATTACH_H + +#include "cst_agent.h" + +#include + +#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 ("."); 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 diff --git a/include/cst_clipboard.h b/include/cst_clipboard.h new file mode 100644 index 0000000..16db43a --- /dev/null +++ b/include/cst_clipboard.h @@ -0,0 +1,35 @@ +#ifndef CST_CLIPBOARD_H +#define CST_CLIPBOARD_H + +#include + +#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 diff --git a/include/cst_keymap.h b/include/cst_keymap.h index 062864e..4b13657 100644 --- a/include/cst_keymap.h +++ b/include/cst_keymap.h @@ -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, diff --git a/include/cst_models.h b/include/cst_models.h new file mode 100644 index 0000000..1c1d945 --- /dev/null +++ b/include/cst_models.h @@ -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. /models.json user overrides and extra providers + * 2. /models-cache/.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 /models-cache/.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 . 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 diff --git a/include/cst_version.h b/include/cst_version.h index 09ba168..aded61e 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -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) diff --git a/src/agent_loop.c b/src/agent_loop.c index 0f41946..67ab57a 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -3,7 +3,9 @@ #endif #include "cJSON.h" +#include "cst_attach.h" #include "cst_agent.h" +#include "cst_mcp.h" #include "cst_metrics.h" #include "cst_perms.h" #include "cst_sandbox.h" @@ -324,24 +326,32 @@ static void os_label(char *out, size_t n) { #endif } -static void put_env(cst_buf_t *b, const cst_agent_config_t *cfg) { - char osl[256], br[128], day[32]; - os_label(osl, sizeof(osl)); +static void today(char *out, size_t n) { time_t now = time(NULL); struct tm tmv; os_localtime(&now, &tmv); - strftime(day, sizeof(day), "%Y-%m-%d", &tmv); + strftime(out, n, "%Y-%m-%d", &tmv); +} + +static const char *approval_desc(const cst_agent_config_t *cfg) { + return cfg->auto_approve ? "yolo (no confirmations)" + : cfg->approval == CST_APPROVAL_ASK + ? "ask (the user confirms writes and commands)" + : cfg->approval == CST_APPROVAL_AUTO + ? "auto (edits run; risky commands need confirmation)" + : "yolo (no confirmations)"; +} + +static void put_env(cst_buf_t *b, const cst_agent_config_t *cfg) { + char osl[256], br[128], day[32]; + os_label(osl, sizeof(osl)); + today(day, sizeof(day)); git_branch(cfg->workspace_root, br, sizeof(br)); cst_buf_printf(b, "\nEnvironment:\n- OS: %s; date: %s\n- Workspace: %s%s%s%s\n", osl, day, cfg->workspace_root[0] ? cfg->workspace_root : ".", br[0] ? " (git: " : " (not a git repo", br, ")"); - const char *ap = cfg->auto_approve ? "yolo (no confirmations)" - : cfg->approval == CST_APPROVAL_ASK - ? "ask (the user confirms writes and commands)" - : cfg->approval == CST_APPROVAL_AUTO - ? "auto (edits run; risky commands need confirmation)" - : "yolo (no confirmations)"; + const char *ap = approval_desc(cfg); if (tool_on(cfg, "run_shell")) { int sb = cst_sandbox_probe(); cst_buf_printf(b, "- Approval: %s; shell sandbox: %s\n", ap, @@ -495,6 +505,27 @@ struct cst_agent { char undo_tag[64]; long last_prompt_tokens; cst_perms_t *perms; + /* Images the user picked for the next turn (owned; consumed on send). */ + cst_attachment_t *pending; + int npending, cappending; + /* Prefix cache: the request is the frozen system prompt + tools snapshot + * followed by the session, which only grows until a rebase. */ + cst_model_caps_t caps; + char caps_model[128]; + char *tools_json; /* frozen tools[] (owned) */ + uint64_t tools_sig; /* tool settings it was built from */ + int win_start; /* first session message still sent (chunked window) */ + uint64_t *prev_hash; /* per-message hashes of the previous request */ + int nprev, capprev; + uint64_t prev_tools; + char prev_model[128]; + int rebased; /* the session was compacted since the last request */ + int prefix_off; /* DeepSeek refused prefix completion */ + int hints_off; /* the provider refused cache hints (HTTP 400) */ + int images_off; /* the provider refused a request with images (HTTP 400) */ + char images_off_model[128]; /* ...for this model */ + char cache_key[128]; + cst_usage_t usage, turn_usage; }; typedef enum { @@ -514,6 +545,8 @@ typedef struct { pc_status_t status; char *output; size_t output_len; + cst_attachment_t *attach; /* images the tool produced (owned) */ + int nattach; const char *origin; /* native / ctl / xml */ double t_open, t_ready, t_exec_start, t_exec_end; } pcall_t; @@ -527,8 +560,13 @@ typedef struct { double t0; cst_buf_t text; /* confirmed answer text (no tool syntax) */ cst_buf_t raw; /* visible stream with tool syntax, think stripped */ + cst_buf_t think; /* reasoning, kept for providers that want it back */ size_t raw_mark; int in_think; + int native_opened; /* a native tool call started streaming */ + int cont; /* prefix continuations done in this step */ + int prefix_break; /* first message that differs from the last request */ + const char *prefix_reason; char tagbuf[16]; size_t taglen; ctl_parser_t *ctl; @@ -615,12 +653,14 @@ static void turn_free(turn_t *t) { cst_tool_call_clear(&t->calls[i].call); free(t->calls[i].raw_args); free(t->calls[i].output); + cst_attachments_free(t->calls[i].attach, t->calls[i].nattach); } free(t->calls); t->calls = NULL; t->ncalls = t->cap = 0; cst_buf_free(&t->text); cst_buf_free(&t->raw); + cst_buf_free(&t->think); if (t->mm) mm_scan_destroy(t->mm); if (t->ctl) @@ -892,6 +932,20 @@ static void exec_begin(turn_t *t, pcall_t *pc, exec_t *x) { hook_status(t, "unknown tool"); return; } + /* Plan mode keeps these in tools[] (a stable prefix) and refuses here. */ + if (a->cfg.plan_mode && + (def->perm == CST_TP_WRITE || def->perm == CST_TP_SHELL)) { + char msg[320]; + snprintf(msg, sizeof(msg), + "error: Plan mode is on (read-only), so %.64s was not run. Keep " + "investigating with read-only tools and answer with a plan; the " + "user switches to Build mode to carry it out.", + c->name); + set_output(pc, msg, strlen(msg), 1); + pc->status = PC_DENIED; + hook_tool(t, "denied", c, detail); + return; + } if (pc->invalid) { const char *msg = "error: tool arguments are not a valid JSON object (or " "exceed 1 MiB); nothing was executed"; @@ -1024,6 +1078,11 @@ static void exec_end(turn_t *t, pcall_t *pc, exec_t *x) { } } set_output(pc, tr->out ? tr->out : "", tr->out_len, tr->exit_code); + if (tr->attach && tr->nattach > 0) { + cst_attachments_free(pc->attach, pc->nattach); + pc->attach = cst_attachments_dup(tr->attach, tr->nattach); + pc->nattach = pc->attach ? tr->nattach : 0; + } cst_tool_result_free(tr); if (x->mutating) a->write_epoch++; @@ -1239,6 +1298,8 @@ static void feed_visible(turn_t *t, const char *data, size_t len) { } static void think_out(turn_t *t, const char *s, size_t n) { + if (n && t->a->caps.reasoning_passback) + cst_buf_append(&t->think, s, n); if (t->hooks && t->hooks->on_think_delta && n) t->hooks->on_think_delta(s, n, t->hooks->userdata); } @@ -1297,6 +1358,7 @@ static void on_native_open(int index, const char *id, const char *name, void *ud) { turn_t *t = ud; flush_held_text(t); + t->native_opened = 1; if (index >= 0 && index < 64) t->native_open_t[index] = rel_ms(t); ctl_tool_call_t tmp; @@ -1334,6 +1396,7 @@ static void on_native_tool(const char *id, const char *name, typedef struct { cst_message_t *msgs; int n; + int ntail; /* trailing request-only messages (nudge, prefix) */ char **owned; int nowned; } req_msgs_t; @@ -1426,126 +1489,84 @@ static char *render_calls_as_text(cst_protocol_t p, const char *content, return cst_buf_steal(&b); } -static int ctx_window(void) { - const char *e = getenv("CST_CTX_WINDOW"); - if (e && atoi(e) > 0) - return atoi(e); - return CST_AGENT_CTX_WINDOW; +int cst_message_is_note(const cst_message_t *m) { + return m && m->role == CST_ROLE_USER && m->meta_json && + strstr(m->meta_json, "\"kind\":\"note\""); } +#define msg_is_note cst_message_is_note -/* Big tool outputs from turns this far back go out as a one-line stub (the - * session file keeps them whole). */ -#define STUB_TURNS 3 -#define STUB_MIN (2 * 1024) - -/* "path"/"pattern"/"cmd"/"url" argument of call id in a tool_calls array. */ -static void call_hint(const cJSON *calls, const char *id, char *out, - size_t n) { - out[0] = 0; - const cJSON *tc = NULL; - cJSON_ArrayForEach(tc, calls) { - const cJSON *cid = cJSON_GetObjectItemCaseSensitive(tc, "id"); - if (!cJSON_IsString(cid) || strcmp(cid->valuestring, id) != 0) - continue; - const cJSON *fn = cJSON_GetObjectItemCaseSensitive(tc, "function"); - const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments"); - cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL; - static const char *const keys[] = {"path", "pattern", "cmd", "command", - "url"}; - for (size_t k = 0; k < sizeof(keys) / sizeof(keys[0]); k++) { - const cJSON *v = cJSON_GetObjectItemCaseSensitive(args, keys[k]); - if (cJSON_IsString(v)) { - size_t l = strlen(v->valuestring); - size_t keep = cst_utf8_floor(v->valuestring, l > 80 ? 80 : l); - snprintf(out, n, " %.*s%s", (int)keep, v->valuestring, - keep < l ? "…" : ""); - for (char *q = out; *q; q++) - if (*q == '\n') - *q = ' '; +/* Where the request starts in the session. Normally the very first message, + * so every request extends the previous one. CST_CTX_WINDOW=N keeps the + * start fixed until more than N messages follow it, then jumps so N/2 + * remain: one cache miss per jump instead of one per step. When the last + * request filled 85% of the context window the oldest half is dropped. */ +static int window_start(cst_agent_t *a, int n) { + int start = a->win_start > 0 && a->win_start < n ? a->win_start : 0; + const char *e = getenv("CST_CTX_WINDOW"); + int win = e ? atoi(e) : 0; + int count = 0; + for (int i = start; i < n; i++) + count += cst_session_get(a->session, i)->role != CST_ROLE_SYSTEM; + int keep = -1; + if (win > 0 && count > win) + keep = win / 2 > 0 ? win / 2 : 1; + long ctx = cst_model_context_tokens(a->cfg.model); + if (ctx > 0 && a->last_prompt_tokens > ctx / 100 * 85 && count > 2) { + int half = count / 2; + keep = keep < 0 || half < keep ? half : keep; + a->last_prompt_tokens = 0; /* once until the next usage report */ + } + if (keep >= 0) { + int seen = 0; + for (int i = n - 1; i > start; i--) { + if (cst_session_get(a->session, i)->role == CST_ROLE_SYSTEM) + continue; + if (++seen >= keep) { + start = i; break; } } - cJSON_Delete(args); - return; } -} - -static char *stub_text(const char *name, const char *hint, const char *body, - size_t n) { - size_t lines = 0; - for (size_t i = 0; i < n; i++) - lines += body[i] == '\n'; - if (n && body[n - 1] != '\n') - lines++; - cst_buf_t b = {0}; - cst_buf_printf(&b, - "[output elided: %s%s returned %zu line%s (%.1f KB) in an " - "earlier turn; call it again if you still need it]", - name && name[0] ? name : "tool", hint, lines, - lines == 1 ? "" : "s", (double)n / 1024.0); - return cst_buf_steal(&b); + a->win_start = start; + return start; } static int build_request_msgs(cst_agent_t *a, cst_protocol_t proto, const char *nudge, req_msgs_t *r) { memset(r, 0, sizeof(*r)); int n = cst_session_count(a->session); - r->msgs = calloc((size_t)n + 2, sizeof(cst_message_t)); - r->owned = calloc((size_t)n + 3, sizeof(char *)); + r->msgs = calloc((size_t)n + 3, sizeof(cst_message_t)); + r->owned = calloc((size_t)n + 4, sizeof(char *)); if (!r->msgs || !r->owned) { req_msgs_free(r); return -1; } + /* the system prompt frozen in the session (see ensure_system) */ + const cst_message_t *sys = NULL; + for (int i = 0; i < n && !sys; i++) { + const cst_message_t *m = cst_session_get(a->session, i); + if (m->role == CST_ROLE_SYSTEM) + sys = m; + } r->msgs[r->n].role = CST_ROLE_SYSTEM; r->msgs[r->n].content = - own(r, cst_agent_build_prompt(&a->cfg, proto, a->notes)); + sys ? sys->content + : own(r, cst_agent_build_prompt(&a->cfg, proto, a->notes)); r->n++; - int nonsys = 0; - for (int i = 0; i < n; i++) { - if (cst_session_get(a->session, i)->role != CST_ROLE_SYSTEM) - nonsys++; - } - int skip = nonsys > ctx_window() ? nonsys - ctx_window() : 0; int text_proto = cst_protocol_is_text(proto); int leading = 1; - /* real user messages after each index (drives stubbing) */ - int *later = NULL; - if (a->cfg.tool_profile != CST_TOOLS_MINIMAL && n > 0) { - later = calloc((size_t)n, sizeof(int)); - int cnt = 0; - for (int i = n - 1; later && i >= 0; i--) { - later[i] = cnt; - const cst_message_t *m = cst_session_get(a->session, i); - if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) - cnt++; - } - } - cJSON *calls = NULL; - for (int i = 0; i < n; i++) { + for (int i = window_start(a, n); i < n; i++) { const cst_message_t *m = cst_session_get(a->session, i); if (m->role == CST_ROLE_SYSTEM) continue; - if (m->role == CST_ROLE_ASSISTANT && later) { - cJSON_Delete(calls); - calls = m->tool_calls_json && later[i] >= STUB_TURNS - ? cJSON_Parse(m->tool_calls_json) - : NULL; - } - if (skip > 0) { - skip--; - continue; - } - int stub = later && later[i] >= STUB_TURNS && m->content && - strlen(m->content) > STUB_MIN && - (m->role == CST_ROLE_TOOL || - (m->role == CST_ROLE_USER && m->tool_call_id[0])); /* never start the window with orphan results */ if (leading && (m->role == CST_ROLE_TOOL || (m->role == CST_ROLE_USER && m->tool_call_id[0]))) continue; leading = 0; cst_message_t mm = *m; + mm.prefix = 0; if (text_proto && m->role == CST_ROLE_ASSISTANT && m->tool_calls_json) { mm.content = own(r, render_calls_as_text(proto, m->content, m->tool_calls_json)); @@ -1559,60 +1580,243 @@ static int build_request_msgs(cst_agent_t *a, cst_protocol_t proto, const char *nl = strchr(body, '\n'); if (strncmp(body, "[tool ", 6) == 0 && nl) body = nl + 1; - char *st = NULL; - if (stub) { - char hint[128]; - call_hint(calls, m->tool_call_id, hint, sizeof(hint)); - st = stub_text(m->tool_name, hint, body, strlen(body)); - if (st) - body = st; - } mm.role = CST_ROLE_USER; mm.content = own(r, result_frame(m->tool_call_id, code, body, strlen(body), a->cfg.tool_out_cap)); - free(st); - } else if (stub && m->role == CST_ROLE_TOOL) { - const char *nl = strchr(m->content, '\n'); - size_t hl = nl && strncmp(m->content, "[tool ", 6) == 0 - ? (size_t)(nl - m->content) + 1 - : 0; - char hint[128]; - call_hint(calls, m->tool_call_id, hint, sizeof(hint)); - char *st = stub_text(m->tool_name, hint, m->content + hl, - strlen(m->content + hl)); - cst_buf_t b = {0}; - cst_buf_append(&b, m->content, hl); - cst_buf_puts(&b, st ? st : ""); - free(st); - mm.content = own(r, cst_buf_steal(&b)); - } else if (stub && text_proto && strncmp(m->content, "result ", 7) == 0) { - /* text-protocol frame: "result exit= \n\nend" */ - const char *ex = strstr(m->content, " exit="); - const char *nl = strchr(m->content, '\n'); - size_t len = strlen(m->content); - if (ex && nl && len >= 4 && strcmp(m->content + len - 4, "\nend") == 0 && - nl < m->content + len - 4) { - const char *body = nl + 1; - size_t bn = (size_t)(m->content + len - 4 - body); - char *st = stub_text(m->tool_name, "", body, bn); - if (st) - mm.content = own(r, result_frame(m->tool_call_id, atoi(ex + 6), st, - strlen(st), 0)); - free(st); - } } r->msgs[r->n++] = mm; } - cJSON_Delete(calls); - free(later); if (nudge) { r->msgs[r->n].role = CST_ROLE_USER; r->msgs[r->n].content = (char *)nudge; r->n++; + r->ntail++; } return 0; } +/* Request-only assistant prefix the model continues (DeepSeek beta). */ +static void push_prefix(req_msgs_t *r, const char *text) { + cst_message_t *m = &r->msgs[r->n++]; + memset(m, 0, sizeof(*m)); + m->role = CST_ROLE_ASSISTANT; + m->content = own(r, strdup(text ? text : "")); + m->prefix = 1; + r->ntail++; +} + +/* --- prefix cache bookkeeping -------------------------------------------- */ + +static uint64_t hash_str(uint64_t h, const char *s) { + h = cst_fnv1a64(h, s ? s : "", s ? strlen(s) : 0); + return cst_fnv1a64(h, "\x1f", 1); +} + +static uint64_t msg_hash(const cst_message_t *m, int with_reasoning) { + uint64_t h = cst_fnv1a64(CST_FNV_INIT, &m->role, sizeof(m->role)); + h = hash_str(h, m->content); + h = hash_str(h, m->tool_call_id); + h = hash_str(h, m->tool_calls_json); + if (with_reasoning) + h = hash_str(h, m->reasoning); + for (int i = 0; i < m->nattach; i++) + h = hash_str(h, m->attach[i].id); + return h; +} + +/* Compare this request with the previous one: t->prefix_break is the first + * message that differs (-1: the request only appends; -2: no previous + * request to compare with). */ +static void check_prefix(cst_agent_t *a, turn_t *t, const req_msgs_t *r, + const char *tools_json) { + int n = r->n - r->ntail; + uint64_t th = hash_str(CST_FNV_INIT, tools_json); + int brk = -1; + const char *why = NULL; + if (a->nprev == 0) { + brk = -2; + } else if (strcmp(a->prev_model, a->cfg.model) != 0) { + brk = 0; + why = "model"; + } else if (th != a->prev_tools) { + brk = 0; + why = "tools"; + } + if (n > a->capprev) { + uint64_t *nh = realloc(a->prev_hash, (size_t)n * sizeof(*nh)); + if (!nh) { + a->nprev = 0; + t->prefix_break = -2; + return; + } + a->prev_hash = nh; + a->capprev = n; + } + for (int i = 0; i < n; i++) { + uint64_t h = msg_hash(&r->msgs[i], a->caps.reasoning_passback); + if (brk == -1 && i < a->nprev && h != a->prev_hash[i]) { + brk = i; + why = i == 0 ? "system" : "history"; + } + a->prev_hash[i] = h; + } + if (brk == -1 && n < a->nprev) { + brk = n; + why = "shrunk"; + } + if (brk >= 0 && a->rebased) + why = "rebase"; + a->rebased = 0; + a->nprev = n; + a->prev_tools = th; + snprintf(a->prev_model, sizeof(a->prev_model), "%s", a->cfg.model); + t->prefix_break = brk; + t->prefix_reason = why; +} + +static int images_blocked(const cst_agent_t *a) { + return a->caps.vision == 0 || + (a->images_off && strcmp(a->images_off_model, a->cfg.model) == 0); +} + +static int msgs_have_images(const req_msgs_t *r) { + for (int i = 0; i < r->n; i++) + if (r->msgs[i].nattach > 0) + return 1; + return 0; +} + +static uint64_t tools_sig(const cst_agent_t *a) { + const cst_agent_config_t *c = &a->cfg; + uint64_t h = hash_str(CST_FNV_INIT, c->tools_off); + h = hash_str(h, c->tools_only); + h = cst_fnv1a64(h, &c->read_only, sizeof(c->read_only)); + h = cst_fnv1a64(h, &c->depth, sizeof(c->depth)); + int blind = images_blocked(a); + h = cst_fnv1a64(h, &blind, sizeof(blind)); + return cst_fnv1a64(h, &c->tool_profile, sizeof(c->tool_profile)); +} + +/* tools[] as sent, frozen for the session: rebuilt only when the tool + * settings change or at a rebase, so MCP servers that come up later do not + * reshuffle the cached prefix mid-session. */ +static const char *agent_tools_json(cst_agent_t *a) { + uint64_t sig = tools_sig(a); + if (a->tools_json && sig == a->tools_sig) + return a->tools_json; + if (!a->tools_json) { + const char *e = getenv("CST_MCP_WAIT_MS"); + cst_mcp_wait_ready(e && atoi(e) >= 0 ? atoi(e) : 3000); + } + cst_agent_config_t c = a->cfg; + if (images_blocked(a)) /* a model that cannot see gets no view_image */ + cst_tool_set_disabled(&c, "view_image", 1); + cJSON *arr = cst_tools_schema_cfg(&c); + char *s = arr ? cJSON_PrintUnformatted(arr) : NULL; + cJSON_Delete(arr); + if (!s) + return a->tools_json; + free(a->tools_json); + a->tools_json = s; + a->tools_sig = sig; + return s; +} + +static void reset_prefix_state(cst_agent_t *a) { + free(a->tools_json); + a->tools_json = NULL; + a->win_start = 0; + a->rebased = 1; +} + +/* "https://api.deepseek.com/v1" -> "https://api.deepseek.com/beta" */ +static void beta_base(const char *base, char *out, size_t n) { + size_t l = strlen(base); + while (l && base[l - 1] == '/') + l--; + if (l >= 3 && strncmp(base + l - 3, "/v1", 3) == 0) + l -= 3; + else if (l >= 5 && strncmp(base + l - 5, "/beta", 5) == 0) + l -= 5; + snprintf(out, n, "%.*s/beta", (int)l, base); +} + +static void fill_request(cst_agent_t *a, cst_llm_request_t *req, + const req_msgs_t *rm, cst_protocol_t proto) { + memset(req, 0, sizeof(*req)); + cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &a->caps); + req->api_base = a->cfg.api_base; + req->api_key = a->cfg.api_key; + req->model = a->cfg.model; + req->reasoning_effort = + cst_effort_wire(a->cfg.provider, a->cfg.model, a->cfg.effort); + req->msgs = rm->msgs; + req->nmsgs = rm->n; + req->session_path = cst_session_path(a->session); + req->cancel_flag = &a->cancel; + req->tool_out_cap = a->cfg.tool_out_cap; + if (cst_protocol_is_native(proto)) { + req->enable_tools = 1; + req->tool_cfg = &a->cfg; + req->tools_json = agent_tools_json(a); + } + req->send_reasoning = a->caps.reasoning_passback; + req->images_off = images_blocked(a); + if (!a->hints_off) { + req->cache_style = a->caps.cache_style; + cst_agent_session_id(a, a->cache_key, sizeof(a->cache_key)); + req->cache_key = a->cache_key; + req->cache_ttl = getenv("CST_CACHE_TTL"); + } +} + +/* Count one response into the session and turn totals; returns its cost, + * or -1 when the model has no known price. */ +static double account(cst_agent_t *a, const cst_llm_stats_t *st) { + if (st->prompt_tokens <= 0 && st->completion_tokens <= 0) + return -1; + cst_price_t p; + int priced = cst_model_price(a->cfg.model, &p) == 0; + double c = priced ? cst_price_cost(&p, st->cache_miss_tokens, + st->cached_tokens, st->cache_write_tokens, + st->completion_tokens, time(NULL)) + : -1; + cst_usage_t *us[2] = {&a->usage, &a->turn_usage}; + for (int i = 0; i < 2; i++) { + cst_usage_t *u = us[i]; + if (u->requests == 0) { + u->priced = 1; + snprintf(u->currency, sizeof(u->currency), "%s", + priced ? p.currency : ""); + } + u->requests++; + u->prompt += st->prompt_tokens; + u->cached += st->cached_tokens; + u->miss += st->cache_miss_tokens; + u->write += st->cache_write_tokens; + u->out += st->completion_tokens; + if (priced && strcmp(u->currency, p.currency) == 0) + u->cost += c; + else + u->priced = 0; + } + return c; +} + +void cst_agent_usage(const cst_agent_t *a, cst_usage_t *session, + cst_usage_t *turn) { + if (session) + memset(session, 0, sizeof(*session)); + if (turn) + memset(turn, 0, sizeof(*turn)); + if (!a) + return; + if (session) + *session = a->usage; + if (turn) + *turn = a->turn_usage; +} + /* --- persistence --------------------------------------------------------- */ static char *meta_json(const char *kind, cst_protocol_t p) { @@ -1625,23 +1829,49 @@ static char *meta_json(const char *kind, cst_protocol_t p) { return s; } -static void append_msg(cst_agent_t *a, cst_role_t role, const char *content, - const char *tool_id, const char *tool_name, - const char *tool_calls_json, char *meta) { +static void append_msg_full(cst_agent_t *a, cst_role_t role, + const char *content, const char *tool_id, + const char *tool_name, const char *tool_calls_json, + char *meta, const cst_attachment_t *attach, + int nattach, const char *reasoning) { cst_message_t m; memset(&m, 0, sizeof(m)); m.role = role; m.content = (char *)(content ? content : ""); + m.reasoning = (char *)reasoning; if (tool_id) snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", tool_id); if (tool_name) snprintf(m.tool_name, sizeof(m.tool_name), "%s", tool_name); m.tool_calls_json = (char *)tool_calls_json; m.meta_json = meta; + if (attach && nattach > 0) { + m.attach = cst_attachments_dup(attach, nattach); + m.nattach = m.attach ? nattach : 0; + } cst_session_append(a->session, &m); + cst_attachments_free(m.attach, m.nattach); free(meta); } +static void append_msg(cst_agent_t *a, cst_role_t role, const char *content, + const char *tool_id, const char *tool_name, + const char *tool_calls_json, char *meta) { + append_msg_full(a, role, content, tool_id, tool_name, tool_calls_json, meta, + NULL, 0, NULL); +} + +/* An assistant reply, with the turn's reasoning when the provider wants the + * history to keep it (DeepSeek thinking + tools). */ +static void append_reply(turn_t *t, const char *content, + const char *tool_calls_json, char *meta) { + const char *r = t->a->caps.reasoning_passback && t->think.len + ? t->think.buf + : NULL; + append_msg_full(t->a, CST_ROLE_ASSISTANT, content, NULL, NULL, + tool_calls_json, meta, NULL, 0, r); +} + static char *build_tool_calls_json(const turn_t *t) { cJSON *arr = cJSON_CreateArray(); for (int i = 0; i < t->ncalls; i++) { @@ -1680,8 +1910,7 @@ static void persist_calls(turn_t *t) { memcpy(raw, t->raw.buf ? t->raw.buf : "", n); raw[n] = 0; } - append_msg(a, CST_ROLE_ASSISTANT, raw ? raw : "", NULL, NULL, NULL, - meta_json(NULL, t->proto)); + append_reply(t, raw ? raw : "", NULL, meta_json(NULL, t->proto)); free(raw); for (int i = 0; i < t->ncalls; i++) { pcall_t *pc = &t->calls[i]; @@ -1695,8 +1924,8 @@ static void persist_calls(turn_t *t) { return; } char *tcj = build_tool_calls_json(t); - append_msg(a, CST_ROLE_ASSISTANT, t->text.buf ? trim_left(t->text.buf) : "", - NULL, NULL, tcj, meta_json(NULL, t->proto)); + append_reply(t, t->text.buf ? trim_left(t->text.buf) : "", tcj, + meta_json(NULL, t->proto)); free(tcj); for (int i = 0; i < t->ncalls; i++) { pcall_t *pc = &t->calls[i]; @@ -1704,8 +1933,8 @@ static void persist_calls(turn_t *t) { cst_buf_printf(&b, "[tool %s id=%s exit=%d]\n", pc->call.name, pc->call.id, pc->exit_code); cst_buf_append(&b, pc->output ? pc->output : "", pc->output_len); - append_msg(a, CST_ROLE_TOOL, b.buf, pc->call.id, pc->call.name, NULL, - meta_json(NULL, t->proto)); + append_msg_full(a, CST_ROLE_TOOL, b.buf, pc->call.id, pc->call.name, NULL, + meta_json(NULL, t->proto), pc->attach, pc->nattach, NULL); cst_buf_free(&b); } } @@ -1730,6 +1959,122 @@ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) { out[n - 6] = 0; } +const char *cst_agent_session_path(const cst_agent_t *a) { + return a && a->session ? cst_session_path(a->session) : NULL; +} + +void cst_agent_pending_clear(cst_agent_t *a) { + if (!a) + return; + cst_attachments_free(a->pending, a->npending); + a->pending = NULL; + a->npending = a->cappending = 0; +} + +int cst_agent_pending_count(const cst_agent_t *a) { return a ? a->npending : 0; } + +int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out, + size_t out_sz) { + if (!a || !out || i < 0 || i >= a->npending) + return -1; + cst_attach_describe(&a->pending[i], out, out_sz); + return 0; +} + +int cst_agent_sees_images(const cst_agent_t *a) { + if (!a) + return 0; + cst_model_caps_t c; + cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &c); + if (c.vision == 0 || + (a->images_off && strcmp(a->images_off_model, a->cfg.model) == 0)) + return 0; + return c.vision; +} + +/* Room for one more picked image: the session to store it in, or NULL. */ +static const char *pending_room(cst_agent_t *a, long long *total, char *err, + size_t err_sz) { + if (a->npending >= CST_ATTACH_MAX_COUNT) { + snprintf(err, err_sz, "at most %d images per message", CST_ATTACH_MAX_COUNT); + return NULL; + } + *total = 0; + for (int i = 0; i < a->npending; i++) + *total += a->pending[i].bytes; + if (*total >= CST_ATTACH_MAX_TOTAL) { + snprintf(err, err_sz, "the images already picked fill %d MB", + (int)(CST_ATTACH_MAX_TOTAL >> 20)); + return NULL; + } + const char *session = cst_agent_session_path(a); + if (!session || !session[0]) { + snprintf(err, err_sz, "no session to store the image in"); + return NULL; + } + return session; +} + +static int pending_add(cst_agent_t *a, cst_attachment_t *one, long long total, + char *err, size_t err_sz) { + if (!one) + return -1; + int rc = 0; + if (one->bytes > CST_ATTACH_MAX_TOTAL - total || + cst_attachments_push(&a->pending, &a->npending, &a->cappending, one) != + 0) { + snprintf(err, err_sz, "the images together exceed %d MB", + (int)(CST_ATTACH_MAX_TOTAL >> 20)); + rc = -1; + } + cst_attach_free(one); + return rc; +} + +int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err, + size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (!a || !path || !path[0]) { + snprintf(err, err_sz, "no file to attach"); + return -1; + } + long long total; + const char *session = pending_room(a, &total, err, err_sz); + if (!session) + return -1; + char full[OS_PATH_MAX]; + if (os_realpath(path, full, sizeof(full)) != 0) + snprintf(full, sizeof(full), "%s", path); + return pending_add(a, cst_attach_import_file(session, full, err, err_sz), + total, err, err_sz); +} + +int cst_agent_attach_bytes(cst_agent_t *a, 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 (!a) { + snprintf(err, err_sz, "no agent"); + return -1; + } + long long total; + const char *session = pending_room(a, &total, err, err_sz); + if (!session) + return -1; + return pending_add( + a, cst_attach_import_bytes(session, name, data, n, err, err_sz), total, + err, err_sz); +} + const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; } const char *cst_agent_notes(const cst_agent_t *a) { @@ -1843,19 +2188,32 @@ char *cst_agent_rewind_last_turn(cst_agent_t *a) { for (int i = n - 1; i > 0; i--) { const cst_message_t *m = cst_session_get(s, i); if (m->role == CST_ROLE_USER && !m->tool_call_id[0] && m->content && + !msg_is_note(m) && strncmp(m->content, "[session compacted", 18) != 0) { idx = i; break; } } char *prompt = idx > 0 ? strdup(cst_session_get(s, idx)->content) : NULL; + int nimg = 0; + cst_attachment_t *imgs = NULL; + if (prompt) { + const cst_message_t *m = cst_session_get(s, idx); + imgs = cst_attachments_dup(m->attach, m->nattach); + nimg = imgs ? m->nattach : 0; + } cst_session_unlock(s); if (!prompt) return NULL; if (cst_session_rewrite(s, cst_session_get(s, 0), idx) != 0) { free(prompt); + cst_attachments_free(imgs, nimg); return NULL; } + /* the prompt's images go back to the queue with its text */ + for (int i = 0; i < nimg && a->npending < CST_ATTACH_MAX_COUNT; i++) + cst_attachments_push(&a->pending, &a->npending, &a->cappending, &imgs[i]); + cst_attachments_free(imgs, nimg); a->write_epoch++; return prompt; } @@ -1868,13 +2226,194 @@ int cst_agent_undo_available(const cst_agent_t *a) { return cst_undo_count(a->cfg.workspace_root, sid); } -static void seed_system(cst_agent_t *a) { - if (cst_session_count(a->session) == 0) { - char *sp = cst_agent_build_prompt(&a->cfg, a->cfg.protocol, a->notes); - append_msg(a, CST_ROLE_SYSTEM, sp, NULL, NULL, NULL, - meta_json(NULL, a->cfg.protocol)); - free(sp); +/* --- frozen system prompt ------------------------------------------------ + * Every request starts with the session's system message. It is written + * once (Build mode, the state at that moment) and changes only at a rebase; + * later changes reach the model as persisted notes appended to the history, + * which keeps the cached prefix intact. */ + +#define PROMPT_VERSION 2 + +typedef struct { + int plan; + char approval[96]; + char branch[128]; + char day[16]; + char notes[24]; /* hash of AGENTS.md, "" = none */ +} pstate_t; + +static void state_now(const cst_agent_t *a, pstate_t *s) { + memset(s, 0, sizeof(*s)); + s->plan = a->cfg.plan_mode; + snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg)); + git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch)); + today(s->day, sizeof(s->day)); + if (a->notes && a->notes[0]) + snprintf(s->notes, sizeof(s->notes), "%016llx", + (unsigned long long)cst_fnv1a64(CST_FNV_INIT, a->notes, + strlen(a->notes))); +} + +static cJSON *state_json(const pstate_t *s) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddNumberToObject(o, "plan", s->plan); + cJSON_AddStringToObject(o, "approval", s->approval); + cJSON_AddStringToObject(o, "branch", s->branch); + cJSON_AddStringToObject(o, "day", s->day); + cJSON_AddStringToObject(o, "notes", s->notes); + return o; +} + +static void state_parse(const cJSON *o, pstate_t *s) { + memset(s, 0, sizeof(*s)); + const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "plan"); + s->plan = cJSON_IsNumber(v) ? v->valueint : 0; +#define STATE_STR(key, field) \ + v = cJSON_GetObjectItemCaseSensitive(o, key); \ + if (cJSON_IsString(v)) \ + snprintf(s->field, sizeof(s->field), "%s", v->valuestring); + STATE_STR("approval", approval) + STATE_STR("branch", branch) + STATE_STR("day", day) + STATE_STR("notes", notes) +#undef STATE_STR +} + +static char *system_meta(const cst_agent_t *a, const pstate_t *s) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol)); + cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION); + cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile); + cJSON_AddItemToObject(o, "state", state_json(s)); + char *out = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + return out; +} + +static char *frozen_prompt(const cst_agent_t *a, pstate_t *st) { + cst_agent_config_t c = a->cfg; + c.plan_mode = 0; + state_now(a, st); + st->plan = 0; + return cst_agent_build_prompt(&c, c.protocol, a->notes); +} + +static int system_current(const cst_message_t *m, const cst_agent_config_t *c) { + cJSON *meta = m->meta_json ? cJSON_Parse(m->meta_json) : NULL; + const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv"); + const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol"); + const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools"); + int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION && + cJSON_IsString(pr) && + strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 && + cJSON_IsNumber(tp) && tp->valueint == c->tool_profile; + cJSON_Delete(meta); + return ok; +} + +/* Sessions from older versions, or after a protocol / tool profile change, + * get a new system prompt (a one-time cache miss). */ +static void ensure_system(cst_agent_t *a) { + cst_session_t *s = a->session; + int n = cst_session_count(s); + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + if (m->role != CST_ROLE_SYSTEM) + continue; + if (system_current(m, &a->cfg)) + return; + break; } + pstate_t st; + char *sp = frozen_prompt(a, &st); + char *meta = system_meta(a, &st); + if (n == 0) { + append_msg(a, CST_ROLE_SYSTEM, sp, NULL, NULL, NULL, meta); + free(sp); + return; + } + cst_message_t *v = calloc((size_t)n + 1, sizeof(*v)); + if (v) { + v[0].role = CST_ROLE_SYSTEM; + v[0].content = sp; + v[0].meta_json = meta; + int k = 1; + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + if (m->role != CST_ROLE_SYSTEM) + v[k++] = *m; + } + cst_session_rewrite(s, v, k); + free(v); + } + free(sp); + free(meta); + reset_prefix_state(a); +} + +/* State as of the newest note, or the system prompt. */ +static void last_state(cst_agent_t *a, pstate_t *st) { + memset(st, 0, sizeof(*st)); + for (int i = cst_session_count(a->session) - 1; i >= 0; i--) { + const cst_message_t *m = cst_session_get(a->session, i); + if (!m->meta_json || !strstr(m->meta_json, "\"state\"")) + continue; + cJSON *meta = cJSON_Parse(m->meta_json); + state_parse(cJSON_GetObjectItemCaseSensitive(meta, "state"), st); + cJSON_Delete(meta); + return; + } +} + +/* One persisted note for whatever changed since the last one. */ +static void sync_notes(cst_agent_t *a) { + pstate_t was, now; + last_state(a, &was); + state_now(a, &now); + cst_buf_t b = {0}; + if (now.plan != was.plan) + cst_buf_puts(&b, + now.plan + ? "[cst] Plan mode is now ON: you may only read and " + "research; tools that change files or run shell " + "commands are refused. Investigate, then answer with a " + "concrete step-by-step plan: files to touch, what to " + "change, how to verify. The user switches to Build " + "mode to carry it out.\n" + : "[cst] Plan mode is now OFF (Build mode): every tool " + "is available again.\n"); + if (strcmp(now.approval, was.approval) != 0) + cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval); + if (strcmp(now.branch, was.branch) != 0) + cst_buf_printf(&b, "[cst] The git branch is now %s.\n", + now.branch[0] ? now.branch : "(none)"); + if (strcmp(now.day, was.day) != 0) + cst_buf_printf(&b, "[cst] Today's date is now %s.\n", now.day); + if (strcmp(now.notes, was.notes) != 0) { + if (now.notes[0]) { + size_t len = strlen(a->notes); + if (len > PROJECT_NOTES_MAX) + len = cst_utf8_floor(a->notes, PROJECT_NOTES_MAX); + cst_buf_puts(&b, "[cst] The workspace's AGENTS.md changed; follow " + "these project instructions from now on:\n"); + cst_buf_append(&b, a->notes, len); + cst_buf_puts(&b, "\n"); + } else { + cst_buf_puts(&b, "[cst] The workspace's AGENTS.md was removed; its " + "earlier instructions no longer apply.\n"); + } + } + if (!b.len) + return; + while (b.len && b.buf[b.len - 1] == '\n') + b.buf[--b.len] = 0; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "kind", "note"); + cJSON_AddItemToObject(o, "state", state_json(&now)); + char *meta = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + append_msg(a, CST_ROLE_USER, b.buf, NULL, NULL, NULL, meta); + cst_buf_free(&b); } cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) { @@ -1900,7 +2439,8 @@ cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) { cst_session_path(a->session)); a->perms = cst_perms_open(a->cfg.workspace_root); cst_agent_reload_notes(a); - seed_system(a); + cst_model_caps(a->cfg.provider, a->cfg.api_base, a->cfg.model, &a->caps); + ensure_system(a); restore_todo(a); estimate_tokens(a); return a; @@ -1914,6 +2454,9 @@ void cst_agent_destroy(cst_agent_t *a) { cst_session_close(a->session); free(a->notes); free(a->todo); + free(a->tools_json); + free(a->prev_hash); + cst_agent_pending_clear(a); cst_perms_close(a->perms); free(a); } @@ -1938,7 +2481,8 @@ int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err, return cst_agent_run_turn_ex(a, user_text, NULL, err, err_sz); } -static void emit_step_metrics(turn_t *t, const cst_llm_stats_t *st, int lrc) { +static void emit_step_metrics(turn_t *t, const cst_llm_stats_t *st, int lrc, + double cost) { cJSON *o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "type", "step"); cJSON_AddNumberToObject(o, "turn", t->a->turn_no); @@ -1955,6 +2499,18 @@ static void emit_step_metrics(turn_t *t, const cst_llm_stats_t *st, int lrc) { cJSON_AddNumberToObject(o, "completion_tokens", (double)st->completion_tokens); cJSON_AddNumberToObject(o, "cached_tokens", (double)st->cached_tokens); + cJSON_AddNumberToObject(o, "cache_miss_tokens", + (double)st->cache_miss_tokens); + if (st->cache_write_tokens) + cJSON_AddNumberToObject(o, "cache_write_tokens", + (double)st->cache_write_tokens); + if (cost >= 0) + cJSON_AddNumberToObject(o, "cost", cost); + cJSON_AddNumberToObject(o, "prefix_break", t->prefix_break); + if (t->prefix_reason) + cJSON_AddStringToObject(o, "prefix_reason", t->prefix_reason); + if (t->cont) + cJSON_AddNumberToObject(o, "continuation", t->cont); cJSON_AddNumberToObject(o, "retries", st->retries); cJSON_AddBoolToObject(o, "client_stopped", st->client_stopped); cJSON_AddNumberToObject(o, "calls", t->ncalls); @@ -1994,19 +2550,48 @@ static void emit_turn_metrics(cst_agent_t *a, int steps, int rc, cst_metrics_emit(a->metrics, o); } -/* Compact before a turn when the last request used most of the window. */ +/* DeepSeek can resume a reply that was cut off (max tokens, dropped stream) + * from its own text via prefix completion, instead of a fresh step. */ +static int can_continue(const cst_agent_t *a, const turn_t *t, + const cst_llm_stats_t *st, int lrc, + cst_protocol_t proto) { + if (!cst_protocol_is_native(proto) || !a->caps.prefix_completion || + a->prefix_off || a->cancel || t->stop || t->cont >= 2 || t->ncalls || + t->native_opened || t->in_think || !t->raw.len) + return 0; + if (lrc == 0) + return strcmp(st->finish_reason, "length") == 0; + return st->http_status > 0 && st->http_status < 400 && !st->client_stopped; +} + +/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 60% of + * the context window capped at 256K tokens. Even a cached prompt is billed + * on every step, so huge windows are not worth filling. */ +static long ctx_budget(const cst_agent_t *a) { + const char *e = getenv("CST_CTX_BUDGET"); + if (e && atol(e) > 0) + return atol(e); + long win = cst_model_context_tokens(a->cfg.model); + if (win <= 0) + return 0; + long b = win / 10 * 6; + return b > 262144 ? 262144 : b; +} + +/* Compact before a turn when the last request crossed the budget. */ static void maybe_auto_compact(cst_agent_t *a, const cst_agent_hooks_t *hooks) { const char *e = getenv("CST_AUTO_COMPACT"); if (e && e[0] == '0') return; - long win = cst_model_context_tokens(a->cfg.model); - if (a->last_prompt_tokens <= 0 || win <= 0 || - a->last_prompt_tokens * 10 < win * 8) + long budget = ctx_budget(a); + if (a->last_prompt_tokens <= 0 || budget <= 0 || + a->last_prompt_tokens < budget) return; if (hooks && hooks->on_status) { char msg[128]; - snprintf(msg, sizeof(msg), "context %ld%% full — compacting...", - a->last_prompt_tokens * 100 / win); + snprintf(msg, sizeof(msg), + "prompt %ldK tokens (budget %ldK) — compacting...", + a->last_prompt_tokens / 1000, budget / 1000); hooks->on_status(msg, hooks->userdata); } char err[256]; @@ -2049,8 +2634,13 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, os_set_cancel_flag(&a->cancel); const cst_protocol_t proto = a->cfg.protocol; const double turn_t0 = os_now_ms(); + memset(&a->turn_usage, 0, sizeof(a->turn_usage)); - append_msg(a, CST_ROLE_USER, user_text, NULL, NULL, NULL, NULL); + ensure_system(a); + sync_notes(a); + append_msg_full(a, CST_ROLE_USER, user_text, NULL, NULL, NULL, + meta_json(NULL, proto), a->pending, a->npending, NULL); + cst_agent_pending_clear(a); /* the images now live in the session */ int empty_retries = 0, ctl_retries = 0, tools_ran = 0; const char *nudge = NULL; @@ -2076,6 +2666,8 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, break; } + if (step > 0) + sync_notes(a); req_msgs_t rm; if (build_request_msgs(a, proto, nudge, &rm) != 0) { turn_free(&t); @@ -2089,33 +2681,77 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, hook_status(&t, t.final_pass ? "final answer pass..." : s); } + /* The final pass keeps tools and tool_choice as they were: changing + * either would miss the cache for the whole prompt. Calls it still + * makes are dropped below. */ cst_llm_stats_t st; cst_llm_request_t req; - memset(&req, 0, sizeof(req)); - req.api_base = a->cfg.api_base; - req.api_key = a->cfg.api_key; - req.model = a->cfg.model; - req.reasoning_effort = - cst_effort_wire(a->cfg.provider, a->cfg.model, a->cfg.effort); - req.msgs = rm.msgs; - req.nmsgs = rm.n; + fill_request(a, &req, &rm, proto); req.on_delta = on_delta; req.userdata = &t; - req.cancel_flag = &a->cancel; req.stop_flag = &t.stop; req.stats = &st; - req.tool_out_cap = a->cfg.tool_out_cap; if (cst_protocol_is_native(proto)) { req.on_native_tool = on_native_tool; req.on_native_open = on_native_open; - req.enable_tools = 1; - req.tool_cfg = &a->cfg; - req.tool_choice_none = t.final_pass; } + check_prefix(a, &t, &rm, req.tools_json); t.t0 = os_now_ms(); char lerr[512]; int lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + if (lrc != 0 && st.http_status == 400 && + req.cache_style != CST_CACHE_AUTO && !t.raw.len && !t.ncalls && + !a->cancel) { + a->hints_off = 1; /* the gateway rejects cache hints */ + req.cache_style = CST_CACHE_AUTO; + req.cache_key = NULL; + req.cache_ttl = NULL; + lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + } + if (lrc != 0 && st.http_status == 400 && !req.images_off && + msgs_have_images(&rm) && !t.raw.len && !t.ncalls && !a->cancel) { + a->images_off = 1; /* unknown model turned out not to take images */ + snprintf(a->images_off_model, sizeof(a->images_off_model), "%s", + a->cfg.model); + req.images_off = 1; + if (req.tools_json) + req.tools_json = agent_tools_json(a); + hook_status(&t, "the model refused images — sending notes instead..."); + lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + } + const int lrc0 = lrc; + while (can_continue(a, &t, &st, lrc, proto)) { + emit_step_metrics(&t, &st, lrc, account(a, &st)); + if (st.prompt_tokens > 0) + a->last_prompt_tokens = st.prompt_tokens; + t.cont++; + t.prefix_break = -1; + t.prefix_reason = NULL; + cst_buf_t pre = {0}; + cst_buf_append(&pre, t.raw.buf ? t.raw.buf : "", t.raw.len); + cst_buf_append(&pre, t.tagbuf, t.taglen); + push_prefix(&rm, pre.buf); + cst_buf_free(&pre); + if (a->caps.reasoning_passback && t.think.len) + rm.msgs[rm.n - 1].reasoning = t.think.buf; + char beta[1024]; + beta_base(a->cfg.api_base, beta, sizeof(beta)); + req.api_base = beta; + req.msgs = rm.msgs; + req.nmsgs = rm.n; + hook_status(&t, "output cut off — continuing where it stopped..."); + lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + rm.n--; + rm.ntail--; + req.api_base = a->cfg.api_base; + if (lrc != 0 && st.http_status >= 400) { + if (st.http_status == 400 || st.http_status == 404) + a->prefix_off = 1; /* no prefix completion behind this base */ + lrc = lrc0; + break; + } + } req_msgs_free(&rm); /* flush a dangling partial tag */ @@ -2135,7 +2771,7 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, "malformed tool block, nothing could be parsed"); } - emit_step_metrics(&t, &st, lrc); + emit_step_metrics(&t, &st, lrc, account(a, &st)); if (st.prompt_tokens > 0) a->last_prompt_tokens = st.prompt_tokens; @@ -2149,6 +2785,7 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, cst_tool_call_clear(&t.calls[i].call); free(t.calls[i].raw_args); free(t.calls[i].output); + cst_attachments_free(t.calls[i].attach, t.calls[i].nattach); } } t.ncalls = kept; @@ -2230,8 +2867,7 @@ int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text, } if (has_text) { - append_msg(a, CST_ROLE_ASSISTANT, body, NULL, NULL, NULL, - meta_json(NULL, proto)); + append_reply(&t, body, NULL, meta_json(NULL, proto)); turn_free(&t); hook_status(&t, "done"); rc = 0; @@ -2283,10 +2919,16 @@ static int agent_reopen_session(cst_agent_t *a, const char *path) { return -1; cst_session_close(a->session); a->session = ns; + cst_agent_pending_clear(a); /* images belong to the session that made them */ snprintf(a->cfg.session_path, sizeof(a->cfg.session_path), "%s", path); cst_metrics_close(a->metrics); a->metrics = cst_metrics_open(a->cfg.workspace_root, path); - seed_system(a); + reset_prefix_state(a); + a->nprev = 0; + a->prefix_off = a->hints_off = a->images_off = 0; + memset(&a->usage, 0, sizeof(a->usage)); + memset(&a->turn_usage, 0, sizeof(a->turn_usage)); + ensure_system(a); restore_todo(a); estimate_tokens(a); return 0; @@ -2334,29 +2976,60 @@ static void acc_feed(const char *data, size_t len, void *ud) { cst_buf_append((cst_buf_t *)ud, data, len); } -int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { - char dummy[8]; - if (!err || !err_sz) { - err = dummy; - err_sz = sizeof(dummy); - } - err[0] = 0; - if (!a || !a->session) - return -1; +#define COMPACT_ASK \ + "Summarize the coding session so work can continue from the summary " \ + "alone. Keep: the user's goals, files and paths involved, decisions made, " \ + "current state (what works, what fails), and open items. Write it in the " \ + "language the user used. Do not call tools; output only the summary." +#define COMPACT_HEAD "[session compacted — summary of earlier conversation]\n" +/* The summary request reuses the session's own prefix (system prompt, tools, + * history) and only appends the ask, so it reads almost entirely from the + * cache. DeepSeek also gets the summary heading as an assistant prefix. */ +static int compact_same_prefix(cst_agent_t *a, cst_buf_t *acc, char *lerr, + size_t lerr_sz) { + const cst_protocol_t proto = a->cfg.protocol; + req_msgs_t rm; + if (build_request_msgs(a, proto, "[cst] " COMPACT_ASK, &rm) != 0) + return -1; + cst_llm_stats_t st; + cst_llm_request_t req; + fill_request(a, &req, &rm, proto); + req.on_delta = acc_feed; + req.userdata = acc; + req.stats = &st; + char beta[1024]; + int prefix = cst_protocol_is_native(proto) && a->caps.prefix_completion && + !a->prefix_off; + if (prefix) { + push_prefix(&rm, COMPACT_HEAD); + beta_base(a->cfg.api_base, beta, sizeof(beta)); + req.api_base = beta; + req.nmsgs = rm.n; + } + int rc = cst_llm_chat_stream(&req, lerr, lerr_sz); + if (rc != 0 && prefix && !a->cancel && + (st.http_status == 400 || st.http_status == 404)) { + a->prefix_off = 1; + req.nmsgs = --rm.n; + rm.ntail--; + req.api_base = a->cfg.api_base; + acc->len = 0; + if (acc->buf) + acc->buf[0] = 0; + rc = cst_llm_chat_stream(&req, lerr, lerr_sz); + } + account(a, &st); + req_msgs_free(&rm); + return rc; +} + +/* Fallback: a standalone request carrying a condensed transcript. */ +static int compact_blob(cst_agent_t *a, cst_buf_t *acc, char *lerr, + size_t lerr_sz) { int n = cst_session_count(a->session); - if (n <= 1) { - snprintf(err, err_sz, "nothing to compact"); - return -1; - } - cst_buf_t blob = {0}; - cst_buf_puts(&blob, - "Summarize the coding session below so work can continue from " - "the summary alone. Keep: the user's goals, files and paths " - "involved, decisions made, current state (what works, what " - "fails), and open items. Write it in the language the user " - "used. Do not call tools; output only the summary.\n\n---\n"); + cst_buf_puts(&blob, COMPACT_ASK "\n\n---\n"); for (int i = 0; i < n && blob.len < 120000; i++) { const cst_message_t *m = cst_session_get(a->session, i); if (!m || m->role == CST_ROLE_SYSTEM) @@ -2380,7 +3053,7 @@ int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { reqs[1].role = CST_ROLE_USER; reqs[1].content = blob.buf; - cst_buf_t acc = {0}; + cst_llm_stats_t st; cst_llm_request_t req; memset(&req, 0, sizeof(req)); req.api_base = a->cfg.api_base; @@ -2391,21 +3064,21 @@ int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { req.msgs = reqs; req.nmsgs = 2; req.on_delta = acc_feed; - req.userdata = &acc; + req.userdata = acc; req.cancel_flag = &a->cancel; - - char lerr[512]; - lerr[0] = 0; - int rc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + req.stats = &st; + int rc = cst_llm_chat_stream(&req, lerr, lerr_sz); cst_buf_free(&blob); - if (rc != 0 || !acc.buf || acc.len == 0) { - cst_buf_free(&acc); - snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty"); - return -1; - } + account(a, &st); + return rc; +} - /* Strip ... blocks from summary. */ - char *src = acc.buf, *dst = acc.buf; +/* Strip ... blocks and surrounding blanks; returns the + * length left. */ +static size_t clean_summary(cst_buf_t *acc) { + if (!acc->buf) + return 0; + char *src = acc->buf, *dst = acc->buf; while (*src) { if (strncmp(src, "", 7) == 0) { char *end = strstr(src, ""); @@ -2417,34 +3090,74 @@ int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { *dst++ = *src++; } *dst = 0; - acc.len = (size_t)(dst - acc.buf); - while (acc.len > 0 && - (acc.buf[acc.len - 1] == '\n' || acc.buf[acc.len - 1] == ' ')) - acc.buf[--acc.len] = 0; + acc->len = (size_t)(dst - acc->buf); + while (acc->len > 0 && + (acc->buf[acc->len - 1] == '\n' || acc->buf[acc->len - 1] == ' ')) + acc->buf[--acc->len] = 0; + return acc->len; +} + +int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (!a || !a->session) + return -1; + + int n = cst_session_count(a->session); + if (n <= 1) { + snprintf(err, err_sz, "nothing to compact"); + return -1; + } + + cst_buf_t acc = {0}; + char lerr[512]; + lerr[0] = 0; + int rc = compact_same_prefix(a, &acc, lerr, sizeof(lerr)); + if ((rc != 0 || clean_summary(&acc) < 8) && !a->cancel) { + cst_buf_free(&acc); + lerr[0] = 0; + rc = compact_blob(a, &acc, lerr, sizeof(lerr)); + if (rc == 0) + clean_summary(&acc); + } + if (rc != 0 || acc.len == 0) { + cst_buf_free(&acc); + snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty"); + return -1; + } if (acc.len < 8) { cst_buf_free(&acc); snprintf(err, err_sz, "compact produced empty summary"); return -1; } + const char *body = trim_left(acc.buf); + if (strncmp(body, COMPACT_HEAD, strlen(COMPACT_HEAD) - 1) == 0) + body += strlen(COMPACT_HEAD) - 1; cst_buf_t summary = {0}; - cst_buf_printf(&summary, "[session compacted — summary of earlier " - "conversation]\n%s", - trim_left(acc.buf)); + cst_buf_printf(&summary, COMPACT_HEAD "%s", trim_left(body)); cst_buf_free(&acc); + pstate_t st; cst_message_t keep[2]; memset(keep, 0, sizeof(keep)); keep[0].role = CST_ROLE_SYSTEM; - keep[0].content = cst_agent_build_prompt(&a->cfg, a->cfg.protocol, a->notes); + keep[0].content = frozen_prompt(a, &st); + keep[0].meta_json = system_meta(a, &st); keep[1].role = CST_ROLE_USER; keep[1].content = summary.buf; rc = cst_session_rewrite(a->session, keep, 2); free(keep[0].content); + free(keep[0].meta_json); cst_buf_free(&summary); if (rc != 0) { snprintf(err, err_sz, "rewrite session failed"); return -1; } + reset_prefix_state(a); return 0; } diff --git a/src/agent_store.c b/src/agent_store.c index 23628d4..032960e 100644 --- a/src/agent_store.c +++ b/src/agent_store.c @@ -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; diff --git a/src/attach.c b/src/attach.c new file mode 100644 index 0000000..51ac7ce --- /dev/null +++ b/src/attach.c @@ -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 +#include +#include + +/* --- 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 ------------------------------------------------------------- */ + +/* /attachments/ — 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 "." 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; +} diff --git a/src/clipboard.c b/src/clipboard.c new file mode 100644 index 0000000..e0555c0 --- /dev/null +++ b/src/clipboard.c @@ -0,0 +1,482 @@ +#include "cst_clipboard.h" +#include "cst_attach.h" +#include "os_compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#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; +} diff --git a/src/keymap.c b/src/keymap.c index 72833e8..96616da 100644 --- a/src/keymap.c +++ b/src/keymap.c @@ -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, " c"}, {CST_KA_COPY, " y"}, {CST_KA_EXPORT, " x"}, + {CST_KA_PASTE_IMAGE, "ctrl+v"}, {CST_KA_MODEL, " m"}, {CST_KA_PROVIDER, " p"}, {CST_KA_EFFORT, " e"}, diff --git a/src/llm_openai.c b/src/llm_openai.c index c2028e7..38b1cf8 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -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,34 +353,80 @@ 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 *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"); - cst_buf_puts(&b, c->valuestring); - cJSON_ReplaceItemInObjectCaseSensitive(prev_user, "content", - cJSON_CreateString(b.buf)); - cst_buf_free(&b); - cJSON_Delete(c); + 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) { + cst_buf_t b = {0}; + cst_buf_puts(&b, pc->valuestring); + cst_buf_puts(&b, "\n\n"); + cst_buf_puts(&b, c->valuestring); + cJSON_ReplaceItemInObjectCaseSensitive(prev_user, "content", + cJSON_CreateString(b.buf)); + cst_buf_free(&b); + cJSON_Delete(c); + free(noted); + continue; + } + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "user"); + cJSON_AddItemToObject(o, "content", c); + cJSON_AddItemToArray(msgs, o); + prev_user = o; + free(noted); continue; } - cJSON *o = cJSON_CreateObject(); - cJSON_AddStringToObject(o, "role", "user"); - cJSON_AddItemToObject(o, "content", c); - cJSON_AddItemToArray(msgs, o); - prev_user = o; + 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)); } + 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; diff --git a/src/main.c b/src/main.c index a07b140..4dafbe0 100644 --- a/src/main.c +++ b/src/main.c @@ -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, /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"); diff --git a/src/mcp.c b/src/mcp.c index cef22df..86f0856 100644 --- a/src/mcp.c +++ b/src/mcp.c @@ -6,6 +6,8 @@ #endif #include "cst_mcp.h" +#include "cst_agent.h" +#include "cst_attach.h" #include #include @@ -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; } diff --git a/src/model_registry.c b/src/model_registry.c new file mode 100644 index 0000000..f562556 --- /dev/null +++ b/src/model_registry.c @@ -0,0 +1,818 @@ +#include "cst_models.h" + +#include "cJSON.h" +#include "os_compat.h" + +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/src/models.c b/src/models.c index 95cb433..7e64d26 100644 --- a/src/models.c +++ b/src/models.c @@ -1,120 +1,32 @@ #include "cst_agent.h" -#include -#include #include -#define MODEL_MAX_LEN 128 +/* Provider and model data live in model_registry.c (cst_models.h). */ -static const cst_provider_t PROVIDERS[] = { - {"minimax", "https://api.minimax.cn/v1", "MiniMax-M2.7", "MINIMAX_API_KEY"}, - {"deepseek", "https://api.deepseek.com/v1", "deepseek-flash", - "DEEPSEEK_API_KEY"}, - {"openai", "https://api.openai.com/v1", "gpt-4.1-mini", "OPENAI_API_KEY"}, - /* Router: no fixed model; the first id from GET /models is picked. */ - {"teamorouter", "https://api.teamorouter.com/v1", "", - "TEAMOROUTER_API_KEY"}, -}; - -const cst_provider_t *cst_provider_find(const char *name) { - if (!name || !name[0]) - return NULL; - for (size_t i = 0; i < sizeof(PROVIDERS) / sizeof(PROVIDERS[0]); i++) { - if (strcmp(PROVIDERS[i].name, name) == 0) - return &PROVIDERS[i]; - } - return NULL; -} - -const cst_provider_t *cst_provider_default(void) { - const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER")); - return p ? p : &PROVIDERS[0]; -} - -static int already(char out[][MODEL_MAX_LEN], int n, const char *name) { - for (int i = 0; i < n; i++) { - if (strcmp(out[i], name) == 0) - return 1; - } - return 0; -} - -static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) { - if (!name || !name[0] || *n >= max) +int cst_price_is_peak(time_t when) { + time_t bj = when + 8 * 3600; + struct tm tmv; +#ifdef _WIN32 + gmtime_s(&tmv, &bj); +#else + gmtime_r(&bj, &tmv); +#endif + if (tmv.tm_wday == 0 || tmv.tm_wday == 6) return 0; - if (already(out, *n, name)) - return 0; - snprintf(out[*n], MODEL_MAX_LEN, "%s", name); - (*n)++; - return 1; + return (tmv.tm_hour >= 9 && tmv.tm_hour < 12) || + (tmv.tm_hour >= 14 && tmv.tm_hour < 18); } -int cst_models_list(const char *current, char out[][128], int max_out) { - if (!out || max_out <= 0) +double cst_price_cost(const cst_price_t *p, long miss, long hit, long write, + long out, time_t when) { + if (!p) return 0; - int n = 0; - - const char *env = getenv("CST_MODELS"); - if (env && env[0]) { - char buf[1024]; - snprintf(buf, sizeof(buf), "%s", env); - char *p = buf; - while (*p && n < max_out) { - while (*p == ':' || *p == ' ' || *p == ',') - p++; - if (!*p) - break; - char *start = p; - while (*p && *p != ':' && *p != ',') - p++; - char save = *p; - *p = 0; - char *end = start + strlen(start); - while (end > start && (end[-1] == ' ' || end[-1] == '\t')) - *--end = 0; - push(out, &n, max_out, start); - *p = save; - if (*p) - p++; - } - } else { - push(out, &n, max_out, "deepseek-flash"); - push(out, &n, max_out, "deepseek-v4-pro"); - push(out, &n, max_out, "MiniMax-M2.7"); - push(out, &n, max_out, "MiniMax-M2.5"); - push(out, &n, max_out, "gpt-4.1-mini"); - push(out, &n, max_out, "gpt-4o"); - } - - if (current && current[0]) - push(out, &n, max_out, current); - - return n; -} - -/* Context window in tokens (CST_CTX_TOKENS overrides). Prefix match. */ -long cst_model_context_tokens(const char *model) { - const char *e = getenv("CST_CTX_TOKENS"); - if (e && atol(e) > 0) - return atol(e); - static const struct { - const char *prefix; - long tokens; - } T[] = {{"deepseek", 128000}, {"MiniMax", 196608}, {"minimax", 196608}, - {"gpt-4.1", 1047576}, {"gpt-4o", 128000}, {"gpt-5", 400000}, - {"o3", 200000}, {"o4", 200000}, {"claude", 200000}, - {"qwen", 131072}, {"glm", 128000}, {"kimi", 131072}}; - if (model) { - /* routers name models "vendor/model" */ - const char *slash = strrchr(model, '/'); - if (slash && slash[1]) - model = slash + 1; - for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) { - if (strncmp(model, T[i].prefix, strlen(T[i].prefix)) == 0) - return T[i].tokens; - } - } - return 128000; + double c = (miss * p->miss + hit * p->hit + write * p->write + + out * p->out) / 1e6; + if (p->offpeak_half && !cst_price_is_peak(when)) + c /= 2; + return c; } int cst_effort_valid(const char *effort) { @@ -125,25 +37,3 @@ int cst_effort_valid(const char *effort) { } return 0; } - -const char *cst_effort_wire(const char *provider, const char *model, - const char *effort) { - if (!cst_effort_valid(effort) || !provider || !model) - return NULL; - if (strcmp(provider, "deepseek") == 0) - return effort; - /* OpenAI reasoning models take low | medium | high. */ - if (strcmp(provider, "openai") == 0 && - (strncmp(model, "o1", 2) == 0 || strncmp(model, "o3", 2) == 0 || - strncmp(model, "o4", 2) == 0 || strncmp(model, "gpt-5", 5) == 0)) - return strcmp(effort, "max") == 0 ? "high" : effort; - return NULL; -} - -int cst_provider_count(void) { - return (int)(sizeof(PROVIDERS) / sizeof(PROVIDERS[0])); -} - -const cst_provider_t *cst_provider_at(int i) { - return i >= 0 && i < cst_provider_count() ? &PROVIDERS[i] : NULL; -} diff --git a/src/models_builtin.json b/src/models_builtin.json new file mode 100644 index 0000000..ef9253a --- /dev/null +++ b/src/models_builtin.json @@ -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} + ] +} diff --git a/src/session.c b/src/session.c index 39491c1..515476d 100644 --- a/src/session.c +++ b/src/session.c @@ -7,6 +7,7 @@ #include "cJSON.h" #include "cst_agent.h" +#include "cst_attach.h" #include #include @@ -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; diff --git a/src/tools/fs_tools.c b/src/tools/fs_tools.c index e28ab86..5ab7dab 100644 --- a/src/tools/fs_tools.c +++ b/src/tools/fs_tools.c @@ -14,6 +14,7 @@ #include +#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, diff --git a/src/tools/registry.c b/src/tools/registry.c index 9f056ce..c008aa6 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -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; } diff --git a/src/tools/tools.c b/src/tools/tools.c index 9c33f1e..383817a 100644 --- a/src/tools/tools.c +++ b/src/tools/tools.c @@ -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) { diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index b1b88c6..d7b0f9b 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -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); diff --git a/src/tui.c b/src/tui.c index 8d2dc11..d3c4ce6 100644 --- a/src/tui.c +++ b/src/tui.c @@ -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,12 +3897,49 @@ 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/.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)); - am_store(a->provider, r, ids, 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); pthread_mutex_lock(&g_am_mu); @@ -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); - g_am.state = AM_RUNNING; - g_am.n = 0; + 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 "); + 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; diff --git a/tests/test_attach.c b/tests/test_attach.c new file mode 100644 index 0000000..0b2450f --- /dev/null +++ b/tests/test_attach.c @@ -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 +#include +#include + +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 "); + 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 "); + 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; +} diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index 8d22490..18184e5 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -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(); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index bf8c50e..59222ac 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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(); diff --git a/tests/test_model_registry.c b/tests/test_model_registry.c new file mode 100644 index 0000000..5070f08 --- /dev/null +++ b/tests/test_model_registry.c @@ -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 +#include +#include + +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; +} diff --git a/tests/test_models.c b/tests/test_models.c index c2052cb..eff36de 100644 --- a/tests/test_models.c +++ b/tests/test_models.c @@ -3,6 +3,8 @@ #endif #include "cst_agent.h" +#include "cst_models.h" +#include "os_compat.h" #include #include @@ -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; } diff --git a/tests/test_prefix_stable.c b/tests/test_prefix_stable.c new file mode 100644 index 0000000..153e2a2 --- /dev/null +++ b/tests/test_prefix_stable.c @@ -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 +#include +#include +#include +#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 /* 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; +}