chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback claude-web-search-router
|
||||
LANGUAGES := go c rust
|
||||
BIN_DIR := $(CURDIR)/bin
|
||||
BUILD_DIR := $(BIN_DIR)/build
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
PLUGIN_EXT := dll
|
||||
RUST_DYLIB_PREFIX :=
|
||||
RUST_DYLIB_EXT := dll
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
PLUGIN_EXT := dylib
|
||||
RUST_DYLIB_PREFIX := lib
|
||||
RUST_DYLIB_EXT := dylib
|
||||
else
|
||||
PLUGIN_EXT := so
|
||||
RUST_DYLIB_PREFIX := lib
|
||||
RUST_DYLIB_EXT := so
|
||||
endif
|
||||
|
||||
.PHONY: build list clean
|
||||
|
||||
build: $(foreach example,$(EXAMPLES),$(foreach lang,$(LANGUAGES),$(BIN_DIR)/$(example)-$(lang).$(PLUGIN_EXT)))
|
||||
|
||||
list:
|
||||
@$(foreach example,$(EXAMPLES),$(foreach lang,$(LANGUAGES),echo $(example)/$(lang);))
|
||||
|
||||
clean:
|
||||
rm -rf $(BIN_DIR)
|
||||
|
||||
$(BIN_DIR):
|
||||
mkdir -p $(BIN_DIR)
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
|
||||
$(BIN_DIR)/%-go.$(PLUGIN_EXT): %/go/main.go %/go/go.mod | $(BIN_DIR)
|
||||
cd $*/go && go build -buildmode=c-shared -o $(abspath $@) .
|
||||
rm -f $(BIN_DIR)/$*-go.h
|
||||
|
||||
$(BIN_DIR)/%-c.$(PLUGIN_EXT): %/c/CMakeLists.txt %/c/src/plugin.c | $(BIN_DIR) $(BUILD_DIR)
|
||||
cmake -S $*/c -B $(BUILD_DIR)/$*/c -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$(BIN_DIR)
|
||||
cmake --build $(BUILD_DIR)/$*/c
|
||||
|
||||
$(BIN_DIR)/%-rust.$(PLUGIN_EXT): %/rust/Cargo.toml %/rust/Cargo.lock %/rust/src/lib.rs | $(BIN_DIR) $(BUILD_DIR)
|
||||
cd $*/rust && CARGO_TARGET_DIR=$(abspath $(BUILD_DIR)/$*/rust) cargo build --release --locked
|
||||
cp "$(BUILD_DIR)/$*/rust/release/$(RUST_DYLIB_PREFIX)cliproxy_$(subst -,_,$*)_rust.$(RUST_DYLIB_EXT)" "$@"
|
||||
@@ -0,0 +1,109 @@
|
||||
# Standard Dynamic Library Plugin Examples
|
||||
|
||||
This directory contains standard dynamic library plugin examples for the CLIProxyAPI C ABI.
|
||||
|
||||
## Layout
|
||||
|
||||
- `simple/`- : Go-only plugin resource that calls host auth file callbacks (, , , ).
|
||||
- : full provider-native skeleton that declares every supported capability.
|
||||
- `model/`: model capability only.
|
||||
- `auth/`: auth provider capability only.
|
||||
- `frontend-auth/`: frontend auth provider capability only.
|
||||
- `frontend-auth-exclusive/`: frontend auth provider that becomes the only request authentication provider when selected.
|
||||
- `executor/`: executor capability only.
|
||||
- `protocol-format/`: minimal executor focused on input/output format declarations.
|
||||
- `request-translator/`: request translation capability only.
|
||||
- `request-normalizer/`: request normalization capability only.
|
||||
- `codex-service-tier/`: Go-only request normalizer that sets Codex `gpt-5.5` requests to the priority service tier when enabled.
|
||||
- `scheduler/`: Go-only scheduler that can select a configured auth ID, delegate to a built-in scheduler, or deny picks.
|
||||
- `claude-web-search-router/`: ModelRouter + executor for Claude Code built-in `web_search` (antigravity / codex / xai / Tavily). See `claude-web-search-router/README.md`.
|
||||
- `response-translator/`: response translation capability only.
|
||||
- `response-normalizer/`: response normalization capability only.
|
||||
- `thinking/`: thinking applier capability only.
|
||||
- `usage/`: usage observer capability only.
|
||||
- `cli/`: command-line capability only.
|
||||
- `management-api/`: Management API and resource capability only.
|
||||
- `host-callback/`: minimal plugin resource that demonstrates host callbacks.
|
||||
- `host-callback-auth-files/`: Go-only plugin resource that calls host auth file callbacks.
|
||||
- `host-model-callback/`: Go-only plugin resource that calls the host model execution callbacks.
|
||||
|
||||
Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.
|
||||
|
||||
## Codex Service Tier
|
||||
|
||||
`codex-service-tier` declares the request normalization capability. When `fast` is `true`, it sets `service_tier` to `priority` for requests where `req.ToFormat` is `codex` and `req.Model` is `gpt-5.5`.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
codex-service-tier:
|
||||
enabled: true
|
||||
priority: 1
|
||||
fast: false
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Host Auth Files Callback
|
||||
|
||||
`host-callback-auth-files` declares the Management API capability and exposes a browser resource named `Host Auth Files`. The resource demonstrates `host.auth.list`, `host.auth.get` (physical JSON file), `host.auth.get_runtime`, and `host.auth.save`.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
host-callback-auth-files:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
See `host-callback-auth-files/README.md` for URL examples.
|
||||
|
||||
## Host Model Callback
|
||||
|
||||
`host-model-callback` declares the Management API capability and exposes a browser resource named `Host Model Callback`. The resource calls `host.model.execute` for non-streaming requests and `host.model.execute_stream` plus `host.model.stream_read` for streaming requests. It demonstrates explicit stream close with `host.model.stream_close` and an `implicit_close=true` option for RPC-scope host cleanup.
|
||||
|
||||
When the resource forwards its `host_callback_id`, CPA identifies the plugin that initiated the host model callback and skips that same plugin's interceptors for the nested execution. This makes host model callbacks non-recursive for the caller while allowing other plugins to intercept the nested request.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
host-model-callback:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
The default example model is `gpt-5.5`, but the request succeeds only when the current CPA model and auth configuration can route that model.
|
||||
|
||||
## Scheduler
|
||||
|
||||
`scheduler` declares the scheduler capability. It can select a configured auth ID from the candidate list, delegate to the built-in `fill-first` or `round-robin` scheduler, or reject picks when `deny` is `true`.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
scheduler:
|
||||
enabled: true
|
||||
priority: 1
|
||||
auth_id: ""
|
||||
delegate: ""
|
||||
deny: false
|
||||
```
|
||||
|
||||
`auth_id` selects a matching candidate when `delegate` is empty. `delegate` accepts `""`, `fill-first`, or `round-robin`; other non-empty values leave the pick unhandled. `deny` returns a scheduler error.
|
||||
|
||||
## Build All Examples
|
||||
|
||||
```bash
|
||||
make -C examples/plugin list
|
||||
make -C examples/plugin build
|
||||
```
|
||||
|
||||
Artifacts are written to `examples/plugin/bin`.
|
||||
|
||||
## Notes
|
||||
|
||||
`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.
|
||||
|
||||
`host-callback` uses a minimal plugin resource because host callbacks are invoked from plugin methods and are not standalone capabilities.
|
||||
|
||||
Menu resources returned by `management.register` through the `resources` field are exposed by CPA under `/v0/resource/plugins/<pluginID>/...`. Authenticated plugin Management API routes remain under `/v0/management/...`.
|
||||
@@ -0,0 +1,108 @@
|
||||
- :仅 Go 实现的插件资源,演示 host 凭证文件回调(、、、)。
|
||||
- # 标准动态库插件示例
|
||||
|
||||
本目录包含 CLIProxyAPI C ABI 的标准动态库插件示例。
|
||||
|
||||
## 目录布局
|
||||
|
||||
- `simple/`:声明全部支持能力的完整骨架示例。
|
||||
- `model/`:只演示模型能力。
|
||||
- `auth/`:只演示认证提供方能力。
|
||||
- `frontend-auth/`:只演示前端认证提供方能力。
|
||||
- `frontend-auth-exclusive/`:演示被选中后成为唯一请求认证方式的前端认证提供方。
|
||||
- `executor/`:只演示执行器能力。
|
||||
- `protocol-format/`:使用最小执行器重点演示输入和输出格式声明。
|
||||
- `request-translator/`:只演示请求转换能力。
|
||||
- `request-normalizer/`:只演示请求规整能力。
|
||||
- `codex-service-tier/`:仅 Go 实现的请求规整插件,启用后会将 Codex `gpt-5.5` 请求设置为 priority service tier。
|
||||
- `scheduler/`:仅 Go 实现的调度插件,可选择指定 auth ID、委托内置调度器或拒绝调度。
|
||||
- `response-translator/`:只演示响应转换能力。
|
||||
- `response-normalizer/`:只演示响应规整能力。
|
||||
- `thinking/`:只演示 Thinking 处理能力。
|
||||
- `usage/`:只演示 Usage 观察能力。
|
||||
- `cli/`:只演示命令行扩展能力。
|
||||
- `management-api/`:只演示 Management API 和资源扩展能力。
|
||||
- `host-callback/`:使用最小插件资源演示宿主回调。
|
||||
- `host-callback-auth-files/`:仅 Go 实现的插件资源,演示 host 凭证文件回调。
|
||||
- `host-model-callback/`:仅 Go 实现的插件资源,演示调用宿主模型执行回调。
|
||||
|
||||
多数标准能力示例都包含 `go/`、`c/` 和 `rust/` 三个子目录。专用示例可能只提供所需的实现语言。
|
||||
|
||||
## Codex Service Tier
|
||||
|
||||
`codex-service-tier` 声明请求规整能力。当 `fast` 为 `true` 时,如果 `req.ToFormat` 为 `codex` 且 `req.Model` 为 `gpt-5.5`,它会将 `service_tier` 设置为 `priority`。
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
codex-service-tier:
|
||||
enabled: true
|
||||
priority: 1
|
||||
fast: false
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Host Auth Files 回调
|
||||
|
||||
`host-callback-auth-files` 声明 Management API 能力,并暴露名为 `Host Auth Files` 的浏览器资源,演示 `host.auth.list`、`host.auth.get`(物理 JSON 文件)、`host.auth.get_runtime` 与 `host.auth.save`。
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
host-callback-auth-files:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
详见 `host-callback-auth-files/README.md`。
|
||||
|
||||
## Host Model Callback
|
||||
|
||||
`host-model-callback` 声明 Management API 能力,并暴露名为 `Host Model Callback` 的浏览器资源。该资源在非流式请求中调用 `host.model.execute`,在流式请求中调用 `host.model.execute_stream` 和 `host.model.stream_read`。它演示了通过 `host.model.stream_close` 显式关闭流,也提供 `implicit_close=true` 用于演示 RPC 作用域结束时的宿主隐式清理。
|
||||
|
||||
当该资源转发自身收到的 `host_callback_id` 时,CPA 会识别发起宿主模型回调的插件,并在嵌套模型执行中跳过同一个插件的拦截器。因此宿主模型回调不会递归调用发起插件自身,但其他已启用插件仍可拦截这次嵌套请求。
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
host-model-callback:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
默认示例模型是 `gpt-5.5`,但请求能否成功取决于当前 CPA 模型和认证配置是否可以路由该模型。
|
||||
|
||||
## Scheduler
|
||||
|
||||
`scheduler` 声明调度能力。它可以从候选列表中选择配置的 auth ID,委托内置的 `fill-first` 或 `round-robin` 调度器,或在 `deny` 为 `true` 时拒绝调度。
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
scheduler:
|
||||
enabled: true
|
||||
priority: 1
|
||||
auth_id: ""
|
||||
delegate: ""
|
||||
deny: false
|
||||
```
|
||||
|
||||
`auth_id` 会在 `delegate` 为空时选择匹配候选。`delegate` 支持 `""`、`fill-first` 和 `round-robin`;其他非空值会让本插件不处理本次调度。`deny` 会返回调度错误。
|
||||
|
||||
## 构建全部示例
|
||||
|
||||
```bash
|
||||
make -C examples/plugin list
|
||||
make -C examples/plugin build
|
||||
```
|
||||
|
||||
构建产物会写入 `examples/plugin/bin`。
|
||||
|
||||
## 说明
|
||||
|
||||
`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。
|
||||
|
||||
`host-callback` 使用最小插件资源承载,因为宿主回调只能从插件方法内部发起,不是独立能力。
|
||||
|
||||
`management.register` 通过 `resources` 字段返回的菜单资源会由 CPA 暴露在 `/v0/resource/plugins/<pluginID>/...` 下。需要认证的插件自有 Management API 路由仍保留在 `/v0/management/...` 下。
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_auth_c C)
|
||||
|
||||
add_library(cliproxy_auth_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_auth_c PROPERTIES
|
||||
OUTPUT_NAME "auth-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "auth.identifier") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-auth-c\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "auth.parse") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "auth.login.start") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-auth-c\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "auth.login.poll") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "auth.refresh") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Auth\":{\"Provider\":\"example-auth-c\",\"ID\":\"example-auth-c\",\"FileName\":\"example-auth-c.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWMiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-c\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,181 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}")
|
||||
case "auth.identifier":
|
||||
return okEnvelopeJSON("{\"identifier\":\"example-auth-go\"}")
|
||||
case "auth.parse":
|
||||
return okEnvelopeJSON("{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
|
||||
case "auth.login.start":
|
||||
return okEnvelopeJSON("{\"Provider\":\"example-auth-go\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}")
|
||||
case "auth.login.poll":
|
||||
return okEnvelopeJSON("{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}}}")
|
||||
case "auth.refresh":
|
||||
return okEnvelopeJSON("{\"Auth\":{\"Provider\":\"example-auth-go\",\"ID\":\"example-auth-go\",\"FileName\":\"example-auth-go.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLWdvIiwidG9rZW4iOiJleGFtcGxlLXRva2VuIn0=\",\"Metadata\":{\"type\":\"example-auth-go\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-auth-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-auth-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"auth_provider\":true}}}"); 0 },"auth.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-auth-rust\"}}"); 0 },"auth.parse" => { write_response(response, "{\"ok\":true,\"result\":{\"Handled\":true,\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}}}}"); 0 },"auth.login.start" => { write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-auth-rust\",\"URL\":\"https://example.invalid/login\",\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}"); 0 },"auth.login.poll" => { write_response(response, "{\"ok\":true,\"result\":{\"Status\":\"success\",\"Message\":\"example login complete\",\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}}}}"); 0 },"auth.refresh" => { write_response(response, "{\"ok\":true,\"result\":{\"Auth\":{\"Provider\":\"example-auth-rust\",\"ID\":\"example-auth-rust\",\"FileName\":\"example-auth-rust.json\",\"Label\":\"Auth Example\",\"StorageJSON\":\"eyJ0eXBlIjoiZXhhbXBsZS1hdXRoLXJ1c3QiLCJ0b2tlbiI6ImV4YW1wbGUtdG9rZW4ifQ==\",\"Metadata\":{\"type\":\"example-auth-rust\"}},\"NextRefreshAfter\":\"2030-01-01T00:00:00Z\"}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# Claude Code Web Search Router (ModelRouter example)
|
||||
|
||||
This plugin demonstrates **ModelRouter** on Claude Code built-in `web_search` requests (see `temp/1.json` in the repo root for a captured request/response).
|
||||
|
||||
## What it detects
|
||||
|
||||
- Inbound protocol `claude` / `anthropic`
|
||||
- `tools[]` with `type` `web_search_20250305` or `web_search_20260209`
|
||||
- Optional Claude Code heuristics: system text like “web search tool use”, or user text
|
||||
`Perform a web search for the query: …`
|
||||
|
||||
## Routes (`route` config)
|
||||
|
||||
| Value | Behavior |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fallback` (**default**) | Plugin **executor** runs **antigravity → codex → xai → tavily** (built-ins via `host.model.*`, Tavily in-plugin). On **429/503/502**, tries the next backend in the same request. Backends that fail often are **deprioritized on later requests** (in-memory penalty; no extra config). |
|
||||
| `antigravity_google` / `codex_web_search` / `xai_web_search` / `tavily` | Same orchestration for that backend’s chain member(s): execution retry + penalty apply when multiple backends are eligible. |
|
||||
| `default_provider` | `default_provider` + optional `default_provider_model` via built-in AuthManager (not orchestrated). |
|
||||
Routing for `fallback` requires at least one runnable backend (providers in `AvailableProviders` where needed, resolvable antigravity model, or `tavily_api_keys`).
|
||||
|
||||
### xAI web search notes (aligned with upstream docs)
|
||||
|
||||
- **Model**: xAI documents `grok-4.3` for server-side `web_search`. This example sets `TargetModel` to **`grok-4.3`** when `xai_model` is empty (do not forward `claude-sonnet-4-6` to xAI).
|
||||
- **Request shape**: Responses API `input` + `tools[]` with `"type": "web_search"`. Optional `filters.allowed_domains` / `filters.excluded_domains` (max 5 each, mutually exclusive).
|
||||
- **Claude mapping today**: `internal/translator/codex/claude` copies Claude `allowed_domains` → `filters.allowed_domains`. Claude `blocked_domains` is **not** mapped to `excluded_domains` yet.
|
||||
- **Executor**: `xai_executor` normalizes tools (drops unsupported `external_web_access` if present) and posts to `/responses`.
|
||||
- **Response**: Citations / server tool metadata come back through OpenAI Responses SSE and are converted toward Claude `server_tool_use` / `web_search_tool_result` where the response translator supports it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Plugin config lives under `plugins.configs.claude-web-search-router` (key must match the plugin name). Load the shared library via `plugins.path`.
|
||||
|
||||
### Recommended: fallback chain (default)
|
||||
|
||||
Tries **antigravity → codex → xai → tavily**; configure `tavily_api_keys` so the last step can succeed when built-in providers are missing or unavailable.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
path:
|
||||
- /absolute/path/to/examples/plugin/bin/claude-web-search-router-go.dylib
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: fallback
|
||||
antigravity_model: "" # empty: registry lookup, then first supports_web_search
|
||||
codex_model: "gpt-5.4-mini"
|
||||
xai_model: "grok-4.3"
|
||||
tavily_api_keys:
|
||||
- "tvly-xxxxxxxx"
|
||||
# - "tvly-yyyyyyyy" # optional: round-robin
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
Omit `route` to use the same default (`fallback`).
|
||||
|
||||
### Minimal fallback (Tavily as last resort only)
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: fallback
|
||||
tavily_api_keys:
|
||||
- "tvly-xxxxxxxx"
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
### Single backend (no fallback)
|
||||
|
||||
**Antigravity only:**
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: antigravity_google
|
||||
antigravity_model: "gemini-3.1-flash-lite"
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
**Codex only:**
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: codex_web_search
|
||||
codex_model: "gpt-5.4-mini"
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
**xAI only:**
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: xai_web_search
|
||||
xai_model: "grok-4.3"
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
**Tavily only (plugin executor):**
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: tavily
|
||||
tavily_api_keys:
|
||||
- "tvly-xxxxxxxx"
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
**Built-in provider via `default_provider`:**
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
priority: 20
|
||||
route: default_provider
|
||||
default_provider: claude
|
||||
default_provider_model: ""
|
||||
require_web_search_only: true
|
||||
```
|
||||
|
||||
### Disable or relax detection
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
claude-web-search-router:
|
||||
enabled: false # plugin declines; host may use default Claude path
|
||||
|
||||
# Or keep enabled but allow mixed tool lists:
|
||||
claude-web-search-router:
|
||||
enabled: true
|
||||
route: fallback
|
||||
require_web_search_only: false
|
||||
```
|
||||
|
||||
### Config field reference
|
||||
|
||||
| Field | Description |
|
||||
| ----- | ----------- |
|
||||
| `enabled` | `false` → `Handled: false` for all web_search matches |
|
||||
| `priority` | Host plugin order for ModelRouter (higher runs earlier; see main repo plugins docs) |
|
||||
| `route` | `fallback` (default), `antigravity_google`, `codex_web_search`, `xai_web_search`, `tavily`, `default_provider` |
|
||||
| `antigravity_model` | Antigravity execution model; never the client Claude model name |
|
||||
| `codex_model` | Codex model; empty → `gpt-5.4-mini` |
|
||||
| `xai_model` | xAI model; empty → `grok-4.3` |
|
||||
| `default_provider` / `default_provider_model` | Used when `route=default_provider` |
|
||||
| `tavily_api_keys` | Required for `route=tavily` or fallback last step |
|
||||
| `require_web_search_only` | `true` matches Claude Code–style exclusive `web_search` tools |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make -C examples/plugin bin/claude-web-search-router-go.dylib
|
||||
```
|
||||
|
||||
Use `.so` on Linux and `.dll` on Windows. Point `plugins.path` at the built artifact.
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type claudeStreamBuilder struct {
|
||||
model string
|
||||
messageID string
|
||||
toolUseID string
|
||||
index int
|
||||
inputTokens int
|
||||
}
|
||||
|
||||
func newClaudeStreamBuilder(model string) *claudeStreamBuilder {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-6"
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
return &claudeStreamBuilder{
|
||||
model: model,
|
||||
messageID: fmt.Sprintf("msg_%x", now),
|
||||
toolUseID: fmt.Sprintf("srvtoolu_%d", now),
|
||||
inputTokens: 85,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *claudeStreamBuilder) buildStreamWithQuery(query string, hits []claudeWebSearchHit, answer string) []byte {
|
||||
var chunks []string
|
||||
chunks = append(chunks, b.event("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": b.messageID, "type": "message", "role": "assistant", "content": []any{},
|
||||
"model": b.model, "stop_reason": nil, "stop_sequence": nil,
|
||||
"usage": map[string]any{"input_tokens": b.inputTokens, "output_tokens": 0},
|
||||
},
|
||||
}))
|
||||
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
||||
"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]any{},
|
||||
}))
|
||||
partial, _ := json.Marshal(map[string]string{"query": query})
|
||||
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": b.index,
|
||||
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(partial)},
|
||||
}))
|
||||
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||
b.index++
|
||||
|
||||
resultContent := webSearchResultBlocks(hits)
|
||||
chunks = append(chunks, b.blockStart(b.index, map[string]any{
|
||||
"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": resultContent,
|
||||
}))
|
||||
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||
b.index++
|
||||
|
||||
text := composeAnswerText(answer, hits)
|
||||
outputTokens := estimateTokens(text)
|
||||
chunks = append(chunks, b.blockStart(b.index, map[string]any{"type": "text", "text": ""}))
|
||||
chunks = append(chunks, b.event("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": b.index,
|
||||
"delta": map[string]any{"type": "text_delta", "text": text},
|
||||
}))
|
||||
chunks = append(chunks, b.event("content_block_stop", map[string]any{"type": "content_block_stop", "index": b.index}))
|
||||
|
||||
chunks = append(chunks, b.event("message_delta", map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
"usage": map[string]any{
|
||||
"input_tokens": b.inputTokens, "output_tokens": outputTokens,
|
||||
"server_tool_use": map[string]any{"web_search_requests": 1},
|
||||
},
|
||||
}))
|
||||
chunks = append(chunks, b.event("message_stop", map[string]any{"type": "message_stop"}))
|
||||
return []byte(strings.Join(chunks, ""))
|
||||
}
|
||||
|
||||
func (b *claudeStreamBuilder) buildMessageJSON(query string, hits []claudeWebSearchHit, answer string) []byte {
|
||||
text := composeAnswerText(answer, hits)
|
||||
content := []map[string]any{
|
||||
{"type": "server_tool_use", "id": b.toolUseID, "name": "web_search", "input": map[string]string{"query": query}},
|
||||
{"type": "web_search_tool_result", "tool_use_id": b.toolUseID, "content": webSearchResultBlocks(hits)},
|
||||
{"type": "text", "text": text},
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": b.messageID, "type": "message", "role": "assistant", "model": b.model,
|
||||
"content": content, "stop_reason": "end_turn", "stop_sequence": nil,
|
||||
"usage": map[string]any{
|
||||
"input_tokens": b.inputTokens, "output_tokens": estimateTokens(text),
|
||||
"server_tool_use": map[string]any{"web_search_requests": 1},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return raw
|
||||
}
|
||||
|
||||
func webSearchResultBlocks(hits []claudeWebSearchHit) []map[string]any {
|
||||
resultContent := make([]map[string]any, 0, len(hits))
|
||||
for _, hit := range hits {
|
||||
title := hit.Title
|
||||
if title == "" {
|
||||
title = hostFromURL(hit.URL)
|
||||
}
|
||||
resultContent = append(resultContent, map[string]any{
|
||||
"type": "web_search_result", "title": title, "url": hit.URL, "page_age": nil,
|
||||
})
|
||||
}
|
||||
return resultContent
|
||||
}
|
||||
|
||||
func (b *claudeStreamBuilder) event(eventType string, data map[string]any) string {
|
||||
raw, _ := json.Marshal(data)
|
||||
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(raw))
|
||||
}
|
||||
|
||||
func (b *claudeStreamBuilder) blockStart(index int, block map[string]any) string {
|
||||
return b.event("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": index, "content_block": block,
|
||||
})
|
||||
}
|
||||
|
||||
func composeAnswerText(answer string, hits []claudeWebSearchHit) string {
|
||||
if strings.TrimSpace(answer) != "" {
|
||||
return answer
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return "No web search results were returned."
|
||||
}
|
||||
var buf strings.Builder
|
||||
for i, hit := range hits {
|
||||
if i > 0 {
|
||||
buf.WriteString("\n\n")
|
||||
}
|
||||
if hit.Title != "" {
|
||||
buf.WriteString(hit.Title)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if hit.URL != "" {
|
||||
buf.WriteString(hit.URL)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if hit.Snippet != "" {
|
||||
buf.WriteString(hit.Snippet)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func hostFromURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
withoutScheme := raw
|
||||
if idx := strings.Index(raw, "://"); idx >= 0 {
|
||||
withoutScheme = raw[idx+3:]
|
||||
}
|
||||
if slash := strings.Index(withoutScheme, "/"); slash >= 0 {
|
||||
return withoutScheme[:slash]
|
||||
}
|
||||
return withoutScheme
|
||||
}
|
||||
|
||||
func estimateTokens(text string) int {
|
||||
n := len([]rune(text)) / 4
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfigurePreservesDefaultBooleansWhenConfigIsPartial(t *testing.T) {
|
||||
raw := mustJSON(t, lifecycleRequest{ConfigYAML: []byte("route: codex_web_search\n")})
|
||||
|
||||
if errConfigure := configure(raw); errConfigure != nil {
|
||||
t.Fatalf("configure() error = %v", errConfigure)
|
||||
}
|
||||
|
||||
cfg := loadedConfig()
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("Enabled = false, want default true")
|
||||
}
|
||||
if !cfg.RequireWebSearchOnly {
|
||||
t.Fatal("RequireWebSearchOnly = false, want default true")
|
||||
}
|
||||
if cfg.Route != string(backendCodexWebSearch) {
|
||||
t.Fatalf("Route = %q, want codex_web_search", cfg.Route)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
claudeWebSearchToolTypeA = "web_search_20250305"
|
||||
claudeWebSearchToolTypeB = "web_search_20260209"
|
||||
)
|
||||
|
||||
// isClaudeSourceFormat reports whether the inbound protocol is Claude / Anthropic Messages.
|
||||
func isClaudeSourceFormat(source string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "claude", "anthropic":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isClaudeTypedWebSearchToolType(toolType string) bool {
|
||||
return toolType == claudeWebSearchToolTypeA || toolType == claudeWebSearchToolTypeB
|
||||
}
|
||||
|
||||
func hasClaudeTypedWebSearchTool(body []byte) bool {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasOnlyClaudeTypedWebSearchTools(body []byte) bool {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
hasWebSearch := false
|
||||
for _, tool := range tools.Array() {
|
||||
if isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
hasWebSearch = true
|
||||
continue
|
||||
}
|
||||
if tool.Get("type").String() != "" || tool.Get("name").String() != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasWebSearch
|
||||
}
|
||||
|
||||
func looksLikeClaudeCodeWebSearchAssistant(body []byte) bool {
|
||||
system := gjson.GetBytes(body, "system")
|
||||
if system.IsArray() {
|
||||
for _, block := range system.Array() {
|
||||
text := strings.ToLower(block.Get("text").String())
|
||||
if strings.Contains(text, "web search tool use") ||
|
||||
strings.Contains(text, "performing a web search") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if system.Type == gjson.String {
|
||||
text := strings.ToLower(system.String())
|
||||
if strings.Contains(text, "web search tool use") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.IsArray() {
|
||||
return false
|
||||
}
|
||||
for _, message := range messages.Array() {
|
||||
if message.Get("role").String() != "user" {
|
||||
continue
|
||||
}
|
||||
text := strings.ToLower(extractClaudeMessageText(message.Get("content")))
|
||||
if strings.HasPrefix(text, "perform a web search for the query:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isClaudeCodeBuiltinWebSearchRequest(body []byte, requireWebSearchOnly bool) bool {
|
||||
if !hasClaudeTypedWebSearchTool(body) {
|
||||
return false
|
||||
}
|
||||
if requireWebSearchOnly && !hasOnlyClaudeTypedWebSearchTools(body) {
|
||||
return false
|
||||
}
|
||||
return looksLikeClaudeCodeWebSearchAssistant(body) || hasOnlyClaudeTypedWebSearchTools(body)
|
||||
}
|
||||
|
||||
func extractClaudeWebSearchQuery(body []byte) string {
|
||||
if q := extractQueryFromPerformPrefix(body); q != "" {
|
||||
return q
|
||||
}
|
||||
return extractQueryFromUserMessages(body)
|
||||
}
|
||||
|
||||
func extractQueryFromPerformPrefix(body []byte) string {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.IsArray() {
|
||||
return ""
|
||||
}
|
||||
const prefix = "perform a web search for the query:"
|
||||
for _, message := range messages.Array() {
|
||||
if message.Get("role").String() != "user" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(extractClaudeMessageText(message.Get("content")))
|
||||
lower := strings.ToLower(text)
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return strings.TrimSpace(text[len(prefix):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractQueryFromUserMessages(body []byte) string {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.IsArray() {
|
||||
return ""
|
||||
}
|
||||
arr := messages.Array()
|
||||
for i := len(arr) - 1; i >= 0; i-- {
|
||||
message := arr[i]
|
||||
role := message.Get("role").String()
|
||||
if role != "" && role != "user" {
|
||||
continue
|
||||
}
|
||||
if query := strings.TrimSpace(extractClaudeMessageText(message.Get("content"))); query != "" {
|
||||
return query
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractClaudeMessageText(content gjson.Result) string {
|
||||
if content.Type == gjson.String {
|
||||
return content.String()
|
||||
}
|
||||
if !content.IsArray() {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, block := range content.Array() {
|
||||
if block.Get("type").String() != "text" {
|
||||
continue
|
||||
}
|
||||
if text := strings.TrimSpace(block.Get("text").String()); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func extractClaudeWebSearchMaxUses(body []byte, defaultMax int) int {
|
||||
if defaultMax <= 0 {
|
||||
defaultMax = 5
|
||||
}
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return defaultMax
|
||||
}
|
||||
for _, tool := range tools.Array() {
|
||||
if !isClaudeTypedWebSearchToolType(tool.Get("type").String()) {
|
||||
continue
|
||||
}
|
||||
if maxUses := int(tool.Get("max_uses").Int()); maxUses > 0 {
|
||||
return maxUses
|
||||
}
|
||||
}
|
||||
return defaultMax
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectClaudeCodeWebSearchFromFixture(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "..", "temp", "1.json")
|
||||
raw, errRead := os.ReadFile(root)
|
||||
if errRead != nil {
|
||||
t.Skipf("fixture not found: %v", errRead)
|
||||
}
|
||||
// Fixture is HTTP capture; extract JSON request body between first blank line after headers.
|
||||
body := extractHTTPJSONBody(raw)
|
||||
if len(body) == 0 {
|
||||
t.Fatal("empty JSON body in fixture")
|
||||
}
|
||||
if !hasClaudeTypedWebSearchTool(body) {
|
||||
t.Fatal("fixture should declare web_search_20250305")
|
||||
}
|
||||
if !looksLikeClaudeCodeWebSearchAssistant(body) {
|
||||
t.Fatal("fixture should match Claude Code web search assistant heuristics")
|
||||
}
|
||||
if !isClaudeCodeBuiltinWebSearchRequest(body, true) {
|
||||
t.Fatal("expected match with require_web_search_only=true")
|
||||
}
|
||||
query := extractClaudeWebSearchQuery(body)
|
||||
if query == "" {
|
||||
t.Fatal("expected non-empty search query")
|
||||
}
|
||||
if want := "北京天气 2026年6月16日"; query != want {
|
||||
t.Fatalf("query = %q, want %q", query, want)
|
||||
}
|
||||
}
|
||||
|
||||
func extractHTTPJSONBody(raw []byte) []byte {
|
||||
text := string(raw)
|
||||
idx := 0
|
||||
for {
|
||||
next := findDoubleNewline(text, idx)
|
||||
if next < 0 {
|
||||
return nil
|
||||
}
|
||||
rest := trimLeft(text[next:])
|
||||
if len(rest) > 0 && rest[0] == '{' {
|
||||
return []byte(rest)
|
||||
}
|
||||
idx = next + 1
|
||||
}
|
||||
}
|
||||
|
||||
func findDoubleNewline(s string, from int) int {
|
||||
for i := from; i+1 < len(s); i++ {
|
||||
if s[i] == '\n' && s[i+1] == '\n' {
|
||||
return i + 2
|
||||
}
|
||||
if s[i] == '\r' && i+3 < len(s) && s[i+1] == '\n' && s[i+2] == '\r' && s[i+3] == '\n' {
|
||||
return i + 4
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func trimLeft(s string) string {
|
||||
for len(s) > 0 && (s[0] == '\r' || s[0] == '\n' || s[0] == ' ') {
|
||||
s = s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
type streamOrchestrationRunner func(context.Context, pluginapi.ExecutorRequest, string, string) error
|
||||
|
||||
type pluginStreamCloser func(string, string)
|
||||
|
||||
func executeStream(raw []byte) ([]byte, error) {
|
||||
var req rpcExecutorRequest
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, errUnmarshal
|
||||
}
|
||||
return startExecutorStream(req, runWebSearchStreamOrchestration, closePluginStream)
|
||||
}
|
||||
|
||||
func startExecutorStream(req rpcExecutorRequest, runner streamOrchestrationRunner, closeStream pluginStreamCloser) ([]byte, error) {
|
||||
streamID := strings.TrimSpace(req.StreamID)
|
||||
if streamID == "" {
|
||||
return errorEnvelope("executor_error", "stream_id is required for executor.execute_stream"), nil
|
||||
}
|
||||
if runner == nil {
|
||||
return errorEnvelope("executor_error", "stream orchestration runner is unavailable"), nil
|
||||
}
|
||||
if closeStream == nil {
|
||||
closeStream = func(string, string) {}
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
closeStream(streamID, fmt.Sprintf("stream orchestration panic: %v", recovered))
|
||||
}
|
||||
}()
|
||||
errRun := runner(context.Background(), req.ExecutorRequest, req.HostCallbackID, streamID)
|
||||
if errRun != nil {
|
||||
closeStream(streamID, errRun.Error())
|
||||
return
|
||||
}
|
||||
closeStream(streamID, "")
|
||||
}()
|
||||
return okEnvelope(map[string]any{
|
||||
"headers": http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
type executionPlan struct {
|
||||
backend routeBackend
|
||||
model string
|
||||
}
|
||||
|
||||
func buildExecutionPlans(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
|
||||
return buildExecutionPlansInternal(cfg, req, true)
|
||||
}
|
||||
|
||||
func buildExecutionPlansForExecute(cfg pluginConfig, req pluginapi.ModelRouteRequest) []executionPlan {
|
||||
route := strings.TrimSpace(cfg.Route)
|
||||
if isFallbackRoute(route) {
|
||||
return buildExecutionPlansInternal(cfg, req, false)
|
||||
}
|
||||
return executionPlansForExecuteRoute(cfg, req, route)
|
||||
}
|
||||
|
||||
// executionPlansForExecuteRoute builds plans for plugin executor without requiring
|
||||
// ModelRouteRequest.AvailableProviders (host does not pass it on executor.execute_stream).
|
||||
func executionPlansForExecuteRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
|
||||
backend := routeBackend(strings.TrimSpace(route))
|
||||
if !backendRunnableLenient(backend, cfg, req) {
|
||||
return nil
|
||||
}
|
||||
var plans []executionPlan
|
||||
switch backend {
|
||||
case backendAntigravityGoogle:
|
||||
model := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
plans = append(plans, executionPlan{backend: backend, model: model})
|
||||
case backendCodexWebSearch:
|
||||
plans = append(plans, executionPlan{backend: backend, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
|
||||
case backendXAIWebSearch:
|
||||
plans = append(plans, executionPlan{backend: backend, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
|
||||
case backendTavily:
|
||||
if !newTavilyClient(cfg.TavilyAPIKeys).available() {
|
||||
return nil
|
||||
}
|
||||
plans = append(plans, executionPlan{backend: backend})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func buildExecutionPlansInternal(cfg pluginConfig, req pluginapi.ModelRouteRequest, requireProviders bool) []executionPlan {
|
||||
var plans []executionPlan
|
||||
for _, backend := range defaultWebSearchFallbackChain() {
|
||||
if requireProviders {
|
||||
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
|
||||
continue
|
||||
}
|
||||
} else if !backendRunnableLenient(backend, cfg, req) {
|
||||
continue
|
||||
}
|
||||
switch backend {
|
||||
case backendAntigravityGoogle:
|
||||
plans = append(plans, executionPlan{
|
||||
backend: backend,
|
||||
model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel),
|
||||
})
|
||||
case backendCodexWebSearch:
|
||||
plans = append(plans, executionPlan{
|
||||
backend: backend,
|
||||
model: resolveCodexWebSearchTargetModel(cfg.CodexModel),
|
||||
})
|
||||
case backendXAIWebSearch:
|
||||
plans = append(plans, executionPlan{
|
||||
backend: backend,
|
||||
model: resolveXAIWebSearchTargetModel(cfg.XAIModel),
|
||||
})
|
||||
case backendTavily:
|
||||
plans = append(plans, executionPlan{backend: backend})
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func backendRunnableLenient(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) bool {
|
||||
switch backend {
|
||||
case backendTavily:
|
||||
return newTavilyClient(cfg.TavilyAPIKeys).available()
|
||||
case backendAntigravityGoogle:
|
||||
return resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel) != ""
|
||||
case backendCodexWebSearch, backendXAIWebSearch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func executionPlansForRoute(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) []executionPlan {
|
||||
if isFallbackRoute(route) {
|
||||
return buildExecutionPlans(cfg, req)
|
||||
}
|
||||
backend := routeBackend(strings.TrimSpace(route))
|
||||
if _, ok := tryRouteBackend(backend, cfg, req); !ok {
|
||||
return nil
|
||||
}
|
||||
var plans []executionPlan
|
||||
for _, b := range []routeBackend{backend} {
|
||||
if !backendRunnableLenient(b, cfg, req) {
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case backendAntigravityGoogle:
|
||||
plans = append(plans, executionPlan{backend: b, model: resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)})
|
||||
case backendCodexWebSearch:
|
||||
plans = append(plans, executionPlan{backend: b, model: resolveCodexWebSearchTargetModel(cfg.CodexModel)})
|
||||
case backendXAIWebSearch:
|
||||
plans = append(plans, executionPlan{backend: b, model: resolveXAIWebSearchTargetModel(cfg.XAIModel)})
|
||||
case backendTavily:
|
||||
plans = append(plans, executionPlan{backend: b})
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func claudeRequestBody(exec pluginapi.ExecutorRequest) []byte {
|
||||
if len(exec.OriginalRequest) > 0 {
|
||||
return exec.OriginalRequest
|
||||
}
|
||||
return exec.Payload
|
||||
}
|
||||
|
||||
func runWebSearchWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
|
||||
cfg := loadedConfig()
|
||||
req := pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
RequestedModel: strings.TrimSpace(exec.Model),
|
||||
Body: claudeRequestBody(exec),
|
||||
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||
}
|
||||
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), false)
|
||||
}
|
||||
|
||||
// runWebSearchStreamWithExecutionFallback buffers the full host stream (non-streaming RPC path only).
|
||||
func runWebSearchStreamWithExecutionFallback(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string) ([]byte, http.Header, error) {
|
||||
cfg := loadedConfig()
|
||||
req := pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
RequestedModel: strings.TrimSpace(exec.Model),
|
||||
Body: claudeRequestBody(exec),
|
||||
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||
}
|
||||
return runOrderedExecutionPlans(ctx, exec, hostCallbackID, cfg, buildExecutionPlansForExecute(cfg, req), true)
|
||||
}
|
||||
|
||||
func runOrderedExecutionPlans(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID string, cfg pluginConfig, plans []executionPlan, stream bool) ([]byte, http.Header, error) {
|
||||
if len(plans) == 0 {
|
||||
return nil, nil, fmt.Errorf("web search execution: no backend available")
|
||||
}
|
||||
backends := make([]routeBackend, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
backends = append(backends, p.backend)
|
||||
}
|
||||
ordered := sortBackendsByPenalty(backends)
|
||||
planByBackend := make(map[routeBackend]executionPlan, len(plans))
|
||||
for _, p := range plans {
|
||||
planByBackend[p.backend] = p
|
||||
}
|
||||
|
||||
body := claudeRequestBody(exec)
|
||||
var lastErr error
|
||||
for _, backend := range ordered {
|
||||
plan := planByBackend[backend]
|
||||
switch backend {
|
||||
case backendTavily:
|
||||
var payload []byte
|
||||
var headers http.Header
|
||||
var errRun error
|
||||
if stream {
|
||||
payload, headers, errRun = runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||
} else {
|
||||
payload, headers, errRun = runTavilyClaudeWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||
}
|
||||
if errRun != nil {
|
||||
lastErr = errRun
|
||||
continue
|
||||
}
|
||||
recordBackendSuccess(backend)
|
||||
return payload, headers, nil
|
||||
default:
|
||||
payload, status, errRun := hostModelExecuteClaude(ctx, hostCallbackID, plan.model, body, stream)
|
||||
if errRun != nil {
|
||||
lastErr = errRun
|
||||
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
|
||||
recordBackendFailure(backend)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isRetryableHTTPStatus(status) {
|
||||
recordBackendFailure(backend)
|
||||
lastErr = fmt.Errorf("host model status %d", status)
|
||||
continue
|
||||
}
|
||||
recordBackendSuccess(backend)
|
||||
headers := http.Header{"Content-Type": []string{"application/json"}}
|
||||
if stream {
|
||||
headers = http.Header{"Content-Type": []string{"text/event-stream"}}
|
||||
}
|
||||
return payload, headers, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return nil, nil, fmt.Errorf("web search execution: all backends failed")
|
||||
}
|
||||
|
||||
func availableProvidersFromMetadata(meta map[string]any) []string {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := meta["available_providers"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, okItem := item.(string); okItem {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hostModelExecuteClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, stream bool) ([]byte, int, error) {
|
||||
if stream {
|
||||
return hostModelStreamClaude(ctx, hostCallbackID, execModel, body)
|
||||
}
|
||||
raw, errCall := callHost(pluginabi.MethodHostModelExecute, hostModelExecutionRequest{
|
||||
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||
EntryProtocol: "claude",
|
||||
ExitProtocol: "claude",
|
||||
Model: execModel,
|
||||
Stream: false,
|
||||
Body: body,
|
||||
},
|
||||
HostCallbackID: hostCallbackID,
|
||||
})
|
||||
if errCall != nil {
|
||||
return nil, hostHTTPStatusFromError(errCall), errCall
|
||||
}
|
||||
var resp pluginapi.HostModelExecutionResponse
|
||||
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||
return nil, 0, errDecode
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func hostModelStreamClaude(ctx context.Context, hostCallbackID, execModel string, body []byte) ([]byte, int, error) {
|
||||
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
|
||||
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||
EntryProtocol: "claude",
|
||||
ExitProtocol: "claude",
|
||||
Model: execModel,
|
||||
Stream: true,
|
||||
Body: body,
|
||||
},
|
||||
HostCallbackID: hostCallbackID,
|
||||
})
|
||||
if errCall != nil {
|
||||
return nil, hostHTTPStatusFromError(errCall), errCall
|
||||
}
|
||||
var resp pluginapi.HostModelStreamResponse
|
||||
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||
return nil, 0, errDecode
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
_ = closeHostModelStream(resp.StreamID)
|
||||
return nil, resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(resp.StreamID) == "" {
|
||||
return nil, 0, fmt.Errorf("host model stream: empty stream_id")
|
||||
}
|
||||
defer func() { _ = closeHostModelStream(resp.StreamID) }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
for {
|
||||
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
|
||||
if errRead != nil {
|
||||
return nil, hostHTTPStatusFromError(errRead), errRead
|
||||
}
|
||||
var chunk pluginapi.HostModelStreamReadResponse
|
||||
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
|
||||
return nil, 0, errDecode
|
||||
}
|
||||
if chunk.Error != "" {
|
||||
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
|
||||
return nil, code, fmt.Errorf("%s", chunk.Error)
|
||||
}
|
||||
if len(chunk.Payload) > 0 {
|
||||
buf.Write(chunk.Payload)
|
||||
}
|
||||
if chunk.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), http.StatusOK, nil
|
||||
}
|
||||
|
||||
func closeHostModelStream(streamID string) error {
|
||||
_, errCall := callHost(pluginabi.MethodHostModelStreamClose, pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
|
||||
return errCall
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
func TestBuildExecutionPlansForExecuteRespectsRouteTavily(t *testing.T) {
|
||||
currentConfig.Store(pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendTavily),
|
||||
TavilyAPIKeys: []string{"tvly-test"},
|
||||
})
|
||||
cfg := loadedConfig()
|
||||
req := pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
RequestedModel: "claude-sonnet-4-6",
|
||||
AvailableProviders: []string{"antigravity", "codex", "xai"},
|
||||
}
|
||||
plans := buildExecutionPlansForExecute(cfg, req)
|
||||
if len(plans) != 1 {
|
||||
t.Fatalf("plans len = %d, want 1 for route=tavily", len(plans))
|
||||
}
|
||||
if plans[0].backend != backendTavily {
|
||||
t.Fatalf("backend = %q, want tavily", plans[0].backend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
// defaultWebSearchFallbackChain is the ordered backend try list when route=fallback.
|
||||
func defaultWebSearchFallbackChain() []routeBackend {
|
||||
return []routeBackend{
|
||||
backendAntigravityGoogle,
|
||||
backendCodexWebSearch,
|
||||
backendXAIWebSearch,
|
||||
backendTavily,
|
||||
}
|
||||
}
|
||||
|
||||
func isFallbackRoute(route string) bool {
|
||||
r := strings.ToLower(strings.TrimSpace(route))
|
||||
return r == "" || r == string(backendFallback)
|
||||
}
|
||||
|
||||
// tryRouteBackend returns a handled ModelRouteResponse and true when this backend can serve the request.
|
||||
func tryRouteBackend(backend routeBackend, cfg pluginConfig, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
||||
switch backend {
|
||||
case backendTavily:
|
||||
client := newTavilyClient(cfg.TavilyAPIKeys)
|
||||
if !client.available() {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "tavily_unavailable"}, false
|
||||
}
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetSelf,
|
||||
Reason: "claude_code_web_search_tavily",
|
||||
}, true
|
||||
case backendAntigravityGoogle:
|
||||
if !hasProvider(req.AvailableProviders, "antigravity") {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_unavailable"}, false
|
||||
}
|
||||
targetModel := resolveAntigravityWebSearchTargetModel(cfg.AntigravityModel, req.RequestedModel)
|
||||
if targetModel == "" {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "antigravity_web_search_model_unresolved"}, false
|
||||
}
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetProvider,
|
||||
Target: "antigravity",
|
||||
TargetModel: targetModel,
|
||||
Reason: "claude_code_web_search_antigravity_google",
|
||||
}, true
|
||||
case backendCodexWebSearch:
|
||||
if !hasProvider(req.AvailableProviders, "codex") {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "codex_unavailable"}, false
|
||||
}
|
||||
targetModel := resolveCodexWebSearchTargetModel(cfg.CodexModel)
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetProvider,
|
||||
Target: "codex",
|
||||
TargetModel: targetModel,
|
||||
Reason: "claude_code_web_search_codex",
|
||||
}, true
|
||||
case backendXAIWebSearch:
|
||||
if !hasProvider(req.AvailableProviders, "xai") {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "xai_unavailable"}, false
|
||||
}
|
||||
targetModel := resolveXAIWebSearchTargetModel(cfg.XAIModel)
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetProvider,
|
||||
Target: "xai",
|
||||
TargetModel: targetModel,
|
||||
Reason: "claude_code_web_search_xai",
|
||||
}, true
|
||||
case backendDefaultProvider:
|
||||
provider := cfg.DefaultProvider
|
||||
if provider == "" || !hasProvider(req.AvailableProviders, provider) {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "default_provider_unavailable"}, false
|
||||
}
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetProvider,
|
||||
Target: provider,
|
||||
TargetModel: cfg.DefaultProviderModel,
|
||||
Reason: "claude_code_web_search_default_provider",
|
||||
}, true
|
||||
default:
|
||||
return pluginapi.ModelRouteResponse{Handled: false}, false
|
||||
}
|
||||
}
|
||||
|
||||
func routeWithFallback(cfg pluginConfig, req pluginapi.ModelRouteRequest) pluginapi.ModelRouteResponse {
|
||||
return routeWithExecutionOrchestration(cfg, req, string(backendFallback))
|
||||
}
|
||||
|
||||
func routeWithExecutionOrchestration(cfg pluginConfig, req pluginapi.ModelRouteRequest, route string) pluginapi.ModelRouteResponse {
|
||||
plans := executionPlansForRoute(cfg, req, route)
|
||||
if len(plans) == 0 {
|
||||
return pluginapi.ModelRouteResponse{Handled: false, Reason: "web_search_fallback_exhausted"}
|
||||
}
|
||||
return pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetSelf,
|
||||
Reason: "claude_code_web_search_orchestrated",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
func claudeWebSearchRouteBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
body := []byte(`{
|
||||
"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}],
|
||||
"system":[{"type":"text","text":"You have access to the web search tool use."}],
|
||||
"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: test"}]}]
|
||||
}`)
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeModelRouteResponse(t *testing.T, raw []byte) pluginapi.ModelRouteResponse {
|
||||
t.Helper()
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resp pluginapi.ModelRouteResponse
|
||||
if err := json.Unmarshal(env.Result, &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRouteWithFallbackAntigravityFirst(t *testing.T) {
|
||||
reg := registry.GetGlobalRegistry()
|
||||
const clientID = "test-fallback-antigravity"
|
||||
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
|
||||
{ID: "gem-fallback-test", SupportsWebSearch: true},
|
||||
})
|
||||
t.Cleanup(func() { reg.UnregisterClient(clientID) })
|
||||
|
||||
currentConfig.Store(pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendFallback),
|
||||
})
|
||||
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
|
||||
ModelRouteRequest: pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
Body: claudeWebSearchRouteBody(t),
|
||||
RequestedModel: "claude-sonnet-4-6",
|
||||
AvailableProviders: []string{"antigravity", "codex", "xai"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := decodeModelRouteResponse(t, raw)
|
||||
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
|
||||
t.Fatalf("resp = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteWithFallbackSkipsAntigravityToCodex(t *testing.T) {
|
||||
currentConfig.Store(pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendFallback),
|
||||
})
|
||||
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
|
||||
ModelRouteRequest: pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
Body: claudeWebSearchRouteBody(t),
|
||||
RequestedModel: "claude-sonnet-4-6",
|
||||
AvailableProviders: []string{"codex", "xai"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := decodeModelRouteResponse(t, raw)
|
||||
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
|
||||
t.Fatalf("resp = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteWithFallbackToTavily(t *testing.T) {
|
||||
currentConfig.Store(pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendFallback),
|
||||
TavilyAPIKeys: []string{"tvly-test"},
|
||||
})
|
||||
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
|
||||
ModelRouteRequest: pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
Body: claudeWebSearchRouteBody(t),
|
||||
AvailableProviders: []string{},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := decodeModelRouteResponse(t, raw)
|
||||
if !resp.Handled || resp.TargetKind != pluginapi.ModelRouteTargetSelf {
|
||||
t.Fatalf("resp = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteWithFallbackExhausted(t *testing.T) {
|
||||
currentConfig.Store(pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendFallback),
|
||||
})
|
||||
raw, err := routeModel(mustJSON(t, rpcModelRouteRequest{
|
||||
ModelRouteRequest: pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
Body: claudeWebSearchRouteBody(t),
|
||||
AvailableProviders: []string{},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := decodeModelRouteResponse(t, raw)
|
||||
if resp.Handled {
|
||||
t.Fatalf("expected declined, got %#v", resp)
|
||||
}
|
||||
if resp.Reason == "" || resp.Reason[:len("web_search_fallback_exhausted")] != "web_search_fallback_exhausted" {
|
||||
t.Fatalf("reason = %q", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/claude-web-search-router/go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
|
||||
@@ -0,0 +1,24 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,482 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const pluginIdentifier = "claude-web-search-router"
|
||||
|
||||
type routeBackend string
|
||||
|
||||
const (
|
||||
backendFallback routeBackend = "fallback"
|
||||
backendAntigravityGoogle routeBackend = "antigravity_google"
|
||||
backendCodexWebSearch routeBackend = "codex_web_search"
|
||||
backendXAIWebSearch routeBackend = "xai_web_search"
|
||||
backendTavily routeBackend = "tavily"
|
||||
backendDefaultProvider routeBackend = "default_provider"
|
||||
)
|
||||
|
||||
var currentConfig atomic.Value
|
||||
|
||||
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 pluginConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Route string `yaml:"route"`
|
||||
AntigravityModel string `yaml:"antigravity_model"`
|
||||
CodexModel string `yaml:"codex_model"`
|
||||
XAIModel string `yaml:"xai_model"`
|
||||
DefaultProvider string `yaml:"default_provider"`
|
||||
DefaultProviderModel string `yaml:"default_provider_model"`
|
||||
TavilyAPIKeys []string `yaml:"tavily_api_keys"`
|
||||
RequireWebSearchOnly bool `yaml:"require_web_search_only"`
|
||||
}
|
||||
|
||||
type registration struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
Metadata pluginapi.Metadata `json:"metadata"`
|
||||
Capabilities registrationCapability `json:"capabilities"`
|
||||
}
|
||||
|
||||
type registrationCapability struct {
|
||||
ModelRouter bool `json:"model_router"`
|
||||
Executor bool `json:"executor"`
|
||||
ExecutorModelScope string `json:"executor_model_scope"`
|
||||
ExecutorInputFormats []string `json:"executor_input_formats"`
|
||||
ExecutorOutputFormats []string `json:"executor_output_formats"`
|
||||
}
|
||||
|
||||
type rpcExecutorRequest struct {
|
||||
pluginapi.ExecutorRequest
|
||||
StreamID string `json:"stream_id,omitempty"`
|
||||
HostCallbackID string `json:"host_callback_id,omitempty"`
|
||||
}
|
||||
|
||||
type rpcModelRouteRequest struct {
|
||||
pluginapi.ModelRouteRequest
|
||||
HostCallbackID string `json:"host_callback_id,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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
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, _ 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:
|
||||
if errConfigure := configure(request); errConfigure != nil {
|
||||
return nil, errConfigure
|
||||
}
|
||||
return okEnvelope(pluginRegistration())
|
||||
case pluginabi.MethodModelRoute:
|
||||
return routeModel(request)
|
||||
case pluginabi.MethodExecutorIdentifier:
|
||||
return okEnvelope(map[string]string{"identifier": pluginIdentifier})
|
||||
case pluginabi.MethodExecutorExecute:
|
||||
return execute(request)
|
||||
case pluginabi.MethodExecutorExecuteStream:
|
||||
return executeStream(request)
|
||||
case pluginabi.MethodExecutorCountTokens:
|
||||
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"input_tokens":0}`)})
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func configure(raw []byte) error {
|
||||
var req lifecycleRequest
|
||||
if len(raw) > 0 {
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return errUnmarshal
|
||||
}
|
||||
}
|
||||
cfg := defaultPluginConfig()
|
||||
if len(req.ConfigYAML) > 0 {
|
||||
decoded, errDecode := decodeConfig(req.ConfigYAML)
|
||||
if errDecode != nil {
|
||||
return errDecode
|
||||
}
|
||||
cfg = decoded
|
||||
}
|
||||
currentConfig.Store(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultPluginConfig() pluginConfig {
|
||||
return pluginConfig{
|
||||
Enabled: true,
|
||||
Route: string(backendFallback),
|
||||
RequireWebSearchOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeConfig(raw []byte) (pluginConfig, error) {
|
||||
cfg := defaultPluginConfig()
|
||||
if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil {
|
||||
return pluginConfig{}, errUnmarshal
|
||||
}
|
||||
cfg.Route = strings.TrimSpace(cfg.Route)
|
||||
cfg.AntigravityModel = strings.TrimSpace(cfg.AntigravityModel)
|
||||
cfg.CodexModel = strings.TrimSpace(cfg.CodexModel)
|
||||
cfg.XAIModel = strings.TrimSpace(cfg.XAIModel)
|
||||
cfg.DefaultProvider = strings.ToLower(strings.TrimSpace(cfg.DefaultProvider))
|
||||
cfg.DefaultProviderModel = strings.TrimSpace(cfg.DefaultProviderModel)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadedConfig() pluginConfig {
|
||||
raw := currentConfig.Load()
|
||||
if cfg, ok := raw.(pluginConfig); ok {
|
||||
return cfg
|
||||
}
|
||||
return defaultPluginConfig()
|
||||
}
|
||||
|
||||
func pluginRegistration() registration {
|
||||
return registration{
|
||||
SchemaVersion: pluginabi.SchemaVersion,
|
||||
Metadata: pluginapi.Metadata{
|
||||
Name: "claude-web-search-router",
|
||||
Version: "0.1.0",
|
||||
Author: "router-for-me",
|
||||
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
|
||||
ConfigFields: []pluginapi.ConfigField{
|
||||
{Name: "enabled", Type: pluginapi.ConfigFieldTypeBoolean, Description: "When false, the router declines all Claude web_search requests."},
|
||||
{Name: "route", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{
|
||||
string(backendFallback), string(backendAntigravityGoogle), string(backendCodexWebSearch),
|
||||
string(backendXAIWebSearch), string(backendTavily), string(backendDefaultProvider),
|
||||
}, Description: "Backend for Claude Code web_search. fallback (default): antigravity → codex → xai → tavily."},
|
||||
{Name: "antigravity_model", Type: pluginapi.ConfigFieldTypeString, Description: "Antigravity googleSearch model (empty: registry lookup, then first supports_web_search)."},
|
||||
{Name: "codex_model", Type: pluginapi.ConfigFieldTypeString, Description: "Codex Responses model for web_search (empty defaults to gpt-5.4, never client Claude model)."},
|
||||
{Name: "xai_model", Type: pluginapi.ConfigFieldTypeString, Description: "xAI Responses model with web_search (empty uses grok-4.3, not the client Claude model)."},
|
||||
{Name: "default_provider", Type: pluginapi.ConfigFieldTypeString, Description: "Built-in provider key when route=default_provider."},
|
||||
{Name: "default_provider_model", Type: pluginapi.ConfigFieldTypeString, Description: "Optional execution model on default_provider route."},
|
||||
{Name: "tavily_api_keys", Type: pluginapi.ConfigFieldTypeArray, Description: "Tavily API keys (round-robin) when route=tavily."},
|
||||
{Name: "require_web_search_only", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Require tools to be exclusively typed web_search (matches antigravity-only path)."},
|
||||
},
|
||||
},
|
||||
Capabilities: registrationCapability{
|
||||
ModelRouter: true,
|
||||
Executor: true,
|
||||
ExecutorModelScope: string(pluginapi.ExecutorModelScopeStatic),
|
||||
ExecutorInputFormats: []string{"claude"},
|
||||
ExecutorOutputFormats: []string{"claude"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func routeModel(raw []byte) ([]byte, error) {
|
||||
var req rpcModelRouteRequest
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, errUnmarshal
|
||||
}
|
||||
cfg := loadedConfig()
|
||||
if !cfg.Enabled {
|
||||
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
|
||||
}
|
||||
if !isClaudeSourceFormat(req.SourceFormat) {
|
||||
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
|
||||
}
|
||||
if !isClaudeCodeBuiltinWebSearchRequest(req.Body, cfg.RequireWebSearchOnly) {
|
||||
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
|
||||
}
|
||||
route := strings.TrimSpace(cfg.Route)
|
||||
if isFallbackRoute(route) {
|
||||
return okEnvelope(routeWithFallback(cfg, req.ModelRouteRequest))
|
||||
}
|
||||
if plans := executionPlansForRoute(cfg, req.ModelRouteRequest, route); len(plans) > 0 {
|
||||
return okEnvelope(pluginapi.ModelRouteResponse{
|
||||
Handled: true,
|
||||
TargetKind: pluginapi.ModelRouteTargetSelf,
|
||||
Reason: "claude_code_web_search_orchestrated",
|
||||
})
|
||||
}
|
||||
backend := routeBackend(route)
|
||||
resp, ok := tryRouteBackend(backend, cfg, req.ModelRouteRequest)
|
||||
if ok {
|
||||
return okEnvelope(resp)
|
||||
}
|
||||
if strings.TrimSpace(resp.Reason) != "" {
|
||||
return okEnvelope(resp)
|
||||
}
|
||||
return okEnvelope(pluginapi.ModelRouteResponse{Handled: false})
|
||||
}
|
||||
|
||||
func hasProvider(providers []string, key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
for _, p := range providers {
|
||||
if strings.ToLower(strings.TrimSpace(p)) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func execute(raw []byte) ([]byte, error) {
|
||||
var req rpcExecutorRequest
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, errUnmarshal
|
||||
}
|
||||
body, headers, errRun := runWebSearchWithExecutionFallback(context.Background(), req.ExecutorRequest, req.HostCallbackID)
|
||||
if errRun != nil {
|
||||
return errorEnvelope("executor_error", errRun.Error()), nil
|
||||
}
|
||||
return okEnvelope(pluginapi.ExecutorResponse{Payload: body, Headers: headers})
|
||||
}
|
||||
|
||||
func runTavilyClaude(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
|
||||
return runTavilyClaudeWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
|
||||
}
|
||||
|
||||
func runTavilyClaudeWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
|
||||
query := extractClaudeWebSearchQuery(req.OriginalRequest)
|
||||
if query == "" {
|
||||
query = extractClaudeWebSearchQuery(req.Payload)
|
||||
}
|
||||
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
|
||||
hits, answer, errSearch := client.search(ctx, query, maxResults)
|
||||
if errSearch != nil {
|
||||
return nil, nil, errSearch
|
||||
}
|
||||
model := strings.TrimSpace(req.Model)
|
||||
builder := newClaudeStreamBuilder(model)
|
||||
payload := builder.buildMessageJSON(query, hits, answer)
|
||||
headers := http.Header{"Content-Type": []string{"application/json"}}
|
||||
return payload, headers, nil
|
||||
}
|
||||
|
||||
func runTavilyClaudeStream(ctx context.Context, req pluginapi.ExecutorRequest) ([]byte, http.Header, error) {
|
||||
return runTavilyClaudeStreamWithClient(ctx, req, newTavilyClient(loadedConfig().TavilyAPIKeys))
|
||||
}
|
||||
|
||||
func runTavilyClaudeStreamWithClient(ctx context.Context, req pluginapi.ExecutorRequest, client *tavilyClient) ([]byte, http.Header, error) {
|
||||
query := extractClaudeWebSearchQuery(req.OriginalRequest)
|
||||
if query == "" {
|
||||
query = extractClaudeWebSearchQuery(req.Payload)
|
||||
}
|
||||
maxResults := extractClaudeWebSearchMaxUses(req.OriginalRequest, 5)
|
||||
hits, answer, errSearch := client.search(ctx, query, maxResults)
|
||||
if errSearch != nil {
|
||||
return nil, nil, errSearch
|
||||
}
|
||||
model := strings.TrimSpace(req.Model)
|
||||
builder := newClaudeStreamBuilder(model)
|
||||
payload := builder.buildStreamWithQuery(query, hits, answer)
|
||||
headers := http.Header{"Content-Type": []string{"text/event-stream"}}
|
||||
return payload, headers, nil
|
||||
}
|
||||
|
||||
type hostModelExecutionRequest struct {
|
||||
pluginapi.HostModelExecutionRequest
|
||||
HostCallbackID string `json:"host_callback_id,omitempty"`
|
||||
}
|
||||
|
||||
func callHost(method string, payload any) (json.RawMessage, error) {
|
||||
rawPayload, errMarshal := json.Marshal(payload)
|
||||
if errMarshal != nil {
|
||||
return nil, fmt.Errorf("marshal host callback %s: %w", method, errMarshal)
|
||||
}
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
|
||||
var response C.cliproxy_buffer
|
||||
var requestPtr *C.uint8_t
|
||||
if len(rawPayload) > 0 {
|
||||
cPayload := C.CBytes(rawPayload)
|
||||
if cPayload == nil {
|
||||
return nil, fmt.Errorf("allocate host callback %s", method)
|
||||
}
|
||||
defer C.free(cPayload)
|
||||
requestPtr = (*C.uint8_t)(cPayload)
|
||||
}
|
||||
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
|
||||
var rawResponse []byte
|
||||
if response.ptr != nil && response.len > 0 {
|
||||
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
|
||||
}
|
||||
if response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
if len(rawResponse) == 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("decode host envelope %s: %w", method, errUnmarshal)
|
||||
}
|
||||
if !env.OK {
|
||||
if env.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("host callback %s failed", method)
|
||||
}
|
||||
if callCode != 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
|
||||
}
|
||||
return append(json.RawMessage(nil), env.Result...), nil
|
||||
}
|
||||
|
||||
func hostHTTPStatusFromError(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, code := range []int{429, 503, 502} {
|
||||
if strings.Contains(msg, fmt.Sprintf("%d", code)) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isRetryableHTTPStatus(code int) bool {
|
||||
return code == 429 || code == 503 || code == 502
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default Codex model for Claude web_search → Codex Responses (override with codex_model).
|
||||
defaultCodexWebSearchModel = "gpt-5.4-mini"
|
||||
// Default xAI model for server-side web_search per https://docs.x.ai/developers/tools/web-search
|
||||
defaultXAIWebSearchModel = "grok-4.3"
|
||||
)
|
||||
|
||||
// resolveAntigravityWebSearchTargetModel picks an Antigravity model that can run native googleSearch.
|
||||
// Config antigravity_model wins; otherwise registry.AntigravityWebSearchModelFor(requested) or the
|
||||
// first available antigravity model with SupportsWebSearch.
|
||||
func resolveAntigravityWebSearchTargetModel(configured, requested string) string {
|
||||
if m := strings.TrimSpace(configured); m != "" {
|
||||
return m
|
||||
}
|
||||
if m := registry.AntigravityWebSearchModelFor(strings.TrimSpace(requested)); m != "" {
|
||||
return m
|
||||
}
|
||||
for _, model := range registry.GetGlobalRegistry().GetAvailableModelsByProvider("antigravity") {
|
||||
if model == nil || !model.SupportsWebSearch {
|
||||
continue
|
||||
}
|
||||
if id := strings.TrimSpace(model.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveCodexWebSearchTargetModel never forwards the client Claude model to Codex.
|
||||
func resolveCodexWebSearchTargetModel(configured string) string {
|
||||
if m := strings.TrimSpace(configured); m != "" {
|
||||
return m
|
||||
}
|
||||
return defaultCodexWebSearchModel
|
||||
}
|
||||
|
||||
// resolveXAIWebSearchTargetModel never forwards the client Claude model to xAI Responses.
|
||||
func resolveXAIWebSearchTargetModel(configured string) string {
|
||||
if m := strings.TrimSpace(configured); m != "" {
|
||||
return m
|
||||
}
|
||||
return defaultXAIWebSearchModel
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
func TestResolveCodexWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
|
||||
got := resolveCodexWebSearchTargetModel("")
|
||||
if got != defaultCodexWebSearchModel {
|
||||
t.Fatalf("empty config = %q, want %q", got, defaultCodexWebSearchModel)
|
||||
}
|
||||
if got := resolveCodexWebSearchTargetModel("gpt-5.5"); got != "gpt-5.5" {
|
||||
t.Fatalf("configured = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveXAIWebSearchTargetModelNeverUsesClaudeName(t *testing.T) {
|
||||
got := resolveXAIWebSearchTargetModel("")
|
||||
if got != defaultXAIWebSearchModel {
|
||||
t.Fatalf("empty config = %q, want %q", got, defaultXAIWebSearchModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAntigravityWebSearchTargetModelConfiguredWins(t *testing.T) {
|
||||
if got := resolveAntigravityWebSearchTargetModel("my-gemini", "claude-sonnet-4-6"); got != "my-gemini" {
|
||||
t.Fatalf("configured = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAntigravityWebSearchTargetModelFromRegistry(t *testing.T) {
|
||||
reg := registry.GetGlobalRegistry()
|
||||
const clientID = "test-claude-web-search-router-antigravity"
|
||||
reg.RegisterClient(clientID, "antigravity", []*registry.ModelInfo{
|
||||
{ID: "gemini-web-search-test", SupportsWebSearch: true},
|
||||
})
|
||||
t.Cleanup(func() { reg.UnregisterClient(clientID) })
|
||||
got := resolveAntigravityWebSearchTargetModel("", "claude-sonnet-4-6")
|
||||
if got != "gemini-web-search-test" {
|
||||
t.Fatalf("fallback = %q, want gemini-web-search-test", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
penaltyBumpOn429503 = 5
|
||||
penaltyDecaySuccess = 1
|
||||
)
|
||||
|
||||
var backendPenalties = struct {
|
||||
sync.Mutex
|
||||
scores map[routeBackend]int
|
||||
}{
|
||||
scores: make(map[routeBackend]int),
|
||||
}
|
||||
|
||||
func recordBackendFailure(backend routeBackend) {
|
||||
backendPenalties.Lock()
|
||||
defer backendPenalties.Unlock()
|
||||
backendPenalties.scores[backend] += penaltyBumpOn429503
|
||||
}
|
||||
|
||||
func recordBackendSuccess(backend routeBackend) {
|
||||
backendPenalties.Lock()
|
||||
defer backendPenalties.Unlock()
|
||||
score := backendPenalties.scores[backend] - penaltyDecaySuccess
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
backendPenalties.scores[backend] = score
|
||||
}
|
||||
|
||||
func penaltyScore(backend routeBackend) int {
|
||||
backendPenalties.Lock()
|
||||
defer backendPenalties.Unlock()
|
||||
return backendPenalties.scores[backend]
|
||||
}
|
||||
|
||||
func sortBackendsByPenalty(backends []routeBackend) []routeBackend {
|
||||
if len(backends) <= 1 {
|
||||
return append([]routeBackend(nil), backends...)
|
||||
}
|
||||
out := append([]routeBackend(nil), backends...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return penaltyScore(out[i]) < penaltyScore(out[j])
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func resetBackendPenaltiesForTest() {
|
||||
backendPenalties.Lock()
|
||||
defer backendPenalties.Unlock()
|
||||
backendPenalties.scores = make(map[routeBackend]int)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSortBackendsByPenaltyDeprioritizesFailures(t *testing.T) {
|
||||
resetBackendPenaltiesForTest()
|
||||
t.Cleanup(resetBackendPenaltiesForTest)
|
||||
recordBackendFailure(backendAntigravityGoogle)
|
||||
recordBackendFailure(backendAntigravityGoogle)
|
||||
ordered := sortBackendsByPenalty([]routeBackend{
|
||||
backendAntigravityGoogle,
|
||||
backendCodexWebSearch,
|
||||
backendXAIWebSearch,
|
||||
})
|
||||
if ordered[0] != backendCodexWebSearch {
|
||||
t.Fatalf("ordered = %v, want codex first after antigravity penalty", ordered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
type rpcStreamEmitRequest struct {
|
||||
StreamID string `json:"stream_id"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcStreamCloseRequest struct {
|
||||
StreamID string `json:"stream_id"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func emitPluginStreamChunk(streamID string, payload []byte) error {
|
||||
if strings.TrimSpace(streamID) == "" {
|
||||
return fmt.Errorf("plugin stream id is required")
|
||||
}
|
||||
_, errCall := callHost(pluginabi.MethodHostStreamEmit, rpcStreamEmitRequest{
|
||||
StreamID: streamID,
|
||||
Payload: payload,
|
||||
})
|
||||
return errCall
|
||||
}
|
||||
|
||||
func closePluginStream(streamID, errMsg string) {
|
||||
if strings.TrimSpace(streamID) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = callHost(pluginabi.MethodHostStreamClose, rpcStreamCloseRequest{
|
||||
StreamID: streamID,
|
||||
Error: strings.TrimSpace(errMsg),
|
||||
})
|
||||
}
|
||||
|
||||
func looksLikeOpenAIResponsesSSE(payload []byte) bool {
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
s := string(payload)
|
||||
if strings.Contains(s, "event: message_start") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s, "event: response.") ||
|
||||
strings.Contains(s, `"type":"response.`) ||
|
||||
strings.Contains(s, `"type": "response.`)
|
||||
}
|
||||
|
||||
func runWebSearchStreamOrchestration(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
|
||||
cfg := loadedConfig()
|
||||
req := pluginapi.ModelRouteRequest{
|
||||
SourceFormat: "claude",
|
||||
RequestedModel: strings.TrimSpace(exec.Model),
|
||||
Body: claudeRequestBody(exec),
|
||||
AvailableProviders: availableProvidersFromMetadata(exec.Metadata),
|
||||
}
|
||||
return runOrderedExecutionPlansStream(ctx, exec, hostCallbackID, pluginStreamID, cfg, buildExecutionPlansForExecute(cfg, req))
|
||||
}
|
||||
|
||||
func runOrderedExecutionPlansStream(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string, cfg pluginConfig, plans []executionPlan) error {
|
||||
if len(plans) == 0 {
|
||||
return fmt.Errorf("web search execution: no backend available")
|
||||
}
|
||||
backends := make([]routeBackend, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
backends = append(backends, p.backend)
|
||||
}
|
||||
ordered := sortBackendsByPenalty(backends)
|
||||
planByBackend := make(map[routeBackend]executionPlan, len(plans))
|
||||
for _, p := range plans {
|
||||
planByBackend[p.backend] = p
|
||||
}
|
||||
|
||||
body := claudeRequestBody(exec)
|
||||
var lastErr error
|
||||
for _, backend := range ordered {
|
||||
plan := planByBackend[backend]
|
||||
switch backend {
|
||||
case backendTavily:
|
||||
payload, _, errRun := runTavilyClaudeStreamWithClient(ctx, exec, newTavilyClient(cfg.TavilyAPIKeys))
|
||||
if errRun != nil {
|
||||
lastErr = errRun
|
||||
continue
|
||||
}
|
||||
if errEmit := emitPluginStreamChunk(pluginStreamID, payload); errEmit != nil {
|
||||
return errEmit
|
||||
}
|
||||
recordBackendSuccess(backend)
|
||||
return nil
|
||||
default:
|
||||
status, errRun := hostModelStreamForwardClaude(ctx, hostCallbackID, plan.model, body, pluginStreamID)
|
||||
if errRun != nil {
|
||||
lastErr = errRun
|
||||
if isRetryableHTTPStatus(hostHTTPStatusFromError(errRun)) {
|
||||
recordBackendFailure(backend)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isRetryableHTTPStatus(status) {
|
||||
recordBackendFailure(backend)
|
||||
lastErr = fmt.Errorf("host model status %d", status)
|
||||
continue
|
||||
}
|
||||
recordBackendSuccess(backend)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return fmt.Errorf("web search execution: all backends failed")
|
||||
}
|
||||
|
||||
func hostModelStreamForwardClaude(ctx context.Context, hostCallbackID, execModel string, body []byte, pluginStreamID string) (int, error) {
|
||||
raw, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
|
||||
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||
EntryProtocol: "claude",
|
||||
ExitProtocol: "claude",
|
||||
Model: execModel,
|
||||
Stream: true,
|
||||
Body: body,
|
||||
},
|
||||
HostCallbackID: hostCallbackID,
|
||||
})
|
||||
if errCall != nil {
|
||||
return hostHTTPStatusFromError(errCall), errCall
|
||||
}
|
||||
var resp pluginapi.HostModelStreamResponse
|
||||
if errDecode := json.Unmarshal(raw, &resp); errDecode != nil {
|
||||
return 0, errDecode
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
_ = closeHostModelStream(resp.StreamID)
|
||||
return resp.StatusCode, fmt.Errorf("host model status %d", resp.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(resp.StreamID) == "" {
|
||||
return 0, fmt.Errorf("host model stream: empty stream_id")
|
||||
}
|
||||
defer func() { _ = closeHostModelStream(resp.StreamID) }()
|
||||
|
||||
firstPayload := true
|
||||
for {
|
||||
chunkRaw, errRead := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
|
||||
if errRead != nil {
|
||||
return hostHTTPStatusFromError(errRead), errRead
|
||||
}
|
||||
var chunk pluginapi.HostModelStreamReadResponse
|
||||
if errDecode := json.Unmarshal(chunkRaw, &chunk); errDecode != nil {
|
||||
return 0, errDecode
|
||||
}
|
||||
if chunk.Error != "" {
|
||||
code := hostHTTPStatusFromError(fmt.Errorf("%s", chunk.Error))
|
||||
return code, fmt.Errorf("%s", chunk.Error)
|
||||
}
|
||||
if len(chunk.Payload) > 0 {
|
||||
if firstPayload && looksLikeOpenAIResponsesSSE(chunk.Payload) {
|
||||
return 0, fmt.Errorf("host model stream returned OpenAI Responses SSE instead of Claude Messages SSE")
|
||||
}
|
||||
firstPayload = false
|
||||
if errEmit := emitPluginStreamChunk(pluginStreamID, bytes.Clone(chunk.Payload)); errEmit != nil {
|
||||
return 0, errEmit
|
||||
}
|
||||
}
|
||||
if chunk.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
func TestLooksLikeOpenAIResponsesSSE(t *testing.T) {
|
||||
if !looksLikeOpenAIResponsesSSE([]byte("event: response.created\ndata: {\"type\":\"response.created\"}\n\n")) {
|
||||
t.Fatal("expected OpenAI Responses SSE detection")
|
||||
}
|
||||
if looksLikeOpenAIResponsesSSE([]byte("event: message_start\ndata: {\"type\":\"message_start\"}\n\n")) {
|
||||
t.Fatal("expected Claude Messages SSE to not match Responses detector")
|
||||
}
|
||||
if looksLikeOpenAIResponsesSSE(nil) {
|
||||
t.Fatal("empty payload should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartExecutorStreamRunsOrchestrationAfterRPCReturns(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
closed := make(chan string, 1)
|
||||
req := rpcExecutorRequest{
|
||||
ExecutorRequest: pluginapi.ExecutorRequest{Stream: true},
|
||||
StreamID: "stream-1",
|
||||
HostCallbackID: "callback-1",
|
||||
}
|
||||
|
||||
raw, errStart := startExecutorStream(req, func(ctx context.Context, exec pluginapi.ExecutorRequest, hostCallbackID, pluginStreamID string) error {
|
||||
if hostCallbackID != "callback-1" || pluginStreamID != "stream-1" {
|
||||
t.Errorf("runner ids = %q/%q, want callback-1/stream-1", hostCallbackID, pluginStreamID)
|
||||
}
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
}, func(streamID, errMsg string) {
|
||||
closed <- streamID + "|" + errMsg
|
||||
})
|
||||
if errStart != nil {
|
||||
t.Fatalf("startExecutorStream() error = %v", errStart)
|
||||
}
|
||||
if !strings.Contains(string(raw), "text/event-stream") {
|
||||
t.Fatalf("response does not include stream headers: %s", raw)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("orchestration did not start")
|
||||
}
|
||||
select {
|
||||
case got := <-closed:
|
||||
t.Fatalf("stream closed before orchestration finished: %q", got)
|
||||
default:
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case got := <-closed:
|
||||
if got != "stream-1|" {
|
||||
t.Fatalf("close call = %q, want stream-1|", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stream was not closed after orchestration finished")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const tavilySearchURL = "https://api.tavily.com/search"
|
||||
|
||||
type tavilyClient struct {
|
||||
keys []string
|
||||
idx atomic.Uint64
|
||||
http *http.Client
|
||||
baseURL string // empty → https://api.tavily.com/search
|
||||
}
|
||||
|
||||
func newTavilyClient(keys []string) *tavilyClient {
|
||||
return newTavilyClientWithOptions(keys, nil, "")
|
||||
}
|
||||
|
||||
func newTavilyClientWithOptions(keys []string, httpClient *http.Client, baseURL string) *tavilyClient {
|
||||
trimmed := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
trimmed = append(trimmed, k)
|
||||
}
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{}
|
||||
}
|
||||
return &tavilyClient{
|
||||
keys: trimmed,
|
||||
http: httpClient,
|
||||
baseURL: strings.TrimSpace(baseURL),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *tavilyClient) searchEndpoint() string {
|
||||
if c != nil && c.baseURL != "" {
|
||||
return c.baseURL
|
||||
}
|
||||
return tavilySearchURL
|
||||
}
|
||||
|
||||
func (c *tavilyClient) available() bool {
|
||||
return c != nil && len(c.keys) > 0
|
||||
}
|
||||
|
||||
func (c *tavilyClient) nextKey() string {
|
||||
if len(c.keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
n := c.idx.Add(1)
|
||||
return c.keys[int(n-1)%len(c.keys)]
|
||||
}
|
||||
|
||||
type tavilySearchRequest struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Query string `json:"query"`
|
||||
SearchDepth string `json:"search_depth,omitempty"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
IncludeAnswer bool `json:"include_answer,omitempty"`
|
||||
}
|
||||
|
||||
type tavilySearchResponse struct {
|
||||
Answer string `json:"answer"`
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type claudeWebSearchHit struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (c *tavilyClient) search(ctx context.Context, query string, maxResults int) ([]claudeWebSearchHit, string, error) {
|
||||
if !c.available() {
|
||||
return nil, "", fmt.Errorf("tavily_api_keys is empty")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, "", fmt.Errorf("web search query is empty")
|
||||
}
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
payload, errMarshal := json.Marshal(tavilySearchRequest{
|
||||
APIKey: c.nextKey(),
|
||||
Query: query,
|
||||
SearchDepth: "basic",
|
||||
MaxResults: maxResults,
|
||||
IncludeAnswer: true,
|
||||
})
|
||||
if errMarshal != nil {
|
||||
return nil, "", errMarshal
|
||||
}
|
||||
req, errNew := http.NewRequestWithContext(ctx, http.MethodPost, c.searchEndpoint(), bytes.NewReader(payload))
|
||||
if errNew != nil {
|
||||
return nil, "", errNew
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, errDo := c.http.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, "", errDo
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, errRead := io.ReadAll(resp.Body)
|
||||
if errRead != nil {
|
||||
return nil, "", errRead
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("tavily http %d: %s", resp.StatusCode, truncate(string(body), 512))
|
||||
}
|
||||
var parsed tavilySearchResponse
|
||||
if errDecode := json.Unmarshal(body, &parsed); errDecode != nil {
|
||||
return nil, "", errDecode
|
||||
}
|
||||
hits := make([]claudeWebSearchHit, 0, len(parsed.Results))
|
||||
for _, r := range parsed.Results {
|
||||
hits = append(hits, claudeWebSearchHit{
|
||||
Title: strings.TrimSpace(r.Title),
|
||||
URL: strings.TrimSpace(r.URL),
|
||||
Snippet: strings.TrimSpace(r.Content),
|
||||
})
|
||||
}
|
||||
return hits, strings.TrimSpace(parsed.Answer), nil
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestTavilyClientSearchMockAPI(t *testing.T) {
|
||||
var gotBody tavilySearchRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
raw, errRead := io.ReadAll(r.Body)
|
||||
if errRead != nil {
|
||||
t.Fatal(errRead)
|
||||
}
|
||||
if errDecode := json.Unmarshal(raw, &gotBody); errDecode != nil {
|
||||
t.Fatal(errDecode)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"query": "北京天气",
|
||||
"answer": "明天晴。",
|
||||
"results": [
|
||||
{"title": "Example Weather", "url": "https://example.com/w", "content": "snippet one"}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTavilyClientWithOptions([]string{"tvly-test-key"}, server.Client(), server.URL)
|
||||
hits, answer, errSearch := client.search(context.Background(), "北京天气", 3)
|
||||
if errSearch != nil {
|
||||
t.Fatalf("search() error = %v", errSearch)
|
||||
}
|
||||
if gotBody.APIKey != "tvly-test-key" {
|
||||
t.Fatalf("api_key = %q", gotBody.APIKey)
|
||||
}
|
||||
if gotBody.Query != "北京天气" {
|
||||
t.Fatalf("query = %q", gotBody.Query)
|
||||
}
|
||||
if gotBody.MaxResults != 3 {
|
||||
t.Fatalf("max_results = %d, want 3", gotBody.MaxResults)
|
||||
}
|
||||
if !gotBody.IncludeAnswer {
|
||||
t.Fatal("include_answer should be true")
|
||||
}
|
||||
if answer != "明天晴。" {
|
||||
t.Fatalf("answer = %q", answer)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].URL != "https://example.com/w" {
|
||||
t.Fatalf("hits = %#v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyClientSearchEmptyKeys(t *testing.T) {
|
||||
client := newTavilyClient(nil)
|
||||
_, _, err := client.search(context.Background(), "q", 5)
|
||||
if err == nil || !strings.Contains(err.Error(), "tavily_api_keys") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyClientSearchHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"bad key"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTavilyClientWithOptions([]string{"bad"}, server.Client(), server.URL)
|
||||
_, _, err := client.search(context.Background(), "q", 5)
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTavilyClientRoundRobinKeys(t *testing.T) {
|
||||
var keys []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body tavilySearchRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
keys = append(keys, body.APIKey)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTavilyClientWithOptions([]string{"k1", "k2"}, server.Client(), server.URL)
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, _, err := client.search(context.Background(), "q", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(keys) != 4 || keys[0] != "k1" || keys[1] != "k2" || keys[2] != "k1" || keys[3] != "k2" {
|
||||
t.Fatalf("key rotation = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTavilyClaudeStreamWithMock(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"answer": "2026年6月16日北京多雨。",
|
||||
"results": [
|
||||
{"title": "bjmy.gov.cn", "url": "https://www.bjmy.gov.cn/x", "content": "预报"}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
claudeBody := []byte(`{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"stream": true,
|
||||
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "Perform a web search for the query: 北京天气 2026年6月16日"}]}]
|
||||
}`)
|
||||
client := newTavilyClientWithOptions([]string{"tvly-mock"}, server.Client(), server.URL)
|
||||
payload, headers, errRun := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||
Model: "claude-sonnet-4-6",
|
||||
Stream: true,
|
||||
OriginalRequest: claudeBody,
|
||||
}, client)
|
||||
if errRun != nil {
|
||||
t.Fatalf("runTavilyClaudeStreamWithClient() error = %v", errRun)
|
||||
}
|
||||
if headers.Get("Content-Type") != "text/event-stream" {
|
||||
t.Fatalf("content-type = %q", headers.Get("Content-Type"))
|
||||
}
|
||||
text := string(payload)
|
||||
for _, needle := range []string{
|
||||
"event: message_start",
|
||||
`"type":"server_tool_use"`,
|
||||
`"name":"web_search"`,
|
||||
`"type":"web_search_tool_result"`,
|
||||
`"type":"web_search_result"`,
|
||||
`https://www.bjmy.gov.cn/x`,
|
||||
`"web_search_requests":1`,
|
||||
"event: message_stop",
|
||||
"北京天气 2026年6月16日",
|
||||
"2026年6月16日北京多雨",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("SSE missing %q in:\n%s", needle, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTavilyClaudeJSONWithMock(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"answer":"ok","results":[{"title":"T","url":"https://t.example","content":"c"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
claudeBody := []byte(`{
|
||||
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
|
||||
"messages": [{"role": "user", "content": "Perform a web search for the query: test query"}]
|
||||
}`)
|
||||
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
|
||||
payload, _, errRun := runTavilyClaudeWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||
Model: "claude-sonnet-4-6",
|
||||
OriginalRequest: claudeBody,
|
||||
}, client)
|
||||
if errRun != nil {
|
||||
t.Fatal(errRun)
|
||||
}
|
||||
root := gjson.ParseBytes(payload)
|
||||
if root.Get("type").String() != "message" {
|
||||
t.Fatalf("type = %s", root.Get("type").String())
|
||||
}
|
||||
if root.Get("content.0.type").String() != "server_tool_use" {
|
||||
t.Fatalf("content.0 = %s", root.Get("content.0.type").String())
|
||||
}
|
||||
if root.Get("content.1.type").String() != "web_search_tool_result" {
|
||||
t.Fatalf("content.1 = %s", root.Get("content.1.type").String())
|
||||
}
|
||||
if root.Get("content.2.text").String() != "ok" {
|
||||
t.Fatalf("text = %s", root.Get("content.2.text").String())
|
||||
}
|
||||
if root.Get("usage.server_tool_use.web_search_requests").Int() != 1 {
|
||||
t.Fatalf("web_search_requests = %d", root.Get("usage.server_tool_use.web_search_requests").Int())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStreamRPCWithMockTavily(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"answer":"rpc-ok","results":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
currentConfig.Store(pluginConfig{
|
||||
Route: string(backendTavily),
|
||||
TavilyAPIKeys: []string{"k"},
|
||||
})
|
||||
// Override client by patching: executeStream uses loadedConfig keys + real URL.
|
||||
// Test runTavilyClaudeStreamWithClient directly instead; for execute() we need config + mock URL.
|
||||
// Use executor path with injected client via runTavilyClaudeStreamWithClient already covered.
|
||||
_ = server
|
||||
claudeBody := []byte(`{"messages":[{"role":"user","content":"Perform a web search for the query: q"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
|
||||
client := newTavilyClientWithOptions([]string{"k"}, server.Client(), server.URL)
|
||||
body, _, err := runTavilyClaudeStreamWithClient(context.Background(), pluginapi.ExecutorRequest{
|
||||
Model: "m", Stream: true, OriginalRequest: claudeBody,
|
||||
}, client)
|
||||
if err != nil || !strings.Contains(string(body), "rpc-ok") {
|
||||
t.Fatalf("err=%v body=%s", err, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_cli_c C)
|
||||
|
||||
add_library(cliproxy_cli_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_cli_c PROPERTIES
|
||||
OUTPUT_NAME "cli-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "command_line.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"example-cli-c-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "command_line.execute") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Stdout\":\"ImV4YW1wbGUtY2xpLWMgY29tbWFuZCBleGVjdXRlZFxcbiI=\",\"ExitCode\":0}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}")
|
||||
case "command_line.register":
|
||||
return okEnvelopeJSON("{\"Flags\":[{\"Name\":\"example-cli-go-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}")
|
||||
case "command_line.execute":
|
||||
return okEnvelopeJSON("{\"Stdout\":\"ImV4YW1wbGUtY2xpLWdvIGNvbW1hbmQgZXhlY3V0ZWRcXG4i\",\"ExitCode\":0}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-cli-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-cli-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-cli-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-cli-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"command_line_plugin\":true}}}"); 0 },"command_line.register" => { write_response(response, "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"example-cli-rust-command\",\"Usage\":\"Run the example plugin command\",\"Type\":\"bool\"}]}}"); 0 },"command_line.execute" => { write_response(response, "{\"ok\":true,\"result\":{\"Stdout\":\"ImV4YW1wbGUtY2xpLXJ1c3QgY29tbWFuZCBleGVjdXRlZFxcbiI=\",\"ExitCode\":0}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Codex Service Tier Plugin
|
||||
|
||||
This plugin is a request normalizer for Codex outbound requests.
|
||||
|
||||
When the plugin is enabled and `fast` is set to `true`, it sets the top-level `service_tier` field to `priority` for requests where:
|
||||
|
||||
- `req.ToFormat` is `codex`
|
||||
- `req.Model` is `gpt-5.5`
|
||||
|
||||
Requests that do not match these conditions are returned unchanged.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the plugin under `plugins.configs`:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
configs:
|
||||
codex-service-tier:
|
||||
enabled: true
|
||||
priority: 1
|
||||
fast: false
|
||||
```
|
||||
|
||||
`fast` is a boolean field. Set it to `true` to enable priority service tier shaping for matching Codex `gpt-5.5` requests.
|
||||
@@ -0,0 +1,17 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/codex-service-tier/go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
|
||||
@@ -0,0 +1,13 @@
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct {
|
||||
void* ptr;
|
||||
size_t len;
|
||||
} cliproxy_buffer;
|
||||
|
||||
typedef struct {
|
||||
uint32_t abi_version;
|
||||
void* host_ctx;
|
||||
void* call;
|
||||
void* 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"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
"github.com/tidwall/sjson"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var fastEnabled atomic.Bool
|
||||
|
||||
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 pluginConfig struct {
|
||||
Fast bool `yaml:"fast"`
|
||||
}
|
||||
|
||||
type registration struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
Metadata pluginapi.Metadata `json:"metadata"`
|
||||
Capabilities registrationCapability `json:"capabilities"`
|
||||
}
|
||||
|
||||
type registrationCapability struct {
|
||||
RequestNormalizer bool `json:"request_normalizer"`
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
//export cliproxy_plugin_init
|
||||
func cliproxy_plugin_init(_ *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:
|
||||
if errConfigure := configure(request); errConfigure != nil {
|
||||
return nil, errConfigure
|
||||
}
|
||||
return okEnvelope(pluginRegistration())
|
||||
case pluginabi.MethodRequestNormalize:
|
||||
return normalizeRequest(request)
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func configure(raw []byte) error {
|
||||
var req lifecycleRequest
|
||||
if len(raw) > 0 {
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return errUnmarshal
|
||||
}
|
||||
}
|
||||
|
||||
cfg := pluginConfig{}
|
||||
if len(req.ConfigYAML) > 0 {
|
||||
fast, errDecodeFast := decodeFastConfig(req.ConfigYAML)
|
||||
if errDecodeFast != nil {
|
||||
return errDecodeFast
|
||||
}
|
||||
cfg.Fast = fast
|
||||
}
|
||||
fastEnabled.Store(cfg.Fast)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginRegistration() registration {
|
||||
return registration{
|
||||
SchemaVersion: pluginabi.SchemaVersion,
|
||||
Metadata: pluginapi.Metadata{
|
||||
Name: "codex-service-tier",
|
||||
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: "fast",
|
||||
Type: pluginapi.ConfigFieldTypeBoolean,
|
||||
Description: "Sets Codex gpt-5.5 Responses requests to the priority service tier.",
|
||||
}},
|
||||
},
|
||||
Capabilities: registrationCapability{
|
||||
RequestNormalizer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRequest(raw []byte) ([]byte, error) {
|
||||
var req pluginapi.RequestTransformRequest
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, errUnmarshal
|
||||
}
|
||||
body := req.Body
|
||||
if !shouldSetPriorityServiceTier(req) {
|
||||
return okEnvelope(pluginapi.PayloadResponse{Body: body})
|
||||
}
|
||||
updated, okSet := setPriorityServiceTier(body)
|
||||
if !okSet {
|
||||
return okEnvelope(pluginapi.PayloadResponse{Body: body})
|
||||
}
|
||||
return okEnvelope(pluginapi.PayloadResponse{Body: updated})
|
||||
}
|
||||
|
||||
func shouldSetPriorityServiceTier(req pluginapi.RequestTransformRequest) bool {
|
||||
if !fastEnabled.Load() {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(req.ToFormat, "codex") {
|
||||
return false
|
||||
}
|
||||
return req.Model == "gpt-5.5"
|
||||
}
|
||||
|
||||
func decodeFastConfig(configYAML []byte) (bool, error) {
|
||||
var cfg pluginConfig
|
||||
if errUnmarshal := yaml.Unmarshal(configYAML, &cfg); errUnmarshal != nil {
|
||||
return false, errUnmarshal
|
||||
}
|
||||
return cfg.Fast, nil
|
||||
}
|
||||
|
||||
func setPriorityServiceTier(body []byte) ([]byte, bool) {
|
||||
updated, errSet := sjson.SetBytes(body, "service_tier", "priority")
|
||||
if errSet != nil {
|
||||
return nil, false
|
||||
}
|
||||
return updated, true
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_executor_c C)
|
||||
|
||||
add_library(cliproxy_executor_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_executor_c PROPERTIES
|
||||
OUTPUT_NAME "executor-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.identifier") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-executor-c\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.execute") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItYyIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiJ9\",\"Headers\":{\"content-type\":[\"application/json\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.execute_stream") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItY1xuXG4i\"}]}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.count_tokens") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.http_request") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWMifQ==\"}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,181 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}")
|
||||
case "executor.identifier":
|
||||
return okEnvelopeJSON("{\"identifier\":\"example-executor-go\"}")
|
||||
case "executor.execute":
|
||||
return okEnvelopeJSON("{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItZ28iLCJvYmplY3QiOiJjaGF0LmNvbXBsZXRpb24ifQ==\",\"Headers\":{\"content-type\":[\"application/json\"]}}")
|
||||
case "executor.execute_stream":
|
||||
return okEnvelopeJSON("{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItZ29cblxuIg==\"}]}")
|
||||
case "executor.count_tokens":
|
||||
return okEnvelopeJSON("{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}")
|
||||
case "executor.http_request":
|
||||
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLWdvIn0=\"}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-executor-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-executor-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-executor-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-executor-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"chat-completions\"]}}}"); 0 },"executor.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-executor-rust\"}}"); 0 },"executor.execute" => { write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtZXhlY3V0b3ItcnVzdCIsIm9iamVjdCI6ImNoYXQuY29tcGxldGlvbiJ9\",\"Headers\":{\"content-type\":[\"application/json\"]}}}"); 0 },"executor.execute_stream" => { write_response(response, "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]},\"chunks\":[{\"Payload\":\"ImRhdGE6IGV4YW1wbGUtZXhlY3V0b3ItcnVzdFxuXG4i\"}]}}"); 0 },"executor.count_tokens" => { write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJ0b3RhbF90b2tlbnMiOjB9\"}}"); 0 },"executor.http_request" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWV4ZWN1dG9yLXJ1c3QifQ==\"}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Frontend Auth Exclusive Plugin Example
|
||||
|
||||
This example registers a frontend auth provider with `frontend_auth_provider_exclusive: true`.
|
||||
|
||||
When enabled and selected, this provider becomes the only request authentication provider. Built-in config API keys and other frontend auth providers do not authenticate requests while this provider is active.
|
||||
|
||||
The example accepts requests that include:
|
||||
|
||||
```http
|
||||
X-Example-Frontend-Auth: exclusive
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cd examples/plugin/frontend-auth-exclusive/go
|
||||
go build -buildmode=c-shared -o /tmp/cliproxy-frontend-auth-exclusive.dylib .
|
||||
```
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth-exclusive/go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
|
||||
|
||||
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
|
||||
@@ -0,0 +1,194 @@
|
||||
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"
|
||||
"unsafe"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
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 registration struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
Metadata pluginapi.Metadata `json:"metadata"`
|
||||
Capabilities capabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
type capabilities struct {
|
||||
FrontendAuthProvider bool `json:"frontend_auth_provider"`
|
||||
FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"`
|
||||
}
|
||||
|
||||
type identifierResponse struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
//export cliproxy_plugin_init
|
||||
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
|
||||
_ = host
|
||||
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)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string, request []byte) ([]byte, error) {
|
||||
switch method {
|
||||
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
|
||||
return okEnvelope(exampleRegistration())
|
||||
case pluginabi.MethodFrontendAuthIdentifier:
|
||||
return okEnvelope(identifierResponse{Identifier: "example-frontend-auth-exclusive-go"})
|
||||
case pluginabi.MethodFrontendAuthAuthenticate:
|
||||
return authenticate(request)
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func exampleRegistration() registration {
|
||||
return registration{
|
||||
SchemaVersion: pluginabi.SchemaVersion,
|
||||
Metadata: pluginapi.Metadata{
|
||||
Name: "example-frontend-auth-exclusive-go",
|
||||
Version: "0.1.0",
|
||||
Author: "router-for-me",
|
||||
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
|
||||
Logo: "https://example.invalid/example-frontend-auth-exclusive-go.png",
|
||||
ConfigFields: []pluginapi.ConfigField{},
|
||||
},
|
||||
Capabilities: capabilities{
|
||||
FrontendAuthProvider: true,
|
||||
FrontendAuthProviderExclusive: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(request []byte) ([]byte, error) {
|
||||
var req pluginapi.FrontendAuthRequest
|
||||
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
|
||||
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false})
|
||||
}
|
||||
if req.Headers.Get("X-Example-Frontend-Auth") != "exclusive" {
|
||||
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false})
|
||||
}
|
||||
return okEnvelope(pluginapi.FrontendAuthResponse{
|
||||
Authenticated: true,
|
||||
Principal: "example-frontend-auth-exclusive-go",
|
||||
Metadata: map[string]string{
|
||||
"mode": "exclusive",
|
||||
"provider": "example-frontend-auth-exclusive-go",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_frontend_auth_c C)
|
||||
|
||||
add_library(cliproxy_frontend_auth_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_frontend_auth_c PROPERTIES
|
||||
OUTPUT_NAME "frontend-auth-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "frontend_auth.identifier") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-frontend-auth-c\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "frontend_auth.authenticate") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-c\",\"Metadata\":{\"provider\":\"example-frontend-auth-c\"}}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}")
|
||||
case "frontend_auth.identifier":
|
||||
return okEnvelopeJSON("{\"identifier\":\"example-frontend-auth-go\"}")
|
||||
case "frontend_auth.authenticate":
|
||||
return okEnvelopeJSON("{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-go\",\"Metadata\":{\"provider\":\"example-frontend-auth-go\"}}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-frontend-auth-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-frontend-auth-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-frontend-auth-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-frontend-auth-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"frontend_auth_provider\":true}}}"); 0 },"frontend_auth.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-frontend-auth-rust\"}}"); 0 },"frontend_auth.authenticate" => { write_response(response, "{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"example-frontend-auth-rust\",\"Metadata\":{\"provider\":\"example-frontend-auth-rust\"}}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Host Callback Auth Files Plugin
|
||||
|
||||
This Go-only plugin demonstrates how a plugin-owned browser resource can call the host auth file callbacks:
|
||||
|
||||
- `host.auth.list`
|
||||
- `host.auth.get`
|
||||
- `host.auth.get_runtime`
|
||||
- `host.auth.save`
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
The plugin registers a Management API resource named `Host Auth Files` at `/status`. CPA exposes it under:
|
||||
|
||||
```text
|
||||
/v0/resource/plugins/host-callback-auth-files/status
|
||||
```
|
||||
|
||||
The resource reads URL query parameters, calls the host auth callbacks, and renders the result in HTML. It does not implement executor, translator, auth provider, or scheduler capabilities.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
cd go
|
||||
go build -buildmode=c-shared -o host-callback-auth-files.dylib .
|
||||
rm -f host-callback-auth-files.dylib host-callback-auth-files.h
|
||||
```
|
||||
|
||||
Use the platform extension expected by your target system:
|
||||
|
||||
- `.dylib` on macOS
|
||||
- `.so` on Linux
|
||||
- `.dll` on Windows
|
||||
|
||||
## Configuration
|
||||
|
||||
Build the dynamic library and place it under the configured plugin directory with a basename that matches the plugin ID. For example, `plugins/host-callback-auth-files.dylib` maps to `plugins.configs.host-callback-auth-files`.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
enabled: true
|
||||
dir: "plugins"
|
||||
configs:
|
||||
host-callback-auth-files:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
This plugin does not define plugin-specific configuration fields.
|
||||
|
||||
## Resource URL Examples
|
||||
|
||||
List all auth files:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=list
|
||||
```
|
||||
|
||||
Read physical JSON by auth index:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=get&auth_index=<AUTH_INDEX>
|
||||
```
|
||||
|
||||
Read runtime info by auth index:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=runtime&auth_index=<AUTH_INDEX>
|
||||
```
|
||||
|
||||
Save physical JSON:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=save&name=example-auth.json&json=%7B%22type%22%3A%22gemini%22%2C%22email%22%3A%22demo%40example.com%22%2C%22api_key%22%3A%22demo-key%22%7D
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
- `op`: one of `list`, `get`, `runtime`, `save`. Default is `list`.
|
||||
- `auth_index`: required for `get` and `runtime`.
|
||||
- `name`: required for `save`. Must end with `.json`.
|
||||
- `json`: required for `save`. Must be valid JSON.
|
||||
|
||||
## Notes
|
||||
|
||||
- `host.auth.get` returns the physical auth file JSON.
|
||||
- `host.auth.get_runtime` returns runtime credential metadata.
|
||||
- `host.auth.save` writes the JSON to the auth directory and upserts the runtime auth record.
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback-auth-files/go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
|
||||
|
||||
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
|
||||
@@ -0,0 +1,531 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginName = "host-callback-auth-files"
|
||||
resourcePath = "/status"
|
||||
resourceContentType = "text/html; charset=utf-8"
|
||||
)
|
||||
|
||||
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 registration struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
Metadata pluginapi.Metadata `json:"metadata"`
|
||||
Capabilities registrationCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
type registrationCapabilities struct {
|
||||
ManagementAPI bool `json:"management_api"`
|
||||
}
|
||||
|
||||
type managementRegistration struct {
|
||||
Resources []managementResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type managementResource struct {
|
||||
Path string `json:"Path"`
|
||||
Menu string `json:"Menu"`
|
||||
Description string `json:"Description"`
|
||||
}
|
||||
|
||||
type managementRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Headers http.Header
|
||||
Query url.Values
|
||||
Body []byte
|
||||
HostCallbackID string `json:"host_callback_id,omitempty"`
|
||||
}
|
||||
|
||||
type managementResponse struct {
|
||||
StatusCode int `json:"StatusCode"`
|
||||
Headers http.Header `json:"Headers"`
|
||||
Body []byte `json:"Body"`
|
||||
}
|
||||
|
||||
type authListResponse struct {
|
||||
Files []pluginapi.HostAuthFileEntry `json:"files"`
|
||||
}
|
||||
|
||||
type authOpOptions struct {
|
||||
Op string
|
||||
AuthIndex string
|
||||
Name string
|
||||
JSON json.RawMessage
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
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)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string, request []byte) ([]byte, error) {
|
||||
switch method {
|
||||
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
|
||||
return okEnvelope(pluginRegistration())
|
||||
case pluginabi.MethodManagementRegister:
|
||||
return okEnvelope(managementRegistration{
|
||||
Resources: []managementResource{{
|
||||
Path: resourcePath,
|
||||
Menu: "Host Auth Files",
|
||||
Description: "Lists auth files and demonstrates host.auth list/get/runtime/save callbacks.",
|
||||
}},
|
||||
})
|
||||
case pluginabi.MethodManagementHandle:
|
||||
return handleManagement(request)
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func pluginRegistration() registration {
|
||||
return registration{
|
||||
SchemaVersion: pluginabi.SchemaVersion,
|
||||
Metadata: pluginapi.Metadata{
|
||||
Name: pluginName,
|
||||
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{},
|
||||
},
|
||||
Capabilities: registrationCapabilities{
|
||||
ManagementAPI: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleManagement(raw []byte) ([]byte, error) {
|
||||
var req managementRequest
|
||||
if len(raw) > 0 {
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("decode management request: %w", errUnmarshal)
|
||||
}
|
||||
}
|
||||
opts, errOptions := optionsFromManagementRequest(req)
|
||||
if errOptions != nil {
|
||||
page := renderPage(opts, nil, errOptions.Error())
|
||||
return okEnvelope(htmlResponse(http.StatusBadRequest, page))
|
||||
}
|
||||
result, errRun := runAuthOp(opts)
|
||||
if errRun != nil {
|
||||
page := renderPage(opts, nil, errRun.Error())
|
||||
return okEnvelope(htmlResponse(http.StatusOK, page))
|
||||
}
|
||||
page := renderPage(opts, result, "")
|
||||
return okEnvelope(htmlResponse(http.StatusOK, page))
|
||||
}
|
||||
|
||||
func optionsFromManagementRequest(req managementRequest) (authOpOptions, error) {
|
||||
opts := authOpOptions{Op: "list"}
|
||||
if len(req.Body) > 0 {
|
||||
var bodyOpts authOpOptions
|
||||
if errUnmarshal := json.Unmarshal(req.Body, &bodyOpts); errUnmarshal != nil {
|
||||
return opts, fmt.Errorf("decode JSON request body: %w", errUnmarshal)
|
||||
}
|
||||
applyAuthOpOptions(&opts, bodyOpts)
|
||||
}
|
||||
if errApply := applyQueryAuthOptions(&opts, req.Query); errApply != nil {
|
||||
return opts, errApply
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func applyAuthOpOptions(dst *authOpOptions, src authOpOptions) {
|
||||
if strings.TrimSpace(src.Op) != "" {
|
||||
dst.Op = strings.ToLower(strings.TrimSpace(src.Op))
|
||||
}
|
||||
if strings.TrimSpace(src.AuthIndex) != "" {
|
||||
dst.AuthIndex = strings.TrimSpace(src.AuthIndex)
|
||||
}
|
||||
if strings.TrimSpace(src.Name) != "" {
|
||||
dst.Name = strings.TrimSpace(src.Name)
|
||||
}
|
||||
if len(src.JSON) > 0 && string(src.JSON) != "null" {
|
||||
dst.JSON = append(json.RawMessage(nil), src.JSON...)
|
||||
}
|
||||
}
|
||||
|
||||
func applyQueryAuthOptions(opts *authOpOptions, query url.Values) error {
|
||||
if query == nil {
|
||||
return nil
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("op")); raw != "" {
|
||||
opts.Op = strings.ToLower(raw)
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("auth_index")); raw != "" {
|
||||
opts.AuthIndex = raw
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("name")); raw != "" {
|
||||
opts.Name = raw
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("json")); raw != "" {
|
||||
if !json.Valid([]byte(raw)) {
|
||||
return fmt.Errorf("query json must be valid JSON")
|
||||
}
|
||||
opts.JSON = json.RawMessage(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAuthOp(opts authOpOptions) (any, error) {
|
||||
switch opts.Op {
|
||||
case "list", "":
|
||||
return callHostAuthList()
|
||||
case "get":
|
||||
if opts.AuthIndex == "" {
|
||||
return nil, fmt.Errorf("auth_index is required for op=get")
|
||||
}
|
||||
return callHostAuthGet(opts.AuthIndex)
|
||||
case "runtime", "get_runtime":
|
||||
if opts.AuthIndex == "" {
|
||||
return nil, fmt.Errorf("auth_index is required for op=runtime")
|
||||
}
|
||||
return callHostAuthGetRuntime(opts.AuthIndex)
|
||||
case "save":
|
||||
if opts.Name == "" {
|
||||
return nil, fmt.Errorf("name is required for op=save")
|
||||
}
|
||||
if len(opts.JSON) == 0 {
|
||||
return nil, fmt.Errorf("json is required for op=save")
|
||||
}
|
||||
return callHostAuthSave(opts.Name, opts.JSON)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown op %q: use list, get, runtime, or save", opts.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func callHostAuthList() (authListResponse, error) {
|
||||
result, errCall := callHost(pluginabi.MethodHostAuthList, map[string]any{})
|
||||
if errCall != nil {
|
||||
return authListResponse{}, errCall
|
||||
}
|
||||
var resp authListResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return authListResponse{}, fmt.Errorf("decode host.auth.list result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func callHostAuthGet(authIndex string) (pluginapi.HostAuthGetResponse, error) {
|
||||
result, errCall := callHost(pluginabi.MethodHostAuthGet, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
|
||||
if errCall != nil {
|
||||
return pluginapi.HostAuthGetResponse{}, errCall
|
||||
}
|
||||
var resp pluginapi.HostAuthGetResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return pluginapi.HostAuthGetResponse{}, fmt.Errorf("decode host.auth.get result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func callHostAuthGetRuntime(authIndex string) (pluginapi.HostAuthGetRuntimeResponse, error) {
|
||||
result, errCall := callHost(pluginabi.MethodHostAuthGetRuntime, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
|
||||
if errCall != nil {
|
||||
return pluginapi.HostAuthGetRuntimeResponse{}, errCall
|
||||
}
|
||||
var resp pluginapi.HostAuthGetRuntimeResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return pluginapi.HostAuthGetRuntimeResponse{}, fmt.Errorf("decode host.auth.get_runtime result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func callHostAuthSave(name string, rawJSON json.RawMessage) (pluginapi.HostAuthSaveResponse, error) {
|
||||
result, errCall := callHost(pluginabi.MethodHostAuthSave, pluginapi.HostAuthSaveRequest{
|
||||
Name: name,
|
||||
JSON: rawJSON,
|
||||
})
|
||||
if errCall != nil {
|
||||
return pluginapi.HostAuthSaveResponse{}, errCall
|
||||
}
|
||||
var resp pluginapi.HostAuthSaveResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return pluginapi.HostAuthSaveResponse{}, fmt.Errorf("decode host.auth.save result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func callHost(method string, payload any) (json.RawMessage, error) {
|
||||
rawPayload, errMarshal := json.Marshal(payload)
|
||||
if errMarshal != nil {
|
||||
return nil, fmt.Errorf("marshal host callback payload %s: %w", method, errMarshal)
|
||||
}
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
|
||||
var response C.cliproxy_buffer
|
||||
var requestPtr *C.uint8_t
|
||||
if len(rawPayload) > 0 {
|
||||
cPayload := C.CBytes(rawPayload)
|
||||
if cPayload == nil {
|
||||
return nil, fmt.Errorf("allocate host callback payload %s", method)
|
||||
}
|
||||
defer C.free(cPayload)
|
||||
requestPtr = (*C.uint8_t)(cPayload)
|
||||
}
|
||||
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
|
||||
var rawResponse []byte
|
||||
if response.ptr != nil && response.len > 0 {
|
||||
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
|
||||
}
|
||||
if response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
if len(rawResponse) == 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("decode host callback envelope %s: %w", method, errUnmarshal)
|
||||
}
|
||||
if !env.OK {
|
||||
if env.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("host callback %s failed", method)
|
||||
}
|
||||
if callCode != 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
|
||||
}
|
||||
return append(json.RawMessage(nil), env.Result...), nil
|
||||
}
|
||||
|
||||
func htmlResponse(statusCode int, body []byte) managementResponse {
|
||||
return managementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: http.Header{
|
||||
"content-type": []string{resourceContentType},
|
||||
},
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func renderPage(opts authOpOptions, result any, errText string) []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>Host Auth Files</title>")
|
||||
out.WriteString("<style>body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:2rem;line-height:1.45;color:#1f2933}code,pre{background:#f3f4f6;border-radius:6px}code{padding:.1rem .3rem}pre{padding:1rem;overflow:auto;white-space:pre-wrap}dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}dt{font-weight:600}dd{margin:0}.error{color:#b42318}</style>")
|
||||
out.WriteString("</head><body><main>")
|
||||
out.WriteString("<h1>Host Auth Files</h1>")
|
||||
out.WriteString("<dl>")
|
||||
writeDefinition(&out, "op", opts.Op)
|
||||
if opts.AuthIndex != "" {
|
||||
writeDefinition(&out, "auth_index", opts.AuthIndex)
|
||||
}
|
||||
if opts.Name != "" {
|
||||
writeDefinition(&out, "name", opts.Name)
|
||||
}
|
||||
out.WriteString("</dl>")
|
||||
if errText != "" {
|
||||
out.WriteString("<h2>Error</h2><pre class=\"error\">")
|
||||
out.WriteString(html.EscapeString(errText))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if result != nil {
|
||||
out.WriteString("<h2>Result</h2><pre>")
|
||||
out.WriteString(html.EscapeString(prettyJSON(result)))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
out.WriteString("<h2>Usage</h2><ul>")
|
||||
out.WriteString("<li><code>?op=list</code></li>")
|
||||
out.WriteString("<li><code>?op=get&auth_index=<AUTH_INDEX></code></li>")
|
||||
out.WriteString("<li><code>?op=runtime&auth_index=<AUTH_INDEX></code></li>")
|
||||
out.WriteString("<li><code>?op=save&name=example.json&json=...</code></li>")
|
||||
out.WriteString("</ul>")
|
||||
out.WriteString("</main></body></html>")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeDefinition(out *bytes.Buffer, key string, value string) {
|
||||
out.WriteString("<dt>")
|
||||
out.WriteString(html.EscapeString(key))
|
||||
out.WriteString("</dt><dd><code>")
|
||||
out.WriteString(html.EscapeString(value))
|
||||
out.WriteString("</code></dd>")
|
||||
}
|
||||
|
||||
func prettyBody(raw []byte) string {
|
||||
var buf bytes.Buffer
|
||||
if errIndent := json.Indent(&buf, raw, "", " "); errIndent == nil {
|
||||
return buf.String()
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func prettyJSON(v any) string {
|
||||
raw, errMarshal := json.MarshalIndent(v, "", " ")
|
||||
if errMarshal != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func cloneHeader(headers http.Header) http.Header {
|
||||
if headers == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(http.Header, len(headers))
|
||||
for key, values := range headers {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, items := range values {
|
||||
cloned[key] = append([]string(nil), items...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_host_callback_c C)
|
||||
|
||||
add_library(cliproxy_host_callback_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_host_callback_c PROPERTIES
|
||||
OUTPUT_NAME "host-callback-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "management.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-c/status.\"}]}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "management.handle") == 0) {
|
||||
call_host("host.log", "{\"level\":\"info\",\"message\":\"example-host-callback-c host callback log\",\"fields\":{\"plugin\":\"example-host-callback-c\"}}");
|
||||
call_host("host.http.do", "{\"method\":\"GET\",\"url\":\"https://example.com\",\"headers\":{\"user-agent\":[\"example-host-callback-c\"]}}");
|
||||
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
|
||||
case "management.register":
|
||||
return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-go/status.\"}]}")
|
||||
case "management.handle":
|
||||
callHost("host.log", []byte(`{"level":"info","message":"example-host-callback-go host callback log","fields":{"plugin":"example-host-callback-go"}}`))
|
||||
callHost("host.http.do", []byte(`{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-go"]}}`))
|
||||
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-host-callback-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-host-callback-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-host-callback-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-host-callback-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Host Callback\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-host-callback-rust/status.\"}]}}"); 0 },"management.handle" => {
|
||||
call_host("host.log", r#"{"level":"info","message":"example-host-callback-rust host callback log","fields":{"plugin":"example-host-callback-rust"}}"#);
|
||||
call_host("host.http.do", r#"{"method":"GET","url":"https://example.com","headers":{"user-agent":["example-host-callback-rust"]}}"#);
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPkhvc3QgQ2FsbGJhY2s8L3RpdGxlPjxtYWluPkhvc3QgQ2FsbGJhY2sgcmVzb3VyY2U8L21haW4+\"}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# Host Model Callback Plugin
|
||||
|
||||
This Go-only plugin demonstrates how a plugin-owned browser resource can call the host model execution callbacks instead of sending any external HTTP request itself.
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
The plugin registers a Management API resource named `Host Model Callback` at `/status`. CPA exposes it under:
|
||||
|
||||
```text
|
||||
/v0/resource/plugins/host-model-callback/status
|
||||
```
|
||||
|
||||
The resource examples are query-based. The resource reads URL query parameters, builds an OpenAI-compatible chat request, and calls:
|
||||
|
||||
- `host.model.execute` for non-streaming model execution.
|
||||
- `host.model.execute_stream`, `host.model.stream_read`, and `host.model.stream_close` for streaming execution.
|
||||
|
||||
This example is intentionally limited to host model callbacks. It does not implement an executor, translator, normalizer, auth provider, scheduler, or any direct outbound HTTP client.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
cd go
|
||||
go build -buildmode=c-shared -o host-model-callback.dylib .
|
||||
rm -f host-model-callback.dylib host-model-callback.h
|
||||
```
|
||||
|
||||
Use the platform extension expected by your target system:
|
||||
|
||||
- `.dylib` on macOS
|
||||
- `.so` on Linux
|
||||
- `.dll` on Windows
|
||||
|
||||
## Configuration
|
||||
|
||||
Build the dynamic library and place it under the configured plugin directory with a basename that matches the plugin ID. For example, `plugins/host-model-callback.dylib` maps to `plugins.configs.host-model-callback`.
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
enabled: true
|
||||
dir: "plugins"
|
||||
configs:
|
||||
host-model-callback:
|
||||
enabled: true
|
||||
priority: 1
|
||||
```
|
||||
|
||||
This plugin does not define plugin-specific configuration fields.
|
||||
|
||||
## Resource URL Examples
|
||||
|
||||
Non-streaming request with defaults:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-model-callback/status
|
||||
```
|
||||
|
||||
Non-streaming request with explicit protocol and prompt:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-model-callback/status?entry_protocol=openai&exit_protocol=openai&model=gpt-5.5&prompt=Say%20hello%20in%20one%20sentence
|
||||
```
|
||||
|
||||
Streaming request with explicit close:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-model-callback/status?stream=true&model=gpt-5.5&prompt=Write%20three%20short%20tokens
|
||||
```
|
||||
|
||||
Streaming request that relies on RPC-scope implicit close:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-model-callback/status?stream=true&implicit_close=true
|
||||
```
|
||||
|
||||
The default model ID is `gpt-5.5` to match the current nearby Codex example documentation and code. It is only an example model identifier; the request succeeds only when your CPA configuration can route that model.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `entry_protocol`: inbound client protocol passed to the host model execution path. The default is `openai`.
|
||||
- `exit_protocol`: target provider protocol passed to the host model execution path. The default is `openai`.
|
||||
- `model`: model identifier passed in the host model execution request. The default is `gpt-5.5`; availability depends on the configured model registry and auth records.
|
||||
- `stream`: boolean flag. The default is `false`; set `stream=true` to use `host.model.execute_stream`.
|
||||
- `prompt`: text used to build the default OpenAI-compatible request body.
|
||||
- `body`: optional JSON string in the URL query used as the raw model request body. When `body` is provided, it replaces the generated body.
|
||||
- `alt`: optional alternate route or mode suffix passed through the host model request.
|
||||
- `implicit_close`: streaming-only boolean flag. The default is `false`.
|
||||
|
||||
The generated default body is OpenAI-compatible:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"stream": false,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Summarize host model callbacks in one short sentence."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For example, a URL-encoded `body` query value can provide the raw OpenAI-compatible request:
|
||||
|
||||
```text
|
||||
http://localhost:8080/v0/resource/plugins/host-model-callback/status?body=%7B%22model%22%3A%22gpt-5.5%22%2C%22stream%22%3Afalse%2C%22messages%22%3A%5B%7B%22role%22%3A%22user%22%2C%22content%22%3A%22Say%20hello%20in%20one%20sentence%22%7D%5D%7D
|
||||
```
|
||||
|
||||
## Stream Close Semantics
|
||||
|
||||
By default, streaming mode explicitly closes the host-owned stream with `host.model.stream_close` through a deferred close call. This is the preferred pattern for plugins because it releases stream resources as soon as the plugin has finished reading.
|
||||
|
||||
When `implicit_close=true` is set, the plugin intentionally skips the explicit close call. CPA injects `host_callback_id` into the `management.handle` request, and this example forwards that callback ID to `host.model.execute_stream` so the host can close the stream when the `management.handle` RPC callback scope returns. This mode exists only to demonstrate host cleanup behavior; normal plugin code should explicitly close streams it opens.
|
||||
|
||||
## Recursion Guard
|
||||
|
||||
This example forwards the `host_callback_id` received from `management.handle` when it calls `host.model.execute` or `host.model.execute_stream`. CPA uses that callback scope to identify the plugin that initiated the host model callback and skips that same plugin's request, response, and stream interceptors for the nested model execution.
|
||||
|
||||
Host model callbacks are therefore not recursive for the caller. Other enabled plugins can still intercept the nested request.
|
||||
|
||||
## Billing and Usage
|
||||
|
||||
The callback uses the existing CPA model executor path. Usage collection, request accounting, and billing metadata are handled by the same executor and usage reporter path as normal proxied requests. The callback layer does not bill twice and does not create an additional usage record by itself.
|
||||
|
||||
## Error Handling and Troubleshooting
|
||||
|
||||
The page displays the model status, response headers, body, stream chunks, close mode, and any callback error returned by the host envelope.
|
||||
|
||||
Common issues:
|
||||
|
||||
- `host model executor is unavailable`: the host model executor path is not initialized for this plugin callback context.
|
||||
- `unsupported model` or provider-specific routing errors: the `model` value is not routable with the current CPA model/auth configuration.
|
||||
- `host.model.execute requires stream=false`: non-stream execution was called with a streaming request.
|
||||
- `host.model.execute_stream requires stream=true`: streaming execution was called without `stream=true`.
|
||||
- Empty or partial stream output: inspect the page error section and host logs; upstream stream errors are returned through `host.model.stream_read`.
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-model-callback/go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
|
||||
|
||||
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
|
||||
@@ -0,0 +1,731 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultModel = "gpt-5.5"
|
||||
defaultPrompt = "Summarize host model callbacks in one short sentence."
|
||||
pluginName = "host-model-callback"
|
||||
resourcePath = "/status"
|
||||
resourceContentType = "text/html; charset=utf-8"
|
||||
)
|
||||
|
||||
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 registration struct {
|
||||
SchemaVersion uint32 `json:"schema_version"`
|
||||
Metadata pluginapi.Metadata `json:"metadata"`
|
||||
Capabilities registrationCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
type registrationCapabilities struct {
|
||||
ManagementAPI bool `json:"management_api"`
|
||||
}
|
||||
|
||||
type managementRegistration struct {
|
||||
Resources []managementResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type managementResource struct {
|
||||
Path string `json:"Path"`
|
||||
Menu string `json:"Menu"`
|
||||
Description string `json:"Description"`
|
||||
}
|
||||
|
||||
type managementRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Headers http.Header
|
||||
Query url.Values
|
||||
Body []byte
|
||||
HostCallbackID string `json:"host_callback_id,omitempty"`
|
||||
}
|
||||
|
||||
type managementResponse struct {
|
||||
StatusCode int `json:"StatusCode"`
|
||||
Headers http.Header `json:"Headers"`
|
||||
Body []byte `json:"Body"`
|
||||
}
|
||||
|
||||
type managementBodyOptions struct {
|
||||
Model string `json:"model"`
|
||||
Mode string `json:"mode"`
|
||||
EntryProtocol string `json:"entry_protocol"`
|
||||
ExitProtocol string `json:"exit_protocol"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream *bool `json:"stream"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Query url.Values `json:"query"`
|
||||
Alt string `json:"alt"`
|
||||
ImplicitClose *bool `json:"implicit_close"`
|
||||
}
|
||||
|
||||
type hostModelExecutionRequest struct {
|
||||
pluginapi.HostModelExecutionRequest
|
||||
HostCallbackID string `json:"host_callback_id,omitempty"`
|
||||
}
|
||||
|
||||
type runOptions struct {
|
||||
Model string
|
||||
Mode string
|
||||
EntryProtocol string
|
||||
ExitProtocol string
|
||||
Prompt string
|
||||
Stream bool
|
||||
Body []byte
|
||||
Headers http.Header
|
||||
Query url.Values
|
||||
Alt string
|
||||
ImplicitClose bool
|
||||
HostCallbackID string
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type streamPageData struct {
|
||||
StatusCode int
|
||||
Headers http.Header
|
||||
StreamID string
|
||||
Chunks []string
|
||||
Error string
|
||||
CloseMode string
|
||||
CloseError string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
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)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string, request []byte) ([]byte, error) {
|
||||
switch method {
|
||||
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
|
||||
return okEnvelope(pluginRegistration())
|
||||
case pluginabi.MethodManagementRegister:
|
||||
return okEnvelope(managementRegistration{
|
||||
Resources: []managementResource{{
|
||||
Path: resourcePath,
|
||||
Menu: "Host Model Callback",
|
||||
Description: "Runs a model request through host.model callbacks and displays the result.",
|
||||
}},
|
||||
})
|
||||
case pluginabi.MethodManagementHandle:
|
||||
return handleManagement(request)
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func pluginRegistration() registration {
|
||||
return registration{
|
||||
SchemaVersion: pluginabi.SchemaVersion,
|
||||
Metadata: pluginapi.Metadata{
|
||||
Name: pluginName,
|
||||
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{},
|
||||
},
|
||||
Capabilities: registrationCapabilities{
|
||||
ManagementAPI: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleManagement(raw []byte) ([]byte, error) {
|
||||
var req managementRequest
|
||||
if len(raw) > 0 {
|
||||
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("decode management request: %w", errUnmarshal)
|
||||
}
|
||||
}
|
||||
opts, errOptions := optionsFromManagementRequest(req)
|
||||
if errOptions != nil {
|
||||
page := renderPage(opts, 0, nil, nil, nil, errOptions.Error(), "", "")
|
||||
return okEnvelope(htmlResponse(http.StatusBadRequest, page))
|
||||
}
|
||||
if opts.Stream {
|
||||
data := executeStream(opts)
|
||||
page := renderPage(opts, data.StatusCode, data.Headers, nil, data.Chunks, data.Error, data.CloseMode, data.CloseError)
|
||||
return okEnvelope(htmlResponse(http.StatusOK, page))
|
||||
}
|
||||
resp, errExecute := executeOnce(opts)
|
||||
if errExecute != nil {
|
||||
page := renderPage(opts, 0, nil, nil, nil, errExecute.Error(), "", "")
|
||||
return okEnvelope(htmlResponse(http.StatusOK, page))
|
||||
}
|
||||
page := renderPage(opts, resp.StatusCode, resp.Headers, resp.Body, nil, "", "", "")
|
||||
return okEnvelope(htmlResponse(http.StatusOK, page))
|
||||
}
|
||||
|
||||
func optionsFromManagementRequest(req managementRequest) (runOptions, error) {
|
||||
opts := runOptions{
|
||||
Model: defaultModel,
|
||||
Mode: "non-stream",
|
||||
EntryProtocol: "openai",
|
||||
ExitProtocol: "openai",
|
||||
Prompt: defaultPrompt,
|
||||
Headers: http.Header{},
|
||||
Query: url.Values{},
|
||||
}
|
||||
opts.HostCallbackID = strings.TrimSpace(req.HostCallbackID)
|
||||
if len(req.Body) > 0 {
|
||||
if errApplyBody := applyBodyOptions(&opts, req.Body); errApplyBody != nil {
|
||||
return opts, errApplyBody
|
||||
}
|
||||
}
|
||||
if errApplyQuery := applyQueryOptions(&opts, req.Query); errApplyQuery != nil {
|
||||
return opts, errApplyQuery
|
||||
}
|
||||
if opts.Stream {
|
||||
opts.Mode = "stream"
|
||||
} else {
|
||||
opts.Mode = "non-stream"
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func applyBodyOptions(opts *runOptions, raw []byte) error {
|
||||
var bodyOpts managementBodyOptions
|
||||
if errUnmarshal := json.Unmarshal(raw, &bodyOpts); errUnmarshal != nil {
|
||||
return fmt.Errorf("decode JSON request body: %w", errUnmarshal)
|
||||
}
|
||||
if strings.TrimSpace(bodyOpts.Model) != "" {
|
||||
opts.Model = strings.TrimSpace(bodyOpts.Model)
|
||||
}
|
||||
if strings.TrimSpace(bodyOpts.Mode) != "" {
|
||||
applyMode(opts, bodyOpts.Mode)
|
||||
}
|
||||
if strings.TrimSpace(bodyOpts.EntryProtocol) != "" {
|
||||
opts.EntryProtocol = strings.TrimSpace(bodyOpts.EntryProtocol)
|
||||
}
|
||||
if strings.TrimSpace(bodyOpts.ExitProtocol) != "" {
|
||||
opts.ExitProtocol = strings.TrimSpace(bodyOpts.ExitProtocol)
|
||||
}
|
||||
if bodyOpts.Prompt != "" {
|
||||
opts.Prompt = bodyOpts.Prompt
|
||||
}
|
||||
if bodyOpts.Stream != nil {
|
||||
opts.Stream = *bodyOpts.Stream
|
||||
}
|
||||
if len(bodyOpts.Body) > 0 && string(bodyOpts.Body) != "null" {
|
||||
if !json.Valid(bodyOpts.Body) {
|
||||
return fmt.Errorf("body must be valid JSON")
|
||||
}
|
||||
opts.Body = append([]byte(nil), bodyOpts.Body...)
|
||||
}
|
||||
if bodyOpts.Headers != nil {
|
||||
opts.Headers = cloneHeader(bodyOpts.Headers)
|
||||
}
|
||||
if bodyOpts.Query != nil {
|
||||
opts.Query = cloneValues(bodyOpts.Query)
|
||||
}
|
||||
if bodyOpts.Alt != "" {
|
||||
opts.Alt = bodyOpts.Alt
|
||||
}
|
||||
if bodyOpts.ImplicitClose != nil {
|
||||
opts.ImplicitClose = *bodyOpts.ImplicitClose
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyQueryOptions(opts *runOptions, query url.Values) error {
|
||||
if query == nil {
|
||||
return nil
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("model")); raw != "" {
|
||||
opts.Model = raw
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("mode")); raw != "" {
|
||||
applyMode(opts, raw)
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("entry_protocol")); raw != "" {
|
||||
opts.EntryProtocol = raw
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("exit_protocol")); raw != "" {
|
||||
opts.ExitProtocol = raw
|
||||
}
|
||||
if raw := query.Get("prompt"); raw != "" {
|
||||
opts.Prompt = raw
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("body")); raw != "" {
|
||||
body := []byte(raw)
|
||||
if !json.Valid(body) {
|
||||
return fmt.Errorf("query body must be valid JSON")
|
||||
}
|
||||
opts.Body = append([]byte(nil), body...)
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("alt")); raw != "" {
|
||||
opts.Alt = raw
|
||||
}
|
||||
if errStream := applyBoolQuery(query, "stream", &opts.Stream); errStream != nil {
|
||||
return errStream
|
||||
}
|
||||
if errImplicitClose := applyBoolQuery(query, "implicit_close", &opts.ImplicitClose); errImplicitClose != nil {
|
||||
return errImplicitClose
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMode(opts *runOptions, mode string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(mode))
|
||||
switch normalized {
|
||||
case "stream", "streaming":
|
||||
opts.Stream = true
|
||||
case "non-stream", "non_stream", "nonstream", "sync":
|
||||
opts.Stream = false
|
||||
}
|
||||
}
|
||||
|
||||
func applyBoolQuery(query url.Values, key string, target *bool) error {
|
||||
raw := strings.TrimSpace(query.Get(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, errParse := strconv.ParseBool(raw)
|
||||
if errParse != nil {
|
||||
return fmt.Errorf("%s must be a boolean: %w", key, errParse)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeOnce(opts runOptions) (pluginapi.HostModelExecutionResponse, error) {
|
||||
body, errBody := modelRequestBody(opts)
|
||||
if errBody != nil {
|
||||
return pluginapi.HostModelExecutionResponse{}, errBody
|
||||
}
|
||||
// Forward HostCallbackID so the host skips this plugin's interceptors on the
|
||||
// nested model execution. Host model callbacks do not recursively call the
|
||||
// originating plugin's interceptor chain.
|
||||
result, errCall := callHost(pluginabi.MethodHostModelExecute, hostModelExecutionRequest{
|
||||
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||
EntryProtocol: opts.EntryProtocol,
|
||||
ExitProtocol: opts.ExitProtocol,
|
||||
Model: opts.Model,
|
||||
Stream: false,
|
||||
Body: body,
|
||||
Headers: cloneHeader(opts.Headers),
|
||||
Query: cloneValues(opts.Query),
|
||||
Alt: opts.Alt,
|
||||
},
|
||||
HostCallbackID: opts.HostCallbackID,
|
||||
})
|
||||
if errCall != nil {
|
||||
return pluginapi.HostModelExecutionResponse{}, errCall
|
||||
}
|
||||
var resp pluginapi.HostModelExecutionResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return pluginapi.HostModelExecutionResponse{}, fmt.Errorf("decode host.model.execute result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func executeStream(opts runOptions) (data streamPageData) {
|
||||
body, errBody := modelRequestBody(opts)
|
||||
if errBody != nil {
|
||||
data.Error = errBody.Error()
|
||||
return data
|
||||
}
|
||||
// Forward HostCallbackID so the host skips this plugin's interceptors on the
|
||||
// nested model execution. Host model callbacks do not recursively call the
|
||||
// originating plugin's interceptor chain.
|
||||
result, errCall := callHost(pluginabi.MethodHostModelExecuteStream, hostModelExecutionRequest{
|
||||
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
|
||||
EntryProtocol: opts.EntryProtocol,
|
||||
ExitProtocol: opts.ExitProtocol,
|
||||
Model: opts.Model,
|
||||
Stream: true,
|
||||
Body: body,
|
||||
Headers: cloneHeader(opts.Headers),
|
||||
Query: cloneValues(opts.Query),
|
||||
Alt: opts.Alt,
|
||||
},
|
||||
HostCallbackID: opts.HostCallbackID,
|
||||
})
|
||||
if errCall != nil {
|
||||
data.Error = errCall.Error()
|
||||
return data
|
||||
}
|
||||
var resp pluginapi.HostModelStreamResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
data.Error = fmt.Sprintf("decode host.model.execute_stream result: %v", errUnmarshal)
|
||||
return data
|
||||
}
|
||||
data.StatusCode = resp.StatusCode
|
||||
data.Headers = cloneHeader(resp.Headers)
|
||||
data.StreamID = resp.StreamID
|
||||
if resp.StreamID == "" {
|
||||
data.Error = "host.model.execute_stream returned an empty stream_id"
|
||||
return data
|
||||
}
|
||||
if opts.ImplicitClose {
|
||||
// When implicit_close=true, the host closes this stream when the management.handle RPC callback scope returns.
|
||||
data.CloseMode = "implicit close at management.handle return"
|
||||
} else {
|
||||
data.CloseMode = "explicit close through host.model.stream_close"
|
||||
defer func() {
|
||||
if errClose := closeHostModelStream(resp.StreamID); errClose != nil {
|
||||
data.CloseError = errClose.Error()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for {
|
||||
chunk, errRead := readHostModelStream(resp.StreamID)
|
||||
if errRead != nil {
|
||||
data.Error = errRead.Error()
|
||||
return data
|
||||
}
|
||||
if len(chunk.Payload) > 0 {
|
||||
data.Chunks = append(data.Chunks, string(chunk.Payload))
|
||||
}
|
||||
if chunk.Error != "" {
|
||||
data.Error = chunk.Error
|
||||
return data
|
||||
}
|
||||
if chunk.Done {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readHostModelStream(streamID string) (pluginapi.HostModelStreamReadResponse, error) {
|
||||
result, errCall := callHost(pluginabi.MethodHostModelStreamRead, pluginapi.HostModelStreamReadRequest{StreamID: streamID})
|
||||
if errCall != nil {
|
||||
return pluginapi.HostModelStreamReadResponse{}, errCall
|
||||
}
|
||||
var resp pluginapi.HostModelStreamReadResponse
|
||||
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
|
||||
return pluginapi.HostModelStreamReadResponse{}, fmt.Errorf("decode host.model.stream_read result: %w", errUnmarshal)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func closeHostModelStream(streamID string) error {
|
||||
_, errCall := callHost(pluginabi.MethodHostModelStreamClose, pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
|
||||
return errCall
|
||||
}
|
||||
|
||||
func modelRequestBody(opts runOptions) ([]byte, error) {
|
||||
if len(opts.Body) > 0 {
|
||||
return append([]byte(nil), opts.Body...), nil
|
||||
}
|
||||
raw, errMarshal := json.Marshal(chatCompletionRequest{
|
||||
Model: opts.Model,
|
||||
Stream: opts.Stream,
|
||||
Messages: []chatMessage{{
|
||||
Role: "user",
|
||||
Content: opts.Prompt,
|
||||
}},
|
||||
})
|
||||
if errMarshal != nil {
|
||||
return nil, fmt.Errorf("marshal OpenAI-compatible request body: %w", errMarshal)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func callHost(method string, payload any) (json.RawMessage, error) {
|
||||
rawPayload, errMarshal := json.Marshal(payload)
|
||||
if errMarshal != nil {
|
||||
return nil, fmt.Errorf("marshal host callback payload %s: %w", method, errMarshal)
|
||||
}
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
|
||||
var response C.cliproxy_buffer
|
||||
var requestPtr *C.uint8_t
|
||||
if len(rawPayload) > 0 {
|
||||
cPayload := C.CBytes(rawPayload)
|
||||
if cPayload == nil {
|
||||
return nil, fmt.Errorf("allocate host callback payload %s", method)
|
||||
}
|
||||
defer C.free(cPayload)
|
||||
requestPtr = (*C.uint8_t)(cPayload)
|
||||
}
|
||||
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
|
||||
var rawResponse []byte
|
||||
if response.ptr != nil && response.len > 0 {
|
||||
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
|
||||
}
|
||||
if response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
if len(rawResponse) == 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("decode host callback envelope %s: %w", method, errUnmarshal)
|
||||
}
|
||||
if !env.OK {
|
||||
if env.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("host callback %s failed", method)
|
||||
}
|
||||
if callCode != 0 {
|
||||
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
|
||||
}
|
||||
return append(json.RawMessage(nil), env.Result...), nil
|
||||
}
|
||||
|
||||
func htmlResponse(statusCode int, body []byte) managementResponse {
|
||||
return managementResponse{
|
||||
StatusCode: statusCode,
|
||||
Headers: http.Header{
|
||||
"content-type": []string{resourceContentType},
|
||||
},
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func renderPage(opts runOptions, status int, headers http.Header, body []byte, chunks []string, errText string, closeMode string, closeError string) []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>Host Model Callback</title>")
|
||||
out.WriteString("<style>body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:2rem;line-height:1.45;color:#1f2933}code,pre{background:#f3f4f6;border-radius:6px}code{padding:.1rem .3rem}pre{padding:1rem;overflow:auto;white-space:pre-wrap}dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}dt{font-weight:600}dd{margin:0}.error{color:#b42318}</style>")
|
||||
out.WriteString("</head><body><main>")
|
||||
out.WriteString("<h1>Host Model Callback</h1>")
|
||||
out.WriteString("<dl>")
|
||||
writeDefinition(&out, "model", opts.Model)
|
||||
writeDefinition(&out, "mode", opts.Mode)
|
||||
writeDefinition(&out, "entry_protocol", opts.EntryProtocol)
|
||||
writeDefinition(&out, "exit_protocol", opts.ExitProtocol)
|
||||
writeDefinition(&out, "stream", strconv.FormatBool(opts.Stream))
|
||||
writeDefinition(&out, "implicit_close", strconv.FormatBool(opts.ImplicitClose))
|
||||
if closeMode != "" {
|
||||
writeDefinition(&out, "close", closeMode)
|
||||
}
|
||||
writeDefinition(&out, "status", strconv.Itoa(status))
|
||||
out.WriteString("</dl>")
|
||||
if errText != "" {
|
||||
out.WriteString("<h2>Error</h2><pre class=\"error\">")
|
||||
out.WriteString(html.EscapeString(errText))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if closeError != "" {
|
||||
out.WriteString("<h2>Close Error</h2><pre class=\"error\">")
|
||||
out.WriteString(html.EscapeString(closeError))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if headers != nil {
|
||||
out.WriteString("<h2>Headers</h2><pre>")
|
||||
out.WriteString(html.EscapeString(prettyJSON(headers)))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if len(chunks) > 0 {
|
||||
out.WriteString("<h2>Stream Chunks</h2><pre>")
|
||||
out.WriteString(html.EscapeString(strings.Join(chunks, "")))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
if len(body) > 0 {
|
||||
out.WriteString("<h2>Body</h2><pre>")
|
||||
out.WriteString(html.EscapeString(prettyBody(body)))
|
||||
out.WriteString("</pre>")
|
||||
}
|
||||
out.WriteString("</main></body></html>")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeDefinition(out *bytes.Buffer, key string, value string) {
|
||||
out.WriteString("<dt>")
|
||||
out.WriteString(html.EscapeString(key))
|
||||
out.WriteString("</dt><dd><code>")
|
||||
out.WriteString(html.EscapeString(value))
|
||||
out.WriteString("</code></dd>")
|
||||
}
|
||||
|
||||
func prettyBody(raw []byte) string {
|
||||
var buf bytes.Buffer
|
||||
if errIndent := json.Indent(&buf, raw, "", " "); errIndent == nil {
|
||||
return buf.String()
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func prettyJSON(v any) string {
|
||||
raw, errMarshal := json.MarshalIndent(v, "", " ")
|
||||
if errMarshal != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func cloneHeader(headers http.Header) http.Header {
|
||||
if headers == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(http.Header, len(headers))
|
||||
for key, values := range headers {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, items := range values {
|
||||
cloned[key] = append([]string(nil), items...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_management_api_c C)
|
||||
|
||||
add_library(cliproxy_management_api_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_management_api_c PROPERTIES
|
||||
OUTPUT_NAME "management-api-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "management.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-c/status.\"}]}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "management.handle") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/management-api/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}")
|
||||
case "management.register":
|
||||
return okEnvelopeJSON("{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-go/status.\"}]}")
|
||||
case "management.handle":
|
||||
return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-management-api-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-management-api-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-management-api-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-management-api-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"management_api\":true}}}"); 0 },"management.register" => { write_response(response, "{\"ok\":true,\"result\":{\"resources\":[{\"Path\":\"/status\",\"Menu\":\"Management API\",\"Description\":\"CPA exposes this menu resource under /v0/resource/plugins/example-management-api-rust/status.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"text/html; charset=utf-8\"]},\"Body\":\"PCFkb2N0eXBlIGh0bWw+PHRpdGxlPk1hbmFnZW1lbnQgQVBJPC90aXRsZT48bWFpbj5NYW5hZ2VtZW50IEFQSSByZXNvdXJjZTwvbWFpbj4=\"}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_model_c C)
|
||||
|
||||
add_library(cliproxy_model_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_model_c PROPERTIES
|
||||
OUTPUT_NAME "model-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "model.static") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-model-c\",\"Models\":[{\"ID\":\"example-model-c-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-c\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "model.for_auth") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-model-c\",\"Models\":[{\"ID\":\"example-model-c-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-c\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/model/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}")
|
||||
case "model.static":
|
||||
return okEnvelopeJSON("{\"Provider\":\"example-model-go\",\"Models\":[{\"ID\":\"example-model-go-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-go\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}")
|
||||
case "model.for_auth":
|
||||
return okEnvelopeJSON("{\"Provider\":\"example-model-go\",\"Models\":[{\"ID\":\"example-model-go-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-go\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-model-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-model-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-model-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-model-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"model_provider\":true}}}"); 0 },"model.static" => { write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-model-rust\",\"Models\":[{\"ID\":\"example-model-rust-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-rust\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}"); 0 },"model.for_auth" => { write_response(response, "{\"ok\":true,\"result\":{\"Provider\":\"example-model-rust\",\"Models\":[{\"ID\":\"example-model-rust-model\",\"Object\":\"model\",\"OwnedBy\":\"example-model-rust\",\"DisplayName\":\"Model Example Model\",\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192,\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_protocol_format_c C)
|
||||
|
||||
add_library(cliproxy_protocol_format_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_protocol_format_c PROPERTIES
|
||||
OUTPUT_NAME "protocol-format-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.identifier") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-protocol-format-c\"}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "executor.execute") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtcHJvdG9jb2wtZm9ybWF0LWMiLCJvYmplY3QiOiJjaGF0LmNvbXBsZXRpb24ifQ==\",\"Headers\":{\"content-type\":[\"application/json\"]}}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/protocol-format/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}")
|
||||
case "executor.identifier":
|
||||
return okEnvelopeJSON("{\"identifier\":\"example-protocol-format-go\"}")
|
||||
case "executor.execute":
|
||||
return okEnvelopeJSON("{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtcHJvdG9jb2wtZm9ybWF0LWdvIiwib2JqZWN0IjoiY2hhdC5jb21wbGV0aW9uIn0=\",\"Headers\":{\"content-type\":[\"application/json\"]}}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-protocol-format-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-protocol-format-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-protocol-format-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-protocol-format-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"executor\":true,\"executor_model_scope\":\"both\",\"executor_input_formats\":[\"chat-completions\"],\"executor_output_formats\":[\"responses\"]}}}"); 0 },"executor.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-protocol-format-rust\"}}"); 0 },"executor.execute" => { write_response(response, "{\"ok\":true,\"result\":{\"Payload\":\"eyJpZCI6ImV4YW1wbGUtcHJvdG9jb2wtZm9ybWF0LXJ1c3QiLCJvYmplY3QiOiJjaGF0LmNvbXBsZXRpb24ifQ==\",\"Headers\":{\"content-type\":[\"application/json\"]}}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cliproxy_request_normalizer_c C)
|
||||
|
||||
add_library(cliproxy_request_normalizer_c SHARED src/plugin.c)
|
||||
set_target_properties(cliproxy_request_normalizer_c PROPERTIES
|
||||
OUTPUT_NAME "request-normalizer-c"
|
||||
PREFIX ""
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
#include <stdint.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 const cliproxy_host_api* stored_host = NULL;
|
||||
|
||||
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 void call_host(const char* method, const char* payload) {
|
||||
if (stored_host == NULL || stored_host->call == NULL || method == NULL) {
|
||||
return;
|
||||
}
|
||||
cliproxy_buffer response = {0};
|
||||
const uint8_t* request = (const uint8_t*)payload;
|
||||
size_t request_len = payload == NULL ? 0 : strlen(payload);
|
||||
if (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {
|
||||
stored_host->free_buffer(response.ptr, response.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, "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}");
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(method, "plugin.register") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "plugin.reconfigure") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}}");
|
||||
return 0;
|
||||
}
|
||||
if (strcmp(method, "request.normalize") == 0) {
|
||||
write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJub3JtYWxpemVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LW5vcm1hbGl6ZXItYyJ9\"}}");
|
||||
return 0;
|
||||
}
|
||||
write_response(response, "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}");
|
||||
(void)request;
|
||||
(void)request_len;
|
||||
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;
|
||||
}
|
||||
stored_host = host;
|
||||
plugin->abi_version = ABI_VERSION;
|
||||
plugin->call = plugin_call;
|
||||
plugin->free_buffer = plugin_free;
|
||||
plugin->shutdown = plugin_shutdown;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/request-normalizer/go
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
|
||||
static const cliproxy_host_api* stored_host;
|
||||
|
||||
static void store_host_api(const cliproxy_host_api* host) {
|
||||
stored_host = host;
|
||||
}
|
||||
|
||||
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
|
||||
if (stored_host == NULL || stored_host->call == NULL) {
|
||||
return 1;
|
||||
}
|
||||
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
|
||||
}
|
||||
|
||||
static void free_host_buffer(void* ptr, size_t len) {
|
||||
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
|
||||
stored_host->free_buffer(ptr, len);
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const abiVersion uint32 = 1
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
C.store_host_api(host)
|
||||
plugin.abi_version = C.uint32_t(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
|
||||
}
|
||||
raw, errHandle := handleMethod(C.GoString(method))
|
||||
if errHandle != nil {
|
||||
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
|
||||
return 1
|
||||
}
|
||||
writeResponse(response, raw)
|
||||
_ = request
|
||||
_ = requestLen
|
||||
return 0
|
||||
}
|
||||
|
||||
//export cliproxyPluginFree
|
||||
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
|
||||
if ptr != nil {
|
||||
C.free(ptr)
|
||||
}
|
||||
_ = len
|
||||
}
|
||||
|
||||
//export cliproxyPluginShutdown
|
||||
func cliproxyPluginShutdown() {}
|
||||
|
||||
func handleMethod(method string) ([]byte, error) {
|
||||
_ = http.StatusOK
|
||||
_ = time.Second
|
||||
switch method {
|
||||
case "plugin.register":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}")
|
||||
case "plugin.reconfigure":
|
||||
return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}")
|
||||
case "request.normalize":
|
||||
return okEnvelopeJSON("{\"Body\":\"eyJub3JtYWxpemVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LW5vcm1hbGl6ZXItZ28ifQ==\"}")
|
||||
default:
|
||||
return errorEnvelope("unknown_method", "unknown method: "+method), nil
|
||||
}
|
||||
}
|
||||
|
||||
func okEnvelopeJSON(result string) ([]byte, error) {
|
||||
return json.Marshal(envelope{OK: true, Result: json.RawMessage(result)})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func callHost(method string, payload []byte) {
|
||||
cMethod := C.CString(method)
|
||||
defer C.free(unsafe.Pointer(cMethod))
|
||||
var response C.cliproxy_buffer
|
||||
var req *C.uint8_t
|
||||
if len(payload) > 0 {
|
||||
req = (*C.uint8_t)(C.CBytes(payload))
|
||||
defer C.free(unsafe.Pointer(req))
|
||||
}
|
||||
if C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {
|
||||
C.free_host_buffer(response.ptr, response.len)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cliproxy-request-normalizer-rust"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cliproxy-request-normalizer-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::ptr;
|
||||
|
||||
const ABI_VERSION: u32 = 1;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
static mut STORED_HOST: *const CliproxyHostApi = ptr::null();
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 {
|
||||
if plugin.is_null() {
|
||||
return 1;
|
||||
}
|
||||
unsafe {
|
||||
STORED_HOST = host;
|
||||
(*plugin).abi_version = ABI_VERSION;
|
||||
(*plugin).call = Some(plugin_call);
|
||||
(*plugin).free_buffer = Some(plugin_free);
|
||||
(*plugin).shutdown = Some(plugin_shutdown);
|
||||
}
|
||||
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, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#);
|
||||
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;
|
||||
let _ = request_len;
|
||||
match method {
|
||||
"plugin.register" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-normalizer-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-normalizer-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_normalizer\":true}}}"); 0 },"request.normalize" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJub3JtYWxpemVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LW5vcm1hbGl6ZXItcnVzdCJ9\"}}"); 0 },
|
||||
_ => {
|
||||
write_response(response, r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn call_host(method: &str, payload: &str) {
|
||||
unsafe {
|
||||
if STORED_HOST.is_null() {
|
||||
return;
|
||||
}
|
||||
let host = &*STORED_HOST;
|
||||
let Some(call) = host.call else {
|
||||
return;
|
||||
};
|
||||
let mut method_bytes = method.as_bytes().to_vec();
|
||||
method_bytes.push(0);
|
||||
let mut response = CliproxyBuffer { ptr: ptr::null_mut(), len: 0 };
|
||||
let rc = call(
|
||||
host.host_ctx,
|
||||
method_bytes.as_ptr() as *const c_char,
|
||||
payload.as_ptr(),
|
||||
payload.len(),
|
||||
&mut response,
|
||||
);
|
||||
if rc == 0 && !response.ptr.is_null() {
|
||||
if let Some(free_buffer) = host.free_buffer {
|
||||
free_buffer(response.ptr as *mut std::ffi::c_void, response.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user