chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:43 +08:00
commit 0e2cc6b102
927 changed files with 260929 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
# Example Standard Dynamic Library Plugin
This is the full mixed-capability skeleton. For single-capability examples, see `../README.md`.
This directory is the reference skeleton for the current standard dynamic library plugin ABI. The ABI is language-neutral: the host loads a native dynamic library, calls `cliproxy_plugin_init`, and then exchanges JSON envelopes through a stable C function table.
This directory contains complete Go, C, and Rust implementations of the same mixed-capability sample. The Go sample uses `-buildmode=c-shared`; the C sample uses CMake; the Rust sample uses a `cdylib` crate.
## Entry Point
Every plugin must export:
```c
int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin);
```
The plugin fills `cliproxy_plugin_api` with:
```c
int call(char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response);
void free_buffer(void* ptr, size_t len);
void shutdown(void);
```
The host provides `cliproxy_host_api` with:
```c
int call(void* host_ctx, char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response);
void free_buffer(void* ptr, size_t len);
```
The C ABI never passes Go interfaces, Go slices, Go maps, Go channels, `context.Context`, or Go errors.
## JSON Envelope
Successful responses use:
```json
{
"ok": true,
"result": {}
}
```
Errors use:
```json
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "request is invalid"
}
}
```
Raw byte fields are encoded as base64 by JSON.
## Capabilities
`plugin.register` and `plugin.reconfigure` return metadata and capability flags. This sample declares the full provider-native surface:
- model provider
- model registrar
- auth provider
- frontend auth provider
- executor
- request and response transforms
- thinking applier
- usage observer
- command-line plugin
- Management API plugin
Executor plugins must declare `executor_input_formats` and `executor_output_formats` in their capability block. The host passes requests through directly when the client protocol is declared by the executor. Otherwise, the host translates the inbound request into one declared input format and translates the executor response back to the client protocol. This example declares `chat-completions` for both lists, so non-chat-completions protocols are translated by the host. The host also accepts the existing internal aliases `openai`, `openai-response`, and `claude` for Chat Completions, Responses, and Anthropic protocols.
The host keeps the existing precedence rules: native logic wins, plugins fill gaps, and higher-priority plugins run before lower-priority plugins.
## Layout
- `go/`: full mixed-capability Go implementation.
- `c/`: full mixed-capability C implementation with no external dependencies.
- `rust/`: full mixed-capability Rust implementation with no external dependencies.
All three implementations parse incoming JSON requests for the methods where request content matters. Auth methods persist the raw request payload as `StorageJSON`; request and response transforms echo the inbound `Body`; Thinking decodes `Body` and appends `plugin_example_thinking`; executor methods use request fields such as `Model`, `Format`, and `Payload`; Usage keeps an in-process count.
## Build
Build from the repository root.
Build all plugin examples:
```bash
make -C examples/plugin build
```
Artifacts are written to `examples/plugin/bin` as `simple-go`, `simple-c`, and `simple-rust` with the current platform dynamic-library extension.
Manual Go build on macOS:
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
go build -buildmode=c-shared -o plugins/darwin/$(go env GOARCH)/simple-go.dylib ./examples/plugin/simple/go
rm -f plugins/darwin/$(go env GOARCH)/simple-go.h
```
Manual C build on macOS:
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
cmake -S examples/plugin/simple/c -B /tmp/cliproxy-simple-c-build -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PWD/plugins/darwin/$(go env GOARCH)
cmake --build /tmp/cliproxy-simple-c-build
```
Manual Rust build on macOS:
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
cd examples/plugin/simple/rust
CARGO_TARGET_DIR=/tmp/cliproxy-simple-rust-target cargo build --release --locked
cp /tmp/cliproxy-simple-rust-target/release/libcliproxy_simple_rust.dylib ../../../../plugins/darwin/$(go env GOARCH)/simple-rust.dylib
```
For Linux, FreeBSD, or Windows, keep the same source directory and use the platform extension selected by `examples/plugin/Makefile`.
The plugin ID is the dynamic library basename without the platform extension. Makefile-built artifacts map to `plugins.configs.simple-go`, `plugins.configs.simple-c`, and `plugins.configs.simple-rust`.
## Discovery
The host searches:
```text
plugins/<GOOS>/<GOARCH>
plugins
```
Accepted extensions are:
- `.so` on Linux and FreeBSD
- `.dylib` on macOS
- `.dll` on Windows
Plugin IDs must match:
```text
[A-Za-z0-9][A-Za-z0-9._-]{0,127}
```
## Configuration
Dynamic plugins are disabled by default.
```yaml
plugins:
enabled: true
dir: "plugins"
configs:
simple-go:
enabled: true
priority: 1
config1: true
config2: "string"
config3: 3
mode: "safe"
```
`plugins.configs.<pluginID>` is passed to `plugin.register` or `plugin.reconfigure` as normalized YAML bytes inside the JSON request.
## Host HTTP Bridge
Plugins can call host functionality through `host.call`. The HTTP bridge method is:
```text
host.http.do
```
The host still performs the real HTTP request, so proxy handling, transport policy, auth context, and request logging stay under host control.
## Management API
The native plugin management endpoints are:
```text
GET /v0/management/plugins
DELETE /v0/management/plugins/{pluginID}
PATCH /v0/management/plugins/{pluginID}/enabled
GET /v0/management/plugins/{pluginID}/config
PUT /v0/management/plugins/{pluginID}/config
PATCH /v0/management/plugins/{pluginID}/config
```
Plugin-owned Management API routes are registered through the `routes` field of `management.register` and handled through `management.handle`.
Browser-navigable menu resources are registered through the `resources` field of `management.register`. CPA exposes those resources under `/v0/resource/plugins/<pluginID>/...`; for example, a plugin with ID `example` and resource path `/status` is served as `/v0/resource/plugins/example/status`.
## Trust Boundary
Standard dynamic library plugins are trusted in-process code. Panic recovery can protect host-managed calls, but it cannot prevent a plugin from exiting the process, corrupting memory, mutating global process state, or leaking secrets. Install only plugins you trust as much as the service binary.
## Verification
Current platform sample builds:
```bash
make -C examples/plugin list
make -C examples/plugin build
find examples/plugin/bin -maxdepth 1 -type f | wc -l
make -C examples/plugin clean
```
After changing Go code in this repository, also run:
```bash
go build -o test-output ./cmd/server && rm test-output
```
+212
View File
@@ -0,0 +1,212 @@
# 标准动态库插件示例
这是混合全部能力的完整骨架示例。单能力示例请查看 `../README_CN.md`
本目录是当前标准动态库插件 ABI 的参考骨架。ABI 与语言无关:宿主加载原生动态库,调用 `cliproxy_plugin_init`,然后通过稳定的 C 函数表交换 JSON 信封。
本目录包含同一个混合能力示例的 Go、C、Rust 三种完整实现。Go 示例使用 `-buildmode=c-shared`C 示例使用 CMake,Rust 示例使用 `cdylib` crate。
## 入口
每个插件必须导出:
```c
int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin);
```
插件填充 `cliproxy_plugin_api`
```c
int call(char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response);
void free_buffer(void* ptr, size_t len);
void shutdown(void);
```
宿主提供 `cliproxy_host_api`
```c
int call(void* host_ctx, char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response);
void free_buffer(void* ptr, size_t len);
```
C ABI 不传递 Go interface、Go slice、Go map、Go channel、`context.Context` 或 Go error。
## JSON 信封
成功响应:
```json
{
"ok": true,
"result": {}
}
```
错误响应:
```json
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "request is invalid"
}
}
```
原始字节字段通过 JSON 自动使用 base64 编码。
## 能力
`plugin.register``plugin.reconfigure` 返回 metadata 和能力开关。本示例声明完整的提供方插件能力:
- 模型提供方
- 模型注册器
- 认证提供方
- 前端认证提供方
- 执行器
- 请求和响应转换
- 思考配置处理
- 用量观察
- 命令行插件
- Management API 插件
宿主保留现有优先级规则:原生逻辑优先,插件补齐缺口,高优先级插件先于低优先级插件执行。
## 目录布局
- `go/`:完整混合能力 Go 实现。
- `c/`:完整混合能力 C 实现,不依赖外部库。
- `rust/`:完整混合能力 Rust 实现,不依赖外部库。
三种实现都会在需要请求内容的方法中解析传入 JSON。认证方法会把原始请求作为 `StorageJSON`,请求和响应转换会回显传入 `Body`Thinking 会解码 `Body` 并追加 `plugin_example_thinking`,执行器方法会使用 `Model``Format``Payload` 等请求字段,Usage 会维护进程内计数。
## 构建
在仓库根目录构建。
构建全部插件示例,包括 `simple` 的三种语言实现:
```bash
make -C examples/plugin build
```
产物会写入 `examples/plugin/bin`,当前平台扩展名下分别为 `simple-go``simple-c``simple-rust`
macOS 手动构建 Go
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
go build -buildmode=c-shared -o plugins/darwin/$(go env GOARCH)/simple-go.dylib ./examples/plugin/simple/go
rm -f plugins/darwin/$(go env GOARCH)/simple-go.h
```
macOS 手动构建 C
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
cmake -S examples/plugin/simple/c -B /tmp/cliproxy-simple-c-build -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PWD/plugins/darwin/$(go env GOARCH)
cmake --build /tmp/cliproxy-simple-c-build
```
macOS 手动构建 Rust
```bash
mkdir -p plugins/darwin/$(go env GOARCH)
cd examples/plugin/simple/rust
CARGO_TARGET_DIR=/tmp/cliproxy-simple-rust-target cargo build --release --locked
cp /tmp/cliproxy-simple-rust-target/release/libcliproxy_simple_rust.dylib ../../../../plugins/darwin/$(go env GOARCH)/simple-rust.dylib
```
Linux、FreeBSD 或 Windows 使用相同源码目录,平台扩展名以 `examples/plugin/Makefile` 的规则为准。
插件 ID 来自动态库文件名去掉平台扩展名。通过 Makefile 构建的产物分别对应 `plugins.configs.simple-go``plugins.configs.simple-c``plugins.configs.simple-rust`
## 发现规则
宿主搜索:
```text
plugins/<GOOS>/<GOARCH>
plugins
```
支持的扩展名:
- Linux 和 FreeBSD 使用 `.so`
- macOS 使用 `.dylib`
- Windows 使用 `.dll`
插件 ID 必须匹配:
```text
[A-Za-z0-9][A-Za-z0-9._-]{0,127}
```
## 配置
动态插件默认关闭。
```yaml
plugins:
enabled: true
dir: "plugins"
configs:
simple-go:
enabled: true
priority: 1
config1: true
config2: "string"
config3: 3
mode: "safe"
```
`plugins.configs.<pluginID>` 会作为标准化 YAML 字节放进 JSON 请求,传给 `plugin.register``plugin.reconfigure`
## 宿主 HTTP 桥接
插件可以通过 `host.call` 调用宿主能力。HTTP 桥接方法是:
```text
host.http.do
```
真实 HTTP 请求仍由宿主执行,因此代理、传输策略、认证上下文和请求日志仍由宿主控制。
## Management API
原生插件管理接口包括:
```text
GET /v0/management/plugins
DELETE /v0/management/plugins/{pluginID}
PATCH /v0/management/plugins/{pluginID}/enabled
GET /v0/management/plugins/{pluginID}/config
PUT /v0/management/plugins/{pluginID}/config
PATCH /v0/management/plugins/{pluginID}/config
```
插件自有 Management API 路由通过 `management.register``routes` 字段注册,并通过 `management.handle` 处理。
可由浏览器直接访问的菜单资源通过 `management.register``resources` 字段注册。CPA 会将这些资源暴露在 `/v0/resource/plugins/<pluginID>/...` 下;例如插件 ID 为 `example` 且资源路径为 `/status` 时,最终路径是 `/v0/resource/plugins/example/status`
## 信任边界
标准动态库插件是可信进程内代码。panic 恢复可以保护宿主管理的调用,但不能阻止插件退出进程、破坏内存、修改进程全局状态或泄露敏感数据。只安装你像信任服务二进制一样信任的插件。
## 验证
当前平台示例构建:
```bash
make -C examples/plugin list
make -C examples/plugin build
find examples/plugin/bin -maxdepth 1 -type f | wc -l
make -C examples/plugin clean
```
如果修改了本仓库的 Go 代码,还需要运行:
```bash
go build -o test-output ./cmd/server && rm test-output
```
+8
View File
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
project(cliproxy_simple_c C)
add_library(cliproxy_simple_c SHARED src/plugin.c)
set_target_properties(cliproxy_simple_c PROPERTIES
OUTPUT_NAME "simple-c"
PREFIX ""
)
+615
View File
@@ -0,0 +1,615 @@
#include <ctype.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#define CLIPROXY_EXPORT __declspec(dllexport)
#else
#define CLIPROXY_EXPORT __attribute__((visibility("default")))
#endif
#define ABI_VERSION 1
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
static long usage_count = 0;
static const char* REGISTRATION_RESPONSE =
"{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-simple-c\","
"\"Version\":\"0.1.0\",\"Author\":\"router-for-me\","
"\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\","
"\"Logo\":\"https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png\","
"\"ConfigFields\":["
"{\"Name\":\"config1\",\"Type\":\"boolean\",\"Description\":\"Enables the example boolean option.\"},"
"{\"Name\":\"config2\",\"Type\":\"string\",\"Description\":\"Stores the example string option.\"},"
"{\"Name\":\"config3\",\"Type\":\"integer\",\"Description\":\"Stores the example integer option.\"},"
"{\"Name\":\"mode\",\"Type\":\"enum\",\"EnumValues\":[\"safe\",\"fast\"],"
"\"Description\":\"Selects the example execution mode.\"}]},"
"\"capabilities\":{\"model_registrar\":true,\"model_provider\":true,\"auth_provider\":true,"
"\"frontend_auth_provider\":true,\"executor\":true,\"executor_model_scope\":\"both\","
"\"executor_input_formats\":[\"chat-completions\"],"
"\"executor_output_formats\":[\"chat-completions\"],\"request_translator\":true,"
"\"request_normalizer\":true,\"response_translator\":true,\"response_before_translator\":true,"
"\"response_after_translator\":true,\"thinking_applier\":true,\"usage_plugin\":true,"
"\"command_line_plugin\":true,\"management_api\":true}}}";
static const char* MODEL_RESPONSE =
"{\"ok\":true,\"result\":{\"Provider\":\"plugin-example-c\",\"Models\":[{\"ID\":\"plugin-example-c-model\","
"\"Object\":\"model\",\"OwnedBy\":\"plugin-example-c\",\"DisplayName\":\"Plugin Example C Model\","
"\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,"
"\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}";
static const char* IDENTIFIER_RESPONSE = "{\"ok\":true,\"result\":{\"identifier\":\"plugin-example-c\"}}";
static const char* LOGIN_START_RESPONSE =
"{\"ok\":true,\"result\":{\"Provider\":\"plugin-example-c\",\"URL\":\"https://example.invalid/plugin-login\","
"\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}";
static const char* LOGIN_POLL_RESPONSE =
"{\"ok\":true,\"result\":{\"Status\":\"error\",\"Message\":\"example plugin has no interactive login\"}}";
static const char* FRONTEND_AUTH_RESPONSE =
"{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"plugin-example-c\","
"\"Metadata\":{\"provider\":\"plugin-example-c\"}}}";
static const char* STREAM_RESPONSE =
"{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},"
"\"chunks\":[{\"Payload\":\"cGx1Z2luLWV4YW1wbGUtYwo=\"}]}}";
static const char* CLI_REGISTER_RESPONSE =
"{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"plugin-example-c-command\","
"\"Usage\":\"Run the example C ABI plugin command\",\"Type\":\"bool\"}]}}";
static const char* CLI_EXECUTE_RESPONSE =
"{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}";
static const char* MANAGEMENT_REGISTER_RESPONSE =
"{\"ok\":true,\"result\":{\"Resources\":[{\"Path\":\"/status\","
"\"Menu\":\"Example C Plugin\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-c/status.\"}]}}";
static const char* UNKNOWN_METHOD_RESPONSE =
"{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}";
static const char* INVALID_METHOD_RESPONSE =
"{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}";
static const char BASE64_TABLE[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static char* format_string(const char* format, ...) {
va_list args;
va_start(args, format);
va_list args_copy;
va_copy(args_copy, args);
int len = vsnprintf(NULL, 0, format, args);
va_end(args);
if (len < 0) {
va_end(args_copy);
return NULL;
}
char* out = (char*)malloc((size_t)len + 1);
if (out == NULL) {
va_end(args_copy);
return NULL;
}
vsnprintf(out, (size_t)len + 1, format, args_copy);
va_end(args_copy);
return out;
}
static char* copy_request_string(const uint8_t* request, size_t request_len) {
char* out = (char*)malloc(request_len + 1);
if (out == NULL) {
return NULL;
}
if (request_len > 0 && request != NULL) {
memcpy(out, request, request_len);
}
out[request_len] = '\0';
return out;
}
static char* json_escape(const char* value) {
if (value == NULL) {
return format_string("");
}
size_t len = strlen(value);
char* out = (char*)malloc((len * 2) + 1);
if (out == NULL) {
return NULL;
}
size_t pos = 0;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)value[i];
if (c == '"' || c == '\\') {
out[pos++] = '\\';
out[pos++] = (char)c;
} else if (c == '\n') {
out[pos++] = '\\';
out[pos++] = 'n';
} else if (c == '\r') {
out[pos++] = '\\';
out[pos++] = 'r';
} else if (c == '\t') {
out[pos++] = '\\';
out[pos++] = 't';
} else if (c < 0x20) {
out[pos++] = ' ';
} else {
out[pos++] = (char)c;
}
}
out[pos] = '\0';
return out;
}
static char* base64_encode(const uint8_t* data, size_t len) {
size_t out_len = ((len + 2) / 3) * 4;
char* out = (char*)malloc(out_len + 1);
if (out == NULL) {
return NULL;
}
size_t i = 0;
size_t j = 0;
while (i < len) {
uint32_t octet_a = i < len ? data[i++] : 0;
uint32_t octet_b = i < len ? data[i++] : 0;
uint32_t octet_c = i < len ? data[i++] : 0;
uint32_t triple = (octet_a << 16) | (octet_b << 8) | octet_c;
out[j++] = BASE64_TABLE[(triple >> 18) & 0x3F];
out[j++] = BASE64_TABLE[(triple >> 12) & 0x3F];
out[j++] = BASE64_TABLE[(triple >> 6) & 0x3F];
out[j++] = BASE64_TABLE[triple & 0x3F];
}
if (len % 3 == 1) {
out[out_len - 2] = '=';
out[out_len - 1] = '=';
} else if (len % 3 == 2) {
out[out_len - 1] = '=';
}
out[out_len] = '\0';
return out;
}
static int base64_value(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 == '+') {
return 62;
}
if (c == '/') {
return 63;
}
return -1;
}
static uint8_t* base64_decode(const char* input, size_t* out_len) {
size_t len = input == NULL ? 0 : strlen(input);
uint8_t* out = (uint8_t*)malloc(((len * 3) / 4) + 4);
if (out == NULL) {
return NULL;
}
int value = 0;
int bits = -8;
size_t pos = 0;
for (size_t i = 0; i < len; i++) {
if (input[i] == '=') {
break;
}
int digit = base64_value(input[i]);
if (digit < 0) {
continue;
}
value = (value << 6) | digit;
bits += 6;
if (bits >= 0) {
out[pos++] = (uint8_t)((value >> bits) & 0xFF);
bits -= 8;
}
}
*out_len = pos;
return out;
}
static char* extract_json_string(const char* json, const char* key) {
char* pattern = format_string("\"%s\"", key);
if (pattern == NULL || json == NULL) {
free(pattern);
return NULL;
}
const char* pos = json;
size_t pattern_len = strlen(pattern);
while ((pos = strstr(pos, pattern)) != NULL) {
const char* p = pos + pattern_len;
while (*p != '\0' && isspace((unsigned char)*p)) {
p++;
}
if (*p++ != ':') {
pos += pattern_len;
continue;
}
while (*p != '\0' && isspace((unsigned char)*p)) {
p++;
}
if (*p++ != '"') {
pos += pattern_len;
continue;
}
char* out = (char*)malloc(strlen(p) + 1);
if (out == NULL) {
free(pattern);
return NULL;
}
size_t out_pos = 0;
while (*p != '\0') {
if (*p == '"') {
out[out_pos] = '\0';
free(pattern);
return out;
}
if (*p == '\\' && p[1] != '\0') {
p++;
if (*p == 'n') {
out[out_pos++] = '\n';
} else if (*p == 'r') {
out[out_pos++] = '\r';
} else if (*p == 't') {
out[out_pos++] = '\t';
} else {
out[out_pos++] = *p;
}
} else {
out[out_pos++] = *p;
}
p++;
}
free(out);
pos += pattern_len;
}
free(pattern);
return NULL;
}
static long extract_json_int(const char* json, const char* key, long fallback) {
char* pattern = format_string("\"%s\"", key);
if (pattern == NULL || json == NULL) {
free(pattern);
return fallback;
}
const char* pos = strstr(json, pattern);
free(pattern);
if (pos == NULL) {
return fallback;
}
const char* p = strchr(pos, ':');
if (p == NULL) {
return fallback;
}
p++;
while (*p != '\0' && isspace((unsigned char)*p)) {
p++;
}
char* end = NULL;
long value = strtol(p, &end, 10);
return end == p ? fallback : value;
}
static char* wrap_ok(const char* result_json) {
return format_string("{\"ok\":true,\"result\":%s}", result_json == NULL ? "{}" : result_json);
}
static char* make_error(const char* code, const char* message) {
char* escaped = json_escape(message);
char* out = format_string("{\"ok\":false,\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", code, escaped == NULL ? "" : escaped);
free(escaped);
return out;
}
static char* make_auth_data(const uint8_t* request, size_t request_len) {
char* storage = base64_encode(request == NULL ? (const uint8_t*)"" : request, request == NULL ? 0 : request_len);
char* out = format_string(
"{\"Provider\":\"plugin-example-c\",\"ID\":\"plugin-example-c\",\"FileName\":\"plugin-example-c.json\","
"\"Label\":\"Plugin Example C\",\"StorageJSON\":\"%s\",\"Metadata\":{\"type\":\"plugin-example-c\"}}",
storage == NULL ? "" : storage);
free(storage);
return out;
}
static char* make_auth_parse_response(const uint8_t* request, size_t request_len) {
char* auth = make_auth_data(request, request_len);
char* result = format_string("{\"Handled\":true,\"Auth\":%s}", auth == NULL ? "{}" : auth);
char* out = wrap_ok(result);
free(auth);
free(result);
return out;
}
static char* make_auth_refresh_response(const uint8_t* request, size_t request_len) {
char* auth = make_auth_data(request, request_len);
char* result = format_string("{\"Auth\":%s}", auth == NULL ? "{}" : auth);
char* out = wrap_ok(result);
free(auth);
free(result);
return out;
}
static char* make_payload_echo_response(const uint8_t* request, size_t request_len) {
char* json = copy_request_string(request, request_len);
char* body = extract_json_string(json, "Body");
char* out = NULL;
if (body == NULL) {
out = make_error("invalid_request", "request body field is required");
} else {
char* result = format_string("{\"Body\":\"%s\"}", body);
out = wrap_ok(result);
free(result);
}
free(json);
free(body);
return out;
}
static char* make_executor_response(const uint8_t* request, size_t request_len) {
char* json = copy_request_string(request, request_len);
char* model = extract_json_string(json, "Model");
char* format = extract_json_string(json, "Format");
char* model_escaped = json_escape(model == NULL ? "plugin-example-c-model" : model);
char* format_escaped = json_escape(format == NULL ? "chat-completions" : format);
char* payload_json = format_string(
"{\"id\":\"plugin-example-c\",\"object\":\"chat.completion\",\"model\":\"%s\",\"format\":\"%s\"}",
model_escaped == NULL ? "" : model_escaped,
format_escaped == NULL ? "" : format_escaped);
char* payload = base64_encode((const uint8_t*)payload_json, payload_json == NULL ? 0 : strlen(payload_json));
char* result = format_string("{\"Payload\":\"%s\",\"Headers\":{\"content-type\":[\"application/json\"]}}", payload == NULL ? "" : payload);
char* out = wrap_ok(result);
free(json);
free(model);
free(format);
free(model_escaped);
free(format_escaped);
free(payload_json);
free(payload);
free(result);
return out;
}
static char* make_count_tokens_response(const uint8_t* request, size_t request_len) {
char* json = copy_request_string(request, request_len);
char* payload = extract_json_string(json, "Payload");
size_t decoded_len = 0;
uint8_t* decoded = base64_decode(payload == NULL ? "" : payload, &decoded_len);
long tokens = decoded_len == 0 ? 0 : (long)((decoded_len + 3) / 4);
char* payload_json = format_string("{\"total_tokens\":%ld}", tokens);
char* payload_b64 = base64_encode((const uint8_t*)payload_json, payload_json == NULL ? 0 : strlen(payload_json));
char* result = format_string("{\"Payload\":\"%s\",\"Headers\":{\"content-type\":[\"application/json\"]}}", payload_b64 == NULL ? "" : payload_b64);
char* out = wrap_ok(result);
free(json);
free(payload);
free(decoded);
free(payload_json);
free(payload_b64);
free(result);
return out;
}
static char* make_http_response(const uint8_t* request, size_t request_len) {
char* json = copy_request_string(request, request_len);
char* method = extract_json_string(json, "Method");
char* url = extract_json_string(json, "URL");
char* path = extract_json_string(json, "Path");
char* method_escaped = json_escape(method == NULL ? "GET" : method);
char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/v0/resource/plugins/example-c/status" : path));
char* body_json = format_string(
"{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}",
method_escaped == NULL ? "" : method_escaped,
target_escaped == NULL ? "" : target_escaped);
char* body = base64_encode((const uint8_t*)body_json, body_json == NULL ? 0 : strlen(body_json));
char* result = format_string(
"{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"%s\"}",
body == NULL ? "" : body);
char* out = wrap_ok(result);
free(json);
free(method);
free(url);
free(path);
free(method_escaped);
free(target_escaped);
free(body_json);
free(body);
free(result);
return out;
}
static char* inject_thinking(const uint8_t* body, size_t body_len, const char* mode, long budget, const char* level) {
char* body_text = (char*)malloc(body_len + 1);
if (body_text == NULL) {
return NULL;
}
memcpy(body_text, body, body_len);
body_text[body_len] = '\0';
char* mode_escaped = json_escape(mode == NULL ? "" : mode);
char* level_escaped = json_escape(level == NULL ? "" : level);
size_t start = 0;
while (body_text[start] != '\0' && isspace((unsigned char)body_text[start])) {
start++;
}
size_t end = strlen(body_text);
while (end > start && isspace((unsigned char)body_text[end - 1])) {
end--;
}
char* out = NULL;
if (end > start + 1 && body_text[start] == '{' && body_text[end - 1] == '}') {
int has_fields = 0;
for (size_t i = start + 1; i < end - 1; i++) {
if (!isspace((unsigned char)body_text[i])) {
has_fields = 1;
break;
}
}
out = format_string(
"%.*s%s\"plugin_example_thinking\":{\"mode\":\"%s\",\"budget\":%ld,\"level\":\"%s\"}}",
(int)(end - 1 - start),
body_text + start,
has_fields ? "," : "",
mode_escaped == NULL ? "" : mode_escaped,
budget,
level_escaped == NULL ? "" : level_escaped);
} else {
char* escaped_body = json_escape(body_text);
out = format_string(
"{\"original_body\":\"%s\",\"plugin_example_thinking\":{\"mode\":\"%s\",\"budget\":%ld,\"level\":\"%s\"}}",
escaped_body == NULL ? "" : escaped_body,
mode_escaped == NULL ? "" : mode_escaped,
budget,
level_escaped == NULL ? "" : level_escaped);
free(escaped_body);
}
free(body_text);
free(mode_escaped);
free(level_escaped);
return out;
}
static char* make_thinking_response(const uint8_t* request, size_t request_len) {
char* json = copy_request_string(request, request_len);
char* body_b64 = extract_json_string(json, "Body");
char* mode = extract_json_string(json, "Mode");
char* level = extract_json_string(json, "Level");
long budget = extract_json_int(json, "Budget", 0);
size_t body_len = 0;
uint8_t* body = base64_decode(body_b64 == NULL ? "e30=" : body_b64, &body_len);
char* body_json = inject_thinking(body == NULL ? (const uint8_t*)"{}" : body, body == NULL ? 2 : body_len, mode, budget, level);
char* out_b64 = base64_encode((const uint8_t*)body_json, body_json == NULL ? 0 : strlen(body_json));
char* result = format_string("{\"Body\":\"%s\"}", out_b64 == NULL ? "" : out_b64);
char* out = wrap_ok(result);
free(json);
free(body_b64);
free(mode);
free(level);
free(body);
free(body_json);
free(out_b64);
free(result);
return out;
}
static char* make_usage_response(void) {
usage_count++;
char* result = format_string("{\"Count\":%ld}", usage_count);
char* out = wrap_ok(result);
free(result);
return out;
}
static void write_response(cliproxy_buffer* response, const char* text) {
if (response == NULL || text == NULL) {
return;
}
size_t len = strlen(text);
void* ptr = malloc(len);
if (ptr == NULL) {
response->ptr = NULL;
response->len = 0;
return;
}
memcpy(ptr, text, len);
response->ptr = ptr;
response->len = len;
}
static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (response != NULL) {
response->ptr = NULL;
response->len = 0;
}
if (method == NULL) {
write_response(response, INVALID_METHOD_RESPONSE);
return 1;
}
const char* static_response = NULL;
char* dynamic_response = NULL;
if (strcmp(method, "plugin.register") == 0 || strcmp(method, "plugin.reconfigure") == 0) {
static_response = REGISTRATION_RESPONSE;
} else if (strcmp(method, "model.register") == 0 || strcmp(method, "model.static") == 0 || strcmp(method, "model.for_auth") == 0) {
static_response = MODEL_RESPONSE;
} else if (strcmp(method, "auth.identifier") == 0 || strcmp(method, "frontend_auth.identifier") == 0 || strcmp(method, "executor.identifier") == 0 || strcmp(method, "thinking.identifier") == 0) {
static_response = IDENTIFIER_RESPONSE;
} else if (strcmp(method, "auth.parse") == 0) {
dynamic_response = make_auth_parse_response(request, request_len);
} else if (strcmp(method, "auth.login.start") == 0) {
static_response = LOGIN_START_RESPONSE;
} else if (strcmp(method, "auth.login.poll") == 0) {
static_response = LOGIN_POLL_RESPONSE;
} else if (strcmp(method, "auth.refresh") == 0) {
dynamic_response = make_auth_refresh_response(request, request_len);
} else if (strcmp(method, "frontend_auth.authenticate") == 0) {
static_response = FRONTEND_AUTH_RESPONSE;
} else if (strcmp(method, "executor.execute") == 0) {
dynamic_response = make_executor_response(request, request_len);
} else if (strcmp(method, "executor.execute_stream") == 0) {
static_response = STREAM_RESPONSE;
} else if (strcmp(method, "executor.count_tokens") == 0) {
dynamic_response = make_count_tokens_response(request, request_len);
} else if (strcmp(method, "executor.http_request") == 0 || strcmp(method, "management.handle") == 0) {
dynamic_response = make_http_response(request, request_len);
} else if (strcmp(method, "request.translate") == 0 || strcmp(method, "request.normalize") == 0 || strcmp(method, "response.translate") == 0 || strcmp(method, "response.normalize_before") == 0 || strcmp(method, "response.normalize_after") == 0) {
dynamic_response = make_payload_echo_response(request, request_len);
} else if (strcmp(method, "thinking.apply") == 0) {
dynamic_response = make_thinking_response(request, request_len);
} else if (strcmp(method, "usage.handle") == 0) {
dynamic_response = make_usage_response();
} else if (strcmp(method, "command_line.register") == 0) {
static_response = CLI_REGISTER_RESPONSE;
} else if (strcmp(method, "command_line.execute") == 0) {
static_response = CLI_EXECUTE_RESPONSE;
} else if (strcmp(method, "management.register") == 0) {
static_response = MANAGEMENT_REGISTER_RESPONSE;
} else {
static_response = UNKNOWN_METHOD_RESPONSE;
}
write_response(response, dynamic_response != NULL ? dynamic_response : static_response);
free(dynamic_response);
return 0;
}
static void plugin_free(void* ptr, size_t len) {
(void)len;
free(ptr);
}
static void plugin_shutdown(void) {}
CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
if (plugin == NULL) {
return 1;
}
(void)host;
plugin->abi_version = ABI_VERSION;
plugin->call = plugin_call;
plugin->free_buffer = plugin_free;
plugin->shutdown = plugin_shutdown;
return 0;
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/simple/go
go 1.26.0
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
+348
View File
@@ -0,0 +1,348 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
*/
import "C"
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
var usageCount atomic.Int64
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type lifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapability `json:"capabilities"`
}
type registrationCapability struct {
ModelRegistrar bool `json:"model_registrar"`
ModelProvider bool `json:"model_provider"`
AuthProvider bool `json:"auth_provider"`
FrontendAuthProvider bool `json:"frontend_auth_provider"`
Executor bool `json:"executor"`
ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"`
ExecutorInputFormats []string `json:"executor_input_formats,omitempty"`
ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"`
RequestTranslator bool `json:"request_translator"`
RequestNormalizer bool `json:"request_normalizer"`
ResponseTranslator bool `json:"response_translator"`
ResponseBeforeTranslator bool `json:"response_before_translator"`
ResponseAfterTranslator bool `json:"response_after_translator"`
ThinkingApplier bool `json:"thinking_applier"`
UsagePlugin bool `json:"usage_plugin"`
CommandLinePlugin bool `json:"command_line_plugin"`
ManagementAPI bool `json:"management_api"`
}
type identifierResponse struct {
Identifier string `json:"identifier"`
}
type streamResponse struct {
Headers http.Header `json:"headers,omitempty"`
Chunks []pluginapi.ExecutorStreamChunk `json:"chunks,omitempty"`
}
type managementRegistrationResponse struct {
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
return okEnvelope(exampleRegistration())
case pluginabi.MethodModelRegister:
return okEnvelope(pluginapi.ModelRegistrationResponse{Provider: "plugin-example", Models: exampleModels()})
case pluginabi.MethodModelStatic, pluginabi.MethodModelForAuth:
return okEnvelope(pluginapi.ModelResponse{Provider: "plugin-example", Models: exampleModels()})
case pluginabi.MethodAuthIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodAuthParse:
return okEnvelope(pluginapi.AuthParseResponse{Handled: true, Auth: exampleAuthData(request)})
case pluginabi.MethodAuthLoginStart:
return okEnvelope(pluginapi.AuthLoginStartResponse{
Provider: "plugin-example",
URL: "https://example.invalid/plugin-login",
State: "example-state",
ExpiresAt: time.Now().Add(5 * time.Minute).UTC(),
})
case pluginabi.MethodAuthLoginPoll:
return okEnvelope(pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusError, Message: "example plugin has no interactive login"})
case pluginabi.MethodAuthRefresh:
return okEnvelope(pluginapi.AuthRefreshResponse{Auth: exampleAuthData(request)})
case pluginabi.MethodFrontendAuthIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodFrontendAuthAuthenticate:
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: true, Principal: "plugin-example"})
case pluginabi.MethodExecutorIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodExecutorExecute:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"id":"plugin-example","object":"chat.completion"}`)})
case pluginabi.MethodExecutorExecuteStream:
return okEnvelope(streamResponse{Chunks: []pluginapi.ExecutorStreamChunk{{Payload: []byte("plugin-example")}}})
case pluginabi.MethodExecutorCountTokens:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"total_tokens":0}`)})
case pluginabi.MethodExecutorHTTPRequest:
return okEnvelope(pluginapi.ExecutorHTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)})
case pluginabi.MethodRequestTranslate, pluginabi.MethodRequestNormalize:
return payloadEcho(request)
case pluginabi.MethodResponseTranslate, pluginabi.MethodResponseNormalizeBefore, pluginabi.MethodResponseNormalizeAfter:
return responsePayloadEcho(request)
case pluginabi.MethodThinkingIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodThinkingApply:
return applyThinking(request)
case pluginabi.MethodUsageHandle:
usageCount.Add(1)
return okEnvelope(map[string]any{})
case pluginabi.MethodCommandLineRegister:
return okEnvelope(pluginapi.CommandLineRegistrationResponse{Flags: []pluginapi.CommandLineFlag{{
Name: "plugin-example-command",
Usage: "Run the example C ABI plugin command",
Type: "bool",
}}})
case pluginabi.MethodCommandLineExecute:
return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")})
case pluginabi.MethodManagementRegister:
// CPA exposes menu resources under /v0/resource/plugins/<plugin-id>/.
return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{
Path: "/status",
Menu: "Example Plugin",
Description: "Shows example plugin status as a browser-navigable resource.",
}}})
case pluginabi.MethodManagementHandle:
return okEnvelope(pluginapi.ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: []byte(`<!doctype html><title>Example Plugin</title><main>Example Plugin</main>`),
})
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func exampleRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "example",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png",
ConfigFields: []pluginapi.ConfigField{
{Name: "config1", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Enables the example boolean option."},
{Name: "config2", Type: pluginapi.ConfigFieldTypeString, Description: "Stores the example string option."},
{Name: "config3", Type: pluginapi.ConfigFieldTypeInteger, Description: "Stores the example integer option."},
{Name: "mode", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{"safe", "fast"}, Description: "Selects the example execution mode."},
},
},
Capabilities: registrationCapability{
ModelRegistrar: true,
ModelProvider: true,
AuthProvider: true,
FrontendAuthProvider: true,
Executor: true,
ExecutorModelScope: pluginapi.ExecutorModelScopeBoth,
ExecutorInputFormats: []string{"chat-completions"},
ExecutorOutputFormats: []string{"chat-completions"},
RequestTranslator: true,
RequestNormalizer: true,
ResponseTranslator: true,
ResponseBeforeTranslator: true,
ResponseAfterTranslator: true,
ThinkingApplier: true,
UsagePlugin: true,
CommandLinePlugin: true,
ManagementAPI: true,
},
}
}
func exampleModels() []pluginapi.ModelInfo {
return []pluginapi.ModelInfo{{
ID: "plugin-example-model",
Object: "model",
OwnedBy: "plugin-example",
DisplayName: "Plugin Example Model",
SupportedGenerationMethods: []string{"chat"},
ContextLength: 8192,
MaxCompletionTokens: 1024,
UserDefined: true,
}}
}
func exampleAuthData(raw []byte) pluginapi.AuthData {
return pluginapi.AuthData{
Provider: "plugin-example",
ID: "plugin-example",
FileName: "plugin-example.json",
Label: "Plugin Example",
StorageJSON: append([]byte(nil), raw...),
Metadata: map[string]any{"type": "plugin-example"},
}
}
func payloadEcho(raw []byte) ([]byte, error) {
var req pluginapi.RequestTransformRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: req.Body})
}
func responsePayloadEcho(raw []byte) ([]byte, error) {
var req pluginapi.ResponseTransformRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: req.Body})
}
func applyThinking(raw []byte) ([]byte, error) {
var req pluginapi.ThinkingApplyRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
body := map[string]any{}
_ = json.Unmarshal(req.Body, &body)
body["plugin_example_thinking"] = map[string]any{
"mode": req.Config.Mode,
"budget": req.Config.Budget,
"level": req.Config.Level,
}
out, errMarshal := json.Marshal(body)
if errMarshal != nil {
return nil, errMarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: out})
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-simple-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-simple-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+404
View File
@@ -0,0 +1,404 @@
use std::borrow::Cow;
use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;
use std::sync::atomic::{AtomicI64, Ordering};
const ABI_VERSION: u32 = 1;
const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static USAGE_COUNT: AtomicI64 = AtomicI64::new(0);
const REGISTRATION_RESPONSE: &str = r#"{"ok":true,"result":{"schema_version":1,"metadata":{"Name":"example-simple-rust","Version":"0.1.0","Author":"router-for-me","GitHubRepository":"https://github.com/router-for-me/CLIProxyAPI","Logo":"https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png","ConfigFields":[{"Name":"config1","Type":"boolean","Description":"Enables the example boolean option."},{"Name":"config2","Type":"string","Description":"Stores the example string option."},{"Name":"config3","Type":"integer","Description":"Stores the example integer option."},{"Name":"mode","Type":"enum","EnumValues":["safe","fast"],"Description":"Selects the example execution mode."}]},"capabilities":{"model_registrar":true,"model_provider":true,"auth_provider":true,"frontend_auth_provider":true,"executor":true,"executor_model_scope":"both","executor_input_formats":["chat-completions"],"executor_output_formats":["chat-completions"],"request_translator":true,"request_normalizer":true,"response_translator":true,"response_before_translator":true,"response_after_translator":true,"thinking_applier":true,"usage_plugin":true,"command_line_plugin":true,"management_api":true}}}"#;
const MODEL_RESPONSE: &str = r#"{"ok":true,"result":{"Provider":"plugin-example-rust","Models":[{"ID":"plugin-example-rust-model","Object":"model","OwnedBy":"plugin-example-rust","DisplayName":"Plugin Example Rust Model","SupportedGenerationMethods":["chat"],"ContextLength":8192,"MaxCompletionTokens":1024,"UserDefined":true}]}}"#;
const IDENTIFIER_RESPONSE: &str = r#"{"ok":true,"result":{"identifier":"plugin-example-rust"}}"#;
const LOGIN_START_RESPONSE: &str = r#"{"ok":true,"result":{"Provider":"plugin-example-rust","URL":"https://example.invalid/plugin-login","State":"example-state","ExpiresAt":"2030-01-01T00:00:00Z"}}"#;
const LOGIN_POLL_RESPONSE: &str = r#"{"ok":true,"result":{"Status":"error","Message":"example plugin has no interactive login"}}"#;
const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":true,"Principal":"plugin-example-rust","Metadata":{"provider":"plugin-example-rust"}}}"#;
const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#;
const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#;
const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#;
const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Resources":[{"Path":"/status","Menu":"Example Rust Plugin","Description":"CPA exposes this menu resource under /v0/resource/plugins/example-rust/status."}]}}"#;
const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#;
const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#;
#[repr(C)]
pub struct CliproxyBuffer {
ptr: *mut u8,
len: usize,
}
type HostCall = unsafe extern "C" fn(*mut std::ffi::c_void, *const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type HostFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginCall = unsafe extern "C" fn(*const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32;
type PluginFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize);
type PluginShutdown = unsafe extern "C" fn();
#[repr(C)]
pub struct CliproxyHostApi {
abi_version: u32,
host_ctx: *mut std::ffi::c_void,
call: Option<HostCall>,
free_buffer: Option<HostFree>,
}
#[repr(C)]
pub struct CliproxyPluginApi {
abi_version: u32,
call: Option<PluginCall>,
free_buffer: Option<PluginFree>,
shutdown: Option<PluginShutdown>,
}
#[no_mangle]
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
if plugin.is_null() {
return 1;
}
unsafe {
(*plugin).abi_version = ABI_VERSION;
(*plugin).call = Some(plugin_call);
(*plugin).free_buffer = Some(plugin_free);
(*plugin).shutdown = Some(plugin_shutdown);
}
let _ = host;
0
}
unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, request_len: usize, response: *mut CliproxyBuffer) -> i32 {
if !response.is_null() {
(*response).ptr = ptr::null_mut();
(*response).len = 0;
}
if method.is_null() {
write_response(response, INVALID_METHOD_RESPONSE);
return 1;
}
let method = match CStr::from_ptr(method).to_str() {
Ok(value) => value,
Err(_) => {
write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is not utf-8"}}"#);
return 1;
}
};
let request = if request.is_null() || request_len == 0 {
&[]
} else {
std::slice::from_raw_parts(request, request_len)
};
let response_text = handle_method(method, request);
write_response(response, response_text.as_ref());
0
}
fn handle_method(method: &str, request: &[u8]) -> Cow<'static, str> {
match method {
"plugin.register" | "plugin.reconfigure" => Cow::Borrowed(REGISTRATION_RESPONSE),
"model.register" | "model.static" | "model.for_auth" => Cow::Borrowed(MODEL_RESPONSE),
"auth.identifier" | "frontend_auth.identifier" | "executor.identifier" | "thinking.identifier" => Cow::Borrowed(IDENTIFIER_RESPONSE),
"auth.parse" => Cow::Owned(make_auth_parse_response(request)),
"auth.login.start" => Cow::Borrowed(LOGIN_START_RESPONSE),
"auth.login.poll" => Cow::Borrowed(LOGIN_POLL_RESPONSE),
"auth.refresh" => Cow::Owned(make_auth_refresh_response(request)),
"frontend_auth.authenticate" => Cow::Borrowed(FRONTEND_AUTH_RESPONSE),
"executor.execute" => Cow::Owned(make_executor_response(request)),
"executor.execute_stream" => Cow::Borrowed(STREAM_RESPONSE),
"executor.count_tokens" => Cow::Owned(make_count_tokens_response(request)),
"executor.http_request" | "management.handle" => Cow::Owned(make_http_response(request)),
"request.translate" | "request.normalize" | "response.translate" | "response.normalize_before" | "response.normalize_after" => Cow::Owned(make_payload_echo_response(request)),
"thinking.apply" => Cow::Owned(make_thinking_response(request)),
"usage.handle" => Cow::Owned(make_usage_response()),
"command_line.register" => Cow::Borrowed(CLI_REGISTER_RESPONSE),
"command_line.execute" => Cow::Borrowed(CLI_EXECUTE_RESPONSE),
"management.register" => Cow::Borrowed(MANAGEMENT_REGISTER_RESPONSE),
_ => Cow::Borrowed(UNKNOWN_METHOD_RESPONSE),
}
}
fn make_auth_data(request: &[u8]) -> String {
format!(
r#"{{"Provider":"plugin-example-rust","ID":"plugin-example-rust","FileName":"plugin-example-rust.json","Label":"Plugin Example Rust","StorageJSON":"{}","Metadata":{{"type":"plugin-example-rust"}}}}"#,
base64_encode(request),
)
}
fn make_auth_parse_response(request: &[u8]) -> String {
wrap_ok(&format!(r#"{{"Handled":true,"Auth":{}}}"#, make_auth_data(request)))
}
fn make_auth_refresh_response(request: &[u8]) -> String {
wrap_ok(&format!(r#"{{"Auth":{}}}"#, make_auth_data(request)))
}
fn make_payload_echo_response(request: &[u8]) -> String {
let json = String::from_utf8_lossy(request);
match extract_json_string(&json, "Body") {
Some(body) => wrap_ok(&format!(r#"{{"Body":"{}"}}"#, body)),
None => make_error("invalid_request", "request body field is required"),
}
}
fn make_executor_response(request: &[u8]) -> String {
let json = String::from_utf8_lossy(request);
let model = extract_json_string(&json, "Model").unwrap_or_else(|| "plugin-example-rust-model".to_string());
let format = extract_json_string(&json, "Format").unwrap_or_else(|| "chat-completions".to_string());
let payload = format!(
r#"{{"id":"plugin-example-rust","object":"chat.completion","model":"{}","format":"{}"}}"#,
json_escape(&model),
json_escape(&format),
);
wrap_ok(&format!(
r#"{{"Payload":"{}","Headers":{{"content-type":["application/json"]}}}}"#,
base64_encode(payload.as_bytes()),
))
}
fn make_count_tokens_response(request: &[u8]) -> String {
let json = String::from_utf8_lossy(request);
let payload = extract_json_string(&json, "Payload").unwrap_or_default();
let decoded = base64_decode(&payload);
let tokens = if decoded.is_empty() { 0 } else { (decoded.len() + 3) / 4 };
let payload_json = format!(r#"{{"total_tokens":{}}}"#, tokens);
wrap_ok(&format!(
r#"{{"Payload":"{}","Headers":{{"content-type":["application/json"]}}}}"#,
base64_encode(payload_json.as_bytes()),
))
}
fn make_http_response(request: &[u8]) -> String {
let json = String::from_utf8_lossy(request);
let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string());
let target = extract_json_string(&json, "URL")
.or_else(|| extract_json_string(&json, "Path"))
.unwrap_or_else(|| "/v0/resource/plugins/example-rust/status".to_string());
let body = format!(
r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#,
json_escape(&method),
json_escape(&target),
);
wrap_ok(&format!(
r#"{{"StatusCode":200,"Headers":{{"content-type":["application/json"]}},"Body":"{}"}}"#,
base64_encode(body.as_bytes()),
))
}
fn make_thinking_response(request: &[u8]) -> String {
let json = String::from_utf8_lossy(request);
let body_b64 = extract_json_string(&json, "Body").unwrap_or_else(|| "e30=".to_string());
let body = base64_decode(&body_b64);
let mode = extract_json_string(&json, "Mode").unwrap_or_default();
let level = extract_json_string(&json, "Level").unwrap_or_default();
let budget = extract_json_int(&json, "Budget").unwrap_or(0);
let rewritten = inject_thinking(&body, &mode, budget, &level);
wrap_ok(&format!(r#"{{"Body":"{}"}}"#, base64_encode(rewritten.as_bytes())))
}
fn make_usage_response() -> String {
let count = USAGE_COUNT.fetch_add(1, Ordering::SeqCst) + 1;
wrap_ok(&format!(r#"{{"Count":{}}}"#, count))
}
fn inject_thinking(body: &[u8], mode: &str, budget: i64, level: &str) -> String {
let body_text = String::from_utf8_lossy(body);
let trimmed = body_text.trim();
let thinking = format!(
r#""plugin_example_thinking":{{"mode":"{}","budget":{},"level":"{}"}}"#,
json_escape(mode),
budget,
json_escape(level),
);
if trimmed.starts_with('{') && trimmed.ends_with('}') {
let inner = &trimmed[1..trimmed.len() - 1];
if inner.trim().is_empty() {
format!("{{{}}}", thinking)
} else {
format!("{{{},{} }}", inner, thinking)
}
} else {
format!(
r#"{{"original_body":"{}","plugin_example_thinking":{{"mode":"{}","budget":{},"level":"{}"}}}}"#,
json_escape(&body_text),
json_escape(mode),
budget,
json_escape(level),
)
}
}
fn wrap_ok(result_json: &str) -> String {
format!(r#"{{"ok":true,"result":{}}}"#, result_json)
}
fn make_error(code: &str, message: &str) -> String {
format!(
r#"{{"ok":false,"error":{{"code":"{}","message":"{}"}}}}"#,
json_escape(code),
json_escape(message),
)
}
fn extract_json_string(json: &str, key: &str) -> Option<String> {
let pattern = format!(r#""{}""#, key);
let bytes = json.as_bytes();
let mut start = 0;
while let Some(relative) = json[start..].find(&pattern) {
let mut i = start + relative + pattern.len();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
start = i.saturating_add(1);
continue;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'"' {
start = i.saturating_add(1);
continue;
}
i += 1;
let mut out = Vec::new();
while i < bytes.len() {
if bytes[i] == b'"' {
return Some(String::from_utf8_lossy(&out).into_owned());
}
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 1;
match bytes[i] {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
other => out.push(other),
}
} else {
out.push(bytes[i]);
}
i += 1;
}
start = i;
}
None
}
fn extract_json_int(json: &str, key: &str) -> Option<i64> {
let pattern = format!(r#""{}""#, key);
let idx = json.find(&pattern)?;
let bytes = json.as_bytes();
let mut i = idx + pattern.len();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
return None;
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let start = i;
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
json[start..i].parse().ok()
}
fn json_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch.is_control() => out.push(' '),
ch => out.push(ch),
}
}
out
}
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(((data.len() + 2) / 3) * 4);
let mut i = 0;
while i < data.len() {
let a = data[i] as u32;
i += 1;
let b = if i < data.len() { data[i] as u32 } else { 0 };
i += 1;
let c = if i < data.len() { data[i] as u32 } else { 0 };
i += 1;
let triple = (a << 16) | (b << 8) | c;
out.push(BASE64_TABLE[((triple >> 18) & 0x3F) as usize] as char);
out.push(BASE64_TABLE[((triple >> 12) & 0x3F) as usize] as char);
out.push(BASE64_TABLE[((triple >> 6) & 0x3F) as usize] as char);
out.push(BASE64_TABLE[(triple & 0x3F) as usize] as char);
}
match data.len() % 3 {
1 => {
out.pop();
out.pop();
out.push('=');
out.push('=');
}
2 => {
out.pop();
out.push('=');
}
_ => {}
}
out
}
fn base64_decode(input: &str) -> Vec<u8> {
let mut out = Vec::with_capacity((input.len() * 3) / 4);
let mut value: i32 = 0;
let mut bits = -8;
for byte in input.bytes() {
if byte == b'=' {
break;
}
let digit = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => continue,
} as i32;
value = (value << 6) | digit;
bits += 6;
if bits >= 0 {
out.push(((value >> bits) & 0xFF) as u8);
bits -= 8;
}
}
out
}
unsafe extern "C" fn plugin_free(ptr: *mut std::ffi::c_void, len: usize) {
if !ptr.is_null() {
let _ = Vec::from_raw_parts(ptr as *mut u8, len, len);
}
}
unsafe extern "C" fn plugin_shutdown() {}
fn write_response(response: *mut CliproxyBuffer, text: &str) {
if response.is_null() {
return;
}
let mut bytes = text.as_bytes().to_vec();
let len = bytes.len();
let ptr = bytes.as_mut_ptr();
std::mem::forget(bytes);
unsafe {
(*response).ptr = ptr;
(*response).len = len;
}
}