Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b4afd772 | ||
|
|
895b47274c | ||
|
|
0226f417ff |
@@ -30,3 +30,4 @@ vendor/sashidown/*.o
|
|||||||
vendor/sashidown/src/*.o
|
vendor/sashidown/src/*.o
|
||||||
vendor/sashidown/libsashidown.a
|
vendor/sashidown/libsashidown.a
|
||||||
vendor/sashidown/libsashidown-mingw.a
|
vendor/sashidown/libsashidown-mingw.a
|
||||||
|
.gen/models_builtin.inc
|
||||||
|
|||||||
+15
-2
@@ -92,6 +92,8 @@ set(CST_CORE_SOURCES
|
|||||||
src/plugins.c
|
src/plugins.c
|
||||||
src/mcp.c
|
src/mcp.c
|
||||||
src/subagent.c
|
src/subagent.c
|
||||||
|
src/attach.c
|
||||||
|
src/clipboard.c
|
||||||
src/session.c
|
src/session.c
|
||||||
src/tools/tools.c
|
src/tools/tools.c
|
||||||
src/tools/patch.c
|
src/tools/patch.c
|
||||||
@@ -110,8 +112,18 @@ set(CST_CORE_SOURCES
|
|||||||
src/creds.c
|
src/creds.c
|
||||||
src/mm_tool.c
|
src/mm_tool.c
|
||||||
src/models.c
|
src/models.c
|
||||||
|
src/model_registry.c
|
||||||
src/metrics.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)
|
if(WIN32)
|
||||||
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
||||||
include_directories(vendor/tre-regex)
|
include_directories(vendor/tre-regex)
|
||||||
@@ -257,9 +269,10 @@ foreach(t ${_CST_SMALL_TESTS})
|
|||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
# Tests that link the whole core (no TUI).
|
# 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_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_keymap test_shell_compact test_skills
|
||||||
test_plugins test_mcp test_subagent test_lowil
|
test_plugins test_mcp test_subagent test_lowil
|
||||||
test_win_utf8 test_os_dir test_tmpdir)
|
test_win_utf8 test_os_dir test_tmpdir)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ endif
|
|||||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||||
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
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)
|
LDFLAGS += -L$(SD_DIR)
|
||||||
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
|
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ CORE_SRC = \
|
|||||||
src/sandbox_exec.c \
|
src/sandbox_exec.c \
|
||||||
src/keymap.c \
|
src/keymap.c \
|
||||||
src/skills.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/session.c \
|
||||||
src/tools/tools.c \
|
src/tools/tools.c \
|
||||||
src/tools/patch.c \
|
src/tools/patch.c \
|
||||||
@@ -78,12 +78,13 @@ CORE_SRC = \
|
|||||||
src/json_baseline.c \
|
src/json_baseline.c \
|
||||||
src/creds.c \
|
src/creds.c \
|
||||||
src/mm_tool.c \
|
src/mm_tool.c \
|
||||||
src/models.c \
|
src/models.c src/model_registry.c \
|
||||||
src/metrics.c
|
src/metrics.c
|
||||||
|
|
||||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||||
OBJ = $(SRC:.c=.o)
|
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 = \
|
||||||
tests/test_ctl_frontier \
|
tests/test_ctl_frontier \
|
||||||
@@ -93,9 +94,11 @@ TESTS = \
|
|||||||
tests/test_replay_real \
|
tests/test_replay_real \
|
||||||
tests/test_mm_tool \
|
tests/test_mm_tool \
|
||||||
tests/test_llm_body \
|
tests/test_llm_body \
|
||||||
|
tests/test_prefix_stable \
|
||||||
tests/test_sse_decode \
|
tests/test_sse_decode \
|
||||||
tests/test_apply_patch \
|
tests/test_apply_patch \
|
||||||
tests/test_session_roundtrip \
|
tests/test_session_roundtrip \
|
||||||
|
tests/test_attach \
|
||||||
tests/test_shell \
|
tests/test_shell \
|
||||||
tests/test_diff \
|
tests/test_diff \
|
||||||
tests/test_tools_fs \
|
tests/test_tools_fs \
|
||||||
@@ -103,6 +106,7 @@ TESTS = \
|
|||||||
tests/test_undo \
|
tests/test_undo \
|
||||||
tests/test_config \
|
tests/test_config \
|
||||||
tests/test_models \
|
tests/test_models \
|
||||||
|
tests/test_model_registry \
|
||||||
tests/test_sandbox \
|
tests/test_sandbox \
|
||||||
tests/test_keymap \
|
tests/test_keymap \
|
||||||
tests/test_textsel \
|
tests/test_textsel \
|
||||||
@@ -144,6 +148,11 @@ FORCE:
|
|||||||
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||||
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
|
$(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)
|
%.o: %.c $(HDRS)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
@@ -190,7 +199,7 @@ demo: cst-agent
|
|||||||
./cst-agent --demo-ctl --workspace .
|
./cst-agent --demo-ctl --workspace .
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(OBJ) cst-agent $(TESTS)
|
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc
|
||||||
|
|
||||||
install: cst-agent
|
install: cst-agent
|
||||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
|
|||||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||||
|
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||||
|
|
||||||
| 用途 | 来源(优先级从高到低) |
|
| 用途 | 来源(优先级从高到低) |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -276,16 +277,16 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
|||||||
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
|
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
|
||||||
- **沙箱**(C 实现,`src/sandbox_exec.c`):
|
- **沙箱**(C 实现,`src/sandbox_exec.c`):
|
||||||
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
|
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
|
||||||
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。
|
读、执行和联网不受限。
|
||||||
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
|
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
|
||||||
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
|
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
|
||||||
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。
|
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
|
||||||
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
|
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
|
||||||
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
|
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
|
||||||
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
|
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
|
||||||
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。
|
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
|
||||||
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
|
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
|
||||||
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网,所以联网命令仍会询问。
|
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
|
||||||
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
|
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
|
||||||
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
|
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
|
||||||
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
|
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
|
||||||
|
|||||||
@@ -111,9 +111,8 @@ windows-x86_64/
|
|||||||
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
|
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
|
||||||
界面会问是否在沙箱外重跑。请在项目目录里启动。
|
界面会问是否在沙箱外重跑。请在项目目录里启动。
|
||||||
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
|
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
|
||||||
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**。所以像联网的命令
|
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**:`curl`、`git pull`、
|
||||||
(`curl`、`wget`、`ssh`、`git clone/fetch/pull/push`、`npm install`、`pip install`、
|
`npm install` 等联网命令在 `auto` 模式下也直接在沙箱里运行。
|
||||||
`winget install`、`Invoke-WebRequest` 等)在 `auto` 模式下仍然会弹窗确认。
|
|
||||||
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
|
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
|
||||||
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
|
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
|
||||||
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
|
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
|
||||||
|
|||||||
+6
-5
@@ -42,13 +42,14 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
|
|||||||
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
|
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
|
||||||
when one is available. Commands may read and execute anything but may only
|
when one is available. Commands may read and execute anything but may only
|
||||||
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
|
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
|
||||||
|
The network is not restricted.
|
||||||
|
|
||||||
| Platform | Sandbox | Notes |
|
| Platform | Sandbox | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind |
|
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | |
|
||||||
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below |
|
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | see below |
|
||||||
| macOS | `sandbox_init` profile | also denies network |
|
| macOS | `sandbox_init` profile | |
|
||||||
| Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); network is not blocked, so network commands still ask; see `README-windows.md` |
|
| Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); see `README-windows.md` |
|
||||||
| Older kernels | none | falls back to the read-only rules |
|
| Older kernels | none | falls back to the read-only rules |
|
||||||
|
|
||||||
The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
|
The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
|
||||||
@@ -165,7 +166,7 @@ Chinese VMs as Administrator, a standard user and a Chinese user name):
|
|||||||
keep CRLF and BOM; `run_shell` output in Chinese is readable on a
|
keep CRLF and BOM; `run_shell` output in Chinese is readable on a
|
||||||
936 system
|
936 system
|
||||||
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
|
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
|
||||||
asks to rerun; `curl ...` asks before running; `/sandbox reset-labels`
|
asks to rerun; `curl ...` runs without asking; `/sandbox reset-labels`
|
||||||
restores the label
|
restores the label
|
||||||
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
|
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
|
||||||
`busybox.exe` behind
|
`busybox.exe` behind
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
|||||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||||
|
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||||
|
|
||||||
显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。
|
显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。
|
||||||
|
|
||||||
|
|||||||
+97
-2
@@ -4,6 +4,7 @@
|
|||||||
#include "ctl.h"
|
#include "ctl.h"
|
||||||
|
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@@ -11,7 +12,6 @@ extern "C" {
|
|||||||
|
|
||||||
#define CST_AGENT_MAX_MSG 256
|
#define CST_AGENT_MAX_MSG 256
|
||||||
#define CST_AGENT_MAX_CONTENT (512 * 1024)
|
#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_MAX_STEPS 30
|
||||||
#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024)
|
#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. */
|
/* Eager protocols execute a call during the stream, at confirmation time. */
|
||||||
int cst_protocol_is_eager(cst_protocol_t p);
|
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 {
|
typedef struct {
|
||||||
cst_role_t role;
|
cst_role_t role;
|
||||||
char *content;
|
char *content;
|
||||||
@@ -55,6 +68,10 @@ typedef struct {
|
|||||||
char tool_name[CTL_MAX_NAME];
|
char tool_name[CTL_MAX_NAME];
|
||||||
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
|
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
|
||||||
char *meta_json; /* optional JSON object (kind, protocol, ...) */
|
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;
|
} cst_message_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -149,6 +166,10 @@ typedef struct {
|
|||||||
char *out;
|
char *out;
|
||||||
size_t out_len;
|
size_t out_len;
|
||||||
int exit_code;
|
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;
|
} cst_tool_result_t;
|
||||||
|
|
||||||
void cst_tool_result_free(cst_tool_result_t *r);
|
void cst_tool_result_free(cst_tool_result_t *r);
|
||||||
@@ -200,7 +221,9 @@ typedef struct {
|
|||||||
char finish_reason[32];
|
char finish_reason[32];
|
||||||
long prompt_tokens;
|
long prompt_tokens;
|
||||||
long completion_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_first_byte_ms; /* relative to request start; -1 if none */
|
||||||
double t_end_ms;
|
double t_end_ms;
|
||||||
int client_stopped; /* stream ended by stop_flag (not an error) */
|
int client_stopped; /* stream ended by stop_flag (not an error) */
|
||||||
@@ -222,8 +245,15 @@ typedef struct {
|
|||||||
void *userdata;
|
void *userdata;
|
||||||
int enable_tools; /* send tools[] schema */
|
int enable_tools; /* send tools[] schema */
|
||||||
const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */
|
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 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 */
|
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 *cancel_flag; /* non-zero → abort (error "cancelled") */
|
||||||
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
||||||
cst_llm_stats_t *stats; /* optional */
|
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);
|
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
|
||||||
/* Bare id of the current session (file name without .jsonl). */
|
/* 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);
|
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
|
||||||
|
/* Transcript path of the current session (images are stored beside it). */
|
||||||
|
const char *cst_agent_session_path(const cst_agent_t *a);
|
||||||
|
/* Images picked for the next user message (/attach). They ride along with the
|
||||||
|
* next cst_agent_run_turn*() call and are then part of the session.
|
||||||
|
* cst_agent_attach_file copies the file into the session store; *err gets a
|
||||||
|
* short reason on failure (-1). */
|
||||||
|
int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
/* Same for bytes already in memory (clipboard); name is display-only. */
|
||||||
|
int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
|
||||||
|
size_t n, char *err, size_t err_sz);
|
||||||
|
/* Whether the current model is shown images: 1 yes, -1 unknown (tried; a
|
||||||
|
* 400 turns sending off), 0 no (images go as text notes). */
|
||||||
|
int cst_agent_sees_images(const cst_agent_t *a);
|
||||||
|
int cst_agent_pending_count(const cst_agent_t *a);
|
||||||
|
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
|
||||||
|
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
|
||||||
|
size_t out_sz);
|
||||||
|
void cst_agent_pending_clear(cst_agent_t *a);
|
||||||
/* Latest todo_write list of this session, or NULL. */
|
/* Latest todo_write list of this session, or NULL. */
|
||||||
const char *cst_agent_todo(const cst_agent_t *a);
|
const char *cst_agent_todo(const cst_agent_t *a);
|
||||||
/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */
|
/* 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);
|
char *tag, size_t tag_sz);
|
||||||
/* Last prompt_tokens reported by the model (0 if unknown). */
|
/* Last prompt_tokens reported by the model (0 if unknown). */
|
||||||
long cst_agent_last_prompt_tokens(const cst_agent_t *a);
|
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(void);
|
||||||
const char *cst_agent_system_prompt_for(cst_protocol_t p);
|
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). */
|
/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */
|
||||||
long cst_model_context_tokens(const char *model);
|
long cst_model_context_tokens(const char *model);
|
||||||
|
|
||||||
|
/* How a provider caches prompt prefixes.
|
||||||
|
* auto : implicit prefix cache, nothing to send (DeepSeek, MiniMax...)
|
||||||
|
* openai : implicit, routed by "prompt_cache_key"
|
||||||
|
* anthropic : explicit "cache_control" breakpoints on content parts */
|
||||||
|
typedef enum {
|
||||||
|
CST_CACHE_AUTO = 0,
|
||||||
|
CST_CACHE_OPENAI,
|
||||||
|
CST_CACHE_ANTHROPIC
|
||||||
|
} cst_cache_style_t;
|
||||||
|
typedef struct {
|
||||||
|
int cache_style; /* cst_cache_style_t */
|
||||||
|
int reasoning_passback; /* history keeps reasoning_content (DeepSeek) */
|
||||||
|
int prefix_completion; /* assistant "prefix": true via <base>/beta */
|
||||||
|
int vision; /* 1 takes images, 0 text only, -1 unknown */
|
||||||
|
} cst_model_caps_t;
|
||||||
|
/* CST_NO_CACHE_HINTS=1 forces CST_CACHE_AUTO. */
|
||||||
|
void cst_model_caps(const char *provider, const char *api_base,
|
||||||
|
const char *model, cst_model_caps_t *out);
|
||||||
|
|
||||||
|
/* Prices per million tokens. CST_PRICE="miss,hit,out[,write[,currency]]"
|
||||||
|
* overrides the built-in table. */
|
||||||
|
typedef struct {
|
||||||
|
char currency[8]; /* "¥", "$" */
|
||||||
|
double miss, hit, write, out;
|
||||||
|
int offpeak_half; /* DeepSeek: half price outside Beijing peak hours */
|
||||||
|
} cst_price_t;
|
||||||
|
/* 0 when a price is known for model, -1 otherwise. */
|
||||||
|
int cst_model_price(const char *model, cst_price_t *out);
|
||||||
|
/* DeepSeek peak hours: Beijing time Mon-Fri 9:00-12:00 and 14:00-18:00. */
|
||||||
|
int cst_price_is_peak(time_t when);
|
||||||
|
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
|
||||||
|
long out, time_t when);
|
||||||
|
|
||||||
/* Reasoning effort: low | medium | high | max. */
|
/* Reasoning effort: low | medium | high | max. */
|
||||||
#define CST_DEFAULT_EFFORT "medium"
|
#define CST_DEFAULT_EFFORT "medium"
|
||||||
int cst_effort_valid(const char *effort);
|
int cst_effort_valid(const char *effort);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#ifndef CST_ATTACH_H
|
||||||
|
#define CST_ATTACH_H
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* --- image attachments ---------------------------------------------------
|
||||||
|
* An attachment is a validated copy of an image file, stored beside the
|
||||||
|
* session so the bytes a model is shown stay the bytes that were picked.
|
||||||
|
* Only the metadata travels in the JSONL; the bytes are encoded into the
|
||||||
|
* request when it is built. */
|
||||||
|
|
||||||
|
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */
|
||||||
|
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */
|
||||||
|
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
|
||||||
|
#define CST_ATTACH_MAX_COUNT 8 /* per message */
|
||||||
|
|
||||||
|
/* Magic-byte test: 0 and a canonical mime when data holds a supported
|
||||||
|
* image, -1 otherwise (an extension alone never decides). */
|
||||||
|
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz);
|
||||||
|
/* Pixel size from the header when it is readable; 0 for unknown. */
|
||||||
|
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h);
|
||||||
|
|
||||||
|
/* A stored file name as store() makes it ("<id>.<ext>"); rejects anything
|
||||||
|
* that could leave the attachment directory. */
|
||||||
|
int cst_attach_rel_ok(const char *rel);
|
||||||
|
/* Directory holding this session's stored images (created on demand). */
|
||||||
|
int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
|
||||||
|
/* Absolute path of a stored image (0 / -1). */
|
||||||
|
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
||||||
|
char *out, size_t out_sz);
|
||||||
|
|
||||||
|
/* Read src_path, verify it is a supported image within limits, and copy it
|
||||||
|
* into the session's attachment directory. Returns a malloc'd entry the
|
||||||
|
* caller owns (cst_attach_free), or NULL with err filled. */
|
||||||
|
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||||
|
const char *src_path, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
/* Store already-read bytes (clipboard, downloads); name is display-only. */
|
||||||
|
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||||
|
const char *name, const char *data,
|
||||||
|
size_t n, char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Validate and read back a stored image, or NULL when it is gone. */
|
||||||
|
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||||
|
size_t *out_len, char *err, size_t err_sz);
|
||||||
|
/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */
|
||||||
|
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
|
||||||
|
/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */
|
||||||
|
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
|
||||||
|
|
||||||
|
void cst_attach_free(cst_attachment_t *a);
|
||||||
|
/* Free a list of n entries allocated as separate blocks. */
|
||||||
|
void cst_attachments_free(cst_attachment_t *list, int n);
|
||||||
|
/* Deep copy (NULL on OOM or when n <= 0). */
|
||||||
|
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n);
|
||||||
|
/* Append src to *list (grows with realloc). 0 / -1. */
|
||||||
|
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
|
||||||
|
const cst_attachment_t *src);
|
||||||
|
|
||||||
|
/* JSON array of n attachments for the session JSONL (malloc'd), or NULL. */
|
||||||
|
char *cst_attachments_to_json(const cst_attachment_t *list, int n);
|
||||||
|
/* Parse a JSON array; caller frees with cst_attachments_free. */
|
||||||
|
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out);
|
||||||
|
|
||||||
|
/* Base64 of n bytes (malloc'd, no line breaks). */
|
||||||
|
char *cst_base64_encode(const void *data, size_t n);
|
||||||
|
/* Standard or URL-safe base64 (whitespace ignored) to bytes; malloc'd,
|
||||||
|
* NULL on a bad character. */
|
||||||
|
char *cst_base64_decode(const char *s, size_t n, size_t *out_len);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef CST_CLIPBOARD_H
|
||||||
|
#define CST_CLIPBOARD_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Image bytes on the system clipboard (PNG, or BMP from a Windows DIB).
|
||||||
|
* Wayland: wl-paste; X11: xclip; macOS: pngpaste or osascript; Windows: the
|
||||||
|
* clipboard API. Returns 0 with *data malloc'd, or -1 with err filled in
|
||||||
|
* ("no image on the clipboard", "install wl-clipboard", ...). */
|
||||||
|
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
|
||||||
|
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
|
||||||
|
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
|
||||||
|
|
||||||
|
#define CST_PASTE_MAX_PATHS 8
|
||||||
|
#define CST_PASTE_PATH_MAX 1024
|
||||||
|
|
||||||
|
/* Split pasted text into file paths the way terminals paste dropped files:
|
||||||
|
* whitespace- or newline-separated, '...' / "..." quoting, file:// URIs
|
||||||
|
* (percent-decoded) and, outside Windows, backslash escapes. Returns the
|
||||||
|
* count, or -1 when the text is empty or holds more than max paths. Whether
|
||||||
|
* the paths exist is up to the caller. */
|
||||||
|
int cst_paste_paths(const char *text, size_t len,
|
||||||
|
char out[][CST_PASTE_PATH_MAX], int max);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -64,6 +64,7 @@ typedef enum {
|
|||||||
CST_KA_COMPACT,
|
CST_KA_COMPACT,
|
||||||
CST_KA_COPY,
|
CST_KA_COPY,
|
||||||
CST_KA_EXPORT,
|
CST_KA_EXPORT,
|
||||||
|
CST_KA_PASTE_IMAGE,
|
||||||
/* model */
|
/* model */
|
||||||
CST_KA_MODEL,
|
CST_KA_MODEL,
|
||||||
CST_KA_PROVIDER,
|
CST_KA_PROVIDER,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#ifndef CST_MODELS_H
|
||||||
|
#define CST_MODELS_H
|
||||||
|
|
||||||
|
/* Model registry: what cst-agent knows about providers and models.
|
||||||
|
*
|
||||||
|
* Three layers, highest first, merged field by field:
|
||||||
|
* 1. <config>/models.json user overrides and extra providers
|
||||||
|
* 2. <config>/models-cache/<provider>.json GET /models metadata, exact id
|
||||||
|
* 3. built-in data (src/models_builtin.json, embedded at build time)
|
||||||
|
* then defaults (128000 tokens, vision unknown). Model rules match the name
|
||||||
|
* without its "vendor/" prefix, case-insensitively: a plain "match" is a
|
||||||
|
* prefix, one with '*' is a glob, one with '/' is tested against the full
|
||||||
|
* id. Within a layer the most specific rule (most literal characters) that
|
||||||
|
* sets a field wins, so table order does not matter.
|
||||||
|
*
|
||||||
|
* CST_CTX_TOKENS, CST_PRICE, CST_MODELS and CST_NO_CACHE_HINTS still
|
||||||
|
* override everything. */
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
long context; /* tokens */
|
||||||
|
int vision; /* 1 takes images, 0 text only, -1 unknown */
|
||||||
|
int cache_style; /* cst_cache_style_t */
|
||||||
|
int reasoning; /* takes OpenAI-style reasoning_effort */
|
||||||
|
int has_price;
|
||||||
|
cst_price_t price;
|
||||||
|
/* Layers that supplied anything, highest first: "user+api+built-in",
|
||||||
|
* "default", "env+built-in"... */
|
||||||
|
char source[48];
|
||||||
|
} cst_model_info_t;
|
||||||
|
|
||||||
|
/* provider may be NULL: then every provider's fetched cache is searched. */
|
||||||
|
void cst_model_info(const char *provider, const char *model,
|
||||||
|
cst_model_info_t *out);
|
||||||
|
/* Short picker label: "1M · vision", "200K · text only", "128K". */
|
||||||
|
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n);
|
||||||
|
|
||||||
|
/* One entry of GET /models, with the optional OpenRouter-style metadata. */
|
||||||
|
typedef struct {
|
||||||
|
char id[128];
|
||||||
|
long context; /* 0 = not given */
|
||||||
|
int vision; /* -1 = not given */
|
||||||
|
int has_price; /* prices in USD per 1M tokens */
|
||||||
|
double miss, hit, write, out;
|
||||||
|
} cst_model_meta_t;
|
||||||
|
|
||||||
|
/* Like cst_llm_list_models, keeping context_length,
|
||||||
|
* architecture.input_modalities (or modalities) and pricing when present. */
|
||||||
|
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
|
||||||
|
cst_model_meta_t *out, int max_out, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
|
||||||
|
#define CST_MODELS_CACHE_TTL (24L * 3600)
|
||||||
|
|
||||||
|
/* Store a fetched list as <config>/models-cache/<provider>.json and use it
|
||||||
|
* from now on. 0 / -1. */
|
||||||
|
int cst_models_cache_save(const char *provider, const char *api_base,
|
||||||
|
const cst_model_meta_t *list, int n);
|
||||||
|
/* The cached list for provider: count (up to max), or -1 when there is
|
||||||
|
* none. *age_s gets the seconds since it was fetched. */
|
||||||
|
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
|
||||||
|
long *age_s);
|
||||||
|
|
||||||
|
/* The "models" a provider entry lists (built-in or user file). */
|
||||||
|
int cst_provider_models(const cst_provider_t *p, char out[][128], int max);
|
||||||
|
/* Where a provider entry came from: "built-in" or "user". */
|
||||||
|
const char *cst_provider_source(const cst_provider_t *p);
|
||||||
|
/* The user file's path (it may not exist). */
|
||||||
|
int cst_models_user_path(char *out, size_t out_sz);
|
||||||
|
|
||||||
|
/* Drop everything and read the built-in data, the user file and the caches
|
||||||
|
* again. Provider pointers stay valid but may name another provider. */
|
||||||
|
void cst_models_reload(void);
|
||||||
|
/* Tests: load these documents instead of the built-in data and the user
|
||||||
|
* file (NULL = none). Caches still come from <config>. 0, or -1 with err. */
|
||||||
|
int cst_models_load_json(const char *builtin_json, const char *user_json,
|
||||||
|
char *err, size_t err_sz);
|
||||||
|
/* The embedded built-in document. */
|
||||||
|
const char *cst_models_builtin_json(void);
|
||||||
|
/* Problems found while loading (bad user file...), or "". */
|
||||||
|
const char *cst_models_load_error(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -2,20 +2,19 @@
|
|||||||
#define CST_SANDBOX_H
|
#define CST_SANDBOX_H
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* OS sandbox for run_shell: reads and exec stay allowed everywhere, writes
|
* OS sandbox for run_shell: reads, exec and the network stay allowed;
|
||||||
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and
|
* writes are limited to the workspace, the temp dir, /dev/null and /dev/tty.
|
||||||
* the network is cut where the OS can do it.
|
* Linux Landlock
|
||||||
* Linux Landlock (network needs ABI 4, kernel 6.7+)
|
|
||||||
* Linux seccomp user-notify when Landlock is missing (Android/Termux,
|
* Linux seccomp user-notify when Landlock is missing (Android/Termux,
|
||||||
* older kernels): a supervisor thread checks the path of every
|
* older kernels): a supervisor thread checks the path of every
|
||||||
* write-type syscall and IPv4/IPv6 sockets are refused. Guards
|
* write-type syscall. Guards
|
||||||
* against mistakes, not hostile code (the path is checked before
|
* against mistakes, not hostile code (the path is checked before
|
||||||
* the kernel reads it again).
|
* the kernel reads it again).
|
||||||
* macOS sandbox_init (Seatbelt profile)
|
* macOS sandbox_init (Seatbelt profile)
|
||||||
* Windows Low integrity level: a restricted token (no admin privileges,
|
* Windows Low integrity level: a restricted token (no admin privileges,
|
||||||
* Low IL) can only write objects labeled Low, so the workspace and
|
* Low IL) can only write objects labeled Low, so the workspace and
|
||||||
* a temp dir get that label; the credentials file is labeled
|
* a temp dir get that label; the credentials file is labeled
|
||||||
* no-read-up. The network stays open (callers ask first).
|
* no-read-up.
|
||||||
* other none: callers fall back to rule-based approval
|
* other none: callers fall back to rule-based approval
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ int cst_sandbox_probe(void);
|
|||||||
int cst_sandbox_reprobe(void);
|
int cst_sandbox_reprobe(void);
|
||||||
/* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */
|
/* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */
|
||||||
const char *cst_sandbox_name(int kind);
|
const char *cst_sandbox_name(int kind);
|
||||||
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */
|
/* Landlock ABI version (0 when unavailable). */
|
||||||
int cst_sandbox_landlock_abi(void);
|
int cst_sandbox_landlock_abi(void);
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -69,7 +68,7 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
|
|||||||
size_t max_out, os_run_result_t *r);
|
size_t max_out, os_run_result_t *r);
|
||||||
|
|
||||||
/* Output of a failed sandboxed run that looks like the sandbox got in the
|
/* Output of a failed sandboxed run that looks like the sandbox got in the
|
||||||
* way (EACCES / EPERM / EROFS / blocked network). */
|
* way (EACCES / EPERM / EROFS). */
|
||||||
int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
|
int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
|
||||||
|
|
||||||
/* lowil: directories this user's cst-agent labeled Low (recorded in the
|
/* lowil: directories this user's cst-agent labeled Low (recorded in the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
#define CST_VERSION_H
|
#define CST_VERSION_H
|
||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 6
|
#define CST_VERSION_MINOR 7
|
||||||
#define CST_VERSION_PATCH 1
|
#define CST_VERSION_PATCH 1
|
||||||
|
|
||||||
#define CST_VERSION_STR_(x) #x
|
#define CST_VERSION_STR_(x) #x
|
||||||
|
|||||||
+940
-233
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -288,7 +288,10 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
|||||||
cJSON *o = cJSON_Parse(line);
|
cJSON *o = cJSON_Parse(line);
|
||||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
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]) {
|
cJSON_IsString(c) && c->valuestring[0]) {
|
||||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||||
have_first = 1;
|
have_first = 1;
|
||||||
|
|||||||
+646
@@ -0,0 +1,646 @@
|
|||||||
|
#ifndef _DEFAULT_SOURCE
|
||||||
|
#define _DEFAULT_SOURCE
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_util.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* --- base64 -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static const char B64[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
char *cst_base64_encode(const void *data, size_t n) {
|
||||||
|
const unsigned char *p = data;
|
||||||
|
size_t olen = ((n + 2) / 3) * 4;
|
||||||
|
char *out = malloc(olen + 1);
|
||||||
|
if (!out)
|
||||||
|
return NULL;
|
||||||
|
size_t o = 0;
|
||||||
|
for (size_t i = 0; i < n; i += 3) {
|
||||||
|
unsigned v = (unsigned)p[i] << 16;
|
||||||
|
if (i + 1 < n)
|
||||||
|
v |= (unsigned)p[i + 1] << 8;
|
||||||
|
if (i + 2 < n)
|
||||||
|
v |= (unsigned)p[i + 2];
|
||||||
|
out[o++] = B64[(v >> 18) & 63];
|
||||||
|
out[o++] = B64[(v >> 12) & 63];
|
||||||
|
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
|
||||||
|
out[o++] = i + 2 < n ? B64[v & 63] : '=';
|
||||||
|
}
|
||||||
|
out[o] = 0;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int b64_val(unsigned char c) {
|
||||||
|
if (c >= 'A' && c <= 'Z')
|
||||||
|
return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z')
|
||||||
|
return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
return c - '0' + 52;
|
||||||
|
if (c == '+' || c == '-')
|
||||||
|
return 62;
|
||||||
|
if (c == '/' || c == '_')
|
||||||
|
return 63;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
|
||||||
|
if (out_len)
|
||||||
|
*out_len = 0;
|
||||||
|
if (!s)
|
||||||
|
return NULL;
|
||||||
|
char *out = malloc(n / 4 * 3 + 4);
|
||||||
|
if (!out)
|
||||||
|
return NULL;
|
||||||
|
size_t o = 0;
|
||||||
|
unsigned acc = 0;
|
||||||
|
int bits = 0;
|
||||||
|
for (size_t i = 0; i < n; i++) {
|
||||||
|
unsigned char c = (unsigned char)s[i];
|
||||||
|
if (c == '=')
|
||||||
|
break;
|
||||||
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
|
||||||
|
continue;
|
||||||
|
int v = b64_val(c);
|
||||||
|
if (v < 0) {
|
||||||
|
free(out);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
acc = (acc << 6) | (unsigned)v;
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out[o++] = (char)((acc >> bits) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out_len)
|
||||||
|
*out_len = o;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- format sniffing ----------------------------------------------------- */
|
||||||
|
|
||||||
|
static int be32(const unsigned char *p) {
|
||||||
|
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
static int le32(const unsigned char *p) {
|
||||||
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
|
||||||
|
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int le16(const unsigned char *p) {
|
||||||
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *mime_of_ext(const char *mime) {
|
||||||
|
if (strcmp(mime, "image/png") == 0)
|
||||||
|
return "png";
|
||||||
|
if (strcmp(mime, "image/jpeg") == 0)
|
||||||
|
return "jpg";
|
||||||
|
if (strcmp(mime, "image/gif") == 0)
|
||||||
|
return "gif";
|
||||||
|
if (strcmp(mime, "image/webp") == 0)
|
||||||
|
return "webp";
|
||||||
|
if (strcmp(mime, "image/bmp") == 0)
|
||||||
|
return "bmp";
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
|
||||||
|
static int jpeg_sof(int marker) {
|
||||||
|
switch (marker) {
|
||||||
|
case 0xC0:
|
||||||
|
case 0xC1:
|
||||||
|
case 0xC2:
|
||||||
|
case 0xC3:
|
||||||
|
case 0xC5:
|
||||||
|
case 0xC6:
|
||||||
|
case 0xC7:
|
||||||
|
case 0xC9:
|
||||||
|
case 0xCA:
|
||||||
|
case 0xCB:
|
||||||
|
case 0xCD:
|
||||||
|
case 0xCE:
|
||||||
|
case 0xCF:
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
|
||||||
|
const unsigned char *p = data;
|
||||||
|
const char *m = NULL;
|
||||||
|
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
|
||||||
|
m = "image/png";
|
||||||
|
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
|
||||||
|
m = "image/jpeg";
|
||||||
|
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
|
||||||
|
m = "image/gif";
|
||||||
|
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
|
||||||
|
m = "image/gif";
|
||||||
|
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
|
||||||
|
m = "image/webp";
|
||||||
|
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
|
||||||
|
m = "image/bmp";
|
||||||
|
if (!m)
|
||||||
|
return -1;
|
||||||
|
snprintf(mime, mime_sz, "%s", m);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
|
||||||
|
* container flavours. */
|
||||||
|
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
||||||
|
const unsigned char *p = data;
|
||||||
|
*w = *h = 0;
|
||||||
|
char mime[32];
|
||||||
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
if (strcmp(mime, "image/png") == 0) {
|
||||||
|
/* 8 signature + 4 length + "IHDR" + w + h */
|
||||||
|
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
|
||||||
|
return -1;
|
||||||
|
*w = be32(p + 16);
|
||||||
|
*h = be32(p + 20);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(mime, "image/gif") == 0) {
|
||||||
|
if (n < 10)
|
||||||
|
return -1;
|
||||||
|
*w = le16(p + 6);
|
||||||
|
*h = le16(p + 8);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(mime, "image/bmp") == 0) {
|
||||||
|
if (n < 26)
|
||||||
|
return -1;
|
||||||
|
int wi = le32(p + 18);
|
||||||
|
int hi = le32(p + 22);
|
||||||
|
if (wi < 0)
|
||||||
|
wi = -wi; /* bottom-up rows are stored with a negative height */
|
||||||
|
if (hi < 0)
|
||||||
|
hi = -hi;
|
||||||
|
*w = wi;
|
||||||
|
*h = hi;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (strcmp(mime, "image/webp") == 0) {
|
||||||
|
if (n < 30)
|
||||||
|
return -1;
|
||||||
|
const unsigned char *c = p + 12;
|
||||||
|
if (memcmp(c, "VP8 ", 4) == 0) {
|
||||||
|
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
|
||||||
|
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
|
||||||
|
return -1;
|
||||||
|
*w = le16(c + 10) & 0x3FFF;
|
||||||
|
*h = le16(c + 12) & 0x3FFF;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (memcmp(c, "VP8L", 4) == 0) {
|
||||||
|
if (n < 25 || c[8] != 0x2F)
|
||||||
|
return -1;
|
||||||
|
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
|
||||||
|
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
|
||||||
|
*w = (int)(b & 0x3FFF) + 1;
|
||||||
|
*h = (int)((b >> 14) & 0x3FFF) + 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (memcmp(c, "VP8X", 4) == 0) {
|
||||||
|
if (n < 30)
|
||||||
|
return -1;
|
||||||
|
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
|
||||||
|
((unsigned)c[14] << 16)) + 1;
|
||||||
|
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
|
||||||
|
((unsigned)c[17] << 16)) + 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (strcmp(mime, "image/jpeg") == 0) {
|
||||||
|
size_t i = 2;
|
||||||
|
while (i + 3 < n) {
|
||||||
|
if (p[i] != 0xFF)
|
||||||
|
return -1; /* lost sync */
|
||||||
|
unsigned marker = p[i + 1];
|
||||||
|
i += 2;
|
||||||
|
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
|
||||||
|
continue; /* standalone markers: no payload */
|
||||||
|
if (marker == 0xD9 || marker == 0x01)
|
||||||
|
continue;
|
||||||
|
if (i + 1 >= n)
|
||||||
|
return -1;
|
||||||
|
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
|
||||||
|
if (seg < 2 || i + seg > n)
|
||||||
|
return -1;
|
||||||
|
if (jpeg_sof((int)marker) && seg >= 7) {
|
||||||
|
*h = (p[i + 3] << 8) | p[i + 4];
|
||||||
|
*w = (p[i + 5] << 8) | p[i + 6];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
i += seg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- storage ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
|
||||||
|
* workspace keeps the two together. */
|
||||||
|
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
||||||
|
if (!session_path || !session_path[0])
|
||||||
|
return -1;
|
||||||
|
char dir[OS_PATH_MAX];
|
||||||
|
snprintf(dir, sizeof(dir), "%s", session_path);
|
||||||
|
char *slash = strrchr(dir, '/');
|
||||||
|
#ifdef _WIN32
|
||||||
|
char *bslash = strrchr(dir, '\\');
|
||||||
|
if (bslash && (!slash || bslash > slash))
|
||||||
|
slash = bslash;
|
||||||
|
#endif
|
||||||
|
char leaf[256];
|
||||||
|
const char *base = slash ? slash + 1 : dir;
|
||||||
|
size_t bl = strlen(base);
|
||||||
|
if (bl >= sizeof(leaf))
|
||||||
|
bl = sizeof(leaf) - 1;
|
||||||
|
memcpy(leaf, base, bl);
|
||||||
|
leaf[bl] = 0;
|
||||||
|
if (slash)
|
||||||
|
*(slash + 1) = 0;
|
||||||
|
else {
|
||||||
|
dir[0] = '.';
|
||||||
|
dir[1] = 0;
|
||||||
|
}
|
||||||
|
char *dot = strstr(leaf, ".jsonl");
|
||||||
|
if (dot)
|
||||||
|
*dot = 0;
|
||||||
|
char rel[320];
|
||||||
|
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
||||||
|
char full[OS_PATH_MAX];
|
||||||
|
if (os_path_join(full, sizeof(full), dir, rel) != 0)
|
||||||
|
return -1;
|
||||||
|
if (os_mkdir_p(full, 0700) != 0)
|
||||||
|
return -1;
|
||||||
|
snprintf(out, out_sz, "%s", full);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
|
||||||
|
* (hand-edited or crafted) could point outside the attachment directory. */
|
||||||
|
int cst_attach_rel_ok(const char *rel) {
|
||||||
|
if (!rel || !rel[0] || rel[0] == '.')
|
||||||
|
return 0;
|
||||||
|
for (const char *p = rel; *p; p++) {
|
||||||
|
char c = *p;
|
||||||
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return strstr(rel, "..") == NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
||||||
|
char *out, size_t out_sz) {
|
||||||
|
if (!a || !cst_attach_rel_ok(a->rel))
|
||||||
|
return -1;
|
||||||
|
char dir[OS_PATH_MAX];
|
||||||
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
|
||||||
|
return -1;
|
||||||
|
return os_path_join(out, out_sz, dir, a->rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *read_whole(const char *path, size_t *out_len, size_t max) {
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (!f)
|
||||||
|
return NULL;
|
||||||
|
cst_buf_t b = {0};
|
||||||
|
char chunk[16384];
|
||||||
|
size_t n;
|
||||||
|
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
|
||||||
|
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
|
||||||
|
cst_buf_free(&b);
|
||||||
|
fclose(f);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
*out_len = b.len;
|
||||||
|
return cst_buf_steal(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FNV over the bytes, the name and the clock: short, stable, unique enough
|
||||||
|
* inside one session. */
|
||||||
|
static void make_id(const char *name, const char *data, size_t n, char *out,
|
||||||
|
size_t out_sz) {
|
||||||
|
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
|
||||||
|
h = cst_fnv1a64(h, name, strlen(name));
|
||||||
|
h = cst_fnv1a64(h, &n, sizeof(n));
|
||||||
|
double now = os_now_ms();
|
||||||
|
h = cst_fnv1a64(h, &now, sizeof(now));
|
||||||
|
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
|
||||||
|
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
|
||||||
|
}
|
||||||
|
|
||||||
|
static cst_attachment_t *store(const char *session_path, const char *name,
|
||||||
|
const char *data, size_t n, const char *origin,
|
||||||
|
char *err, size_t err_sz) {
|
||||||
|
if (!name || !name[0])
|
||||||
|
name = "image";
|
||||||
|
char mime[32];
|
||||||
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
|
||||||
|
snprintf(err, err_sz,
|
||||||
|
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
|
||||||
|
"checked by content, not by extension)");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
if (n > CST_ATTACH_MAX_BYTES) {
|
||||||
|
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
||||||
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char dir[OS_PATH_MAX];
|
||||||
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
|
||||||
|
snprintf(err, err_sz, "cannot create the attachment directory");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
cst_attachment_t *a = calloc(1, sizeof(*a));
|
||||||
|
if (!a) {
|
||||||
|
snprintf(err, err_sz, "out of memory");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
make_id(name, data, n, a->id, sizeof(a->id));
|
||||||
|
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
|
||||||
|
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
|
||||||
|
snprintf(a->mime, sizeof(a->mime), "%s", mime);
|
||||||
|
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
|
||||||
|
snprintf(a->detail, sizeof(a->detail), "auto");
|
||||||
|
a->bytes = (long long)n;
|
||||||
|
cst_attach_dimensions(data, n, &a->width, &a->height);
|
||||||
|
|
||||||
|
char full[OS_PATH_MAX];
|
||||||
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
||||||
|
snprintf(err, err_sz, "attachment path is too long");
|
||||||
|
free(a);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
FILE *f = fopen(full, "wb");
|
||||||
|
if (!f) {
|
||||||
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
||||||
|
free(a);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
size_t w = fwrite(data, 1, n, f);
|
||||||
|
if (fclose(f) != 0 || w != n) {
|
||||||
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
||||||
|
free(a);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||||
|
const char *name, const char *data,
|
||||||
|
size_t n, char *err, size_t err_sz) {
|
||||||
|
char dummy[8];
|
||||||
|
if (!err || !err_sz) {
|
||||||
|
err = dummy;
|
||||||
|
err_sz = sizeof(dummy);
|
||||||
|
}
|
||||||
|
err[0] = 0;
|
||||||
|
if (!session_path || !session_path[0]) {
|
||||||
|
snprintf(err, err_sz, "no session to store the image in");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
if (!data || !n) {
|
||||||
|
snprintf(err, err_sz, "image is empty");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return store(session_path, name, data, n, "user", err, err_sz);
|
||||||
|
}
|
||||||
|
|
||||||
|
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||||
|
const char *src_path, char *err,
|
||||||
|
size_t err_sz) {
|
||||||
|
char dummy[8];
|
||||||
|
if (!err || !err_sz) {
|
||||||
|
err = dummy;
|
||||||
|
err_sz = sizeof(dummy);
|
||||||
|
}
|
||||||
|
err[0] = 0;
|
||||||
|
size_t n = 0;
|
||||||
|
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
|
||||||
|
if (!data) {
|
||||||
|
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
||||||
|
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
const char *leaf = src_path;
|
||||||
|
for (const char *p = src_path; *p; p++)
|
||||||
|
if (*p == '/' || *p == '\\')
|
||||||
|
leaf = p + 1;
|
||||||
|
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
|
||||||
|
free(data);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||||
|
size_t *out_len, char *err, size_t err_sz) {
|
||||||
|
char dummy[8];
|
||||||
|
if (!err || !err_sz) {
|
||||||
|
err = dummy;
|
||||||
|
err_sz = sizeof(dummy);
|
||||||
|
}
|
||||||
|
err[0] = 0;
|
||||||
|
char full[OS_PATH_MAX];
|
||||||
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
||||||
|
snprintf(err, err_sz, "attachment has no stored file");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
|
||||||
|
if (!data) {
|
||||||
|
snprintf(err, err_sz,
|
||||||
|
"%.200s is missing; the image was deleted from the session "
|
||||||
|
"store",
|
||||||
|
a->name);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char mime[32];
|
||||||
|
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
|
||||||
|
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
|
||||||
|
a->name);
|
||||||
|
free(data);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
|
||||||
|
char err[256];
|
||||||
|
size_t n = 0;
|
||||||
|
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
|
||||||
|
if (!data)
|
||||||
|
return NULL;
|
||||||
|
char mime[32];
|
||||||
|
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
|
||||||
|
char *b64 = cst_base64_encode(data, n);
|
||||||
|
free(data);
|
||||||
|
if (!b64)
|
||||||
|
return NULL;
|
||||||
|
cst_buf_t b = {0};
|
||||||
|
cst_buf_printf(&b, "data:%s;base64,", mime);
|
||||||
|
cst_buf_puts(&b, b64);
|
||||||
|
free(b64);
|
||||||
|
return cst_buf_steal(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
||||||
|
if (!a) {
|
||||||
|
snprintf(out, out_sz, "(none)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
|
||||||
|
char dim[32] = "";
|
||||||
|
if (a->width > 0 && a->height > 0)
|
||||||
|
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
|
||||||
|
out[0] = 0;
|
||||||
|
if (a->bytes >= 1024 * 1024)
|
||||||
|
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
||||||
|
(double)a->bytes / (1024 * 1024));
|
||||||
|
else
|
||||||
|
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
|
||||||
|
(double)a->bytes / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- lifetime ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
void cst_attach_free(cst_attachment_t *a) { free(a); }
|
||||||
|
|
||||||
|
/* Entries always live in one array (a 1-element array for a single import),
|
||||||
|
* so freeing the block releases them all. */
|
||||||
|
void cst_attachments_free(cst_attachment_t *list, int n) {
|
||||||
|
(void)n;
|
||||||
|
free(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
|
||||||
|
if (!list || n <= 0)
|
||||||
|
return NULL;
|
||||||
|
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
|
||||||
|
if (!out)
|
||||||
|
return NULL;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
out[i] = list[i];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
|
||||||
|
const cst_attachment_t *src) {
|
||||||
|
if (!list || !n || !cap || !src)
|
||||||
|
return -1;
|
||||||
|
if (*n == *cap) {
|
||||||
|
int nc = *cap ? *cap * 2 : 4;
|
||||||
|
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
|
||||||
|
if (!nv)
|
||||||
|
return -1;
|
||||||
|
*list = nv;
|
||||||
|
*cap = nc;
|
||||||
|
}
|
||||||
|
(*list)[(*n)++] = *src;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- JSONL --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
|
||||||
|
if (!list || n <= 0)
|
||||||
|
return NULL;
|
||||||
|
cJSON *arr = cJSON_CreateArray();
|
||||||
|
if (!arr)
|
||||||
|
return NULL;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
const cst_attachment_t *a = &list[i];
|
||||||
|
cJSON *o = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(o, "id", a->id);
|
||||||
|
cJSON_AddStringToObject(o, "name", a->name);
|
||||||
|
cJSON_AddStringToObject(o, "mime", a->mime);
|
||||||
|
cJSON_AddStringToObject(o, "rel", a->rel);
|
||||||
|
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
|
||||||
|
if (a->width > 0 && a->height > 0) {
|
||||||
|
cJSON_AddNumberToObject(o, "width", a->width);
|
||||||
|
cJSON_AddNumberToObject(o, "height", a->height);
|
||||||
|
}
|
||||||
|
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
|
||||||
|
cJSON_AddStringToObject(o, "detail", a->detail);
|
||||||
|
if (a->origin[0])
|
||||||
|
cJSON_AddStringToObject(o, "origin", a->origin);
|
||||||
|
cJSON_AddItemToArray(arr, o);
|
||||||
|
}
|
||||||
|
char *s = cJSON_PrintUnformatted(arr);
|
||||||
|
cJSON_Delete(arr);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
|
||||||
|
if (n_out)
|
||||||
|
*n_out = 0;
|
||||||
|
const cJSON *arr = array_json;
|
||||||
|
if (!cJSON_IsArray(arr))
|
||||||
|
return NULL;
|
||||||
|
int n = cJSON_GetArraySize(arr);
|
||||||
|
if (n <= 0)
|
||||||
|
return NULL;
|
||||||
|
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
|
||||||
|
if (!list)
|
||||||
|
return NULL;
|
||||||
|
int k = 0;
|
||||||
|
const cJSON *it = NULL;
|
||||||
|
cJSON_ArrayForEach(it, arr) {
|
||||||
|
if (!cJSON_IsObject(it))
|
||||||
|
continue;
|
||||||
|
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
|
||||||
|
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
|
||||||
|
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
|
||||||
|
!cst_attach_rel_ok(jrel->valuestring))
|
||||||
|
continue;
|
||||||
|
cst_attachment_t *a = &list[k];
|
||||||
|
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
|
||||||
|
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
|
||||||
|
snprintf(a->name, sizeof(a->name), "%s",
|
||||||
|
cJSON_IsString(v) ? v->valuestring : a->id);
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
|
||||||
|
snprintf(a->mime, sizeof(a->mime), "%s",
|
||||||
|
cJSON_IsString(v) ? v->valuestring : "image/*");
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
|
||||||
|
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "width");
|
||||||
|
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "height");
|
||||||
|
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
|
||||||
|
snprintf(a->detail, sizeof(a->detail), "%s",
|
||||||
|
cJSON_IsString(v) ? v->valuestring : "auto");
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
|
||||||
|
snprintf(a->origin, sizeof(a->origin), "%s",
|
||||||
|
cJSON_IsString(v) ? v->valuestring : "");
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
if (k == 0) {
|
||||||
|
free(list);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
if (n_out)
|
||||||
|
*n_out = k;
|
||||||
|
return list;
|
||||||
|
}
|
||||||
+482
@@ -0,0 +1,482 @@
|
|||||||
|
#include "cst_clipboard.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static void set_err(char *err, size_t err_sz, const char *msg) {
|
||||||
|
if (err && err_sz)
|
||||||
|
snprintf(err, err_sz, "%s", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_le32(unsigned char *p, unsigned long v) {
|
||||||
|
p[0] = (unsigned char)(v & 0xff);
|
||||||
|
p[1] = (unsigned char)((v >> 8) & 0xff);
|
||||||
|
p[2] = (unsigned char)((v >> 16) & 0xff);
|
||||||
|
p[3] = (unsigned char)((v >> 24) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_be32(unsigned char *p, unsigned long v) {
|
||||||
|
p[0] = (unsigned char)((v >> 24) & 0xff);
|
||||||
|
p[1] = (unsigned char)((v >> 16) & 0xff);
|
||||||
|
p[2] = (unsigned char)((v >> 8) & 0xff);
|
||||||
|
p[3] = (unsigned char)(v & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned long get_le32(const unsigned char *p) {
|
||||||
|
return (unsigned long)p[0] | (unsigned long)p[1] << 8 |
|
||||||
|
(unsigned long)p[2] << 16 | (unsigned long)p[3] << 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned long crc32_update(unsigned long crc, const unsigned char *p,
|
||||||
|
size_t n) {
|
||||||
|
static unsigned long table[256];
|
||||||
|
static int ready;
|
||||||
|
if (!ready) {
|
||||||
|
for (unsigned long i = 0; i < 256; i++) {
|
||||||
|
unsigned long c = i;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
c = c & 1 ? 0xedb88320UL ^ (c >> 1) : c >> 1;
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
ready = 1;
|
||||||
|
}
|
||||||
|
crc ^= 0xffffffffUL;
|
||||||
|
for (size_t i = 0; i < n; i++)
|
||||||
|
crc = table[(crc ^ p[i]) & 0xff] ^ (crc >> 8);
|
||||||
|
return crc ^ 0xffffffffUL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned char *png_chunk(unsigned char *o, const char *type,
|
||||||
|
const unsigned char *data, size_t n) {
|
||||||
|
put_be32(o, (unsigned long)n);
|
||||||
|
memcpy(o + 4, type, 4);
|
||||||
|
if (n)
|
||||||
|
memcpy(o + 8, data, n);
|
||||||
|
put_be32(o + 8 + n, crc32_update(0, o + 4, n + 4));
|
||||||
|
return o + 12 + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RGB rows (filter byte 0 each) in stored deflate blocks: no compressor in
|
||||||
|
* the tree, and clipboard screenshots stay well under the attach cap. */
|
||||||
|
static char *png_from_raw(const unsigned char *raw, size_t raw_n, long w,
|
||||||
|
long h, size_t *n) {
|
||||||
|
size_t blocks = raw_n / 65535 + 1;
|
||||||
|
size_t z_n = 2 + raw_n + blocks * 5 + 4;
|
||||||
|
size_t total = 8 + (12 + 13) + (12 + z_n) + 12;
|
||||||
|
unsigned char *out = malloc(total);
|
||||||
|
unsigned char *z = malloc(z_n);
|
||||||
|
if (!out || !z) {
|
||||||
|
free(out);
|
||||||
|
free(z);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
unsigned char *q = z;
|
||||||
|
*q++ = 0x78;
|
||||||
|
*q++ = 0x01;
|
||||||
|
unsigned long a = 1, b = 0;
|
||||||
|
size_t left = raw_n, pos = 0;
|
||||||
|
do {
|
||||||
|
size_t len = left > 65535 ? 65535 : left;
|
||||||
|
*q++ = left == len ? 1 : 0;
|
||||||
|
q[0] = (unsigned char)(len & 0xff);
|
||||||
|
q[1] = (unsigned char)(len >> 8);
|
||||||
|
q[2] = (unsigned char)(~len & 0xff);
|
||||||
|
q[3] = (unsigned char)((~len >> 8) & 0xff);
|
||||||
|
q += 4;
|
||||||
|
memcpy(q, raw + pos, len);
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
a = (a + raw[pos + i]) % 65521;
|
||||||
|
b = (b + a) % 65521;
|
||||||
|
}
|
||||||
|
q += len;
|
||||||
|
pos += len;
|
||||||
|
left -= len;
|
||||||
|
} while (left);
|
||||||
|
put_be32(q, b << 16 | a);
|
||||||
|
q += 4;
|
||||||
|
|
||||||
|
unsigned char ihdr[13];
|
||||||
|
put_be32(ihdr, (unsigned long)w);
|
||||||
|
put_be32(ihdr + 4, (unsigned long)h);
|
||||||
|
ihdr[8] = 8; /* bit depth */
|
||||||
|
ihdr[9] = 2; /* RGB */
|
||||||
|
ihdr[10] = ihdr[11] = ihdr[12] = 0;
|
||||||
|
unsigned char *o = out;
|
||||||
|
memcpy(o, "\x89PNG\r\n\x1a\n", 8);
|
||||||
|
o = png_chunk(o + 8, "IHDR", ihdr, sizeof(ihdr));
|
||||||
|
o = png_chunk(o, "IDAT", z, (size_t)(q - z));
|
||||||
|
o = png_chunk(o, "IEND", NULL, 0);
|
||||||
|
free(z);
|
||||||
|
*n = (size_t)(o - out);
|
||||||
|
return (char *)out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CF_DIB is a BMP without its 14-byte file header; the pixel offset depends
|
||||||
|
* on the header size, BI_BITFIELDS masks and the color table. */
|
||||||
|
char *cst_dib_to_image(const void *dib_v, size_t sz, size_t *n) {
|
||||||
|
const unsigned char *dib = dib_v;
|
||||||
|
if (!dib || sz < 40)
|
||||||
|
return NULL;
|
||||||
|
unsigned long hsize = get_le32(dib);
|
||||||
|
long w = (long)(int)get_le32(dib + 4);
|
||||||
|
long hh = (long)(int)get_le32(dib + 8);
|
||||||
|
unsigned bits = (unsigned)(dib[14] | dib[15] << 8);
|
||||||
|
unsigned long comp = get_le32(dib + 16);
|
||||||
|
unsigned long used = get_le32(dib + 32);
|
||||||
|
if (hsize < 40 || hsize > sz)
|
||||||
|
return NULL;
|
||||||
|
size_t off = hsize;
|
||||||
|
if (hsize == 40 && comp == 3 /* BI_BITFIELDS */)
|
||||||
|
off += 12;
|
||||||
|
size_t colors = used;
|
||||||
|
if (!colors && bits <= 8)
|
||||||
|
colors = (size_t)1 << bits;
|
||||||
|
if (colors > 256)
|
||||||
|
return NULL;
|
||||||
|
off += colors * 4;
|
||||||
|
if (off > sz)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
long h = hh < 0 ? -hh : hh;
|
||||||
|
if ((bits == 24 || bits == 32) && (comp == 0 || comp == 3) && w > 0 &&
|
||||||
|
h > 0 && w <= 32768 && h <= 32768) {
|
||||||
|
size_t stride = ((size_t)w * bits / 8 + 3) & ~(size_t)3;
|
||||||
|
size_t raw_n = (size_t)h * (1 + (size_t)w * 3);
|
||||||
|
if (stride * (size_t)h <= sz - off && raw_n < CST_ATTACH_MAX_BYTES) {
|
||||||
|
unsigned char *raw = malloc(raw_n);
|
||||||
|
if (!raw)
|
||||||
|
return NULL;
|
||||||
|
size_t bpp = bits / 8;
|
||||||
|
for (long y = 0; y < h; y++) {
|
||||||
|
long src_y = hh > 0 ? h - 1 - y : y; /* positive height = bottom-up */
|
||||||
|
const unsigned char *s = dib + off + (size_t)src_y * stride;
|
||||||
|
unsigned char *d = raw + (size_t)y * (1 + (size_t)w * 3);
|
||||||
|
*d++ = 0;
|
||||||
|
for (long x = 0; x < w; x++, s += bpp) {
|
||||||
|
*d++ = s[2];
|
||||||
|
*d++ = s[1];
|
||||||
|
*d++ = s[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
char *png = png_from_raw(raw, raw_n, w, h, n);
|
||||||
|
free(raw);
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
char *out = malloc(14 + sz);
|
||||||
|
if (!out)
|
||||||
|
return NULL;
|
||||||
|
unsigned char *hd = (unsigned char *)out;
|
||||||
|
hd[0] = 'B';
|
||||||
|
hd[1] = 'M';
|
||||||
|
put_le32(hd + 2, (unsigned long)(14 + sz));
|
||||||
|
put_le32(hd + 6, 0);
|
||||||
|
put_le32(hd + 10, (unsigned long)(14 + off));
|
||||||
|
memcpy(out + 14, dib, sz);
|
||||||
|
*n = 14 + sz;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
|
||||||
|
*data = NULL;
|
||||||
|
*n = 0;
|
||||||
|
UINT png = RegisterClipboardFormatA("PNG");
|
||||||
|
if (!OpenClipboard(NULL)) {
|
||||||
|
set_err(err, err_sz, "could not open the clipboard");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
HANDLE h = NULL;
|
||||||
|
int is_png = 0;
|
||||||
|
if (png && IsClipboardFormatAvailable(png)) {
|
||||||
|
h = GetClipboardData(png);
|
||||||
|
is_png = 1;
|
||||||
|
} else if (IsClipboardFormatAvailable(CF_DIB)) {
|
||||||
|
h = GetClipboardData(CF_DIB);
|
||||||
|
}
|
||||||
|
const unsigned char *p = h ? GlobalLock(h) : NULL;
|
||||||
|
if (!p) {
|
||||||
|
CloseClipboard();
|
||||||
|
set_err(err, err_sz, "no image on the clipboard");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
size_t sz = (size_t)GlobalSize(h);
|
||||||
|
char *out = NULL;
|
||||||
|
if (sz > CST_ATTACH_MAX_BYTES) {
|
||||||
|
set_err(err, err_sz, "clipboard image is too large");
|
||||||
|
} else if (is_png) {
|
||||||
|
out = malloc(sz ? sz : 1);
|
||||||
|
if (out) {
|
||||||
|
memcpy(out, p, sz);
|
||||||
|
*n = sz;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out = cst_dib_to_image(p, sz, n);
|
||||||
|
if (!out)
|
||||||
|
set_err(err, err_sz, "clipboard bitmap is malformed");
|
||||||
|
}
|
||||||
|
GlobalUnlock(h);
|
||||||
|
CloseClipboard();
|
||||||
|
if (!out)
|
||||||
|
return -1;
|
||||||
|
*data = out;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
/* Runs cmd (stderr already redirected by the caller); 127 = not installed. */
|
||||||
|
static int run_bytes(const char *cmd, char **data, size_t *n) {
|
||||||
|
os_run_result_t r;
|
||||||
|
memset(&r, 0, sizeof(r));
|
||||||
|
if (os_run_shell_c(NULL, cmd, CST_ATTACH_MAX_BYTES + 1, &r) != 0) {
|
||||||
|
os_run_result_free(&r);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (r.exit_code != 0 || r.out_len == 0) {
|
||||||
|
int code = r.exit_code == 127 ? 127 : -1;
|
||||||
|
os_run_result_free(&r);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
*data = r.out;
|
||||||
|
*n = r.out_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int type_ok(const char *s, size_t n) {
|
||||||
|
if (n < 7 || n > 63 || strncmp(s, "image/", 6) != 0)
|
||||||
|
return 0;
|
||||||
|
for (size_t i = 0; i < n; i++)
|
||||||
|
if (!isalnum((unsigned char)s[i]) && !strchr("/.+-", s[i]))
|
||||||
|
return 0;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Best image MIME type from a newline-separated list: PNG first. */
|
||||||
|
static int pick_type(const char *list, size_t len, char *out, size_t out_sz) {
|
||||||
|
out[0] = 0;
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
size_t j = i;
|
||||||
|
while (j < len && list[j] != '\n' && list[j] != '\r')
|
||||||
|
j++;
|
||||||
|
if (type_ok(list + i, j - i)) {
|
||||||
|
int is_png = j - i == 9 && strncmp(list + i, "image/png", 9) == 0;
|
||||||
|
if (is_png || !out[0])
|
||||||
|
snprintf(out, out_sz, "%.*s", (int)(j - i), list + i);
|
||||||
|
if (is_png)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
return out[0] ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int via_lister(const char *list_cmd, const char *get_fmt,
|
||||||
|
const char *tool, char **data, size_t *n, char *err,
|
||||||
|
size_t err_sz) {
|
||||||
|
char *types = NULL;
|
||||||
|
size_t tn = 0;
|
||||||
|
int rc = run_bytes(list_cmd, &types, &tn);
|
||||||
|
if (rc == 127) {
|
||||||
|
char msg[96];
|
||||||
|
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
|
||||||
|
set_err(err, err_sz, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
char type[64];
|
||||||
|
int ok = rc == 0 && pick_type(types, tn, type, sizeof(type)) == 0;
|
||||||
|
free(types);
|
||||||
|
if (!ok) {
|
||||||
|
set_err(err, err_sz, "no image on the clipboard");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
char cmd[256];
|
||||||
|
snprintf(cmd, sizeof(cmd), get_fmt, type);
|
||||||
|
if (run_bytes(cmd, data, n) != 0) {
|
||||||
|
set_err(err, err_sz, "could not read the clipboard image");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __APPLE__
|
||||||
|
static int mac_image(char **data, size_t *n, char *err, size_t err_sz) {
|
||||||
|
int rc = run_bytes("pngpaste - 2>/dev/null", data, n);
|
||||||
|
if (rc == 0)
|
||||||
|
return 0;
|
||||||
|
char path[1024];
|
||||||
|
int fd = os_mkstemp(path, sizeof(path), "cst-clip");
|
||||||
|
if (fd < 0) {
|
||||||
|
set_err(err, err_sz, "could not create a temp file");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
char cmd[1600];
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"osascript -e 'set f to open for access (POSIX file \"%s\") with "
|
||||||
|
"write permission' -e 'set eof f to 0' -e 'write (the clipboard as "
|
||||||
|
"\xc2\xabclass PNGf\xc2\xbb) to f' -e 'close access f' "
|
||||||
|
">/dev/null 2>&1",
|
||||||
|
path);
|
||||||
|
os_run_result_t r;
|
||||||
|
memset(&r, 0, sizeof(r));
|
||||||
|
int ran = os_run_shell_c(NULL, cmd, 4096, &r);
|
||||||
|
int code = r.exit_code;
|
||||||
|
os_run_result_free(&r);
|
||||||
|
char *buf = NULL;
|
||||||
|
size_t len = 0;
|
||||||
|
if (ran == 0 && code == 0) {
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (f) {
|
||||||
|
char chunk[65536];
|
||||||
|
size_t got;
|
||||||
|
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 &&
|
||||||
|
len <= CST_ATTACH_MAX_BYTES) {
|
||||||
|
char *nb = realloc(buf, len + got);
|
||||||
|
if (!nb)
|
||||||
|
break;
|
||||||
|
buf = nb;
|
||||||
|
memcpy(buf + len, chunk, got);
|
||||||
|
len += got;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove(path);
|
||||||
|
if (!buf || !len) {
|
||||||
|
free(buf);
|
||||||
|
set_err(err, err_sz, "no image on the clipboard");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*data = buf;
|
||||||
|
*n = len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
|
||||||
|
*data = NULL;
|
||||||
|
*n = 0;
|
||||||
|
#ifdef __APPLE__
|
||||||
|
return mac_image(data, n, err, err_sz);
|
||||||
|
#else
|
||||||
|
const char *wl = getenv("WAYLAND_DISPLAY");
|
||||||
|
if (wl && *wl)
|
||||||
|
return via_lister("wl-paste --list-types 2>/dev/null",
|
||||||
|
"wl-paste --no-newline --type '%s' 2>/dev/null",
|
||||||
|
"wl-clipboard (wl-paste)", data, n, err, err_sz);
|
||||||
|
const char *x = getenv("DISPLAY");
|
||||||
|
if (x && *x)
|
||||||
|
return via_lister("xclip -selection clipboard -t TARGETS -o 2>/dev/null",
|
||||||
|
"xclip -selection clipboard -t '%s' -o 2>/dev/null",
|
||||||
|
"xclip", data, n, err, err_sz);
|
||||||
|
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
|
||||||
|
return -1;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static int hexval(int c) {
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f')
|
||||||
|
return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F')
|
||||||
|
return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* file://[host]/path -> /path, percent-decoded; 0 on success. */
|
||||||
|
static int from_file_uri(char *s) {
|
||||||
|
const char *p = s + 7;
|
||||||
|
const char *slash = strchr(p, '/');
|
||||||
|
if (!slash)
|
||||||
|
return -1;
|
||||||
|
if (slash != p && !(slash - p == 9 && strncmp(p, "localhost", 9) == 0))
|
||||||
|
return -1;
|
||||||
|
char *o = s;
|
||||||
|
for (const char *q = slash; *q; q++) {
|
||||||
|
if (*q == '%' && hexval(q[1]) >= 0 && hexval(q[2]) >= 0) {
|
||||||
|
*o++ = (char)(hexval(q[1]) * 16 + hexval(q[2]));
|
||||||
|
q += 2;
|
||||||
|
} else {
|
||||||
|
*o++ = *q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*o = 0;
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (s[0] == '/' && isalpha((unsigned char)s[1]) && s[2] == ':')
|
||||||
|
memmove(s, s + 1, strlen(s));
|
||||||
|
#endif
|
||||||
|
return s[0] ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int is_space(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_paste_paths(const char *text, size_t len,
|
||||||
|
char out[][CST_PASTE_PATH_MAX], int max) {
|
||||||
|
int n = 0;
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
while (i < len && is_space(text[i]))
|
||||||
|
i++;
|
||||||
|
if (i >= len)
|
||||||
|
break;
|
||||||
|
if (n >= max)
|
||||||
|
return -1;
|
||||||
|
char *tok = out[n];
|
||||||
|
size_t k = 0;
|
||||||
|
char q = 0;
|
||||||
|
while (i < len) {
|
||||||
|
char c = text[i];
|
||||||
|
if (!c)
|
||||||
|
return -1;
|
||||||
|
if (q) {
|
||||||
|
if (c == q) {
|
||||||
|
q = 0;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c == '\'' || c == '"') {
|
||||||
|
q = c;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (is_space(c))
|
||||||
|
break;
|
||||||
|
#ifndef _WIN32
|
||||||
|
if (c == '\\' && i + 1 < len)
|
||||||
|
c = text[++i];
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
if (k + 1 >= CST_PASTE_PATH_MAX)
|
||||||
|
return -1;
|
||||||
|
tok[k++] = c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (q || !k)
|
||||||
|
return -1;
|
||||||
|
tok[k] = 0;
|
||||||
|
if (strncmp(tok, "file://", 7) == 0 && from_file_uri(tok) != 0)
|
||||||
|
return -1;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n > 0 ? n : -1;
|
||||||
|
}
|
||||||
@@ -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_COPY] = {"copy", "Session", "copy the last reply", "/copy"},
|
||||||
[CST_KA_EXPORT] = {"export", "Session", "export the transcript",
|
[CST_KA_EXPORT] = {"export", "Session", "export the transcript",
|
||||||
"/export"},
|
"/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_MODEL] = {"model", "Model", "pick the model", "/model"},
|
||||||
[CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"},
|
[CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"},
|
||||||
[CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"},
|
[CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"},
|
||||||
@@ -89,6 +91,7 @@ static const struct {
|
|||||||
{CST_KA_COMPACT, "<leader> c"},
|
{CST_KA_COMPACT, "<leader> c"},
|
||||||
{CST_KA_COPY, "<leader> y"},
|
{CST_KA_COPY, "<leader> y"},
|
||||||
{CST_KA_EXPORT, "<leader> x"},
|
{CST_KA_EXPORT, "<leader> x"},
|
||||||
|
{CST_KA_PASTE_IMAGE, "ctrl+v"},
|
||||||
{CST_KA_MODEL, "<leader> m"},
|
{CST_KA_MODEL, "<leader> m"},
|
||||||
{CST_KA_PROVIDER, "<leader> p"},
|
{CST_KA_PROVIDER, "<leader> p"},
|
||||||
{CST_KA_EFFORT, "<leader> e"},
|
{CST_KA_EFFORT, "<leader> e"},
|
||||||
|
|||||||
+444
-26
@@ -61,6 +61,9 @@ static void llm_apply_ca_bundle(CURL *curl) {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_models.h"
|
||||||
|
|
||||||
/* --- wire body ----------------------------------------------------------- */
|
/* --- wire body ----------------------------------------------------------- */
|
||||||
|
|
||||||
static size_t wire_tool_cap(const cst_llm_request_t *req) {
|
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;
|
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) {
|
static const char *role_api(cst_role_t r) {
|
||||||
switch (r) {
|
switch (r) {
|
||||||
case CST_ROLE_SYSTEM:
|
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");
|
cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
|
||||||
size_t tcap = wire_tool_cap(req);
|
size_t tcap = wire_tool_cap(req);
|
||||||
cJSON *prev_user = NULL; /* merge consecutive user messages */
|
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++) {
|
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;
|
cst_role_t role = m->role;
|
||||||
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
|
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
|
||||||
role = CST_ROLE_USER;
|
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) {
|
if (role == CST_ROLE_USER) {
|
||||||
/* Text-protocol result frames are capped by the agent before framing
|
/* Text-protocol result frames are capped by the agent before framing
|
||||||
* (their byte counts must stay exact), so never cap user text here. */
|
* (their byte counts must stay exact), so never cap user text here. */
|
||||||
cJSON *c = clean_string(m->content, 0);
|
cJSON *pc = prev_user
|
||||||
if (prev_user) {
|
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
|
||||||
cJSON *pc = cJSON_GetObjectItemCaseSensitive(prev_user, "content");
|
: NULL;
|
||||||
cst_buf_t b = {0};
|
if (!has_img && !cJSON_IsArray(pc)) {
|
||||||
cst_buf_puts(&b, pc->valuestring);
|
cJSON *c = clean_string(m->content, 0);
|
||||||
cst_buf_puts(&b, "\n\n");
|
if (prev_user) {
|
||||||
cst_buf_puts(&b, c->valuestring);
|
cst_buf_t b = {0};
|
||||||
cJSON_ReplaceItemInObjectCaseSensitive(prev_user, "content",
|
cst_buf_puts(&b, pc->valuestring);
|
||||||
cJSON_CreateString(b.buf));
|
cst_buf_puts(&b, "\n\n");
|
||||||
cst_buf_free(&b);
|
cst_buf_puts(&b, c->valuestring);
|
||||||
cJSON_Delete(c);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
cJSON *o = cJSON_CreateObject();
|
cJSON *o = prev_user;
|
||||||
cJSON_AddStringToObject(o, "role", "user");
|
if (!o) {
|
||||||
cJSON_AddItemToObject(o, "content", c);
|
o = cJSON_CreateObject();
|
||||||
cJSON_AddItemToArray(msgs, o);
|
cJSON_AddStringToObject(o, "role", "user");
|
||||||
prev_user = o;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
prev_user = NULL;
|
prev_user = NULL;
|
||||||
@@ -198,17 +451,39 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
|||||||
} else {
|
} else {
|
||||||
cJSON_AddItemToObject(o, "content", clean_string(m->content, 0));
|
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);
|
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) {
|
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) {
|
if (tools) {
|
||||||
cJSON_AddItemToObject(root, "tools", tools);
|
cJSON_AddItemToObject(root, "tools", tools);
|
||||||
cJSON_AddStringToObject(root, "tool_choice",
|
cJSON_AddStringToObject(root, "tool_choice",
|
||||||
req->tool_choice_none ? "none" : "auto");
|
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);
|
char *out = cJSON_PrintUnformatted(root);
|
||||||
cJSON_Delete(root);
|
cJSON_Delete(root);
|
||||||
return out;
|
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 *pt = cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens");
|
||||||
const cJSON *ct =
|
const cJSON *ct =
|
||||||
cJSON_GetObjectItemCaseSensitive(usage, "completion_tokens");
|
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 =
|
const cJSON *hit =
|
||||||
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens");
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens");
|
||||||
|
const cJSON *miss =
|
||||||
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_miss_tokens");
|
||||||
const cJSON *det =
|
const cJSON *det =
|
||||||
cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details");
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details");
|
||||||
const cJSON *cached = cJSON_GetObjectItemCaseSensitive(det, "cached_tokens");
|
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))
|
if (cJSON_IsNumber(pt))
|
||||||
s->st->prompt_tokens = (long)pt->valuedouble;
|
s->st->prompt_tokens = (long)pt->valuedouble;
|
||||||
if (cJSON_IsNumber(ct))
|
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;
|
s->st->cached_tokens = (long)hit->valuedouble;
|
||||||
else if (cJSON_IsNumber(cached))
|
else if (cJSON_IsNumber(cached))
|
||||||
s->st->cached_tokens = (long)cached->valuedouble;
|
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 *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
|
||||||
const cJSON *ch = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0)
|
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);
|
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,
|
static int replay_run(sse_t *s, const char *dir, int seq, char *err,
|
||||||
size_t err_sz) {
|
size_t err_sz) {
|
||||||
char path[OS_PATH_MAX];
|
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));
|
trace_path(dir, seq, "sse", path, sizeof(path));
|
||||||
FILE *fp = fopen(path, "rb");
|
FILE *fp = fopen(path, "rb");
|
||||||
if (!fp) {
|
if (!fp) {
|
||||||
snprintf(err, err_sz, "replay: missing %s", path);
|
snprintf(err, err_sz, "replay: missing %s", path);
|
||||||
return -1;
|
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;
|
size_t chunk = 16;
|
||||||
const char *e = getenv("CST_REPLAY_CHUNK");
|
const char *e = getenv("CST_REPLAY_CHUNK");
|
||||||
if (e && atoi(e) > 0)
|
if (e && atoi(e) > 0)
|
||||||
@@ -742,7 +1063,7 @@ finish:
|
|||||||
|
|
||||||
/* --- model listing -------------------------------------------------------- */
|
/* --- 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) {
|
static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
|
||||||
cst_buf_t *b = 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;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int add_model(char out[][128], int n, int max_out, const char *id) {
|
/* A number, or a numeric string (OpenRouter prices are strings). */
|
||||||
if (!id || !id[0] || n >= max_out || strlen(id) >= 128)
|
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;
|
return n;
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
if (strcmp(out[i], id) == 0)
|
if (strcmp(out[i].id, id) == 0)
|
||||||
return n;
|
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;
|
return n + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */
|
/* {"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);
|
cJSON *root = cJSON_Parse(json);
|
||||||
if (!root)
|
if (!root)
|
||||||
return -1;
|
return -1;
|
||||||
@@ -781,9 +1181,9 @@ static int parse_models(const char *json, char out[][128], int max_out) {
|
|||||||
if (!cJSON_IsString(id))
|
if (!cJSON_IsString(id))
|
||||||
id = cJSON_GetObjectItemCaseSensitive(it, "name");
|
id = cJSON_GetObjectItemCaseSensitive(it, "name");
|
||||||
if (cJSON_IsString(id))
|
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))
|
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);
|
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,
|
int cst_llm_list_models(const char *api_base, const char *api_key,
|
||||||
char out[][128], int max_out, char *err,
|
char out[][128], int max_out, char *err,
|
||||||
size_t err_sz) {
|
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];
|
char dummy[8];
|
||||||
if (!err || !err_sz) {
|
if (!err || !err_sz) {
|
||||||
err = dummy;
|
err = dummy;
|
||||||
|
|||||||
+94
-1
@@ -1,5 +1,6 @@
|
|||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
#include "cst_ext.h"
|
#include "cst_ext.h"
|
||||||
|
#include "cst_models.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
#include "cst_version.h"
|
#include "cst_version.h"
|
||||||
#include "os_compat.h"
|
#include "os_compat.h"
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
static void usage(const char *argv0) {
|
static void usage(const char *argv0) {
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"Usage: %s [options] (works in the current directory)\n"
|
"Usage: %s [options] (works in the current directory)\n"
|
||||||
" -P, --provider NAME minimax | deepseek | openai (last used)\n"
|
" -P, --provider NAME minimax | deepseek | openai | teamorouter\n"
|
||||||
|
" (last used)\n"
|
||||||
" -m, --model NAME model (last used for the provider)\n"
|
" -m, --model NAME model (last used for the provider)\n"
|
||||||
" -e, --effort LEVEL low | medium | high | max (last used)\n"
|
" -e, --effort LEVEL low | medium | high | max (last used)\n"
|
||||||
" -w, --workspace DIR project directory (default: current)\n"
|
" -w, --workspace DIR project directory (default: current)\n"
|
||||||
@@ -28,6 +30,8 @@ static void usage(const char *argv0) {
|
|||||||
" -N, --new start a new session\n"
|
" -N, --new start a new session\n"
|
||||||
" -R, --resume ID open a session by id or unique prefix\n"
|
" -R, --resume ID open a session by id or unique prefix\n"
|
||||||
" -L, --list-sessions list sessions and exit\n"
|
" -L, --list-sessions list sessions and exit\n"
|
||||||
|
" --list-models [P] what is known about each provider's models\n"
|
||||||
|
" (built-in, <config>/models.json, GET /models)\n"
|
||||||
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
|
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
|
||||||
" the sandbox or by safe rules) | yolo\n"
|
" the sandbox or by safe rules) | yolo\n"
|
||||||
" -y, --yolo same as --approval yolo (this run only)\n"
|
" -y, --yolo same as --approval yolo (this run only)\n"
|
||||||
@@ -80,6 +84,62 @@ static int parse_confirm(const char *s, cst_agent_config_t *cfg) {
|
|||||||
return 0;
|
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 int list_sessions(const char *ws) {
|
||||||
static char ids[200][128];
|
static char ids[200][128];
|
||||||
int n = cst_agent_store_list(ws, ids, 200);
|
int n = cst_agent_store_list(ws, ids, 200);
|
||||||
@@ -285,6 +345,7 @@ int main(int argc, char **argv) {
|
|||||||
{"effort", required_argument, 0, 'e'},
|
{"effort", required_argument, 0, 'e'},
|
||||||
{"help", no_argument, 0, 'h'},
|
{"help", no_argument, 0, 'h'},
|
||||||
{"version", no_argument, 0, 'V'},
|
{"version", no_argument, 0, 'V'},
|
||||||
|
{"list-models", optional_argument, 0, 0x100},
|
||||||
{0, 0, 0, 0}};
|
{0, 0, 0, 0}};
|
||||||
int c;
|
int c;
|
||||||
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
|
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
|
||||||
@@ -384,6 +445,14 @@ int main(int argc, char **argv) {
|
|||||||
usage(argv[0]);
|
usage(argv[0]);
|
||||||
curl_global_cleanup();
|
curl_global_cleanup();
|
||||||
return 0;
|
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':
|
case 'V':
|
||||||
printf("cst-agent %s\n", CST_VERSION);
|
printf("cst-agent %s\n", CST_VERSION);
|
||||||
curl_global_cleanup();
|
curl_global_cleanup();
|
||||||
@@ -539,6 +608,30 @@ int main(int argc, char **argv) {
|
|||||||
curl_global_cleanup();
|
curl_global_cleanup();
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
/* 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) {
|
||||||
|
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");
|
||||||
|
curl_global_cleanup();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"using %s model=%s effort=%s protocol=%s tools=%s max_steps=%d\n",
|
"using %s model=%s effort=%s protocol=%s tools=%s max_steps=%d\n",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include "cst_mcp.h"
|
#include "cst_mcp.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <stdatomic.h>
|
#include <stdatomic.h>
|
||||||
@@ -602,7 +604,56 @@ static int auto_approved(const mcp_srv_t *s, const char *tool) {
|
|||||||
return 0;
|
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 cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type");
|
||||||
const char *t = cJSON_IsString(type) ? type->valuestring : "";
|
const char *t = cJSON_IsString(type) ? type->valuestring : "";
|
||||||
if (b->len && b->buf[b->len - 1] != '\n')
|
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");
|
const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "text");
|
||||||
if (cJSON_IsString(x))
|
if (cJSON_IsString(x))
|
||||||
cst_buf_puts(b, x->valuestring);
|
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) {
|
} else if (strcmp(t, "image") == 0 || strcmp(t, "audio") == 0) {
|
||||||
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
|
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
|
||||||
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
|
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,
|
static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
cst_tool_result_t *r, void *ud) {
|
cst_tool_result_t *r, void *ud) {
|
||||||
(void)ctx;
|
|
||||||
mtool_t *mt = ud;
|
mtool_t *mt = ud;
|
||||||
mcp_srv_t *s = mt->srv;
|
mcp_srv_t *s = mt->srv;
|
||||||
pthread_mutex_lock(&s->mu);
|
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 *res = cJSON_GetObjectItemCaseSensitive(resp, "result");
|
||||||
const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content");
|
const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content");
|
||||||
cst_buf_t b = {0};
|
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;
|
const cJSON *it = NULL;
|
||||||
cJSON_ArrayForEach(it, content) {
|
cJSON_ArrayForEach(it, content) {
|
||||||
put_content(&b, it);
|
put_content(&b, it, &im);
|
||||||
if (b.len > MCP_OUT_MAX)
|
if (b.len > MCP_OUT_MAX)
|
||||||
break;
|
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);
|
cJSON_Delete(resp);
|
||||||
r->out_len = b.len;
|
r->out_len = b.len;
|
||||||
r->out = cst_buf_steal(&b);
|
r->out = cst_buf_steal(&b);
|
||||||
|
r->attach = im.list; /* one block, freed by cst_tool_result_free */
|
||||||
|
r->nattach = im.n;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,818 @@
|
|||||||
|
#include "cst_models.h"
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
static const char BUILTIN[] = {
|
||||||
|
#include "models_builtin.inc"
|
||||||
|
0};
|
||||||
|
|
||||||
|
const char *cst_models_builtin_json(void) { return BUILTIN; }
|
||||||
|
|
||||||
|
#define MAX_PROV 32
|
||||||
|
#define MAX_PMODELS 16
|
||||||
|
#define MAX_RULES 256
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
cst_provider_t pub; /* points into the buffers below */
|
||||||
|
char name[64];
|
||||||
|
char api_base[256];
|
||||||
|
char model[128];
|
||||||
|
char key_env[64];
|
||||||
|
int passback, prefix;
|
||||||
|
char effort[16]; /* "", "passthrough", "openai" */
|
||||||
|
char models[MAX_PMODELS][128];
|
||||||
|
int nmodels;
|
||||||
|
const char *source;
|
||||||
|
} prov_t;
|
||||||
|
|
||||||
|
enum { F_CONTEXT = 1, F_VISION = 2, F_CACHE = 4, F_REASONING = 8, F_PRICE = 16 };
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char match[128]; /* lowercase */
|
||||||
|
int spec; /* literal characters: higher is more specific */
|
||||||
|
unsigned has; /* F_* set by this rule */
|
||||||
|
long context;
|
||||||
|
int vision, cache, reasoning;
|
||||||
|
cst_price_t price;
|
||||||
|
} rule_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char provider[64];
|
||||||
|
long fetched_at;
|
||||||
|
cst_model_meta_t m;
|
||||||
|
} cached_t;
|
||||||
|
|
||||||
|
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
|
||||||
|
static struct {
|
||||||
|
int loaded;
|
||||||
|
prov_t prov[MAX_PROV];
|
||||||
|
int nprov;
|
||||||
|
rule_t builtin[MAX_RULES];
|
||||||
|
int nbuiltin;
|
||||||
|
rule_t user[MAX_RULES];
|
||||||
|
int nuser;
|
||||||
|
cached_t *cache;
|
||||||
|
int ncache;
|
||||||
|
char err[1400];
|
||||||
|
} R;
|
||||||
|
|
||||||
|
/* --- parsing ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static void lower_copy(char *out, size_t n, const char *s) {
|
||||||
|
size_t i = 0;
|
||||||
|
for (; s && s[i] && i + 1 < n; i++)
|
||||||
|
out[i] = (char)tolower((unsigned char)s[i]);
|
||||||
|
out[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *jstr(const cJSON *o, const char *k) {
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
|
||||||
|
return cJSON_IsString(v) ? v->valuestring : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fix_pub(prov_t *p) {
|
||||||
|
p->pub.name = p->name;
|
||||||
|
p->pub.api_base = p->api_base;
|
||||||
|
p->pub.model = p->model;
|
||||||
|
p->pub.key_env = p->key_env;
|
||||||
|
}
|
||||||
|
|
||||||
|
static prov_t *prov_by_name(const char *name) {
|
||||||
|
for (int i = 0; i < R.nprov; i++)
|
||||||
|
if (strcmp(R.prov[i].name, name) == 0)
|
||||||
|
return &R.prov[i];
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void parse_providers(const cJSON *obj, const char *source) {
|
||||||
|
const cJSON *it = NULL;
|
||||||
|
cJSON_ArrayForEach(it, obj) {
|
||||||
|
if (!cJSON_IsObject(it) || !it->string || !it->string[0])
|
||||||
|
continue;
|
||||||
|
prov_t *p = prov_by_name(it->string);
|
||||||
|
if (!p) {
|
||||||
|
if (R.nprov >= MAX_PROV)
|
||||||
|
continue;
|
||||||
|
p = &R.prov[R.nprov++];
|
||||||
|
memset(p, 0, sizeof(*p));
|
||||||
|
snprintf(p->name, sizeof(p->name), "%s", it->string);
|
||||||
|
fix_pub(p);
|
||||||
|
}
|
||||||
|
p->source = source;
|
||||||
|
const char *s;
|
||||||
|
if ((s = jstr(it, "api_base")))
|
||||||
|
snprintf(p->api_base, sizeof(p->api_base), "%s", s);
|
||||||
|
if ((s = jstr(it, "key_env")))
|
||||||
|
snprintf(p->key_env, sizeof(p->key_env), "%s", s);
|
||||||
|
if ((s = jstr(it, "default_model")))
|
||||||
|
snprintf(p->model, sizeof(p->model), "%s", s);
|
||||||
|
if ((s = jstr(it, "effort")))
|
||||||
|
snprintf(p->effort, sizeof(p->effort), "%s", s);
|
||||||
|
const cJSON *b = cJSON_GetObjectItemCaseSensitive(it, "reasoning_passback");
|
||||||
|
if (cJSON_IsBool(b))
|
||||||
|
p->passback = cJSON_IsTrue(b);
|
||||||
|
b = cJSON_GetObjectItemCaseSensitive(it, "prefix_completion");
|
||||||
|
if (cJSON_IsBool(b))
|
||||||
|
p->prefix = cJSON_IsTrue(b);
|
||||||
|
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(it, "models");
|
||||||
|
if (cJSON_IsArray(ms)) {
|
||||||
|
p->nmodels = 0;
|
||||||
|
const cJSON *m = NULL;
|
||||||
|
cJSON_ArrayForEach(m, ms) {
|
||||||
|
if (cJSON_IsString(m) && m->valuestring[0] && p->nmodels < MAX_PMODELS)
|
||||||
|
snprintf(p->models[p->nmodels++], 128, "%s", m->valuestring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parse_price(const cJSON *o, cst_price_t *pr) {
|
||||||
|
const cJSON *miss = cJSON_GetObjectItemCaseSensitive(o, "miss");
|
||||||
|
const cJSON *out = cJSON_GetObjectItemCaseSensitive(o, "out");
|
||||||
|
if (!cJSON_IsNumber(miss) || !cJSON_IsNumber(out))
|
||||||
|
return -1;
|
||||||
|
memset(pr, 0, sizeof(*pr));
|
||||||
|
pr->miss = miss->valuedouble;
|
||||||
|
pr->out = out->valuedouble;
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "hit");
|
||||||
|
pr->hit = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "write");
|
||||||
|
pr->write = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
|
||||||
|
const char *c = jstr(o, "currency");
|
||||||
|
snprintf(pr->currency, sizeof(pr->currency), "%s", c ? c : "$");
|
||||||
|
pr->offpeak_half =
|
||||||
|
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(o, "offpeak_half"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parse_rule(const cJSON *o, rule_t *r) {
|
||||||
|
const char *m = jstr(o, "match");
|
||||||
|
if (!m || !m[0])
|
||||||
|
return -1;
|
||||||
|
memset(r, 0, sizeof(*r));
|
||||||
|
lower_copy(r->match, sizeof(r->match), m);
|
||||||
|
for (const char *q = r->match; *q; q++)
|
||||||
|
if (*q != '*')
|
||||||
|
r->spec++;
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
|
||||||
|
if (cJSON_IsNumber(v) && v->valuedouble > 0) {
|
||||||
|
r->context = (long)v->valuedouble;
|
||||||
|
r->has |= F_CONTEXT;
|
||||||
|
}
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
|
||||||
|
if (cJSON_IsBool(v)) {
|
||||||
|
r->vision = cJSON_IsTrue(v);
|
||||||
|
r->has |= F_VISION;
|
||||||
|
}
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "reasoning");
|
||||||
|
if (cJSON_IsBool(v)) {
|
||||||
|
r->reasoning = cJSON_IsTrue(v);
|
||||||
|
r->has |= F_REASONING;
|
||||||
|
}
|
||||||
|
const char *c = jstr(o, "cache");
|
||||||
|
if (c) {
|
||||||
|
if (strcmp(c, "openai") == 0)
|
||||||
|
r->cache = CST_CACHE_OPENAI;
|
||||||
|
else if (strcmp(c, "anthropic") == 0)
|
||||||
|
r->cache = CST_CACHE_ANTHROPIC;
|
||||||
|
else
|
||||||
|
r->cache = CST_CACHE_AUTO;
|
||||||
|
r->has |= F_CACHE;
|
||||||
|
}
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "price");
|
||||||
|
if (cJSON_IsObject(v) && parse_price(v, &r->price) == 0)
|
||||||
|
r->has |= F_PRICE;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parse_doc(const char *json, int user, char *err, size_t err_sz) {
|
||||||
|
cJSON *doc = cJSON_Parse(json);
|
||||||
|
if (!cJSON_IsObject(doc)) {
|
||||||
|
cJSON_Delete(doc);
|
||||||
|
snprintf(err, err_sz, "not a JSON object");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
parse_providers(cJSON_GetObjectItemCaseSensitive(doc, "providers"),
|
||||||
|
user ? "user" : "built-in");
|
||||||
|
rule_t *rules = user ? R.user : R.builtin;
|
||||||
|
int *n = user ? &R.nuser : &R.nbuiltin;
|
||||||
|
const cJSON *it = NULL;
|
||||||
|
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
|
||||||
|
if (*n < MAX_RULES && parse_rule(it, &rules[*n]) == 0)
|
||||||
|
(*n)++;
|
||||||
|
}
|
||||||
|
cJSON_Delete(doc);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *read_file(const char *path) {
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (!f)
|
||||||
|
return NULL;
|
||||||
|
char *buf = NULL;
|
||||||
|
size_t len = 0;
|
||||||
|
char chunk[8192];
|
||||||
|
size_t got;
|
||||||
|
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 && len < (8u << 20)) {
|
||||||
|
char *nb = realloc(buf, len + got + 1);
|
||||||
|
if (!nb)
|
||||||
|
break;
|
||||||
|
buf = nb;
|
||||||
|
memcpy(buf + len, chunk, got);
|
||||||
|
len += got;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
if (buf)
|
||||||
|
buf[len] = 0;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cache_path(const char *provider, char *out, size_t n) {
|
||||||
|
char dir[1024];
|
||||||
|
if (!provider || !provider[0] || os_config_dir(dir, sizeof(dir)) != 0)
|
||||||
|
return -1;
|
||||||
|
for (const char *q = provider; *q; q++)
|
||||||
|
if (!isalnum((unsigned char)*q) && !strchr("._-", *q))
|
||||||
|
return -1;
|
||||||
|
int w = snprintf(out, n, "%s/models-cache/%s.json", dir, provider);
|
||||||
|
return w > 0 && (size_t)w < n ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void meta_from_json(const cJSON *o, cst_model_meta_t *m) {
|
||||||
|
memset(m, 0, sizeof(*m));
|
||||||
|
m->vision = -1;
|
||||||
|
const char *id = jstr(o, "id");
|
||||||
|
snprintf(m->id, sizeof(m->id), "%s", id ? id : "");
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
|
||||||
|
if (cJSON_IsNumber(v) && v->valuedouble > 0)
|
||||||
|
m->context = (long)v->valuedouble;
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
|
||||||
|
if (cJSON_IsBool(v))
|
||||||
|
m->vision = cJSON_IsTrue(v);
|
||||||
|
v = cJSON_GetObjectItemCaseSensitive(o, "price");
|
||||||
|
cst_price_t pr;
|
||||||
|
if (cJSON_IsObject(v) && parse_price(v, &pr) == 0) {
|
||||||
|
m->has_price = 1;
|
||||||
|
m->miss = pr.miss;
|
||||||
|
m->hit = pr.hit;
|
||||||
|
m->write = pr.write;
|
||||||
|
m->out = pr.out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Appends provider's cache file to R.cache (caller holds the lock). */
|
||||||
|
static void load_cache(const char *provider) {
|
||||||
|
char path[1100];
|
||||||
|
if (cache_path(provider, path, sizeof(path)) != 0)
|
||||||
|
return;
|
||||||
|
char *txt = read_file(path);
|
||||||
|
cJSON *doc = txt ? cJSON_Parse(txt) : NULL;
|
||||||
|
free(txt);
|
||||||
|
const cJSON *fa = cJSON_GetObjectItemCaseSensitive(doc, "fetched_at");
|
||||||
|
long at = cJSON_IsNumber(fa) ? (long)fa->valuedouble : 0;
|
||||||
|
const cJSON *it = NULL;
|
||||||
|
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
|
||||||
|
cst_model_meta_t m;
|
||||||
|
meta_from_json(it, &m);
|
||||||
|
if (!m.id[0])
|
||||||
|
continue;
|
||||||
|
cached_t *nc = realloc(R.cache, (size_t)(R.ncache + 1) * sizeof(*nc));
|
||||||
|
if (!nc)
|
||||||
|
break;
|
||||||
|
R.cache = nc;
|
||||||
|
cached_t *c = &R.cache[R.ncache++];
|
||||||
|
snprintf(c->provider, sizeof(c->provider), "%s", provider);
|
||||||
|
c->fetched_at = at;
|
||||||
|
c->m = m;
|
||||||
|
}
|
||||||
|
cJSON_Delete(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drop_cache(const char *provider) {
|
||||||
|
int w = 0;
|
||||||
|
for (int i = 0; i < R.ncache; i++)
|
||||||
|
if (provider && strcmp(R.cache[i].provider, provider) != 0)
|
||||||
|
R.cache[w++] = R.cache[i];
|
||||||
|
R.ncache = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reset_locked(void) {
|
||||||
|
R.nprov = 0;
|
||||||
|
R.nbuiltin = 0;
|
||||||
|
R.nuser = 0;
|
||||||
|
drop_cache(NULL);
|
||||||
|
R.err[0] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void load_caches_locked(void) {
|
||||||
|
for (int i = 0; i < R.nprov; i++)
|
||||||
|
load_cache(R.prov[i].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_models_user_path(char *out, size_t out_sz) {
|
||||||
|
char dir[1024];
|
||||||
|
if (os_config_dir(dir, sizeof(dir)) != 0)
|
||||||
|
return -1;
|
||||||
|
int w = snprintf(out, out_sz, "%s/models.json", dir);
|
||||||
|
return w > 0 && (size_t)w < out_sz ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void load_default_locked(void) {
|
||||||
|
reset_locked();
|
||||||
|
char e[128];
|
||||||
|
if (parse_doc(BUILTIN, 0, e, sizeof(e)) != 0)
|
||||||
|
snprintf(R.err, sizeof(R.err), "built-in model data: %s", e);
|
||||||
|
char path[1100];
|
||||||
|
char *user = cst_models_user_path(path, sizeof(path)) == 0
|
||||||
|
? read_file(path)
|
||||||
|
: NULL;
|
||||||
|
if (user && parse_doc(user, 1, e, sizeof(e)) != 0)
|
||||||
|
snprintf(R.err, sizeof(R.err), "%s: %s", path, e);
|
||||||
|
free(user);
|
||||||
|
load_caches_locked();
|
||||||
|
R.loaded = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_locked(void) {
|
||||||
|
if (!R.loaded)
|
||||||
|
load_default_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void cst_models_reload(void) {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
load_default_locked();
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_models_load_json(const char *builtin_json, const char *user_json,
|
||||||
|
char *err, size_t err_sz) {
|
||||||
|
char dummy[8];
|
||||||
|
if (!err || !err_sz) {
|
||||||
|
err = dummy;
|
||||||
|
err_sz = sizeof(dummy);
|
||||||
|
}
|
||||||
|
err[0] = 0;
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
reset_locked();
|
||||||
|
int rc = 0;
|
||||||
|
if (builtin_json && parse_doc(builtin_json, 0, err, err_sz) != 0)
|
||||||
|
rc = -1;
|
||||||
|
if (rc == 0 && user_json && parse_doc(user_json, 1, err, err_sz) != 0)
|
||||||
|
rc = -1;
|
||||||
|
load_caches_locked();
|
||||||
|
R.loaded = 1;
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *cst_models_load_error(void) {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return R.err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- providers ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
const cst_provider_t *cst_provider_find(const char *name) {
|
||||||
|
if (!name || !name[0])
|
||||||
|
return NULL;
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
prov_t *p = prov_by_name(name);
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return p ? &p->pub : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cst_provider_t *cst_provider_default(void) {
|
||||||
|
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
|
||||||
|
return p ? p : cst_provider_at(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_provider_count(void) {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
int n = R.nprov;
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cst_provider_t *cst_provider_at(int i) {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
const cst_provider_t *p = i >= 0 && i < R.nprov ? &R.prov[i].pub : NULL;
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *cst_provider_source(const cst_provider_t *p) {
|
||||||
|
for (int i = 0; p && i < MAX_PROV; i++)
|
||||||
|
if (p == &R.prov[i].pub)
|
||||||
|
return R.prov[i].source ? R.prov[i].source : "built-in";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_provider_models(const cst_provider_t *p, char out[][128], int max) {
|
||||||
|
int n = 0;
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
for (int i = 0; p && i < R.nprov; i++) {
|
||||||
|
if (p != &R.prov[i].pub)
|
||||||
|
continue;
|
||||||
|
for (int k = 0; k < R.prov[i].nmodels && n < max; k++)
|
||||||
|
snprintf(out[n++], 128, "%s", R.prov[i].models[k]);
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int push_name(char out[][128], int *n, int max, const char *name) {
|
||||||
|
if (!name || !name[0] || *n >= max)
|
||||||
|
return 0;
|
||||||
|
for (int i = 0; i < *n; i++)
|
||||||
|
if (strcmp(out[i], name) == 0)
|
||||||
|
return 0;
|
||||||
|
snprintf(out[(*n)++], 128, "%s", name);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_models_list(const char *current, char out[][128], int max_out) {
|
||||||
|
if (!out || max_out <= 0)
|
||||||
|
return 0;
|
||||||
|
int n = 0;
|
||||||
|
const char *env = getenv("CST_MODELS");
|
||||||
|
if (env && env[0]) {
|
||||||
|
char buf[1024];
|
||||||
|
snprintf(buf, sizeof(buf), "%s", env);
|
||||||
|
for (char *tok = buf; *tok;) {
|
||||||
|
while (*tok == ':' || *tok == ',' || *tok == ' ')
|
||||||
|
tok++;
|
||||||
|
char *end = tok;
|
||||||
|
while (*end && *end != ':' && *end != ',')
|
||||||
|
end++;
|
||||||
|
char save = *end;
|
||||||
|
*end = 0;
|
||||||
|
char *t = end;
|
||||||
|
while (t > tok && (t[-1] == ' ' || t[-1] == '\t'))
|
||||||
|
*--t = 0;
|
||||||
|
push_name(out, &n, max_out, tok);
|
||||||
|
*end = save;
|
||||||
|
tok = *end ? end + 1 : end;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
for (int i = 0; i < R.nprov; i++)
|
||||||
|
for (int k = 0; k < R.prov[i].nmodels; k++)
|
||||||
|
push_name(out, &n, max_out, R.prov[i].models[k]);
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
}
|
||||||
|
push_name(out, &n, max_out, current);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- model lookup -------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* '*' matches any run; without one the pattern is a prefix. */
|
||||||
|
static int glob(const char *p, const char *s) {
|
||||||
|
if (!strchr(p, '*'))
|
||||||
|
return strncmp(s, p, strlen(p)) == 0;
|
||||||
|
while (*p) {
|
||||||
|
if (*p == '*') {
|
||||||
|
while (*p == '*')
|
||||||
|
p++;
|
||||||
|
if (!*p)
|
||||||
|
return 1;
|
||||||
|
for (; *s; s++)
|
||||||
|
if (glob(p, s))
|
||||||
|
return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (*p != *s)
|
||||||
|
return 0;
|
||||||
|
p++;
|
||||||
|
s++;
|
||||||
|
}
|
||||||
|
return !*s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_field(cst_model_info_t *o, unsigned f, const rule_t *r) {
|
||||||
|
switch (f) {
|
||||||
|
case F_CONTEXT:
|
||||||
|
o->context = r->context;
|
||||||
|
break;
|
||||||
|
case F_VISION:
|
||||||
|
o->vision = r->vision;
|
||||||
|
break;
|
||||||
|
case F_CACHE:
|
||||||
|
o->cache_style = r->cache;
|
||||||
|
break;
|
||||||
|
case F_REASONING:
|
||||||
|
o->reasoning = r->reasoning;
|
||||||
|
break;
|
||||||
|
case F_PRICE:
|
||||||
|
o->price = r->price;
|
||||||
|
o->has_price = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void add_source(cst_model_info_t *o, const char *src) {
|
||||||
|
if (strstr(o->source, src))
|
||||||
|
return;
|
||||||
|
size_t l = strlen(o->source);
|
||||||
|
snprintf(o->source + l, sizeof(o->source) - l, "%s%s", l ? "+" : "", src);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void apply_rules(const rule_t *rules, int n, const char *full,
|
||||||
|
const char *bare, unsigned *have, cst_model_info_t *o,
|
||||||
|
const char *src) {
|
||||||
|
for (unsigned f = 1; f <= F_PRICE; f <<= 1) {
|
||||||
|
if (*have & f)
|
||||||
|
continue;
|
||||||
|
const rule_t *best = NULL;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
const rule_t *r = &rules[i];
|
||||||
|
if (!(r->has & f) || (best && r->spec <= best->spec))
|
||||||
|
continue;
|
||||||
|
if (glob(r->match, strchr(r->match, '/') ? full : bare))
|
||||||
|
best = r;
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
set_field(o, f, best);
|
||||||
|
*have |= f;
|
||||||
|
add_source(o, src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void apply_cache(const char *provider, const char *model,
|
||||||
|
unsigned *have, cst_model_info_t *o) {
|
||||||
|
for (int i = 0; i < R.ncache; i++) {
|
||||||
|
const cached_t *c = &R.cache[i];
|
||||||
|
if ((provider && strcmp(c->provider, provider) != 0) ||
|
||||||
|
strcmp(c->m.id, model) != 0)
|
||||||
|
continue;
|
||||||
|
unsigned before = *have;
|
||||||
|
if (!(*have & F_CONTEXT) && c->m.context > 0) {
|
||||||
|
o->context = c->m.context;
|
||||||
|
*have |= F_CONTEXT;
|
||||||
|
}
|
||||||
|
if (!(*have & F_VISION) && c->m.vision >= 0) {
|
||||||
|
o->vision = c->m.vision;
|
||||||
|
*have |= F_VISION;
|
||||||
|
}
|
||||||
|
if (!(*have & F_PRICE) && c->m.has_price) {
|
||||||
|
memset(&o->price, 0, sizeof(o->price));
|
||||||
|
snprintf(o->price.currency, sizeof(o->price.currency), "$");
|
||||||
|
o->price.miss = c->m.miss;
|
||||||
|
o->price.hit = c->m.hit;
|
||||||
|
o->price.write = c->m.write;
|
||||||
|
o->price.out = c->m.out;
|
||||||
|
o->has_price = 1;
|
||||||
|
*have |= F_PRICE;
|
||||||
|
}
|
||||||
|
if (*have != before)
|
||||||
|
add_source(o, "api");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int env_price(cst_price_t *out) {
|
||||||
|
const char *e = getenv("CST_PRICE");
|
||||||
|
if (!e || !e[0])
|
||||||
|
return -1;
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
char cur[8] = "";
|
||||||
|
int n = sscanf(e, "%lf,%lf,%lf,%lf,%7s", &out->miss, &out->hit, &out->out,
|
||||||
|
&out->write, cur);
|
||||||
|
if (n < 3)
|
||||||
|
return -1;
|
||||||
|
if (n < 4)
|
||||||
|
out->write = out->miss;
|
||||||
|
snprintf(out->currency, sizeof(out->currency), "%s", cur);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cst_model_info(const char *provider, const char *model,
|
||||||
|
cst_model_info_t *out) {
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
out->vision = -1;
|
||||||
|
unsigned have = 0;
|
||||||
|
const char *e = getenv("CST_CTX_TOKENS");
|
||||||
|
if (e && atol(e) > 0) {
|
||||||
|
out->context = atol(e);
|
||||||
|
have |= F_CONTEXT;
|
||||||
|
add_source(out, "env");
|
||||||
|
}
|
||||||
|
if (env_price(&out->price) == 0) {
|
||||||
|
out->has_price = 1;
|
||||||
|
have |= F_PRICE;
|
||||||
|
add_source(out, "env");
|
||||||
|
}
|
||||||
|
if (model && model[0]) {
|
||||||
|
char full[256], bare[256];
|
||||||
|
lower_copy(full, sizeof(full), model);
|
||||||
|
const char *slash = strrchr(full, '/');
|
||||||
|
snprintf(bare, sizeof(bare), "%s", slash && slash[1] ? slash + 1 : full);
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
apply_rules(R.user, R.nuser, full, bare, &have, out, "user");
|
||||||
|
if (provider && !prov_by_name(provider))
|
||||||
|
provider = NULL;
|
||||||
|
apply_cache(provider, model, &have, out);
|
||||||
|
apply_rules(R.builtin, R.nbuiltin, full, bare, &have, out, "built-in");
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
}
|
||||||
|
if (!(have & F_CONTEXT)) {
|
||||||
|
out->context = 128000;
|
||||||
|
add_source(out, "default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n) {
|
||||||
|
char ctx[32];
|
||||||
|
if (mi->context >= 1000000)
|
||||||
|
snprintf(ctx, sizeof(ctx), "%.0fM", (double)mi->context / 1e6);
|
||||||
|
else
|
||||||
|
snprintf(ctx, sizeof(ctx), "%ldK", (mi->context + 500) / 1000);
|
||||||
|
snprintf(out, n, "%s%s", ctx,
|
||||||
|
mi->vision == 1 ? " · vision"
|
||||||
|
: mi->vision == 0 ? " · text only"
|
||||||
|
: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- wrappers kept for the rest of the code ------------------------------ */
|
||||||
|
|
||||||
|
long cst_model_context_tokens(const char *model) {
|
||||||
|
cst_model_info_t i;
|
||||||
|
cst_model_info(NULL, model, &i);
|
||||||
|
return i.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_model_price(const char *model, cst_price_t *out) {
|
||||||
|
cst_model_info_t i;
|
||||||
|
cst_model_info(NULL, model, &i);
|
||||||
|
*out = i.price;
|
||||||
|
if (!i.has_price)
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
return i.has_price ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "https://api.deepseek.com/v1" -> "api.deepseek.com" */
|
||||||
|
static void host_of(const char *url, char *out, size_t n) {
|
||||||
|
const char *s = strstr(url, "://");
|
||||||
|
s = s ? s + 3 : url;
|
||||||
|
size_t i = 0;
|
||||||
|
for (; s[i] && s[i] != '/' && i + 1 < n; i++)
|
||||||
|
out[i] = s[i];
|
||||||
|
out[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cst_model_caps(const char *provider, const char *api_base,
|
||||||
|
const char *model, cst_model_caps_t *out) {
|
||||||
|
memset(out, 0, sizeof(*out));
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
/* provider extensions follow the provider, or its host behind any name */
|
||||||
|
for (int i = 0; i < R.nprov; i++) {
|
||||||
|
const prov_t *p = &R.prov[i];
|
||||||
|
if (!p->passback && !p->prefix)
|
||||||
|
continue;
|
||||||
|
char host[256];
|
||||||
|
host_of(p->api_base, host, sizeof(host));
|
||||||
|
if ((provider && strcmp(provider, p->name) == 0) ||
|
||||||
|
(api_base && host[0] && strstr(api_base, host))) {
|
||||||
|
out->reasoning_passback |= p->passback;
|
||||||
|
out->prefix_completion |= p->prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
cst_model_info_t mi;
|
||||||
|
cst_model_info(provider, model, &mi);
|
||||||
|
out->vision = mi.vision;
|
||||||
|
const char *e = getenv("CST_NO_CACHE_HINTS");
|
||||||
|
if ((e && e[0] == '1') || !model)
|
||||||
|
return;
|
||||||
|
out->cache_style = mi.cache_style;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *cst_effort_wire(const char *provider, const char *model,
|
||||||
|
const char *effort) {
|
||||||
|
if (!cst_effort_valid(effort) || !provider || !model)
|
||||||
|
return NULL;
|
||||||
|
char mode[16] = "";
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
const prov_t *p = prov_by_name(provider);
|
||||||
|
if (p)
|
||||||
|
snprintf(mode, sizeof(mode), "%s", p->effort);
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
if (strcmp(mode, "passthrough") == 0)
|
||||||
|
return effort;
|
||||||
|
if (strcmp(mode, "openai") == 0) {
|
||||||
|
cst_model_info_t mi;
|
||||||
|
cst_model_info(provider, model, &mi);
|
||||||
|
if (mi.reasoning)
|
||||||
|
return strcmp(effort, "max") == 0 ? "high" : effort;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- fetched cache ------------------------------------------------------- */
|
||||||
|
|
||||||
|
int cst_models_cache_save(const char *provider, const char *api_base,
|
||||||
|
const cst_model_meta_t *list, int n) {
|
||||||
|
char path[1100];
|
||||||
|
if (!list || n < 0 || cache_path(provider, path, sizeof(path)) != 0)
|
||||||
|
return -1;
|
||||||
|
char dir[1100];
|
||||||
|
snprintf(dir, sizeof(dir), "%s", path);
|
||||||
|
char *sl = strrchr(dir, '/');
|
||||||
|
if (sl)
|
||||||
|
*sl = 0;
|
||||||
|
os_mkdir_p(dir, 0700);
|
||||||
|
|
||||||
|
long now = (long)time(NULL);
|
||||||
|
cJSON *doc = cJSON_CreateObject();
|
||||||
|
cJSON_AddNumberToObject(doc, "fetched_at", (double)now);
|
||||||
|
if (api_base)
|
||||||
|
cJSON_AddStringToObject(doc, "api_base", api_base);
|
||||||
|
cJSON *arr = cJSON_AddArrayToObject(doc, "models");
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
const cst_model_meta_t *m = &list[i];
|
||||||
|
cJSON *o = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(o, "id", m->id);
|
||||||
|
if (m->context > 0)
|
||||||
|
cJSON_AddNumberToObject(o, "context", (double)m->context);
|
||||||
|
if (m->vision >= 0)
|
||||||
|
cJSON_AddBoolToObject(o, "vision", m->vision);
|
||||||
|
if (m->has_price) {
|
||||||
|
cJSON *p = cJSON_AddObjectToObject(o, "price");
|
||||||
|
cJSON_AddStringToObject(p, "currency", "$");
|
||||||
|
cJSON_AddNumberToObject(p, "miss", m->miss);
|
||||||
|
cJSON_AddNumberToObject(p, "hit", m->hit);
|
||||||
|
cJSON_AddNumberToObject(p, "write", m->write);
|
||||||
|
cJSON_AddNumberToObject(p, "out", m->out);
|
||||||
|
}
|
||||||
|
cJSON_AddItemToArray(arr, o);
|
||||||
|
}
|
||||||
|
char *txt = cJSON_Print(doc);
|
||||||
|
cJSON_Delete(doc);
|
||||||
|
if (!txt)
|
||||||
|
return -1;
|
||||||
|
char tmp[1200];
|
||||||
|
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
||||||
|
FILE *f = fopen(tmp, "wb");
|
||||||
|
int rc = -1;
|
||||||
|
if (f) {
|
||||||
|
size_t len = strlen(txt);
|
||||||
|
rc = fwrite(txt, 1, len, f) == len ? 0 : -1;
|
||||||
|
if (fclose(f) != 0)
|
||||||
|
rc = -1;
|
||||||
|
if (rc == 0)
|
||||||
|
rc = os_rename_replace(tmp, path);
|
||||||
|
if (rc != 0)
|
||||||
|
remove(tmp);
|
||||||
|
}
|
||||||
|
free(txt);
|
||||||
|
if (rc == 0) {
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
drop_cache(provider);
|
||||||
|
load_cache(provider);
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
|
||||||
|
long *age_s) {
|
||||||
|
if (age_s)
|
||||||
|
*age_s = 0;
|
||||||
|
if (!provider)
|
||||||
|
return -1;
|
||||||
|
pthread_mutex_lock(&g_mu);
|
||||||
|
ensure_locked();
|
||||||
|
int n = 0, found = 0;
|
||||||
|
for (int i = 0; i < R.ncache; i++) {
|
||||||
|
const cached_t *c = &R.cache[i];
|
||||||
|
if (strcmp(c->provider, provider) != 0)
|
||||||
|
continue;
|
||||||
|
if (!found && age_s)
|
||||||
|
*age_s = (long)time(NULL) - c->fetched_at;
|
||||||
|
found = 1;
|
||||||
|
if (out && n < max)
|
||||||
|
out[n++] = c->m;
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_mu);
|
||||||
|
return found ? n : -1;
|
||||||
|
}
|
||||||
+20
-123
@@ -1,113 +1,32 @@
|
|||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#define MODEL_MAX_LEN 128
|
/* Provider and model data live in model_registry.c (cst_models.h). */
|
||||||
|
|
||||||
static const cst_provider_t PROVIDERS[] = {
|
int cst_price_is_peak(time_t when) {
|
||||||
{"minimax", "https://api.minimax.cn/v1", "MiniMax-M2.7", "MINIMAX_API_KEY"},
|
time_t bj = when + 8 * 3600;
|
||||||
{"deepseek", "https://api.deepseek.com/v1", "deepseek-flash",
|
struct tm tmv;
|
||||||
"DEEPSEEK_API_KEY"},
|
#ifdef _WIN32
|
||||||
{"openai", "https://api.openai.com/v1", "gpt-4.1-mini", "OPENAI_API_KEY"},
|
gmtime_s(&tmv, &bj);
|
||||||
};
|
#else
|
||||||
|
gmtime_r(&bj, &tmv);
|
||||||
const cst_provider_t *cst_provider_find(const char *name) {
|
#endif
|
||||||
if (!name || !name[0])
|
if (tmv.tm_wday == 0 || tmv.tm_wday == 6)
|
||||||
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)
|
|
||||||
return 0;
|
return 0;
|
||||||
if (already(out, *n, name))
|
return (tmv.tm_hour >= 9 && tmv.tm_hour < 12) ||
|
||||||
return 0;
|
(tmv.tm_hour >= 14 && tmv.tm_hour < 18);
|
||||||
snprintf(out[*n], MODEL_MAX_LEN, "%s", name);
|
|
||||||
(*n)++;
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int cst_models_list(const char *current, char out[][128], int max_out) {
|
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
|
||||||
if (!out || max_out <= 0)
|
long out, time_t when) {
|
||||||
|
if (!p)
|
||||||
return 0;
|
return 0;
|
||||||
int n = 0;
|
double c = (miss * p->miss + hit * p->hit + write * p->write +
|
||||||
|
out * p->out) / 1e6;
|
||||||
const char *env = getenv("CST_MODELS");
|
if (p->offpeak_half && !cst_price_is_peak(when))
|
||||||
if (env && env[0]) {
|
c /= 2;
|
||||||
char buf[1024];
|
return c;
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int cst_effort_valid(const char *effort) {
|
int cst_effort_valid(const char *effort) {
|
||||||
@@ -118,25 +37,3 @@ int cst_effort_valid(const char *effort) {
|
|||||||
}
|
}
|
||||||
return 0;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
]
|
||||||
|
}
|
||||||
+5
-19
@@ -86,8 +86,6 @@ static int g_abi;
|
|||||||
#define LL_FS_MAKE_SYM (1ULL << 12)
|
#define LL_FS_MAKE_SYM (1ULL << 12)
|
||||||
#define LL_FS_REFER (1ULL << 13) /* ABI 2 */
|
#define LL_FS_REFER (1ULL << 13) /* ABI 2 */
|
||||||
#define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */
|
#define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */
|
||||||
#define LL_NET_BIND_TCP (1ULL << 0) /* ABI 4 */
|
|
||||||
#define LL_NET_CONNECT_TCP (1ULL << 1) /* ABI 4 */
|
|
||||||
|
|
||||||
struct ll_ruleset_attr {
|
struct ll_ruleset_attr {
|
||||||
uint64_t handled_access_fs;
|
uint64_t handled_access_fs;
|
||||||
@@ -137,9 +135,7 @@ static int ll_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
|
|||||||
int abi = g_abi;
|
int abi = g_abi;
|
||||||
struct ll_ruleset_attr attr;
|
struct ll_ruleset_attr attr;
|
||||||
memset(&attr, 0, sizeof(attr));
|
memset(&attr, 0, sizeof(attr));
|
||||||
attr.handled_access_fs = ll_write_rights(abi);
|
attr.handled_access_fs = ll_write_rights(abi); /* network stays open */
|
||||||
if (abi >= 4)
|
|
||||||
attr.handled_access_net = LL_NET_BIND_TCP | LL_NET_CONNECT_TCP;
|
|
||||||
int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
|
int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
|
||||||
if (fd < 0)
|
if (fd < 0)
|
||||||
return -1;
|
return -1;
|
||||||
@@ -193,8 +189,7 @@ static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
|
|||||||
"(version 1)(allow default)(deny file-write*)"
|
"(version 1)(allow default)(deny file-write*)"
|
||||||
"(allow file-write* (subpath \"%s\") (subpath \"%s\") "
|
"(allow file-write* (subpath \"%s\") (subpath \"%s\") "
|
||||||
"(literal \"/dev/null\") (literal \"/dev/tty\") "
|
"(literal \"/dev/null\") (literal \"/dev/tty\") "
|
||||||
"(literal \"/dev/dtracehelper\"))"
|
"(literal \"/dev/dtracehelper\"))",
|
||||||
"(deny network*)",
|
|
||||||
qws, qtmp);
|
qws, qtmp);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -290,9 +285,8 @@ static const int SC_NOTIFY[] = {
|
|||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Foreign ABIs are refused, IPv4/IPv6 sockets fail with EACCES, opens
|
/* Foreign ABIs are refused, opens without write flags pass untouched,
|
||||||
* without write flags pass untouched, write-type calls go to the
|
* write-type calls go to the supervisor. */
|
||||||
* supervisor. */
|
|
||||||
static void sc_build(sc_prog_t *p) {
|
static void sc_build(sc_prog_t *p) {
|
||||||
p->n = 0;
|
p->n = 0;
|
||||||
SC_LD(offsetof(struct seccomp_data, arch));
|
SC_LD(offsetof(struct seccomp_data, arch));
|
||||||
@@ -303,12 +297,6 @@ static void sc_build(sc_prog_t *p) {
|
|||||||
sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */
|
sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */
|
||||||
SC_RET(SECCOMP_RET_ERRNO | EPERM);
|
SC_RET(SECCOMP_RET_ERRNO | EPERM);
|
||||||
#endif
|
#endif
|
||||||
SC_JEQ(__NR_socket, 0, 5);
|
|
||||||
SC_LD(SC_ARG(0));
|
|
||||||
SC_JEQ(AF_INET, 2, 0);
|
|
||||||
SC_JEQ(AF_INET6, 1, 0);
|
|
||||||
SC_RET(SECCOMP_RET_ALLOW);
|
|
||||||
SC_RET(SECCOMP_RET_ERRNO | EACCES);
|
|
||||||
SC_JEQ(__NR_io_uring_setup, 0, 1);
|
SC_JEQ(__NR_io_uring_setup, 0, 1);
|
||||||
SC_RET(SECCOMP_RET_ERRNO | EPERM);
|
SC_RET(SECCOMP_RET_ERRNO | EPERM);
|
||||||
SC_JEQ(__NR_openat, 0, 4);
|
SC_JEQ(__NR_openat, 0, 4);
|
||||||
@@ -1446,9 +1434,7 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
|
|||||||
static const char *const SIGNS[] = {
|
static const char *const SIGNS[] = {
|
||||||
"Permission denied", "Read-only file system", "Operation not permitted",
|
"Permission denied", "Read-only file system", "Operation not permitted",
|
||||||
"EACCES", "EPERM", "EROFS",
|
"EACCES", "EPERM", "EROFS",
|
||||||
"[cst: sandbox", "Network is unreachable",
|
"[cst: sandbox",
|
||||||
/* curl when the socket itself was refused (seccomp) */
|
|
||||||
"Could not connect to server", "Failed to connect to",
|
|
||||||
/* localized strerror(EACCES/EPERM/EROFS) */
|
/* localized strerror(EACCES/EPERM/EROFS) */
|
||||||
"权限不够", "不允许的操作", "只读文件系统", "權限不足",
|
"权限不够", "不允许的操作", "只读文件系统", "權限不足",
|
||||||
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
|
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
|
||||||
|
|||||||
+54
-1
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -97,7 +98,11 @@ static void msg_free(cst_message_t *m) {
|
|||||||
free(m->content);
|
free(m->content);
|
||||||
free(m->tool_calls_json);
|
free(m->tool_calls_json);
|
||||||
free(m->meta_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) {
|
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);
|
d->tool_calls_json = xstrdup(src->tool_calls_json);
|
||||||
if (src->meta_json && src->meta_json[0])
|
if (src->meta_json && src->meta_json[0])
|
||||||
d->meta_json = xstrdup(src->meta_json);
|
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++;
|
s->nmsgs++;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -174,9 +189,14 @@ static void load_existing(cst_session_t *s) {
|
|||||||
if (cJSON_IsObject(mo))
|
if (cJSON_IsObject(mo))
|
||||||
meta = cJSON_PrintUnformatted(mo);
|
meta = cJSON_PrintUnformatted(mo);
|
||||||
m.meta_json = meta;
|
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);
|
session_push_mem(s, &m);
|
||||||
free(tcj);
|
free(tcj);
|
||||||
free(meta);
|
free(meta);
|
||||||
|
cst_attachments_free(m.attach, m.nattach);
|
||||||
cJSON_Delete(o);
|
cJSON_Delete(o);
|
||||||
}
|
}
|
||||||
free(line);
|
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);
|
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]) {
|
if (msg->meta_json && msg->meta_json[0]) {
|
||||||
cJSON *mo = cJSON_Parse(msg->meta_json);
|
cJSON *mo = cJSON_Parse(msg->meta_json);
|
||||||
if (cJSON_IsObject(mo))
|
if (cJSON_IsObject(mo))
|
||||||
@@ -256,6 +278,28 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
|
|||||||
else
|
else
|
||||||
cJSON_Delete(mo);
|
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);
|
char *txt = cJSON_PrintUnformatted(o);
|
||||||
cJSON_Delete(o);
|
cJSON_Delete(o);
|
||||||
if (!txt)
|
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) {
|
if (s->msgs[i].role == CST_ROLE_SYSTEM) {
|
||||||
sys.role = CST_ROLE_SYSTEM;
|
sys.role = CST_ROLE_SYSTEM;
|
||||||
sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : "");
|
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;
|
have_sys = sys.content != NULL;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -322,6 +367,7 @@ int cst_session_clear(cst_session_t *s, int keep_system) {
|
|||||||
write_line(s->fp, &sys);
|
write_line(s->fp, &sys);
|
||||||
}
|
}
|
||||||
free(sys.content);
|
free(sys.content);
|
||||||
|
free(sys.meta_json);
|
||||||
pthread_mutex_unlock(&s->mu);
|
pthread_mutex_unlock(&s->mu);
|
||||||
return rc;
|
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 =
|
copy[i].tool_calls_json =
|
||||||
msgs[i].tool_calls_json ? strdup(msgs[i].tool_calls_json) : NULL;
|
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].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);
|
pthread_mutex_lock(&s->mu);
|
||||||
free_all(s);
|
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].content);
|
||||||
free(copy[i].tool_calls_json);
|
free(copy[i].tool_calls_json);
|
||||||
free(copy[i].meta_json);
|
free(copy[i].meta_json);
|
||||||
|
free(copy[i].reasoning);
|
||||||
|
cst_attachments_free(copy[i].attach, copy[i].nattach);
|
||||||
}
|
}
|
||||||
free(copy);
|
free(copy);
|
||||||
return rc;
|
return rc;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
#include <regex.h>
|
#include <regex.h>
|
||||||
|
|
||||||
|
#include "cst_attach.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
#include "cst_util.h"
|
#include "cst_util.h"
|
||||||
#include "os_compat.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);
|
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 ----------------------------------------------------------- */
|
/* --- edit_file ----------------------------------------------------------- */
|
||||||
|
|
||||||
static size_t count_occ(const char *hay, size_t hn, const char *needle,
|
static size_t count_occ(const char *hay, size_t hn, const char *needle,
|
||||||
|
|||||||
+42
-4
@@ -56,6 +56,15 @@ static const cst_tool_def_t TOOLS[] = {
|
|||||||
{"limit", I, 0, "maximum number of lines"},
|
{"limit", I, 0, "maximum number of lines"},
|
||||||
{"line_numbers", B, 0, "`N|` line prefixes (default true)"}},
|
{"line_numbers", B, 0, "`N|` line prefixes (default true)"}},
|
||||||
CST_TP_READ, 1, run_read_file, NULL, NO_DYN},
|
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_shell",
|
||||||
"Run a non-interactive shell command in the workspace root for builds, "
|
"Run a non-interactive shell command in the workspace root for builds, "
|
||||||
"tests, git and other CLIs (stdin is empty; long runs are killed). "
|
"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;
|
return 1;
|
||||||
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
|
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
|
||||||
return 0;
|
return 0;
|
||||||
if ((cfg->plan_mode || cfg->read_only) &&
|
/* plan_mode is enforced when a call runs, so tools[] stays the same */
|
||||||
(t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
|
if (cfg->read_only && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
|
||||||
return 0;
|
return 0;
|
||||||
if (cfg->depth > 0 && strcmp(t->name, "task") == 0)
|
if (cfg->depth > 0 && strcmp(t->name, "task") == 0)
|
||||||
return 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;
|
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) {
|
void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) {
|
||||||
cJSON *arr = cJSON_CreateArray();
|
cJSON *arr = cJSON_CreateArray();
|
||||||
if (!arr)
|
if (!arr)
|
||||||
return NULL;
|
return NULL;
|
||||||
int legacy = is_minimal(cfg);
|
int legacy = is_minimal(cfg);
|
||||||
int n = cst_tool_count();
|
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++) {
|
for (int i = 0; i < n; i++) {
|
||||||
const cst_tool_def_t *t = cst_tool_at(i);
|
const cst_tool_def_t *t = cst_tool_at(i);
|
||||||
if (!t || !cst_tool_active(cfg, t))
|
if (t && cst_tool_active(cfg, t) && !is_external(t))
|
||||||
continue;
|
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 *tool = cJSON_CreateObject();
|
||||||
cJSON_AddStringToObject(tool, "type", "function");
|
cJSON_AddStringToObject(tool, "type", "function");
|
||||||
cJSON *fn = cJSON_AddObjectToObject(tool, "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);
|
cJSON_AddItemToArray(arr, tool);
|
||||||
}
|
}
|
||||||
|
free(order);
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
#include "cst_sandbox.h"
|
#include "cst_sandbox.h"
|
||||||
#include "cst_util.h"
|
#include "cst_util.h"
|
||||||
#include "os_compat.h"
|
#include "os_compat.h"
|
||||||
@@ -26,6 +27,9 @@ void cst_tool_result_free(cst_tool_result_t *r) {
|
|||||||
free(r->out);
|
free(r->out);
|
||||||
r->out = NULL;
|
r->out = NULL;
|
||||||
r->out_len = 0;
|
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) {
|
void cst_tool_call_clear(ctl_tool_call_t *c) {
|
||||||
|
|||||||
@@ -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);
|
cst_tool_result_t *r);
|
||||||
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
cst_tool_result_t *r);
|
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,
|
int cst_preview_write_file(const cst_tool_ctx_t *ctx,
|
||||||
const ctl_tool_call_t *c, char **out);
|
const ctl_tool_call_t *c, char **out);
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_clipboard.h"
|
||||||
#include "cst_keymap.h"
|
#include "cst_keymap.h"
|
||||||
#include "cst_ext.h"
|
#include "cst_ext.h"
|
||||||
#include "cst_mcp.h"
|
#include "cst_mcp.h"
|
||||||
|
#include "cst_models.h"
|
||||||
#include "cst_plugins.h"
|
#include "cst_plugins.h"
|
||||||
#include "cst_skills.h"
|
#include "cst_skills.h"
|
||||||
#include "cst_subagent.h"
|
#include "cst_subagent.h"
|
||||||
@@ -340,6 +343,7 @@ typedef struct {
|
|||||||
size_t cur; /* cursor byte offset into inbuf */
|
size_t cur; /* cursor byte offset into inbuf */
|
||||||
int in_paste;
|
int in_paste;
|
||||||
int paste_cr; /* last pasted byte was CR (fold CRLF) */
|
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 status_msg[512];
|
||||||
char title[128]; /* session title or first prompt, for the header */
|
char title[128]; /* session title or first prompt, for the header */
|
||||||
struct timespec ctrlc_at;
|
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;
|
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) {
|
static int approval_mode(const tui_t *t) {
|
||||||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||||||
if (!cfg)
|
if (!cfg)
|
||||||
@@ -2019,8 +2029,20 @@ static void draw_status(tui_t *t) {
|
|||||||
int pct = ctx_percent(t, &used);
|
int pct = ctx_percent(t, &used);
|
||||||
char tok[32];
|
char tok[32];
|
||||||
fmt_tokens(used, tok, sizeof(tok));
|
fmt_tokens(used, tok, sizeof(tok));
|
||||||
snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands",
|
char usage[64] = "";
|
||||||
tok, pct, dot_sep());
|
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);
|
rw = vis_cols(right);
|
||||||
if (rw > w)
|
if (rw > w)
|
||||||
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);
|
x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0);
|
||||||
if (eff) {
|
if (eff) {
|
||||||
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
|
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)
|
if (rw)
|
||||||
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
|
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
|
else
|
||||||
b->expanded = 0;
|
b->expanded = 0;
|
||||||
t->last_tool_idx = t->hist.n - 1;
|
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) {
|
} else if (m->role == CST_ROLE_USER) {
|
||||||
b->kind = BLK_USER;
|
b->kind = BLK_USER;
|
||||||
block_set_title(b, "You");
|
block_set_title(b, "You");
|
||||||
block_set_body(b, m->content ? m->content : "",
|
cst_buf_t ub = {0};
|
||||||
m->content ? strlen(m->content) : 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;
|
b->expanded = 1;
|
||||||
} else if (m->role == CST_ROLE_ASSISTANT) {
|
} else if (m->role == CST_ROLE_ASSISTANT) {
|
||||||
b->kind = BLK_ASSISTANT;
|
b->kind = BLK_ASSISTANT;
|
||||||
@@ -3821,7 +3862,7 @@ static int secret_input_run(tui_t *t, const char *title, const char *hint,
|
|||||||
* File-scope so a probe still running when the TUI exits writes nowhere
|
* File-scope so a probe still running when the TUI exits writes nowhere
|
||||||
* stale. */
|
* stale. */
|
||||||
|
|
||||||
#define AM_MAX 64
|
#define AM_MAX 512 /* routers list hundreds; the picker filters as you type */
|
||||||
enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH };
|
enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH };
|
||||||
|
|
||||||
static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
@@ -3839,6 +3880,7 @@ typedef struct {
|
|||||||
char provider[32];
|
char provider[32];
|
||||||
char base[512];
|
char base[512];
|
||||||
char key[512];
|
char key[512];
|
||||||
|
int have_cache; /* a cached list is showing: keep it if the fetch fails */
|
||||||
} probe_arg_t;
|
} probe_arg_t;
|
||||||
|
|
||||||
static void am_store(const char *provider, int r, char ids[][128],
|
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);
|
pthread_mutex_unlock(&g_am_mu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GET /models; a good answer also refreshes models-cache/<provider>.json. */
|
||||||
|
static int fetch_models(const char *provider, const char *base,
|
||||||
|
const char *key, char ids[][128], int max, char *err,
|
||||||
|
size_t err_sz) {
|
||||||
|
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
|
||||||
|
if (!m) {
|
||||||
|
snprintf(err, err_sz, "out of memory");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int r = cst_llm_list_models_meta(base, key, m, max, err, err_sz);
|
||||||
|
if (r >= 0)
|
||||||
|
cst_models_cache_save(provider, base, m, r);
|
||||||
|
for (int i = 0; i < r; i++)
|
||||||
|
snprintf(ids[i], 128, "%s", m[i].id);
|
||||||
|
free(m);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cached list for provider into ids: count, or -1; *fresh when it is
|
||||||
|
* younger than CST_MODELS_CACHE_TTL. */
|
||||||
|
static int cached_models(const char *provider, char ids[][128], int max,
|
||||||
|
int *fresh) {
|
||||||
|
*fresh = 0;
|
||||||
|
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
|
||||||
|
if (!m)
|
||||||
|
return -1;
|
||||||
|
long age = 0;
|
||||||
|
int n = cst_models_cache_load(provider, m, max, &age);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
snprintf(ids[i], 128, "%s", m[i].id);
|
||||||
|
free(m);
|
||||||
|
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
|
||||||
|
return n > 0 ? n : -1;
|
||||||
|
}
|
||||||
|
|
||||||
static void *probe_thread(void *arg) {
|
static void *probe_thread(void *arg) {
|
||||||
probe_arg_t *a = arg;
|
probe_arg_t *a = arg;
|
||||||
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
|
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
|
||||||
char err[256];
|
char err[256];
|
||||||
int r = cst_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err));
|
int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err,
|
||||||
am_store(a->provider, r, ids, 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));
|
memset(a->key, 0, sizeof(a->key));
|
||||||
free(a);
|
free(a);
|
||||||
pthread_mutex_lock(&g_am_mu);
|
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);
|
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||||||
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
|
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
|
||||||
return;
|
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));
|
probe_arg_t *a = calloc(1, sizeof(*a));
|
||||||
if (!a)
|
if (!a)
|
||||||
return;
|
return;
|
||||||
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
|
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
|
||||||
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
|
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
|
||||||
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
|
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
|
||||||
|
a->have_cache = nc > 0;
|
||||||
pthread_mutex_lock(&g_am_mu);
|
pthread_mutex_lock(&g_am_mu);
|
||||||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
if (nc <= 0) {
|
||||||
g_am.state = AM_RUNNING;
|
g_am.state = AM_RUNNING;
|
||||||
g_am.n = 0;
|
g_am.n = 0;
|
||||||
|
}
|
||||||
g_am_threads++;
|
g_am_threads++;
|
||||||
pthread_mutex_unlock(&g_am_mu);
|
pthread_mutex_unlock(&g_am_mu);
|
||||||
pthread_t th;
|
pthread_t th;
|
||||||
@@ -3920,15 +4011,23 @@ static int am_models(const char *provider, char out[][128], int max) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* After a probe: flag a model the API does not list. */
|
/* After a probe: flag a model the API does not list; with no model yet
|
||||||
|
* (router presets) take the first one listed. */
|
||||||
static void am_check_model(tui_t *t) {
|
static void am_check_model(tui_t *t) {
|
||||||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||||||
char msg[400] = "";
|
char msg[400] = "";
|
||||||
|
int picked = 0;
|
||||||
pthread_mutex_lock(&g_am_mu);
|
pthread_mutex_lock(&g_am_mu);
|
||||||
if (cfg && strcmp(g_am.provider, cfg->provider) == 0) {
|
if (cfg && strcmp(g_am.provider, cfg->provider) == 0) {
|
||||||
if (g_am.state == AM_AUTH) {
|
if (g_am.state == AM_AUTH) {
|
||||||
snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to "
|
snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to "
|
||||||
"replace it", cfg->provider);
|
"replace it", cfg->provider);
|
||||||
|
} else if (g_am.state == AM_OK && g_am.n > 0 && !cfg->model[0]) {
|
||||||
|
snprintf(cfg->model, sizeof(cfg->model), "%s", g_am.ids[0]);
|
||||||
|
snprintf(msg, sizeof(msg), "model → %.127s (%d listed by %s) — /model "
|
||||||
|
"to change",
|
||||||
|
cfg->model, g_am.n, cfg->provider);
|
||||||
|
picked = 1;
|
||||||
} else if (g_am.state == AM_OK && g_am.n > 0) {
|
} else if (g_am.state == AM_OK && g_am.n > 0) {
|
||||||
int found = 0;
|
int found = 0;
|
||||||
for (int i = 0; i < g_am.n && !found; i++)
|
for (int i = 0; i < g_am.n && !found; i++)
|
||||||
@@ -3939,6 +4038,8 @@ static void am_check_model(tui_t *t) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pthread_mutex_unlock(&g_am_mu);
|
pthread_mutex_unlock(&g_am_mu);
|
||||||
|
if (picked)
|
||||||
|
cst_settings_remember(cfg);
|
||||||
if (msg[0])
|
if (msg[0])
|
||||||
set_status(t, msg);
|
set_status(t, msg);
|
||||||
}
|
}
|
||||||
@@ -3963,7 +4064,7 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
|
|||||||
draw_status(t);
|
draw_status(t);
|
||||||
static char ids[AM_MAX][128];
|
static char ids[AM_MAX][128];
|
||||||
char err[256];
|
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) {
|
if (r == CST_MODELS_AUTH) {
|
||||||
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
|
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
|
||||||
"another one",
|
"another one",
|
||||||
@@ -4192,6 +4293,16 @@ static void cmd_delete(tui_t *t, char *arg, int *quit) {
|
|||||||
push_system_line(t, "(deleted session %s)", victim);
|
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) {
|
static void cmd_model(tui_t *t, char *arg, int *quit) {
|
||||||
(void)quit;
|
(void)quit;
|
||||||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||||||
@@ -4202,8 +4313,8 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
|
|||||||
set_status(t, "fetching the model list…");
|
set_status(t, "fetching the model list…");
|
||||||
draw_status(t);
|
draw_status(t);
|
||||||
char err[256];
|
char err[256];
|
||||||
na = cst_llm_list_models(cfg->api_base, cfg->api_key, names, AM_MAX, err,
|
na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names,
|
||||||
sizeof(err));
|
AM_MAX, err, sizeof(err));
|
||||||
pthread_mutex_lock(&g_am_mu);
|
pthread_mutex_lock(&g_am_mu);
|
||||||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
||||||
pthread_mutex_unlock(&g_am_mu);
|
pthread_mutex_unlock(&g_am_mu);
|
||||||
@@ -4216,7 +4327,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
|
|||||||
if (na < 0)
|
if (na < 0)
|
||||||
na = 0;
|
na = 0;
|
||||||
for (int i = 0; i < na; i++)
|
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];
|
char builtin[32][128];
|
||||||
int nb = cst_models_list(cfg->model, builtin, 32);
|
int nb = cst_models_list(cfg->model, builtin, 32);
|
||||||
int n = na;
|
int n = na;
|
||||||
@@ -4227,7 +4338,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
|
|||||||
if (dup)
|
if (dup)
|
||||||
continue;
|
continue;
|
||||||
memcpy(names[n], builtin[i], 128);
|
memcpy(names[n], builtin[i], 128);
|
||||||
memcpy(labels[n], builtin[i], 128);
|
model_label(cfg->provider, builtin[i], 0, labels[n], 128);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
int cur_idx = -1;
|
int cur_idx = -1;
|
||||||
@@ -4242,8 +4353,13 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
|
|||||||
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
|
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
|
||||||
}
|
}
|
||||||
cst_settings_remember(cfg);
|
cst_settings_remember(cfg);
|
||||||
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
|
cst_model_info_t mi;
|
||||||
cst_model_context_tokens(cfg->model));
|
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) {
|
static void cmd_key(tui_t *t, char *arg, int *quit) {
|
||||||
@@ -4310,15 +4426,18 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int n = cst_provider_count();
|
int n = cst_provider_count();
|
||||||
char items[16][128];
|
char items[32][128];
|
||||||
int cur_idx = 0;
|
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);
|
const cst_provider_t *q = cst_provider_at(i);
|
||||||
snprintf(items[i], 128, "%-9s %s (%s)", q->name, q->api_base, q->model);
|
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)
|
if (strcmp(q->name, cfg->provider) == 0)
|
||||||
cur_idx = i;
|
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)
|
if (pick < 0)
|
||||||
return;
|
return;
|
||||||
p = cst_provider_at(pick);
|
p = cst_provider_at(pick);
|
||||||
@@ -4557,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, " provider %s %s", cfg->provider, cfg->api_base);
|
||||||
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
|
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
|
||||||
cfg->model, used, win, win ? used * 100 / win : 0);
|
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,
|
push_system_line(t, " effort %s%s", cfg->effort,
|
||||||
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||||||
? ""
|
? ""
|
||||||
@@ -4585,6 +4713,57 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
|
|||||||
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
|
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
|
||||||
push_system_line(t, " undo %d turn(s) can be undone",
|
push_system_line(t, " undo %d turn(s) can be undone",
|
||||||
cst_agent_undo_available(t->agent));
|
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) {
|
static void cmd_todo(tui_t *t, char *arg, int *quit) {
|
||||||
@@ -4696,8 +4875,17 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
|
|||||||
if (msg_is_tool_result(m)) {
|
if (msg_is_tool_result(m)) {
|
||||||
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
|
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
|
||||||
md_fence(fp, c, 4000);
|
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) {
|
} else if (m->role == CST_ROLE_USER) {
|
||||||
fprintf(fp, "## User\n\n%s\n\n", c);
|
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 {
|
} else {
|
||||||
fprintf(fp, "## Assistant\n\n%s\n\n", c);
|
fprintf(fp, "## Assistant\n\n%s\n\n", c);
|
||||||
if (m->tool_calls_json && m->tool_calls_json[0]) {
|
if (m->tool_calls_json && m->tool_calls_json[0]) {
|
||||||
@@ -4711,6 +4899,116 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
|
|||||||
push_system_line(t, "(exported to %s)", path);
|
push_system_line(t, "(exported to %s)", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The newest `added` picks, and a warning when the model would not see them. */
|
||||||
|
static void report_attached(tui_t *t, int added) {
|
||||||
|
int n = cst_agent_pending_count(t->agent);
|
||||||
|
for (int i = n - added; i < n; i++) {
|
||||||
|
char line[256];
|
||||||
|
if (i >= 0 &&
|
||||||
|
cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
|
||||||
|
push_system_line(t, "Attached %s — it goes with your next message.",
|
||||||
|
line);
|
||||||
|
}
|
||||||
|
if (added > 0 && cst_agent_sees_images(t->agent) == 0)
|
||||||
|
push_system_line(t, "Note: %s does not take images; they are sent as a "
|
||||||
|
"text note until you /model to one that does.",
|
||||||
|
cst_agent_config(t->agent)->model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /attach: stage images for the next user message. The bytes are copied into
|
||||||
|
* the session's attachment directory now, so a picture picked from /tmp keeps
|
||||||
|
* working after the file is gone. */
|
||||||
|
static void cmd_attach(tui_t *t, char *arg, int *quit) {
|
||||||
|
(void)quit;
|
||||||
|
int n = cst_agent_pending_count(t->agent);
|
||||||
|
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
|
||||||
|
if (n == 0) {
|
||||||
|
push_system_line(t, "No images picked. Usage: /attach <path>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
push_system_line(t, "%d image%s with the next message (sent once):", n,
|
||||||
|
n > 1 ? "s" : "");
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
char line[256];
|
||||||
|
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
|
||||||
|
push_system_line(t, " %d. %s", i + 1, line);
|
||||||
|
}
|
||||||
|
push_system_line(t, "(/attach clear drops them)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
|
||||||
|
cst_agent_pending_clear(t->agent);
|
||||||
|
push_system_line(t, "Dropped the picked images.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char err[256];
|
||||||
|
if (strcmp(arg, "paste") == 0) {
|
||||||
|
char *img = NULL;
|
||||||
|
size_t len = 0;
|
||||||
|
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
|
||||||
|
push_system_line(t, "Error: %s", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char mime[32], name[32];
|
||||||
|
const char *ext = cst_attach_sniff(img, len, mime, sizeof(mime)) == 0 &&
|
||||||
|
strchr(mime, '/')
|
||||||
|
? strchr(mime, '/') + 1
|
||||||
|
: "png";
|
||||||
|
snprintf(name, sizeof(name), "clipboard.%s", ext);
|
||||||
|
int rc = cst_agent_attach_bytes(t->agent, name, img, len, err, sizeof(err));
|
||||||
|
free(img);
|
||||||
|
if (rc != 0) {
|
||||||
|
push_system_line(t, "Error: %s", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
report_attached(t, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cst_agent_attach_file(t->agent, arg, err, sizeof(err)) != 0) {
|
||||||
|
push_system_line(t, "Error: %s", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
report_attached(t, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int file_is_image(const char *path) {
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (!f)
|
||||||
|
return 0;
|
||||||
|
unsigned char head[16];
|
||||||
|
size_t got = fread(head, 1, sizeof(head), f);
|
||||||
|
fclose(f);
|
||||||
|
char mime[32];
|
||||||
|
return cst_attach_sniff(head, got, mime, sizeof(mime)) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A paste that is nothing but image file paths (a file dropped on the
|
||||||
|
* terminal) attaches those files instead of typing the paths. */
|
||||||
|
static void paste_image_paths(tui_t *t) {
|
||||||
|
size_t from = t->paste_at, to = t->cur;
|
||||||
|
if (to <= from || to - from != t->in_len - t->paste_len0 ||
|
||||||
|
to - from > CST_PASTE_MAX_PATHS * CST_PASTE_PATH_MAX)
|
||||||
|
return;
|
||||||
|
static char paths[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
|
||||||
|
int n = cst_paste_paths(t->inbuf + from, to - from, paths,
|
||||||
|
CST_PASTE_MAX_PATHS);
|
||||||
|
if (n <= 0)
|
||||||
|
return;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
if (!file_is_image(paths[i]))
|
||||||
|
return;
|
||||||
|
input_erase(t, from, to);
|
||||||
|
int added = 0;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
char err[256];
|
||||||
|
if (cst_agent_attach_file(t->agent, paths[i], err, sizeof(err)) == 0)
|
||||||
|
added++;
|
||||||
|
else
|
||||||
|
push_system_line(t, "Error: %s", err);
|
||||||
|
}
|
||||||
|
report_attached(t, added);
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#define COPY_VIA "clipboard"
|
#define COPY_VIA "clipboard"
|
||||||
#else
|
#else
|
||||||
@@ -5004,7 +5302,7 @@ static void show_subagent(tui_t *t, const char *pid,
|
|||||||
const char *final = NULL;
|
const char *final = NULL;
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
const cst_message_t *m = cst_session_get(s, i);
|
const cst_message_t *m = cst_session_get(s, i);
|
||||||
if (!m->content)
|
if (!m->content || cst_message_is_note(m))
|
||||||
continue;
|
continue;
|
||||||
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
|
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
|
||||||
push_system_line(t, " prompt: %.300s", m->content);
|
push_system_line(t, " prompt: %.300s", m->content);
|
||||||
@@ -5107,6 +5405,8 @@ static const slash_cmd_t SLASH[] = {
|
|||||||
cmd_mouse},
|
cmd_mouse},
|
||||||
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
|
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
|
||||||
{"status", "", "", "session, model, context and settings", 1, cmd_status},
|
{"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},
|
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
|
||||||
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
|
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
|
||||||
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
|
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
|
||||||
@@ -5116,6 +5416,9 @@ static const slash_cmd_t SLASH[] = {
|
|||||||
{"export", "", "[file]", "write the transcript as Markdown", 1,
|
{"export", "", "[file]", "write the transcript as Markdown", 1,
|
||||||
cmd_export},
|
cmd_export},
|
||||||
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
|
{"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},
|
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
|
||||||
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
|
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
|
||||||
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
|
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
|
||||||
@@ -5409,11 +5712,25 @@ static void request_cancel_ui(tui_t *t) {
|
|||||||
|
|
||||||
static int begin_turn(tui_t *t, char *prompt) {
|
static int begin_turn(tui_t *t, char *prompt) {
|
||||||
prompt_hist_push(t, prompt);
|
prompt_hist_push(t, prompt);
|
||||||
|
if (!cst_agent_config(t->agent)->model[0]) {
|
||||||
|
set_status(t, "no model selected yet — /model to pick one (↑ recalls "
|
||||||
|
"your message)");
|
||||||
|
free(prompt);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
block_t *ub = blocks_push(&t->hist);
|
block_t *ub = blocks_push(&t->hist);
|
||||||
if (ub) {
|
if (ub) {
|
||||||
ub->kind = BLK_USER;
|
ub->kind = BLK_USER;
|
||||||
block_set_title(ub, "You");
|
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;
|
ub->expanded = 1;
|
||||||
}
|
}
|
||||||
t->think_idx = -1;
|
t->think_idx = -1;
|
||||||
@@ -6629,10 +6946,13 @@ int cst_tui_run(cst_agent_t *agent) {
|
|||||||
if (wch == KEY_PASTE_BEGIN) {
|
if (wch == KEY_PASTE_BEGIN) {
|
||||||
t.in_paste = 1;
|
t.in_paste = 1;
|
||||||
t.paste_cr = 0;
|
t.paste_cr = 0;
|
||||||
|
t.paste_at = t.cur;
|
||||||
|
t.paste_len0 = t.in_len;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (wch == KEY_PASTE_END) {
|
if (wch == KEY_PASTE_END) {
|
||||||
t.in_paste = 0;
|
t.in_paste = 0;
|
||||||
|
paste_image_paths(&t);
|
||||||
input_changed(&t);
|
input_changed(&t);
|
||||||
after_edit(&t);
|
after_edit(&t);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_clipboard.h"
|
||||||
|
#include "cst_test_util.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int nfail;
|
||||||
|
|
||||||
|
#define EXPECT(cond, msg) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||||
|
nfail++; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static void put_be32(unsigned char *p, unsigned v) {
|
||||||
|
p[0] = (unsigned char)(v >> 24);
|
||||||
|
p[1] = (unsigned char)(v >> 16);
|
||||||
|
p[2] = (unsigned char)(v >> 8);
|
||||||
|
p[3] = (unsigned char)v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_le16(unsigned char *p, unsigned v) {
|
||||||
|
p[0] = (unsigned char)(v & 0xFF);
|
||||||
|
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 8 signature + 4 length + "IHDR" + w + h (what cst_attach_dimensions reads). */
|
||||||
|
static size_t png_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||||
|
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||||
|
memcpy(b, sig, 8);
|
||||||
|
b[8] = b[9] = b[10] = 0;
|
||||||
|
b[11] = 13;
|
||||||
|
memcpy(b + 12, "IHDR", 4);
|
||||||
|
put_be32(b + 16, w);
|
||||||
|
put_be32(b + 20, h);
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t gif_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||||
|
memcpy(b, "GIF89a", 6);
|
||||||
|
put_le16(b + 6, w);
|
||||||
|
put_le16(b + 8, h);
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put_le32(unsigned char *p, unsigned v) {
|
||||||
|
p[0] = (unsigned char)(v & 0xFF);
|
||||||
|
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||||
|
p[2] = (unsigned char)((v >> 16) & 0xFF);
|
||||||
|
p[3] = (unsigned char)((v >> 24) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t bmp_hdr(unsigned char *b, int w, int h) {
|
||||||
|
memset(b, 0, 64);
|
||||||
|
b[0] = 'B';
|
||||||
|
b[1] = 'M';
|
||||||
|
put_le32(b + 18, (unsigned)w);
|
||||||
|
put_le32(b + 22, (unsigned)h);
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FFD8 + a filler APP0 segment + SOF0 carrying the frame size. */
|
||||||
|
static size_t jpeg_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||||
|
size_t i = 0;
|
||||||
|
b[i++] = 0xFF;
|
||||||
|
b[i++] = 0xD8;
|
||||||
|
b[i++] = 0xFF;
|
||||||
|
b[i++] = 0xE0;
|
||||||
|
b[i++] = 0x00;
|
||||||
|
b[i++] = 0x04;
|
||||||
|
b[i++] = 0x00;
|
||||||
|
b[i++] = 0x00;
|
||||||
|
b[i++] = 0xFF;
|
||||||
|
b[i++] = 0xC0;
|
||||||
|
b[i++] = 0x00;
|
||||||
|
b[i++] = 0x0B;
|
||||||
|
b[i++] = 0x08;
|
||||||
|
b[i++] = (unsigned char)(h >> 8);
|
||||||
|
b[i++] = (unsigned char)h;
|
||||||
|
b[i++] = (unsigned char)(w >> 8);
|
||||||
|
b[i++] = (unsigned char)w;
|
||||||
|
b[i++] = 0x01; /* one component */
|
||||||
|
b[i++] = 0x01; /* component id */
|
||||||
|
b[i++] = 0x11; /* sampling */
|
||||||
|
b[i++] = 0x00; /* quant table */
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_sniff(void) {
|
||||||
|
unsigned char b[64];
|
||||||
|
char mime[32];
|
||||||
|
size_t n;
|
||||||
|
|
||||||
|
n = png_hdr(b, 32, 24);
|
||||||
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/png") == 0, "PNG sniffed");
|
||||||
|
n = jpeg_hdr(b, 20, 10);
|
||||||
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/jpeg") == 0, "JPEG sniffed");
|
||||||
|
n = gif_hdr(b, 3, 5);
|
||||||
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/gif") == 0, "GIF sniffed");
|
||||||
|
memcpy(b, "RIFF\0\0\0\0WEBPVP8 ", 16);
|
||||||
|
EXPECT(cst_attach_sniff(b, 30, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/webp") == 0, "WebP sniffed");
|
||||||
|
n = bmp_hdr(b, 7, 9);
|
||||||
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/bmp") == 0, "BMP sniffed");
|
||||||
|
|
||||||
|
/* an extension never decides: text and a PDF are not images */
|
||||||
|
EXPECT(cst_attach_sniff("hello, world", 12, mime, sizeof(mime)) != 0,
|
||||||
|
"text rejected");
|
||||||
|
EXPECT(cst_attach_sniff("%PDF-1.7\n", 9, mime, sizeof(mime)) != 0,
|
||||||
|
"PDF rejected");
|
||||||
|
EXPECT(cst_attach_sniff("\x89PNG", 4, mime, sizeof(mime)) != 0,
|
||||||
|
"truncated PNG rejected");
|
||||||
|
EXPECT(cst_attach_sniff("", 0, mime, sizeof(mime)) != 0, "empty rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_dimensions(void) {
|
||||||
|
unsigned char b[64];
|
||||||
|
int w = 0, h = 0;
|
||||||
|
size_t n;
|
||||||
|
|
||||||
|
n = png_hdr(b, 1280, 720);
|
||||||
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 1280 && h == 720,
|
||||||
|
"PNG dimensions");
|
||||||
|
n = gif_hdr(b, 16, 40);
|
||||||
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 16 && h == 40,
|
||||||
|
"GIF dimensions");
|
||||||
|
n = bmp_hdr(b, 7, 9);
|
||||||
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 7 && h == 9,
|
||||||
|
"BMP dimensions");
|
||||||
|
n = jpeg_hdr(b, 640, 480);
|
||||||
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 640 && h == 480,
|
||||||
|
"JPEG dimensions");
|
||||||
|
|
||||||
|
w = h = -1;
|
||||||
|
EXPECT(cst_attach_dimensions("nope", 4, &w, &h) != 0 && w == 0 && h == 0,
|
||||||
|
"unknown format gives 0x0");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_base64(void) {
|
||||||
|
char *s = cst_base64_encode("Man", 3);
|
||||||
|
EXPECT(s && strcmp(s, "TWFu") == 0, "base64 <Man>");
|
||||||
|
free(s);
|
||||||
|
s = cst_base64_encode("Ma", 2);
|
||||||
|
EXPECT(s && strcmp(s, "TWE=") == 0, "base64 padding 1");
|
||||||
|
free(s);
|
||||||
|
s = cst_base64_encode("M", 1);
|
||||||
|
EXPECT(s && strcmp(s, "TQ==") == 0, "base64 padding 2");
|
||||||
|
free(s);
|
||||||
|
s = cst_base64_encode("\x00\xff\xfe", 3);
|
||||||
|
EXPECT(s && strcmp(s, "AP/+") == 0, "base64 binary");
|
||||||
|
free(s);
|
||||||
|
|
||||||
|
size_t n = 0;
|
||||||
|
char *d = cst_base64_decode("TWFu", 4, &n);
|
||||||
|
EXPECT(d && n == 3 && memcmp(d, "Man", 3) == 0, "decode <Man>");
|
||||||
|
free(d);
|
||||||
|
d = cst_base64_decode("TQ==", 4, &n);
|
||||||
|
EXPECT(d && n == 1 && d[0] == 'M', "decode with padding");
|
||||||
|
free(d);
|
||||||
|
d = cst_base64_decode("AP/+\n", 5, &n);
|
||||||
|
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0,
|
||||||
|
"decode binary, newline ignored");
|
||||||
|
free(d);
|
||||||
|
d = cst_base64_decode("AP_-", 4, &n);
|
||||||
|
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0, "URL-safe alphabet");
|
||||||
|
free(d);
|
||||||
|
EXPECT(cst_base64_decode("A*B=", 4, &n) == NULL, "bad character refused");
|
||||||
|
|
||||||
|
unsigned char raw[200];
|
||||||
|
for (int i = 0; i < 200; i++)
|
||||||
|
raw[i] = (unsigned char)(i * 37);
|
||||||
|
for (size_t len = 0; len < 7; len++) {
|
||||||
|
char *e = cst_base64_encode(raw, 190 + len);
|
||||||
|
d = e ? cst_base64_decode(e, strlen(e), &n) : NULL;
|
||||||
|
EXPECT(d && n == 190 + len && memcmp(d, raw, n) == 0, "round trip");
|
||||||
|
free(e);
|
||||||
|
free(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_rel_ok(const char *root) {
|
||||||
|
EXPECT(cst_attach_rel_ok("img-0a1b2c3d.png"), "normal name accepted");
|
||||||
|
EXPECT(!cst_attach_rel_ok("../x.png"), "parent dir refused");
|
||||||
|
EXPECT(!cst_attach_rel_ok("a/../../b.png"), "slash refused");
|
||||||
|
EXPECT(!cst_attach_rel_ok("/etc/passwd"), "absolute refused");
|
||||||
|
EXPECT(!cst_attach_rel_ok("a\\b.png"), "backslash refused");
|
||||||
|
EXPECT(!cst_attach_rel_ok(".hidden"), "dot file refused");
|
||||||
|
EXPECT(!cst_attach_rel_ok(""), "empty refused");
|
||||||
|
|
||||||
|
cJSON *arr = cJSON_Parse("[{\"id\":\"x\",\"rel\":\"../../secret.png\"},"
|
||||||
|
"{\"id\":\"y\",\"rel\":\"img-1.png\"}]");
|
||||||
|
int n = 0;
|
||||||
|
cst_attachment_t *back = cst_attachments_from_json(arr, &n);
|
||||||
|
EXPECT(n == 1 && back && strcmp(back[0].id, "y") == 0,
|
||||||
|
"escaping rel dropped on load");
|
||||||
|
cst_attachments_free(back, n);
|
||||||
|
cJSON_Delete(arr);
|
||||||
|
|
||||||
|
/* a stored file swapped for non-image bytes is not sent */
|
||||||
|
char session[1024], full[1024], err[256];
|
||||||
|
snprintf(session, sizeof(session), "%.900s/sessions/r.jsonl", root);
|
||||||
|
unsigned char b[64];
|
||||||
|
size_t bn = png_hdr(b, 4, 4);
|
||||||
|
cst_attachment_t *a =
|
||||||
|
cst_attach_import_bytes(session, "x.png", (const char *)b, bn, err,
|
||||||
|
sizeof(err));
|
||||||
|
EXPECT(a != NULL, "stored");
|
||||||
|
if (!a)
|
||||||
|
return;
|
||||||
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0, "path");
|
||||||
|
FILE *f = fopen(full, "wb");
|
||||||
|
if (f) {
|
||||||
|
fputs("plain text now", f);
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
size_t rn = 0;
|
||||||
|
EXPECT(cst_attach_read(session, a, &rn, err, sizeof(err)) == NULL &&
|
||||||
|
strstr(err, "no longer an image"),
|
||||||
|
"read re-checks the bytes");
|
||||||
|
EXPECT(cst_attach_data_url(session, a) == NULL, "no data url for it");
|
||||||
|
snprintf(a->rel, sizeof(a->rel), "../r.jsonl");
|
||||||
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) != 0,
|
||||||
|
"full path refuses an escaping rel");
|
||||||
|
cst_attach_free(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_store(const char *root) {
|
||||||
|
char session[1024], dir[1024];
|
||||||
|
snprintf(session, sizeof(session), "%.900s/sessions/a.jsonl", root);
|
||||||
|
unsigned char b[64];
|
||||||
|
size_t n = png_hdr(b, 32, 24);
|
||||||
|
char err[256];
|
||||||
|
|
||||||
|
cst_attachment_t *a = cst_attach_import_bytes(session, "shot.png",
|
||||||
|
(const char *)b, n, err,
|
||||||
|
sizeof(err));
|
||||||
|
EXPECT(a != NULL, "import stored");
|
||||||
|
if (!a) {
|
||||||
|
fprintf(stderr, " err: %s\n", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EXPECT(strcmp(a->mime, "image/png") == 0, "stored mime");
|
||||||
|
EXPECT(a->width == 32 && a->height == 24, "stored dimensions");
|
||||||
|
EXPECT(a->bytes == (long long)n, "stored size");
|
||||||
|
EXPECT(strcmp(a->name, "shot.png") == 0, "stored name");
|
||||||
|
EXPECT(strcmp(a->origin, "user") == 0, "origin=user");
|
||||||
|
EXPECT(strcmp(a->rel, "img-") != 0 && strstr(a->rel, ".png") != NULL,
|
||||||
|
"rel has a .png extension");
|
||||||
|
|
||||||
|
EXPECT(cst_attach_dir(session, dir, sizeof(dir)) == 0, "attach dir");
|
||||||
|
char full[1024];
|
||||||
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0 &&
|
||||||
|
strstr(full, "attachments/") != NULL,
|
||||||
|
"full path inside attachments/");
|
||||||
|
FILE *f = fopen(full, "rb");
|
||||||
|
EXPECT(f != NULL, "stored file exists");
|
||||||
|
if (f)
|
||||||
|
fclose(f);
|
||||||
|
|
||||||
|
char desc[128];
|
||||||
|
cst_attach_describe(a, desc, sizeof(desc));
|
||||||
|
EXPECT(strstr(desc, "32x24") && strstr(desc, "png"), "describe has size");
|
||||||
|
|
||||||
|
size_t rn = 0;
|
||||||
|
char *data = cst_attach_read(session, a, &rn, err, sizeof(err));
|
||||||
|
EXPECT(data && rn == n && memcmp(data, b, n) == 0, "read back exact bytes");
|
||||||
|
free(data);
|
||||||
|
|
||||||
|
char *url = cst_attach_data_url(session, a);
|
||||||
|
EXPECT(url && strncmp(url, "data:image/png;base64,", 22) == 0,
|
||||||
|
"data url prefix");
|
||||||
|
if (url)
|
||||||
|
EXPECT(strlen(url) == 22 + ((n + 2) / 3) * 4, "data url length");
|
||||||
|
free(url);
|
||||||
|
|
||||||
|
cst_attach_free(a);
|
||||||
|
|
||||||
|
/* a text file named .png is still refused */
|
||||||
|
EXPECT(cst_attach_import_bytes(session, "fake.png", "not an image", 12, err,
|
||||||
|
sizeof(err)) == NULL,
|
||||||
|
"content decides, not the name");
|
||||||
|
EXPECT(strstr(err, "not a supported image") != NULL, "err explains why");
|
||||||
|
EXPECT(cst_attach_import_bytes(session, "empty.png", "", 0, err,
|
||||||
|
sizeof(err)) == NULL,
|
||||||
|
"empty data refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_import_file(const char *root) {
|
||||||
|
char session[1024], src[1024];
|
||||||
|
snprintf(session, sizeof(session), "%.900s/sessions/b.jsonl", root);
|
||||||
|
snprintf(src, sizeof(src), "%.900s/logo.gif", root);
|
||||||
|
unsigned char b[64];
|
||||||
|
size_t n = gif_hdr(b, 8, 6);
|
||||||
|
FILE *f = fopen(src, "wb");
|
||||||
|
EXPECT(f != NULL, "write source image");
|
||||||
|
if (f) {
|
||||||
|
fwrite(b, 1, n, f);
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
char err[256];
|
||||||
|
cst_attachment_t *a =
|
||||||
|
cst_attach_import_file(session, src, err, sizeof(err));
|
||||||
|
EXPECT(a != NULL, "file imported");
|
||||||
|
if (a) {
|
||||||
|
EXPECT(strcmp(a->mime, "image/gif") == 0, "imported mime");
|
||||||
|
EXPECT(strcmp(a->name, "logo.gif") == 0, "imported keeps the leaf name");
|
||||||
|
EXPECT(a->width == 8 && a->height == 6, "imported dimensions");
|
||||||
|
cst_attach_free(a);
|
||||||
|
}
|
||||||
|
EXPECT(cst_attach_import_file(session, "/nonexistent/x.png", err,
|
||||||
|
sizeof(err)) == NULL,
|
||||||
|
"missing file refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_json_roundtrip(void) {
|
||||||
|
cst_attachment_t list[3];
|
||||||
|
memset(list, 0, sizeof(list));
|
||||||
|
snprintf(list[0].id, sizeof(list[0].id), "img-1");
|
||||||
|
snprintf(list[0].name, sizeof(list[0].name), "a.png");
|
||||||
|
snprintf(list[0].mime, sizeof(list[0].mime), "image/png");
|
||||||
|
snprintf(list[0].rel, sizeof(list[0].rel), "img-1.png");
|
||||||
|
list[0].bytes = 12345;
|
||||||
|
list[0].width = 640;
|
||||||
|
list[0].height = 480;
|
||||||
|
snprintf(list[0].origin, sizeof(list[0].origin), "user");
|
||||||
|
|
||||||
|
snprintf(list[1].id, sizeof(list[1].id), "img-2");
|
||||||
|
snprintf(list[1].name, sizeof(list[1].name), "b.jpg");
|
||||||
|
snprintf(list[1].mime, sizeof(list[1].mime), "image/jpeg");
|
||||||
|
snprintf(list[1].rel, sizeof(list[1].rel), "img-2.jpg");
|
||||||
|
list[1].bytes = 4096;
|
||||||
|
snprintf(list[1].detail, sizeof(list[1].detail), "high");
|
||||||
|
snprintf(list[1].origin, sizeof(list[1].origin), "view_image");
|
||||||
|
|
||||||
|
snprintf(list[2].id, sizeof(list[2].id), "img-3");
|
||||||
|
snprintf(list[2].name, sizeof(list[2].name), "c.webp");
|
||||||
|
snprintf(list[2].mime, sizeof(list[2].mime), "image/webp");
|
||||||
|
snprintf(list[2].rel, sizeof(list[2].rel), "img-3.webp");
|
||||||
|
list[2].bytes = 7;
|
||||||
|
|
||||||
|
char *json = cst_attachments_to_json(list, 3);
|
||||||
|
EXPECT(json != NULL, "to_json");
|
||||||
|
int n = 0;
|
||||||
|
cJSON *parsed = cJSON_Parse(json);
|
||||||
|
cst_attachment_t *back = cst_attachments_from_json(parsed, &n);
|
||||||
|
cJSON_Delete(parsed);
|
||||||
|
EXPECT(back != NULL && n == 3, "from_json count");
|
||||||
|
if (back && n == 3) {
|
||||||
|
EXPECT(strcmp(back[0].id, "img-1") == 0 && back[0].bytes == 12345 &&
|
||||||
|
back[0].width == 640 && back[0].height == 480,
|
||||||
|
"first entry survives");
|
||||||
|
EXPECT(strcmp(back[1].detail, "high") == 0 &&
|
||||||
|
strcmp(back[1].origin, "view_image") == 0,
|
||||||
|
"detail and origin survive");
|
||||||
|
EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
|
||||||
|
"entry without dimensions");
|
||||||
|
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
|
||||||
|
cst_attachments_free(back, n);
|
||||||
|
}
|
||||||
|
free(json);
|
||||||
|
|
||||||
|
/* entries without rel/id are dropped; a non-array is NULL */
|
||||||
|
cJSON *arr = cJSON_Parse("[{\"id\":\"x\"},{\"id\":\"y\",\"rel\":\"y.png\"}]");
|
||||||
|
back = cst_attachments_from_json(arr, &n);
|
||||||
|
EXPECT(back != NULL && n == 1 && strcmp(back[0].id, "y") == 0,
|
||||||
|
"incomplete entries skipped");
|
||||||
|
cst_attachments_free(back, n);
|
||||||
|
cJSON_Delete(arr);
|
||||||
|
cJSON *obj = cJSON_Parse("{}");
|
||||||
|
n = 7;
|
||||||
|
EXPECT(cst_attachments_from_json(obj, &n) == NULL && n == 0,
|
||||||
|
"object is not an attachment array");
|
||||||
|
cJSON_Delete(obj);
|
||||||
|
EXPECT(cst_attachments_to_json(NULL, 0) == NULL, "empty list is NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_lists(void) {
|
||||||
|
cst_attachment_t src;
|
||||||
|
memset(&src, 0, sizeof(src));
|
||||||
|
snprintf(src.id, sizeof(src.id), "img-x");
|
||||||
|
snprintf(src.mime, sizeof(src.mime), "image/png");
|
||||||
|
|
||||||
|
cst_attachment_t *list = NULL;
|
||||||
|
int n = 0, cap = 0;
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
EXPECT(cst_attachments_push(&list, &n, &cap, &src) == 0, "push");
|
||||||
|
EXPECT(n == 10 && cap >= 10, "list grew");
|
||||||
|
EXPECT(strcmp(list[9].id, "img-x") == 0, "last entry copied");
|
||||||
|
|
||||||
|
cst_attachment_t *dup = cst_attachments_dup(list, n);
|
||||||
|
EXPECT(dup && strcmp(dup[3].mime, "image/png") == 0, "dup copies fields");
|
||||||
|
dup[3].id[0] = 'Z';
|
||||||
|
EXPECT(list[3].id[0] == 'i', "dup is a deep copy");
|
||||||
|
cst_attachments_free(dup, n);
|
||||||
|
EXPECT(cst_attachments_dup(NULL, 0) == NULL, "dup of nothing is NULL");
|
||||||
|
cst_attachments_free(list, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_agent_pending(const char *root) {
|
||||||
|
cst_agent_config_t cfg;
|
||||||
|
memset(&cfg, 0, sizeof(cfg));
|
||||||
|
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||||
|
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/sessions/c.jsonl",
|
||||||
|
root);
|
||||||
|
cst_agent_t *a = cst_agent_create(&cfg);
|
||||||
|
EXPECT(a != NULL, "agent created");
|
||||||
|
if (!a)
|
||||||
|
return;
|
||||||
|
|
||||||
|
char err[256], line[128];
|
||||||
|
EXPECT(cst_agent_pending_count(a) == 0, "no pending at start");
|
||||||
|
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == -1,
|
||||||
|
"describe out of range");
|
||||||
|
|
||||||
|
unsigned char b[32];
|
||||||
|
size_t n = png_hdr(b, 4, 2);
|
||||||
|
char src[1024];
|
||||||
|
snprintf(src, sizeof(src), "%.900s/pic.png", root);
|
||||||
|
FILE *f = fopen(src, "wb");
|
||||||
|
EXPECT(f != NULL, "write the source png");
|
||||||
|
if (f) {
|
||||||
|
fwrite(b, 1, n, f);
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0,
|
||||||
|
"attach through the agent");
|
||||||
|
EXPECT(cst_agent_pending_count(a) == 1, "one pending");
|
||||||
|
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == 0 &&
|
||||||
|
strstr(line, "4x2") != NULL,
|
||||||
|
"pending describe shows the size");
|
||||||
|
|
||||||
|
EXPECT(cst_agent_attach_file(a, "/nonexistent/x.png", err, sizeof(err)) == -1,
|
||||||
|
"missing file refused");
|
||||||
|
EXPECT(cst_agent_pending_count(a) == 1, "a failure keeps the queue");
|
||||||
|
|
||||||
|
for (int i = 1; i < CST_ATTACH_MAX_COUNT; i++)
|
||||||
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0, "attach more");
|
||||||
|
EXPECT(cst_agent_pending_count(a) == CST_ATTACH_MAX_COUNT, "queue full");
|
||||||
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == -1 &&
|
||||||
|
strstr(err, "at most") != NULL,
|
||||||
|
"one over the limit is refused");
|
||||||
|
|
||||||
|
cst_agent_pending_clear(a);
|
||||||
|
EXPECT(cst_agent_pending_count(a) == 0, "cleared");
|
||||||
|
cst_agent_destroy(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_paste_paths(void) {
|
||||||
|
static char p[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
|
||||||
|
const char *s = "/tmp/a.png";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
||||||
|
strcmp(p[0], "/tmp/a.png") == 0,
|
||||||
|
"single path");
|
||||||
|
s = " '/home/u/my shot.png' \"/x/y z.jpg\"\n/plain.gif\r\n";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 3, "quoted paths count");
|
||||||
|
EXPECT(strcmp(p[0], "/home/u/my shot.png") == 0, "single quotes");
|
||||||
|
EXPECT(strcmp(p[1], "/x/y z.jpg") == 0, "double quotes");
|
||||||
|
EXPECT(strcmp(p[2], "/plain.gif") == 0, "newline separated");
|
||||||
|
s = "file:///home/u/a%20b.png file://localhost/c.png";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 2, "file uris");
|
||||||
|
EXPECT(strcmp(p[0], "/home/u/a b.png") == 0, "percent decoded");
|
||||||
|
EXPECT(strcmp(p[1], "/c.png") == 0, "localhost host");
|
||||||
|
s = "file://evil.example/c.png";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "remote host refused");
|
||||||
|
#ifndef _WIN32
|
||||||
|
s = "/home/u/my\\ shot.png";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
||||||
|
strcmp(p[0], "/home/u/my shot.png") == 0,
|
||||||
|
"backslash escape");
|
||||||
|
#endif
|
||||||
|
s = "'/unterminated.png";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "unbalanced quote");
|
||||||
|
s = " \n ";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "blank");
|
||||||
|
s = "a b c";
|
||||||
|
EXPECT(cst_paste_paths(s, strlen(s), p, 2) == -1, "too many");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_dib(void) {
|
||||||
|
/* 3x2, 24-bit, bottom-up: stride 12 (9 bytes + pad). */
|
||||||
|
unsigned char dib[40 + 24];
|
||||||
|
memset(dib, 0, sizeof(dib));
|
||||||
|
put_le32(dib, 40);
|
||||||
|
put_le32(dib + 4, 3);
|
||||||
|
put_le32(dib + 8, 2);
|
||||||
|
put_le16(dib + 12, 1);
|
||||||
|
put_le16(dib + 14, 24);
|
||||||
|
dib[40] = 0xff; /* bottom-left pixel blue (BGR) */
|
||||||
|
size_t n = 0;
|
||||||
|
char *img = cst_dib_to_image(dib, sizeof(dib), &n);
|
||||||
|
char mime[32];
|
||||||
|
int w = 0, h = 0;
|
||||||
|
EXPECT(img && cst_attach_sniff(img, n, mime, sizeof(mime)) == 0 &&
|
||||||
|
strcmp(mime, "image/png") == 0,
|
||||||
|
"24-bit DIB becomes a PNG");
|
||||||
|
EXPECT(img && cst_attach_dimensions(img, n, &w, &h) == 0 && w == 3 && h == 2,
|
||||||
|
"PNG keeps the size");
|
||||||
|
free(img);
|
||||||
|
|
||||||
|
put_le16(dib + 14, 8); /* palette needs 256 entries: past the end */
|
||||||
|
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "short DIB refused");
|
||||||
|
put_le32(dib, 4);
|
||||||
|
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "bad header size");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char root[1024];
|
||||||
|
if (test_tmpdir(root, sizeof(root), "cst-attach") != 0) {
|
||||||
|
fprintf(stderr, "cannot create a scratch dir\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
test_sniff();
|
||||||
|
test_dimensions();
|
||||||
|
test_base64();
|
||||||
|
test_rel_ok(root);
|
||||||
|
test_paste_paths();
|
||||||
|
test_dib();
|
||||||
|
test_store(root);
|
||||||
|
test_import_file(root);
|
||||||
|
test_json_roundtrip();
|
||||||
|
test_lists();
|
||||||
|
test_agent_pending(root);
|
||||||
|
test_rmrf(root);
|
||||||
|
if (nfail) {
|
||||||
|
fprintf(stderr, "test_attach: %d failed\n", nfail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("test_attach: OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -159,7 +159,177 @@ static void test_tool_cap_head_tail(void) {
|
|||||||
free(big);
|
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) {
|
int main(void) {
|
||||||
|
test_images_off();
|
||||||
|
test_reasoning_and_prefix();
|
||||||
|
test_cache_hints();
|
||||||
|
test_tools_snapshot();
|
||||||
test_escaping();
|
test_escaping();
|
||||||
test_invalid_utf8();
|
test_invalid_utf8();
|
||||||
test_tool_calls_and_null_content();
|
test_tool_calls_and_null_content();
|
||||||
|
|||||||
+54
-2
@@ -5,6 +5,8 @@
|
|||||||
#define _POSIX_C_SOURCE 200809L
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
#include "cst_mcp.h"
|
#include "cst_mcp.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
#include "os_compat.h"
|
#include "os_compat.h"
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
|
|
||||||
static int nfail;
|
static int nfail;
|
||||||
static char g_tmp[OS_PATH_MAX];
|
static char g_tmp[OS_PATH_MAX];
|
||||||
|
static cst_agent_t *g_agent;
|
||||||
|
|
||||||
#define CHECK(cond, msg) \
|
#define CHECK(cond, msg) \
|
||||||
do { \
|
do { \
|
||||||
@@ -99,6 +102,7 @@ static int server(void) {
|
|||||||
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
|
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
|
||||||
cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
|
cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
|
||||||
cJSON_AddItemToArray(tools, tool("crash", "Exits."));
|
cJSON_AddItemToArray(tools, tool("crash", "Exits."));
|
||||||
|
cJSON_AddItemToArray(tools, tool("pic", "Returns images."));
|
||||||
}
|
}
|
||||||
reply(id, r);
|
reply(id, r);
|
||||||
} else if (strcmp(mt, "tools/call") == 0) {
|
} else if (strcmp(mt, "tools/call") == 0) {
|
||||||
@@ -136,6 +140,21 @@ static int server(void) {
|
|||||||
cJSON_AddStringToObject(rr, "text", "resource text");
|
cJSON_AddStringToObject(rr, "text", "resource text");
|
||||||
cJSON_AddItemToArray(c, res);
|
cJSON_AddItemToArray(c, res);
|
||||||
reply(id, r);
|
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) {
|
} else if (strcmp(name, "crash") == 0) {
|
||||||
fprintf(stderr, "fatal: fake crash\n");
|
fprintf(stderr, "fatal: fake crash\n");
|
||||||
exit(3);
|
exit(3);
|
||||||
@@ -163,6 +182,7 @@ static int call(const char *tool, const char *k, const char *v,
|
|||||||
cst_tool_ctx_t ctx;
|
cst_tool_ctx_t ctx;
|
||||||
memset(&ctx, 0, sizeof(ctx));
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
ctx.workspace_root = g_tmp;
|
ctx.workspace_root = g_tmp;
|
||||||
|
ctx.agent = g_agent;
|
||||||
memset(r, 0, sizeof(*r));
|
memset(r, 0, sizeof(*r));
|
||||||
return cst_tool_run(&ctx, &c, 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];
|
cst_mcp_info_t mi[CST_MCP_MAX];
|
||||||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||||||
const cst_mcp_info_t *f = info_of("fake", mi, n);
|
const cst_mcp_info_t *f = info_of("fake", mi, n);
|
||||||
CHECK(f && f->state == CST_MCP_READY && f->ntools == 6,
|
CHECK(f && f->state == CST_MCP_READY && f->ntools == 7,
|
||||||
"fake ready with 6 tools over two pages");
|
"fake ready with 7 tools over two pages");
|
||||||
const cst_mcp_info_t *rm = info_of("remote", mi, n);
|
const cst_mcp_info_t *rm = info_of("remote", mi, n);
|
||||||
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
|
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
|
||||||
"url servers unsupported");
|
"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") &&
|
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") &&
|
||||||
strstr(r.out, "resource text"),
|
strstr(r.out, "resource text"),
|
||||||
"content items mapped to text");
|
"content items mapped to text");
|
||||||
|
CHECK(r.nattach == 0, "no session: images stay text");
|
||||||
cst_tool_result_free(&r);
|
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;
|
volatile int cancel = 1;
|
||||||
os_set_cancel_flag(&cancel);
|
os_set_cancel_flag(&cancel);
|
||||||
double t0 = os_now_ms();
|
double t0 = os_now_ms();
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/* Model registry: built-in data, longest/most specific match, the user file,
|
||||||
|
* the fetched cache and the env overrides. */
|
||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_models.h"
|
||||||
|
#include "cst_test_util.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static int nfail;
|
||||||
|
|
||||||
|
#define EXPECT(cond, msg) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||||
|
nfail++; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static void test_builtin(void) {
|
||||||
|
cJSON *doc = cJSON_Parse(cst_models_builtin_json());
|
||||||
|
EXPECT(cJSON_IsObject(doc), "embedded built-in JSON parses");
|
||||||
|
cJSON_Delete(doc);
|
||||||
|
EXPECT(cst_models_load_error()[0] == 0, "no load error");
|
||||||
|
EXPECT(cst_provider_count() >= 4, "built-in providers");
|
||||||
|
const cst_provider_t *p = cst_provider_at(0);
|
||||||
|
EXPECT(p && strcmp(p->name, "minimax") == 0, "file order kept");
|
||||||
|
p = cst_provider_find("deepseek");
|
||||||
|
EXPECT(p && strcmp(p->model, "deepseek-flash") == 0 &&
|
||||||
|
strcmp(p->key_env, "DEEPSEEK_API_KEY") == 0 &&
|
||||||
|
strcmp(cst_provider_source(p), "built-in") == 0,
|
||||||
|
"deepseek preset");
|
||||||
|
|
||||||
|
cst_model_info_t mi;
|
||||||
|
/* most specific rule per field: price from deepseek-v4-pro, context from
|
||||||
|
* deepseek */
|
||||||
|
cst_model_info("deepseek", "deepseek-v4-pro", &mi);
|
||||||
|
EXPECT(mi.context == 1000000 && mi.vision == 0, "context inherited");
|
||||||
|
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
|
||||||
|
"longer match wins the price");
|
||||||
|
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
|
||||||
|
cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
|
||||||
|
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
|
||||||
|
"gpt-4o-mini over gpt-4o");
|
||||||
|
cst_model_info(NULL, "Qwen/Qwen2.5-VL-72B-Instruct", &mi);
|
||||||
|
EXPECT(mi.vision == 1 && mi.context == 131072, "glob *-vl* + qwen prefix");
|
||||||
|
cst_model_info(NULL, "zhipu/glm-4.5v", &mi);
|
||||||
|
EXPECT(mi.vision == 1 && mi.context == 128000, "glm-*v*");
|
||||||
|
cst_model_info(NULL, "glm-4-plus", &mi);
|
||||||
|
EXPECT(mi.vision == -1, "glm without v: unknown");
|
||||||
|
cst_model_info(NULL, "anthropic/some-new-model", &mi);
|
||||||
|
EXPECT(mi.cache_style == CST_CACHE_ANTHROPIC, "vendor rule on the full id");
|
||||||
|
cst_model_info(NULL, "totally-unknown", &mi);
|
||||||
|
EXPECT(mi.context == 128000 && mi.vision == -1 && !mi.has_price &&
|
||||||
|
strcmp(mi.source, "default") == 0,
|
||||||
|
"defaults");
|
||||||
|
char tag[48];
|
||||||
|
cst_model_info(NULL, "gpt-4.1", &mi);
|
||||||
|
cst_model_info_label(&mi, tag, sizeof(tag));
|
||||||
|
EXPECT(strcmp(tag, "1M · vision") == 0, "label");
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *USER_JSON =
|
||||||
|
"{\"providers\": {"
|
||||||
|
" \"deepseek\": {\"api_base\": \"https://proxy.example/v1\"},"
|
||||||
|
" \"local\": {\"api_base\": \"http://127.0.0.1:8000/v1\","
|
||||||
|
" \"key_env\": \"LOCAL_KEY\", \"default_model\": \"qwen3-32b\","
|
||||||
|
" \"models\": [\"qwen3-32b\", \"my-vl\"]}"
|
||||||
|
"},"
|
||||||
|
"\"models\": ["
|
||||||
|
" {\"match\": \"qwen3\", \"context\": 32768},"
|
||||||
|
" {\"match\": \"my-vl\", \"vision\": true, \"price\": {\"miss\": 1,"
|
||||||
|
" \"out\": 2, \"currency\": \"¥\"}}"
|
||||||
|
"]}";
|
||||||
|
|
||||||
|
static void test_user(void) {
|
||||||
|
char err[256];
|
||||||
|
EXPECT(cst_models_load_json(cst_models_builtin_json(), USER_JSON, err,
|
||||||
|
sizeof(err)) == 0,
|
||||||
|
"load with user file");
|
||||||
|
const cst_provider_t *ds = cst_provider_find("deepseek");
|
||||||
|
EXPECT(ds && strcmp(ds->api_base, "https://proxy.example/v1") == 0 &&
|
||||||
|
strcmp(ds->model, "deepseek-flash") == 0 &&
|
||||||
|
strcmp(cst_provider_source(ds), "user") == 0,
|
||||||
|
"user overrides one field, keeps the rest");
|
||||||
|
const cst_provider_t *lp = cst_provider_find("local");
|
||||||
|
EXPECT(lp && strcmp(lp->model, "qwen3-32b") == 0 &&
|
||||||
|
strcmp(lp->key_env, "LOCAL_KEY") == 0,
|
||||||
|
"user adds a provider");
|
||||||
|
EXPECT(cst_provider_at(cst_provider_count() - 1) == lp, "appended last");
|
||||||
|
|
||||||
|
cst_model_info_t mi;
|
||||||
|
cst_model_info("local", "qwen3-32b", &mi);
|
||||||
|
EXPECT(mi.context == 32768 && strcmp(mi.source, "user") == 0,
|
||||||
|
"user rule beats built-in qwen");
|
||||||
|
cst_model_info("local", "my-vl", &mi);
|
||||||
|
EXPECT(mi.vision == 1 && mi.has_price && mi.price.hit == 1 &&
|
||||||
|
mi.price.write == 1 && strcmp(mi.price.currency, "¥") == 0,
|
||||||
|
"user price, hit/write default to miss");
|
||||||
|
|
||||||
|
/* passback follows the provider name even behind a proxy base */
|
||||||
|
cst_model_caps_t caps;
|
||||||
|
cst_model_caps("deepseek", "https://proxy.example/v1", "deepseek-flash",
|
||||||
|
&caps);
|
||||||
|
EXPECT(caps.reasoning_passback && caps.prefix_completion, "caps by name");
|
||||||
|
|
||||||
|
char names[32][128];
|
||||||
|
int n = cst_models_list(NULL, names, 32);
|
||||||
|
int seen = 0;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
seen += strcmp(names[i], "my-vl") == 0;
|
||||||
|
EXPECT(seen == 1, "picker list includes user models");
|
||||||
|
|
||||||
|
EXPECT(cst_models_load_json(cst_models_builtin_json(), "{not json", err,
|
||||||
|
sizeof(err)) != 0 &&
|
||||||
|
err[0],
|
||||||
|
"bad user file reported");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_cache(void) {
|
||||||
|
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
|
||||||
|
cst_model_meta_t m[3];
|
||||||
|
memset(m, 0, sizeof(m));
|
||||||
|
snprintf(m[0].id, sizeof(m[0].id), "anthropic/claude-sonnet-4");
|
||||||
|
m[0].context = 1000000;
|
||||||
|
m[0].vision = -1;
|
||||||
|
snprintf(m[1].id, sizeof(m[1].id), "deepseek-flash");
|
||||||
|
m[1].vision = 1; /* API claims vision */
|
||||||
|
m[1].has_price = 1;
|
||||||
|
m[1].miss = 0.5;
|
||||||
|
m[1].hit = 0.1;
|
||||||
|
m[1].write = 0.5;
|
||||||
|
m[1].out = 1;
|
||||||
|
snprintf(m[2].id, sizeof(m[2].id), "brand-new");
|
||||||
|
m[2].vision = -1;
|
||||||
|
EXPECT(cst_models_cache_save("teamorouter", "https://x/v1", m, 3) == 0,
|
||||||
|
"cache saved");
|
||||||
|
|
||||||
|
cst_model_info_t mi;
|
||||||
|
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
|
||||||
|
EXPECT(mi.context == 1000000, "API context beats built-in");
|
||||||
|
EXPECT(mi.vision == 1 && mi.cache_style == CST_CACHE_ANTHROPIC,
|
||||||
|
"missing API fields fall through to built-in");
|
||||||
|
EXPECT(strcmp(mi.source, "api+built-in") == 0, "source lists both");
|
||||||
|
cst_model_info("teamorouter", "deepseek-flash", &mi);
|
||||||
|
EXPECT(mi.vision == 1 && mi.price.miss == 0.5 &&
|
||||||
|
strcmp(mi.price.currency, "$") == 0,
|
||||||
|
"API vision and USD price");
|
||||||
|
cst_model_info("deepseek", "deepseek-flash", &mi);
|
||||||
|
EXPECT(mi.vision == 0 && mi.price.miss == 2.0,
|
||||||
|
"another provider's cache is not used");
|
||||||
|
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
|
||||||
|
"no provider: any cache, exact id");
|
||||||
|
|
||||||
|
cst_model_meta_t back[8];
|
||||||
|
long age = -1;
|
||||||
|
int n = cst_models_cache_load("teamorouter", back, 8, &age);
|
||||||
|
EXPECT(n == 3 && age >= 0 && age < 60 &&
|
||||||
|
strcmp(back[2].id, "brand-new") == 0,
|
||||||
|
"cache reads back");
|
||||||
|
EXPECT(cst_models_cache_load("openai", back, 8, &age) == -1, "no cache");
|
||||||
|
EXPECT(cst_models_cache_save("../evil", NULL, m, 1) != 0,
|
||||||
|
"provider name cannot leave the cache dir");
|
||||||
|
|
||||||
|
/* a fresh process (reload) sees the file */
|
||||||
|
cst_models_reload();
|
||||||
|
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
|
||||||
|
EXPECT(mi.context == 1000000, "cache survives a reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_env(void) {
|
||||||
|
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
|
||||||
|
setenv("CST_CTX_TOKENS", "4242", 1);
|
||||||
|
setenv("CST_PRICE", "1,0.5,3", 1);
|
||||||
|
cst_model_info_t mi;
|
||||||
|
cst_model_info(NULL, "deepseek-flash", &mi);
|
||||||
|
EXPECT(mi.context == 4242 && mi.price.miss == 1 && mi.price.write == 1 &&
|
||||||
|
strstr(mi.source, "env"),
|
||||||
|
"env overrides");
|
||||||
|
unsetenv("CST_CTX_TOKENS");
|
||||||
|
unsetenv("CST_PRICE");
|
||||||
|
setenv("CST_NO_CACHE_HINTS", "1", 1);
|
||||||
|
cst_model_caps_t caps;
|
||||||
|
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
|
||||||
|
EXPECT(caps.cache_style == CST_CACHE_AUTO && caps.vision == 1,
|
||||||
|
"no cache hints, vision kept");
|
||||||
|
unsetenv("CST_NO_CACHE_HINTS");
|
||||||
|
setenv("CST_MODELS", "a, b:c", 1);
|
||||||
|
char names[8][128];
|
||||||
|
int n = cst_models_list("cur", names, 8);
|
||||||
|
EXPECT(n == 4 && strcmp(names[1], "b") == 0 && strcmp(names[3], "cur") == 0,
|
||||||
|
"CST_MODELS list");
|
||||||
|
unsetenv("CST_MODELS");
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char root[1024];
|
||||||
|
if (test_tmpdir(root, sizeof(root), "cst-models") != 0) {
|
||||||
|
fprintf(stderr, "cannot create a scratch dir\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
|
||||||
|
unsetenv("CST_CTX_TOKENS");
|
||||||
|
unsetenv("CST_PRICE");
|
||||||
|
unsetenv("CST_MODELS");
|
||||||
|
unsetenv("CST_NO_CACHE_HINTS");
|
||||||
|
test_builtin();
|
||||||
|
test_user();
|
||||||
|
test_cache();
|
||||||
|
test_env();
|
||||||
|
test_rmrf(root);
|
||||||
|
if (nfail) {
|
||||||
|
fprintf(stderr, "test_model_registry: %d failed\n", nfail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("test_model_registry: OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+75
-1
@@ -3,6 +3,8 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
#include "cst_models.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -72,6 +74,52 @@ static int start_server(void) {
|
|||||||
"Connection: close\r\n\r\n" body
|
"Connection: close\r\n\r\n" body
|
||||||
|
|
||||||
int main(void) {
|
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") == 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\":["
|
g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":["
|
||||||
"{\"id\":\"deepseek-flash\"},"
|
"{\"id\":\"deepseek-flash\"},"
|
||||||
"{\"id\":\"deepseek-v4-pro\"},"
|
"{\"id\":\"deepseek-v4-pro\"},"
|
||||||
@@ -81,7 +129,21 @@ int main(void) {
|
|||||||
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
|
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
|
||||||
"{\"name\":\"m2\"}]}");
|
"{\"name\":\"m2\"}]}");
|
||||||
g_responses[4] = RESP("500 Internal Server Error", "{}");
|
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();
|
int port = start_server();
|
||||||
pthread_t th;
|
pthread_t th;
|
||||||
CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
|
CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
|
||||||
@@ -112,6 +174,17 @@ int main(void) {
|
|||||||
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
||||||
CHECK(n == -1 && strstr(err, "500") != NULL);
|
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);
|
pthread_join(th, NULL);
|
||||||
test_sock_close(g_listen);
|
test_sock_close(g_listen);
|
||||||
|
|
||||||
@@ -119,6 +192,7 @@ int main(void) {
|
|||||||
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
||||||
CHECK(n == -1);
|
CHECK(n == -1);
|
||||||
|
|
||||||
|
test_rmrf(cfgdir);
|
||||||
puts("test_models: ok");
|
puts("test_models: ok");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
#define _POSIX_C_SOURCE 200809L
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_util.h"
|
||||||
|
#include "os_compat.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include "cst_test_util.h"
|
||||||
|
|
||||||
|
/* Runs a few replayed turns (tool call, Plan mode on, approval change, a
|
||||||
|
* reply cut off by max tokens) and checks the recorded request bodies: every
|
||||||
|
* request must extend the previous one byte for byte, so the provider's
|
||||||
|
* prefix cache keeps hitting. */
|
||||||
|
|
||||||
|
static int nfail;
|
||||||
|
|
||||||
|
#define EXPECT(cond, msg) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||||
|
nfail++; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
#define USAGE \
|
||||||
|
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000," \
|
||||||
|
"\"completion_tokens\":20,\"prompt_cache_hit_tokens\":900," \
|
||||||
|
"\"prompt_cache_miss_tokens\":100}}\n\n" \
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
#define TOOL(id, name, args) \
|
||||||
|
"data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," \
|
||||||
|
"\"id\":\"" id "\",\"type\":\"function\",\"function\":{\"name\":\"" name \
|
||||||
|
"\",\"arguments\":\"" args "\"}}]},\"finish_reason\":null}]}\n\n" \
|
||||||
|
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":" \
|
||||||
|
"\"tool_calls\"}]}\n\n" USAGE
|
||||||
|
|
||||||
|
#undef TEXT /* <windows.h> has one */
|
||||||
|
#define TEXT(s, finish) \
|
||||||
|
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" s "\"}," \
|
||||||
|
"\"finish_reason\":null}]}\n\n" \
|
||||||
|
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"" finish \
|
||||||
|
"\"}]}\n\n" USAGE
|
||||||
|
|
||||||
|
#define THINK(s) \
|
||||||
|
"data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"" s \
|
||||||
|
"\"},\"finish_reason\":null}]}\n\n"
|
||||||
|
|
||||||
|
static const char *FIXTURES[] = {
|
||||||
|
/* turn 1 */
|
||||||
|
THINK("look at the file") TOOL("c1", "read_file", "{\\\"path\\\":\\\"a.txt\\\"}"),
|
||||||
|
TEXT("done one", "stop"),
|
||||||
|
/* turn 2, Plan mode */
|
||||||
|
TOOL("c2", "write_file",
|
||||||
|
"{\\\"path\\\":\\\"b.txt\\\",\\\"content\\\":\\\"x\\\"}"),
|
||||||
|
TEXT("plan reply", "stop"),
|
||||||
|
/* turn 3, Build mode + yolo; cut off, then continued */
|
||||||
|
TEXT("part one", "length"),
|
||||||
|
TEXT(" part two", "stop"),
|
||||||
|
/* turn 4 */
|
||||||
|
TEXT("bye", "stop"),
|
||||||
|
};
|
||||||
|
#define NFIX ((int)(sizeof(FIXTURES) / sizeof(FIXTURES[0])))
|
||||||
|
|
||||||
|
static void write_file(const char *dir, const char *name, const char *body) {
|
||||||
|
char p[2048];
|
||||||
|
snprintf(p, sizeof(p), "%s/%s", dir, name);
|
||||||
|
FILE *fp = fopen(p, "wb");
|
||||||
|
EXPECT(fp != NULL, "write fixture");
|
||||||
|
if (fp) {
|
||||||
|
fputs(body, fp);
|
||||||
|
fclose(fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static cJSON *load_req(const char *dir, int seq) {
|
||||||
|
char p[2048];
|
||||||
|
snprintf(p, sizeof(p), "%s/%04d.req.json", dir, seq);
|
||||||
|
FILE *fp = fopen(p, "rb");
|
||||||
|
if (!fp)
|
||||||
|
return NULL;
|
||||||
|
cst_buf_t b = {0};
|
||||||
|
char buf[4096];
|
||||||
|
size_t n;
|
||||||
|
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0)
|
||||||
|
cst_buf_append(&b, buf, n);
|
||||||
|
fclose(fp);
|
||||||
|
cJSON *j = b.buf ? cJSON_Parse(b.buf) : NULL;
|
||||||
|
cst_buf_free(&b);
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const cJSON *msgs_of(const cJSON *req) {
|
||||||
|
return cJSON_GetObjectItemCaseSensitive(req, "messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int is_prefix_msg(const cJSON *m) {
|
||||||
|
return cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(m, "prefix"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *str_of(const cJSON *o, const char *key) {
|
||||||
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||||
|
return cJSON_IsString(v) ? v->valuestring : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char *print(const cJSON *j) { return j ? cJSON_PrintUnformatted(j) : NULL; }
|
||||||
|
|
||||||
|
/* messages of `a` (minus a request-only prefix message) start `b` */
|
||||||
|
static int extends(const cJSON *a, const cJSON *b) {
|
||||||
|
const cJSON *ma = msgs_of(a), *mb = msgs_of(b);
|
||||||
|
int na = cJSON_GetArraySize(ma), nb = cJSON_GetArraySize(mb);
|
||||||
|
if (na > 0 && is_prefix_msg(cJSON_GetArrayItem(ma, na - 1)))
|
||||||
|
na--;
|
||||||
|
if (na > nb)
|
||||||
|
return 0;
|
||||||
|
for (int i = 0; i < na; i++) {
|
||||||
|
char *x = print(cJSON_GetArrayItem(ma, i));
|
||||||
|
char *y = print(cJSON_GetArrayItem(mb, i));
|
||||||
|
int same = x && y && strcmp(x, y) == 0;
|
||||||
|
if (!same)
|
||||||
|
fprintf(stderr, " message %d differs:\n %.300s\n %.300s\n", i,
|
||||||
|
x ? x : "(null)", y ? y : "(null)");
|
||||||
|
free(x);
|
||||||
|
free(y);
|
||||||
|
if (!same)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const cJSON *last_msg(const cJSON *req) {
|
||||||
|
const cJSON *m = msgs_of(req);
|
||||||
|
return cJSON_GetArrayItem(m, cJSON_GetArraySize(m) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int any_msg_has(const cJSON *req, const char *key, const char *needle) {
|
||||||
|
const cJSON *m;
|
||||||
|
cJSON_ArrayForEach(m, msgs_of(req)) {
|
||||||
|
const char *s = str_of(m, key);
|
||||||
|
if (s && strstr(s, needle))
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int run(cst_agent_t *a, const char *text) {
|
||||||
|
char err[512];
|
||||||
|
int rc = cst_agent_run_turn(a, text, err, sizeof(err));
|
||||||
|
if (rc != 0)
|
||||||
|
fprintf(stderr, " turn \"%s\": %s\n", text, err);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 8-byte PNG signature + IHDR header: enough for the attachment sniffer. */
|
||||||
|
static size_t tiny_png(unsigned char *b) {
|
||||||
|
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
|
||||||
|
'\n'};
|
||||||
|
memset(b, 0, 32);
|
||||||
|
memcpy(b, sig, 8);
|
||||||
|
b[11] = 13;
|
||||||
|
memcpy(b + 12, "IHDR", 4);
|
||||||
|
b[19] = 2; /* 2 x 2 */
|
||||||
|
b[23] = 2;
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int tools_have(const cJSON *req, const char *name) {
|
||||||
|
char *t = print(cJSON_GetObjectItemCaseSensitive(req, "tools"));
|
||||||
|
int has = t && strstr(t, name) != NULL;
|
||||||
|
free(t);
|
||||||
|
return has;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int has_image_url(const cJSON *req) {
|
||||||
|
char *t = print(msgs_of(req));
|
||||||
|
int has = t && strstr(t, "image_url") != NULL;
|
||||||
|
free(t);
|
||||||
|
return has;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A text-only model never sees images or view_image; a model of unknown
|
||||||
|
* vision that answers 400 to an image is retried with notes, and stays
|
||||||
|
* that way so later requests extend the retried one. */
|
||||||
|
static void test_vision(const char *root) {
|
||||||
|
char dir[1100], sse[1200], rec[1200];
|
||||||
|
snprintf(dir, sizeof(dir), "%s/vision", root);
|
||||||
|
snprintf(sse, sizeof(sse), "%s/sse", dir);
|
||||||
|
snprintf(rec, sizeof(rec), "%s/rec", dir);
|
||||||
|
os_mkdir_p(sse, 0755);
|
||||||
|
write_file(sse, "0000.sse", TEXT("blind reply", "stop"));
|
||||||
|
write_file(sse, "0001.sse", "{\"error\":{\"message\":\"image input is not "
|
||||||
|
"supported\"}}");
|
||||||
|
write_file(sse, "0001.status", "400\n");
|
||||||
|
write_file(sse, "0002.sse", TEXT("noted reply", "stop"));
|
||||||
|
write_file(sse, "0003.sse", TEXT("later reply", "stop"));
|
||||||
|
setenv("CST_REPLAY_SSE", sse, 1);
|
||||||
|
setenv("CST_RECORD_SSE", rec, 1);
|
||||||
|
cst_llm_trace_reset();
|
||||||
|
|
||||||
|
unsigned char png[32];
|
||||||
|
size_t pn = tiny_png(png);
|
||||||
|
char err[256];
|
||||||
|
|
||||||
|
cst_agent_config_t cfg;
|
||||||
|
memset(&cfg, 0, sizeof(cfg));
|
||||||
|
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||||
|
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir);
|
||||||
|
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
|
||||||
|
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||||
|
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||||
|
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||||
|
cfg.max_steps = 4;
|
||||||
|
cfg.headless = 1;
|
||||||
|
cfg.approval = CST_APPROVAL_AUTO;
|
||||||
|
cst_agent_t *a = cst_agent_create(&cfg);
|
||||||
|
EXPECT(a != NULL, "blind agent");
|
||||||
|
if (!a)
|
||||||
|
return;
|
||||||
|
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only");
|
||||||
|
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
|
||||||
|
sizeof(err)) == 0,
|
||||||
|
"attach to a blind model");
|
||||||
|
EXPECT(run(a, "what is in the picture?") == 0, "blind turn");
|
||||||
|
cst_agent_destroy(a);
|
||||||
|
|
||||||
|
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/unknown.jsonl",
|
||||||
|
dir);
|
||||||
|
snprintf(cfg.model, sizeof(cfg.model), "mystery-model-9");
|
||||||
|
snprintf(cfg.provider, sizeof(cfg.provider), "teamorouter");
|
||||||
|
a = cst_agent_create(&cfg);
|
||||||
|
EXPECT(a != NULL, "unknown-vision agent");
|
||||||
|
if (!a)
|
||||||
|
return;
|
||||||
|
EXPECT(cst_agent_sees_images(a) != 0, "unknown vision: images are tried");
|
||||||
|
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
|
||||||
|
sizeof(err)) == 0,
|
||||||
|
"attach to an unknown model");
|
||||||
|
EXPECT(run(a, "describe it") == 0, "400 retried without images");
|
||||||
|
EXPECT(run(a, "and now?") == 0, "next turn");
|
||||||
|
cst_agent_destroy(a);
|
||||||
|
|
||||||
|
cJSON *r[4];
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
r[i] = load_req(rec, i);
|
||||||
|
EXPECT(r[i] != NULL, "vision request recorded");
|
||||||
|
}
|
||||||
|
if (r[0] && r[1] && r[2] && r[3]) {
|
||||||
|
EXPECT(!tools_have(r[0], "view_image"), "blind: no view_image tool");
|
||||||
|
EXPECT(!has_image_url(r[0]), "blind: no image part");
|
||||||
|
const char *c = str_of(last_msg(r[0]), "content");
|
||||||
|
EXPECT(c && strstr(c, "what is in the picture?") &&
|
||||||
|
strstr(c, "not sent: this model does not take images"),
|
||||||
|
"blind: the image becomes a note");
|
||||||
|
|
||||||
|
EXPECT(tools_have(r[1], "view_image") && has_image_url(r[1]),
|
||||||
|
"unknown: first try sends the image");
|
||||||
|
EXPECT(!tools_have(r[2], "view_image") && !has_image_url(r[2]),
|
||||||
|
"retry: no image, no view_image");
|
||||||
|
c = str_of(last_msg(r[2]), "content");
|
||||||
|
EXPECT(c && strstr(c, "not sent"), "retry: note instead");
|
||||||
|
EXPECT(!has_image_url(r[3]) && extends(r[2], r[3]),
|
||||||
|
"images stay off; the next request extends the retry");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
cJSON_Delete(r[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
char root[1024], sse[1100], rec[1100];
|
||||||
|
if (test_tmpdir(root, sizeof(root), "cst-prefix") != 0) {
|
||||||
|
fprintf(stderr, "cannot create a scratch dir\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
snprintf(sse, sizeof(sse), "%s/sse", root);
|
||||||
|
snprintf(rec, sizeof(rec), "%s/rec", root);
|
||||||
|
os_mkdir_p(sse, 0755);
|
||||||
|
for (int i = 0; i < NFIX; i++) {
|
||||||
|
char name[32];
|
||||||
|
snprintf(name, sizeof(name), "%04d.sse", i);
|
||||||
|
write_file(sse, name, FIXTURES[i]);
|
||||||
|
}
|
||||||
|
write_file(root, "a.txt", "hello\n");
|
||||||
|
setenv("CST_REPLAY_SSE", sse, 1);
|
||||||
|
setenv("CST_RECORD_SSE", rec, 1);
|
||||||
|
setenv("CST_MCP_WAIT_MS", "0", 1);
|
||||||
|
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
|
||||||
|
unsetenv("CST_CTX_WINDOW");
|
||||||
|
unsetenv("CST_NO_CACHE_HINTS");
|
||||||
|
unsetenv("CST_PRICE");
|
||||||
|
cst_llm_trace_reset();
|
||||||
|
|
||||||
|
cst_agent_config_t cfg;
|
||||||
|
memset(&cfg, 0, sizeof(cfg));
|
||||||
|
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||||
|
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", root);
|
||||||
|
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
|
||||||
|
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||||
|
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||||
|
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||||
|
cfg.max_steps = 8;
|
||||||
|
cfg.headless = 1;
|
||||||
|
cfg.approval = CST_APPROVAL_AUTO;
|
||||||
|
cst_agent_t *a = cst_agent_create(&cfg);
|
||||||
|
EXPECT(a != NULL, "agent created");
|
||||||
|
if (!a)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
EXPECT(run(a, "read a.txt") == 0, "turn 1");
|
||||||
|
cst_agent_config_mut(a)->plan_mode = 1;
|
||||||
|
EXPECT(run(a, "now write b.txt") == 0, "turn 2 (plan)");
|
||||||
|
cst_agent_config_mut(a)->plan_mode = 0;
|
||||||
|
cst_agent_config_mut(a)->approval = CST_APPROVAL_YOLO;
|
||||||
|
EXPECT(run(a, "a long answer please") == 0, "turn 3 (continued)");
|
||||||
|
EXPECT(run(a, "bye") == 0, "turn 4");
|
||||||
|
|
||||||
|
char bpath[1100];
|
||||||
|
snprintf(bpath, sizeof(bpath), "%s/b.txt", root);
|
||||||
|
EXPECT(access(bpath, F_OK) != 0, "Plan mode refused write_file");
|
||||||
|
|
||||||
|
cJSON *req[NFIX];
|
||||||
|
for (int i = 0; i < NFIX; i++) {
|
||||||
|
req[i] = load_req(rec, i);
|
||||||
|
EXPECT(req[i] != NULL, "request recorded");
|
||||||
|
}
|
||||||
|
if (req[NFIX - 1]) {
|
||||||
|
char *tools0 = print(cJSON_GetObjectItemCaseSensitive(req[0], "tools"));
|
||||||
|
char *sys0 = print(cJSON_GetArrayItem(msgs_of(req[0]), 0));
|
||||||
|
EXPECT(tools0 && strstr(tools0, "write_file"),
|
||||||
|
"write tools stay in the schema");
|
||||||
|
for (int i = 0; i < NFIX; i++) {
|
||||||
|
char *tools = print(cJSON_GetObjectItemCaseSensitive(req[i], "tools"));
|
||||||
|
char *sys = print(cJSON_GetArrayItem(msgs_of(req[i]), 0));
|
||||||
|
EXPECT(tools && tools0 && strcmp(tools, tools0) == 0,
|
||||||
|
"tools[] identical in every request");
|
||||||
|
EXPECT(sys && sys0 && strcmp(sys, sys0) == 0,
|
||||||
|
"system prompt identical in every request");
|
||||||
|
const char *tc = str_of(req[i], "tool_choice");
|
||||||
|
EXPECT(tc && strcmp(tc, "auto") == 0, "tool_choice stays auto");
|
||||||
|
free(tools);
|
||||||
|
free(sys);
|
||||||
|
if (i + 1 < NFIX && !extends(req[i], req[i + 1])) {
|
||||||
|
fprintf(stderr, " request %d does not extend request %d\n", i + 1, i);
|
||||||
|
nfail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(tools0);
|
||||||
|
free(sys0);
|
||||||
|
|
||||||
|
EXPECT(any_msg_has(req[1], "reasoning_content", "look at the file"),
|
||||||
|
"DeepSeek reasoning passed back");
|
||||||
|
EXPECT(strstr(str_of(last_msg(req[2]), "content"), "Plan mode is now ON"),
|
||||||
|
"Plan mode announced by a note");
|
||||||
|
EXPECT(any_msg_has(req[3], "content", "Plan mode is on"),
|
||||||
|
"write_file denied with a Plan mode error");
|
||||||
|
const char *t3 = str_of(last_msg(req[4]), "content");
|
||||||
|
EXPECT(t3 && strstr(t3, "Plan mode is now OFF") &&
|
||||||
|
strstr(t3, "Approval mode is now yolo"),
|
||||||
|
"Build mode and approval change announced");
|
||||||
|
const cJSON *pm = last_msg(req[5]);
|
||||||
|
EXPECT(is_prefix_msg(pm) && strcmp(str_of(pm, "role"), "assistant") == 0 &&
|
||||||
|
strcmp(str_of(pm, "content"), "part one") == 0,
|
||||||
|
"cut-off reply continued through an assistant prefix");
|
||||||
|
EXPECT(any_msg_has(req[6], "content", "part one part two"),
|
||||||
|
"continued reply stored as one message");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < NFIX; i++)
|
||||||
|
cJSON_Delete(req[i]);
|
||||||
|
|
||||||
|
cst_usage_t s, turn;
|
||||||
|
cst_agent_usage(a, &s, &turn);
|
||||||
|
EXPECT(s.requests == NFIX, "every response counted");
|
||||||
|
EXPECT(s.prompt == 1000L * NFIX && s.cached == 900L * NFIX &&
|
||||||
|
s.miss == 100L * NFIX,
|
||||||
|
"hit and miss tokens summed");
|
||||||
|
EXPECT(s.priced && s.cost > 0 && strcmp(s.currency, "¥") == 0,
|
||||||
|
"DeepSeek cost estimated");
|
||||||
|
EXPECT(turn.requests == 1, "last turn usage");
|
||||||
|
|
||||||
|
cst_agent_destroy(a);
|
||||||
|
test_vision(root);
|
||||||
|
test_rmrf(root);
|
||||||
|
if (nfail) {
|
||||||
|
fprintf(stderr, "test_prefix_stable: %d failed\n", nfail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("test_prefix_stable: OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+5
-10
@@ -72,7 +72,7 @@ static void test_blocked(void) {
|
|||||||
CHECK(cst_sandbox_blocked(c, strlen(c), 1));
|
CHECK(cst_sandbox_blocked(c, strlen(c), 1));
|
||||||
const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 "
|
const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 "
|
||||||
"ms: Could not connect to server\n";
|
"ms: Could not connect to server\n";
|
||||||
CHECK(cst_sandbox_blocked(d, strlen(d), 7));
|
CHECK(!cst_sandbox_blocked(d, strlen(d), 7)); /* network is not sandboxed */
|
||||||
const char *e = "Access is denied.\r\n";
|
const char *e = "Access is denied.\r\n";
|
||||||
CHECK(cst_sandbox_blocked(e, strlen(e), 1));
|
CHECK(cst_sandbox_blocked(e, strlen(e), 1));
|
||||||
const char *f = "拒绝访问。\r\n";
|
const char *f = "拒绝访问。\r\n";
|
||||||
@@ -132,7 +132,7 @@ static int run(const char *ws, const char *cmd, os_run_result_t *r) {
|
|||||||
return r->exit_code;
|
return r->exit_code;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Child: enter the sandbox, then try a TCP connect to a local listener. */
|
/* Child: enter the sandbox, then a TCP connect to a local listener works. */
|
||||||
static void test_network(const char *ws) {
|
static void test_network(const char *ws) {
|
||||||
int ls = socket(AF_INET, SOCK_STREAM, 0);
|
int ls = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
CHECK(ls >= 0);
|
CHECK(ls >= 0);
|
||||||
@@ -152,12 +152,11 @@ static void test_network(const char *ws) {
|
|||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
if (cst_sandbox_enter(&plan) != 0)
|
if (cst_sandbox_enter(&plan) != 0)
|
||||||
_exit(3);
|
_exit(3);
|
||||||
/* Landlock refuses the connect, seccomp already the socket */
|
|
||||||
int s = socket(AF_INET, SOCK_STREAM, 0);
|
int s = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
if (s < 0)
|
if (s < 0)
|
||||||
_exit(errno == EACCES ? 0 : 2);
|
_exit(errno == EACCES ? 1 : 2);
|
||||||
int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
|
int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
|
||||||
_exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2);
|
_exit(rc == 0 ? 0 : errno == EACCES ? 1 : 2);
|
||||||
}
|
}
|
||||||
int st = 0;
|
int st = 0;
|
||||||
waitpid(pid, &st, 0);
|
waitpid(pid, &st, 0);
|
||||||
@@ -337,11 +336,7 @@ static void test_confined(int kind) {
|
|||||||
CHECK(fp);
|
CHECK(fp);
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
|
|
||||||
if (kind == CST_SANDBOX_SECCOMP || cst_sandbox_landlock_abi() >= 4)
|
test_network(ws);
|
||||||
test_network(ws);
|
|
||||||
else
|
|
||||||
printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n",
|
|
||||||
cst_sandbox_landlock_abi());
|
|
||||||
|
|
||||||
snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out);
|
snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out);
|
||||||
CHECK(system(cmd) == 0);
|
CHECK(system(cmd) == 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user