chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:43 +08:00
commit 0e2cc6b102
927 changed files with 260929 additions and 0 deletions
+48
View File
@@ -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)" "$@"
+109
View File
@@ -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/...`.
+108
View File
@@ -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/...` 下。
+8
View File
@@ -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 ""
)
+129
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go
go 1.26
+181
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-auth-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-auth-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -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 backends 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 Codestyle 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)
}
}
+8
View File
@@ -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 ""
)
+117
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go
go 1.26
+175
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-cli-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-cli-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -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 ""
)
+129
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go
go 1.26
+181
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-executor-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-executor-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth/go
go 1.26
+175
View File
@@ -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
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-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&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=runtime&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=save&amp;name=example.json&amp;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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback/go
go 1.26
+177
View File
@@ -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
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/management-api/go
go 1.26
+175
View File
@@ -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
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-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);
}
}
}
}
+8
View File
@@ -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 ""
)
+117
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/model/go
go 1.26
+175
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-model-rust"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cliproxy-model-rust"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
+127
View File
@@ -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
+175
View File
@@ -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
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-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)
}
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cliproxy-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