diff --git a/.gitignore b/.gitignore index 9f8bad4fa..3a3c871bb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ conv/* temp/* refs/* plugins/* +examples/plugin/bin/* # Storage backends pgstore/* diff --git a/.goreleaser.yml b/.goreleaser.yml index c479255ea..d7bf49a8f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,7 +3,7 @@ version: 2 builds: - id: "cli-proxy-api" env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index b4caaee32..a666b5a07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM golang:1.26-alpine AS builder WORKDIR /app +RUN apk add --no-cache build-base + COPY go.mod go.sum ./ RUN go mod download @@ -12,7 +14,7 @@ ARG VERSION=dev ARG COMMIT=none ARG BUILD_DATE=unknown -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/ +RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/ FROM alpine:3.23 diff --git a/config.example.yaml b/config.example.yaml index 0070e9d3c..98a3d7539 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -49,8 +49,9 @@ pprof: enable: false addr: "127.0.0.1:8316" -# Go dynamic plugins are trusted in-process code. They are disabled by default. -# Build plugins with go build -buildmode=plugin for the target GOOS/GOARCH. +# Standard dynamic library plugins are trusted in-process code. They are disabled by default. +# Build Go examples with go build -buildmode=c-shared for the target GOOS/GOARCH. +# Other languages can implement the same C ABI and JSON method protocol. # Plugin executors require a matching auth record with the same provider key. # If the same provider is configured as OpenAI-compatible, the native executor wins. # Plugin command-line flags and Management API routes are optional capabilities. diff --git a/examples/plugin/Makefile b/examples/plugin/Makefile new file mode 100644 index 000000000..066756f7c --- /dev/null +++ b/examples/plugin/Makefile @@ -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 +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)" "$@" diff --git a/examples/plugin/README.md b/examples/plugin/README.md index e9c86fc31..e763a20ce 100644 --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -1,416 +1,38 @@ -# Example Go Dynamic Plugin +# Standard Dynamic Library Plugin Examples -This directory is the reference skeleton for writing a provider plugin against the current `sdk/pluginapi` ABI. It is intentionally deterministic and small, but it demonstrates the host integration points that a real provider plugin needs: provider-owned auth parsing, model discovery, execution, HTTP bridging, request/response transforms, thinking config, usage observation, command-line flags, and diagnostic Management API routes. +This directory contains standard dynamic library plugin examples for the CLIProxyAPI C ABI. -The example uses the provider key `plugin-example` and the plugin ID `example`. +## Layout -## What the sample implements +- `simple/`: 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. +- `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. +- `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 capability only. +- `host-callback/`: minimal Management API route that demonstrates host callbacks. -`examples/plugin/main.go` exports the required Go plugin entrypoints: +Each example directory contains `go/`, `c/`, and `rust/` subdirectories. -```go -func Register(configYAML []byte) pluginapi.Plugin -func Reconfigure(configYAML []byte) pluginapi.Plugin -``` - -`Register` is called the first time the host loads the `.so` file. `Reconfigure` is called on config hot reload for a plugin that has already been opened and is still enabled. Both functions must return a `pluginapi.Plugin` value with valid metadata and at least one capability. - -Required metadata fields: - -- `Metadata.Name` -- `Metadata.Version` -- `Metadata.Author` -- `Metadata.GitHubRepository` - -The sample declares these capabilities: - -| Capability | Interface | What this sample shows | -| --- | --- | --- | -| Static and per-auth models | `ModelProvider` | Returns `plugin-example-model` for both static registration and auth-bound discovery. | -| Auth parsing and refresh | `AuthProvider` | Parses auth JSON whose `type` is `plugin-example`, exposes non-interactive login methods, and returns refreshed storage unchanged. | -| Frontend auth | `FrontendAuthProvider` | Accepts inbound requests only when `X-Plugin-Example: allow` is present. | -| Provider execution | `ProviderExecutor` | Implements non-streaming execution, streaming execution, token counting, and raw HTTP passthrough. | -| Executor model scope | `ExecutorModelScope` | Uses `pluginapi.ExecutorModelScopeBoth` so the executor can serve static models and OAuth/auth-bound models. | -| Request conversion | `RequestTranslator`, `RequestNormalizer` | Shows where canonical and provider-specific request payload transforms live. | -| Response conversion | `ResponseTranslator`, `ResponseBeforeTranslator`, `ResponseAfterTranslator` | Shows the response transform hooks before and after native translation. | -| Thinking config | `ThinkingApplier` | Receives canonical thinking config and writes provider-specific payload fields. | -| Usage observation | `UsagePlugin` | Counts completed usage records in memory for diagnostics. | -| Command-line flags | `CommandLinePlugin` | Adds plugin-owned CLI flags and receives all parsed flag values at execution time. | -| Management API | `ManagementAPI` | Adds exact diagnostic routes under `/v0/management/`. | - -`ModelRegistrar` is still present in `sdk/pluginapi` for simple model-only plugins. New provider plugins should normally prefer `ModelProvider`, because it supports both static model metadata and per-auth model discovery through the same provider-native path. - -## Platform and ABI rules - -CLIProxyAPI loads standard Go plugins built with: +## Build All Examples ```bash -go build -buildmode=plugin +make -C examples/plugin list +make -C examples/plugin build ``` -The Go standard `plugin` package is supported on Linux, FreeBSD, and macOS. On unsupported platforms, plugin loading is disabled and the service continues with native logic. +Artifacts are written to `examples/plugin/bin`. -Go plugin ABI compatibility is strict. Build the plugin for the target service binary with the same: +## Notes -- `GOOS` and `GOARCH` -- CPU feature target, when you use CPU-specific directories -- Go toolchain version -- build tags and CGO settings -- module path -- shared dependency versions +`protocol-format` uses a minimal executor because format declarations belong to executor capabilities. -If any of these differ, `plugin.Open` can fail or the loaded symbols can have incompatible types. - -## Build and install - -Build from the repository root: - -```bash -mkdir -p plugins/$(go env GOOS)/$(go env GOARCH) -go build -buildmode=plugin -o plugins/$(go env GOOS)/$(go env GOARCH)/example.so ./examples/plugin -``` - -The plugin ID is the `.so` file basename without the final `.so` suffix. `example.so` maps to `plugins.configs.example`. - -Plugin IDs must match this shape: - -```text -[A-Za-z0-9][A-Za-z0-9._-]{0,127} -``` - -The host searches these directories in order and keeps the first `.so` found for each plugin ID: - -```text -plugins//-/*.so -plugins///*.so -plugins/*.so -``` - -For `amd64`, `` is selected from CPU capabilities as `v4`, `v3`, `v2`, or `v1`. CPU-specific builds therefore belong under paths such as `plugins/linux/amd64-v3/`. - -Replacing an already opened `.so` file requires a process restart. Go plugins cannot be unloaded from the current process. - -## Configure the host - -Dynamic plugins are disabled by default. Enable them in `config.yaml`: - -```yaml -plugins: - enabled: true - dir: "plugins" - configs: - example: - enabled: true - priority: 1 - config1: true - config2: "string" - config3: 3 -``` - -Configuration rules: - -- `plugins.enabled=false` skips all plugin loading and execution. -- `plugins.dir` defaults to `plugins` when omitted or empty. -- `plugins.configs.` is the per-plugin YAML subtree passed to `Register` or `Reconfigure`. -- `enabled` defaults to `true` for a configured plugin instance. -- `priority` defaults to `0`. -- The host injects normalized `enabled` and `priority` into the YAML bytes passed to the plugin when they are missing. -- Higher `priority` plugins run before lower `priority` plugins. Equal priorities are ordered by plugin ID. - -Hot reload updates the runtime plugin snapshot. Already opened plugin binaries stay in memory, but disabled plugins are removed from the active capability set. If a loaded plugin remains enabled, the host calls `Reconfigure(configYAML)` instead of `Register(configYAML)`. - -## 插件 metadata、Logo 和配置字段 - -插件通过 `pluginapi.Metadata` 向宿主管理接口提供展示信息: - -```go -type Metadata struct { - Name string - Version string - Author string - GitHubRepository string - Logo string - ConfigFields []ConfigField -} -``` - -`Logo` 是给管理端展示的字符串。宿主只透传该值,不校验它是 URL、data URI、文件路径或其他格式。 - -`ConfigFields` 描述 `plugins.configs.` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下: - -```go -type ConfigField struct { - Name string - Type ConfigFieldType - EnumValues []string - Description string -} -``` - -支持的 `ConfigFieldType` 值包括 `string`、`number`、`integer`、`boolean`、`enum`、`array` 和 `object`。当类型是 `enum` 时,`EnumValues` 应列出所有可选值。 - -## Add auth material - -Executor-backed plugin models need a matching auth record so the scheduler can select the provider. The auth `type` must match the provider returned by `ModelProvider`, `AuthProvider.Identifier`, and `ProviderExecutor.Identifier`. - -For this sample: - -```json -{ - "type": "plugin-example", - "api_key": "plugin-or-upstream-secret" -} -``` - -Place the file under the configured auth directory, for example: - -```text -auths/plugin-example.json -``` - -Do not configure `base_url`, `compat_name`, or an `openai-compatibility` entry for the same provider unless you intentionally want the native OpenAI-compatible executor to own that provider. Native executors always win over plugin executors. - -Auth provider behavior in this sample: - -- `ParseAuth` accepts JSON offered by the host auth loader and returns `pluginapi.AuthData`. -- `StartLogin` and `PollLogin` are present but return non-interactive errors in this sample. -- `RefreshAuth` returns the current auth data unchanged. -- A real plugin can return `AuthData` from command-line execution or login polling; the host persists it through the normal auth store. - -## Model registration and executor scope - -The current provider-native model path is `ModelProvider`: - -- `StaticModels` returns provider models that are available without inspecting a specific auth record. -- `ModelsForAuth` returns models discovered for one selected auth record and can return an `AuthUpdate` when discovery refreshes persisted provider state. - -The host applies normal model processing after plugin discovery: aliases, excluded models, prefixes, registry reconciliation, and scheduler rules. - -`ExecutorModelScope` controls which model-registration paths are allowed when `Capabilities.Executor` is present: - -| Scope | Meaning | -| --- | --- | -| `pluginapi.ExecutorModelScopeBoth` | The executor supports both static models and auth-bound OAuth-style models. This is the default when the scope is empty or invalid. | -| `pluginapi.ExecutorModelScopeStatic` | The executor supports only non-OAuth static models. `ModelsForAuth` is skipped for executor-backed registration. | -| `pluginapi.ExecutorModelScopeOAuth` | The executor supports only auth-bound models. Static executor model clients are not registered. | - -Use the narrowest scope that matches the provider. This avoids exposing models through the wrong registration path. - -## Execution flow - -A plugin executor runs only when: - -- global plugins are enabled, -- the specific plugin is enabled, -- the plugin has not been panic-fused, -- the selected auth provider matches the executor provider, -- no native executor owns the same provider or selected model, -- and no higher-priority plugin has already claimed the same provider/model. - -`ProviderExecutor` receives a `pluginapi.ExecutorRequest` with: - -- `Model`: the host-resolved model identifier after alias handling, -- `Format`: the target provider format, -- `SourceFormat`: the original client format, -- `OriginalRequest`: the raw client payload, -- `Payload`: the translated provider payload, -- `StorageJSON`, `AuthMetadata`, and `AuthAttributes`: selected auth state, -- `HTTPClient`: the host HTTP bridge. - -Executor upstream HTTP calls must use `req.HTTPClient.Do` or `req.HTTPClient.DoStream`. Do not build a separate proxy-aware client inside the plugin. The host bridge preserves host transport policy and lets `request-log` capture the outbound upstream request and the raw upstream response before plugin-side translation. - -The sample methods are intentionally deterministic: - -- `Execute` returns one OpenAI-shaped JSON response. -- `ExecuteStream` emits one stream chunk and closes the channel. -- `CountTokens` returns zero token counts. -- `HttpRequest` forwards raw HTTP through the host bridge. - -For real providers, use `req.Model` for provider routing and model rewriting decisions. Do not assume every protocol payload has a trustworthy top-level `model` field. - -## Translators, normalizers, and thinking - -Native logic is authoritative. Plugin transforms fill gaps instead of replacing built-in provider support. - -Request and response behavior: - -- Request normalizers run from higher priority to lower priority and are chained. -- Response normalizers before and after translation follow the same priority ordering. -- Request translators and response translators run only when no native translator exists for the format pair. -- Only the highest-priority plugin translator is selected for a missing translation path. - -Thinking behavior: - -- The host parses, normalizes, and validates thinking config centrally. -- `ThinkingApplier` receives canonical `pluginapi.ThinkingConfig`. -- A plugin thinking applier only applies provider keys that are not owned by native thinking providers. -- When a plugin is disabled, removed from the active snapshot, or panic-fused, its thinking applier is removed. - -The sample writes these provider-specific fields into the payload: - -```json -{ - "plugin_example_thinking": { - "mode": "budget", - "budget": 1024, - "level": "" - } -} -``` - -## Command-line flags - -The sample declares two plugin-owned flags: - -```bash -./cli-proxy-api -config config.yaml -plugin-example-command -./cli-proxy-api -config config.yaml -plugin-example-command -plugin-example-message "custom message" -``` - -Plugin command-line flags are registered before normal flag parsing so they appear in `-help`. - -Rules: - -- Supported flag types are `bool`, `string`, `int`, `int64`, `float64`, and `duration`. -- Flag names cannot start with `-`, contain whitespace, contain `=`, or be `help` / `h`. -- Native flags cannot be replaced. -- Higher-priority plugin flags cannot be replaced by lower-priority plugins. -- When any plugin-owned flag is provided, the host passes every argument, every visible parsed flag, and the triggered plugin-owned flags to `ExecuteCommandLine`. -- If final config disables global plugins or this plugin, the flag can still be parsed but plugin execution is skipped. -- If `ExecuteCommandLine` returns `Auths`, the host persists them through the configured auth store and appends saved paths to stdout. - -## Management API routes - -宿主提供原生插件管理接口: - -```text -GET /v0/management/plugins -PATCH /v0/management/plugins/{pluginID}/enabled -PUT /v0/management/plugins/{pluginID}/config -PATCH /v0/management/plugins/{pluginID}/config -``` - -`GET /v0/management/plugins` 会按宿主当前扫描规则列出插件目录中的 `.so` 文件,也会列出只存在于 `plugins.configs` 中的配置项。已成功注册的插件会返回 `logo`、`config_fields` 和 `supports_oauth`。 - -如果插件注册的 Management API 路由是 `GET` 方法,并且 `ManagementRoute.Menu` 不为空,`GET /v0/management/plugins` 会在该插件条目的 `menus` 数组中返回 `path`、`menu` 和 `description`。`Menu` 用作管理端菜单名称,`Description` 用作菜单说明。 - -`PATCH /v0/management/plugins/{pluginID}/enabled` 只更新 `plugins.configs..enabled`,不会隐式修改全局 `plugins.enabled`。因此当 `plugins.enabled=false` 时,单插件可以显示为启用,但实际运行时仍不会加载插件能力。 - -`PUT /v0/management/plugins/{pluginID}/config` 会替换整个插件配置子树。`PATCH /v0/management/plugins/{pluginID}/config` 会做浅层合并;请求中的 `null` 会删除对应字段。 - -The sample routes are: - -```text -GET /v0/management/plugins/example/status -GET /v0/management/plugins/example/capabilities -``` - -Management API route rules: - -- Routes are exact method/path matches under `/v0/management/`. -- A plugin may return relative paths such as `/plugins/example/status`; the host resolves them under `/v0/management`. -- Paths cannot contain whitespace, `:`, or `*`. -- Native Management API routes cannot be replaced. -- Higher-priority plugin routes cannot be replaced by lower-priority plugins. -- Routes require the normal Management API authentication. -- Routes are unavailable when Home mode or Management API availability disables local Management routes. -- The route table is rebuilt on config reload. - -## Frontend authentication - -The sample `FrontendAuthProvider` accepts a request only when this header is present: - -```text -X-Plugin-Example: allow -``` - -The registered frontend provider key is namespaced by the host as: - -```text -plugin:: -``` - -For this sample, the provider identifier is `plugin-example`, so downstream auth metadata is kept separate from native frontend auth providers. - -## Usage plugin - -`UsagePlugin.HandleUsage` receives completed usage records after request execution. The sample increments an in-memory counter that is visible through the diagnostic Management API status route. - -Usage records include provider, executor type, model, alias, selected auth, source, requested reasoning effort, service tier, latency, TTFT, failure details, token counters, and selected response headers. - -Keep this hook lightweight. Usage dispatch is part of the request accounting path, and the host will recover from panics by fusing the plugin. - -## Priority, native precedence, and panic fuse - -The plugin system is additive: - -- Native providers, executors, translators, thinking appliers, flags, and Management routes have priority over plugins. -- Plugins fill provider gaps and add plugin-owned surfaces. -- Higher-priority plugins are considered before lower-priority plugins. -- Plugin executors do not override native executors. -- Plugin Management routes and command-line flags do not override native routes or flags. - -Every lifecycle and capability call is protected by panic recovery. If a plugin panics during `Register`, `Reconfigure`, or any capability method, the host marks that plugin fused for the current process lifetime. A fused plugin is no longer called, even if config reload enables it again. Restart the service to clear the fused state. - -Go plugins are trusted in-process code, not a sandbox. Panic recovery cannot prevent a plugin from calling `os.Exit`, mutating shared process state, starting background work, or leaking secrets. Treat plugin binaries as code with the same trust level as the service binary. - -## Extending this sample - -When turning this sample into a real provider plugin: - -1. Keep `package main` and the exported `Register` / `Reconfigure` functions. -2. Rename metadata, provider keys, model IDs, command-line flags, and Management paths consistently. -3. Build the `.so` filename to match the desired plugin ID. -4. Choose the narrowest `ExecutorModelScope`. -5. Use `HostHTTPClient` for all upstream provider calls. -6. Return `AuthData` instead of writing directly to auth storage when the host is already managing login or command-line persistence. -7. Keep provider-specific payload rewriting inside the plugin boundary. -8. Avoid logging secrets, tokens, raw auth JSON, or signed request headers. -9. Keep background goroutines tied to context or explicit lifecycle state, because Go plugins cannot be unloaded. -10. Add plugin-local tests and build the plugin with the same toolchain as the service. - -## Verification - -Compile the sample plugin: - -```bash -go build -buildmode=plugin -o /tmp/cliproxy-example-plugin.so ./examples/plugin && rm -f /tmp/cliproxy-example-plugin.so -``` - -Check Markdown whitespace after editing docs: - -```bash -git diff --check -- examples/plugin/README.md examples/plugin/README_CN.md -``` - -If you changed Go code as part of a plugin implementation, also run the repository-required server compile: - -```bash -go build -o test-output ./cmd/server && rm test-output -``` - -## Troubleshooting - -`plugin.Open` fails with a type or version error: - -Build the plugin with the same Go version, module path, build tags, and dependency versions as the service binary. - -The plugin is not loaded: - -Confirm `plugins.enabled=true`, the `.so` file is under the selected plugin directory, the plugin ID is valid, and the per-plugin config is not disabled. - -The plugin loads but no capability is active: - -Confirm `Register` or `Reconfigure` returns valid metadata and at least one non-nil capability. - -The executor is not used: - -Confirm a matching auth record exists, the auth `type` matches the provider key, the executor scope allows the desired model path, and no native executor owns the provider or model. - -The command-line flag appears but does nothing: - -Confirm the final loaded config still enables global plugins and this plugin. CLI flags are registered before final config dispatch, but execution is checked against the final active plugin snapshot. - -The Management route returns 404: - -Confirm local Management API routes are available, the route path is exact, the plugin is enabled, and no native or higher-priority route claimed the same method/path. +`host-callback` uses a minimal Management API route because host callbacks are invoked from plugin methods and are not standalone capabilities. diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md index aaaabbe19..fc8605590 100644 --- a/examples/plugin/README_CN.md +++ b/examples/plugin/README_CN.md @@ -1,416 +1,38 @@ -# Go 动态插件示例 +# 标准动态库插件示例 -这个目录是基于当前 `sdk/pluginapi` ABI 编写 provider 插件的参考骨架。它保持确定性和小规模实现,但覆盖真实 provider 插件通常需要接入的宿主能力:provider 自有 auth 解析、模型发现、执行器、HTTP bridge、请求/响应转换、thinking 配置、usage 观察、命令行参数和诊断 Management API 路由。 +本目录包含 CLIProxyAPI C ABI 的标准动态库插件示例。 -示例使用 provider key `plugin-example`,插件 ID 为 `example`。 +## 目录布局 -## 示例实现内容 +- `simple/`:声明全部支持能力的完整骨架示例。 +- `model/`:只演示模型能力。 +- `auth/`:只演示认证提供方能力。 +- `frontend-auth/`:只演示前端认证提供方能力。 +- `executor/`:只演示执行器能力。 +- `protocol-format/`:使用最小执行器重点演示输入和输出格式声明。 +- `request-translator/`:只演示请求转换能力。 +- `request-normalizer/`:只演示请求规整能力。 +- `response-translator/`:只演示响应转换能力。 +- `response-normalizer/`:只演示响应规整能力。 +- `thinking/`:只演示 Thinking 处理能力。 +- `usage/`:只演示 Usage 观察能力。 +- `cli/`:只演示命令行扩展能力。 +- `management-api/`:只演示 Management API 扩展能力。 +- `host-callback/`:使用最小 Management API 路由演示宿主回调。 -`examples/plugin/main.go` 导出了 Go 插件必须提供的入口函数: +每个示例目录都包含 `go/`、`c/` 和 `rust/` 三个子目录。 -```go -func Register(configYAML []byte) pluginapi.Plugin -func Reconfigure(configYAML []byte) pluginapi.Plugin -``` - -宿主第一次加载 `.so` 文件时调用 `Register`。如果插件已经打开并且仍处于启用状态,配置热重载时调用 `Reconfigure`。两个函数都必须返回包含有效 metadata 且至少带有一个能力的 `pluginapi.Plugin`。 - -必须填写的 metadata 字段: - -- `Metadata.Name` -- `Metadata.Version` -- `Metadata.Author` -- `Metadata.GitHubRepository` - -这个示例声明了以下能力: - -| 能力 | 接口 | 示例展示内容 | -| --- | --- | --- | -| 静态模型和按 auth 发现模型 | `ModelProvider` | 为静态注册和 auth 绑定发现都返回 `plugin-example-model`。 | -| Auth 解析和刷新 | `AuthProvider` | 解析 `type` 为 `plugin-example` 的 auth JSON,暴露非交互式登录方法,并原样返回刷新后的存储数据。 | -| 前端鉴权 | `FrontendAuthProvider` | 仅当请求包含 `X-Plugin-Example: allow` 时接受前端请求。 | -| Provider 执行器 | `ProviderExecutor` | 实现非流式执行、流式执行、token 统计和原始 HTTP 透传。 | -| 执行器模型范围 | `ExecutorModelScope` | 使用 `pluginapi.ExecutorModelScopeBoth`,表示执行器同时支持静态模型和 OAuth/auth 绑定模型。 | -| 请求转换 | `RequestTranslator`, `RequestNormalizer` | 展示 canonical 请求和 provider 专属请求 payload 的转换位置。 | -| 响应转换 | `ResponseTranslator`, `ResponseBeforeTranslator`, `ResponseAfterTranslator` | 展示原生翻译前后的响应转换 hook。 | -| Thinking 配置 | `ThinkingApplier` | 接收 canonical thinking 配置,并写入 provider 专属 payload 字段。 | -| Usage 观察 | `UsagePlugin` | 在内存中统计已完成 usage record,供诊断接口展示。 | -| 命令行参数 | `CommandLinePlugin` | 添加插件自有 CLI 参数,并在执行时接收全部解析后的 flag 值。 | -| Management API | `ManagementAPI` | 在 `/v0/management/` 下添加精确匹配的诊断路由。 | - -`sdk/pluginapi` 中仍保留 `ModelRegistrar`,用于简单的纯模型插件。新的 provider 插件通常应优先使用 `ModelProvider`,因为它通过同一条 provider-native 路径同时支持静态模型元数据和按 auth 发现模型。 - -## 平台和 ABI 规则 - -CLIProxyAPI 加载使用以下命令构建的标准 Go 插件: +## 构建全部示例 ```bash -go build -buildmode=plugin +make -C examples/plugin list +make -C examples/plugin build ``` -Go 标准库 `plugin` 包支持 Linux、FreeBSD 和 macOS。在不支持的平台上,插件加载会被禁用,服务会继续使用原生逻辑运行。 +构建产物会写入 `examples/plugin/bin`。 -Go plugin ABI 兼容性非常严格。请使用与目标服务二进制一致的环境构建插件: +## 说明 -- `GOOS` 和 `GOARCH` -- 使用 CPU 专属目录时的 CPU feature target -- Go 工具链版本 -- build tags 和 CGO 设置 -- module path -- 共享依赖版本 +`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。 -如果这些条件不一致,`plugin.Open` 可能失败,或者加载出的符号类型不兼容。 - -## 构建和安装 - -在仓库根目录构建: - -```bash -mkdir -p plugins/$(go env GOOS)/$(go env GOARCH) -go build -buildmode=plugin -o plugins/$(go env GOOS)/$(go env GOARCH)/example.so ./examples/plugin -``` - -插件 ID 来自 `.so` 文件名去掉最后的 `.so` 后缀。`example.so` 对应 `plugins.configs.example`。 - -插件 ID 必须符合以下格式: - -```text -[A-Za-z0-9][A-Za-z0-9._-]{0,127} -``` - -宿主按以下顺序搜索目录,并对每个插件 ID 保留第一个发现的 `.so`: - -```text -plugins//-/*.so -plugins///*.so -plugins/*.so -``` - -对于 `amd64`,`` 会根据 CPU 能力选择为 `v4`、`v3`、`v2` 或 `v1`。因此,CPU 专属构建可以放在类似 `plugins/linux/amd64-v3/` 的路径下。 - -替换已经打开的 `.so` 文件需要重启进程。Go 插件无法从当前进程中卸载。 - -## 配置宿主 - -动态插件默认关闭。请在 `config.yaml` 中启用: - -```yaml -plugins: - enabled: true - dir: "plugins" - configs: - example: - enabled: true - priority: 1 - config1: true - config2: "string" - config3: 3 -``` - -配置规则: - -- `plugins.enabled=false` 会跳过所有插件加载和执行。 -- `plugins.dir` 为空或未配置时默认使用 `plugins`。 -- `plugins.configs.` 是传给 `Register` 或 `Reconfigure` 的插件专属 YAML 子树。 -- 已配置插件实例的 `enabled` 默认值为 `true`。 -- `priority` 默认值为 `0`。 -- 如果插件配置中缺少 `enabled` 或 `priority`,宿主会把规整后的值注入到传给插件的 YAML 字节中。 -- `priority` 越高,插件越先执行。相同优先级按插件 ID 排序。 - -热重载会更新运行时插件快照。已经打开的插件二进制仍然留在内存中,但被禁用的插件会从当前活动能力集合中移除。如果已加载插件仍处于启用状态,宿主会调用 `Reconfigure(configYAML)`,而不是再次调用 `Register(configYAML)`。 - -## 插件 metadata、Logo 和配置字段 - -插件通过 `pluginapi.Metadata` 向宿主管理接口提供展示信息: - -```go -type Metadata struct { - Name string - Version string - Author string - GitHubRepository string - Logo string - ConfigFields []ConfigField -} -``` - -`Logo` 是给管理端展示的字符串。宿主只透传该值,不校验它是 URL、data URI、文件路径或其他格式。 - -`ConfigFields` 描述 `plugins.configs.` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下: - -```go -type ConfigField struct { - Name string - Type ConfigFieldType - EnumValues []string - Description string -} -``` - -支持的 `ConfigFieldType` 值包括 `string`、`number`、`integer`、`boolean`、`enum`、`array` 和 `object`。当类型是 `enum` 时,`EnumValues` 应列出所有可选值。 - -## 添加 auth 材料 - -带执行器的插件模型需要匹配的 auth 记录,这样调度器才能选择对应 provider。auth 的 `type` 必须匹配 `ModelProvider`、`AuthProvider.Identifier` 和 `ProviderExecutor.Identifier` 返回的 provider。 - -这个示例对应: - -```json -{ - "type": "plugin-example", - "api_key": "plugin-or-upstream-secret" -} -``` - -把文件放入已配置的 auth 目录,例如: - -```text -auths/plugin-example.json -``` - -除非你有意让原生 OpenAI-compatible 执行器拥有这个 provider,否则不要为同一个 provider 配置 `base_url`、`compat_name` 或 `openai-compatibility`。原生执行器始终优先于插件执行器。 - -这个示例中的 auth provider 行为: - -- `ParseAuth` 接收宿主 auth loader 提供的 JSON,并返回 `pluginapi.AuthData`。 -- `StartLogin` 和 `PollLogin` 存在,但在示例中返回非交互式错误。 -- `RefreshAuth` 原样返回当前 auth 数据。 -- 真实插件可以从命令行执行或登录轮询中返回 `AuthData`;宿主会通过正常 auth store 持久化这些数据。 - -## 模型注册和执行器范围 - -当前 provider-native 模型路径是 `ModelProvider`: - -- `StaticModels` 返回不依赖具体 auth 记录即可使用的 provider 模型。 -- `ModelsForAuth` 返回为某个选中 auth 记录发现的模型;如果发现过程刷新了 provider 状态,也可以返回 `AuthUpdate`。 - -插件发现模型后,宿主会继续应用正常模型处理流程:别名、排除模型、前缀、registry reconcile 和调度规则。 - -当 `Capabilities.Executor` 存在时,`ExecutorModelScope` 控制允许的模型注册路径: - -| Scope | 含义 | -| --- | --- | -| `pluginapi.ExecutorModelScopeBoth` | 执行器同时支持静态模型和 auth 绑定的 OAuth 风格模型。scope 为空或非法时默认使用这个值。 | -| `pluginapi.ExecutorModelScopeStatic` | 执行器只支持非 OAuth 的静态模型。执行器模型注册会跳过 `ModelsForAuth`。 | -| `pluginapi.ExecutorModelScopeOAuth` | 执行器只支持 auth 绑定模型。不会注册静态 executor model client。 | - -请使用与 provider 匹配的最窄 scope,避免通过错误的注册路径暴露模型。 - -## 执行流程 - -插件执行器只会在以下条件全部满足时运行: - -- 全局插件已启用; -- 当前插件已启用; -- 当前插件没有被 panic fuse; -- 选中的 auth provider 匹配执行器 provider; -- 没有原生执行器拥有同一个 provider 或选中的模型; -- 没有更高优先级插件已经声明同一个 provider/model。 - -`ProviderExecutor` 会收到 `pluginapi.ExecutorRequest`,其中包括: - -- `Model`:经过宿主别名处理后的模型 ID; -- `Format`:目标 provider 格式; -- `SourceFormat`:客户端原始格式; -- `OriginalRequest`:客户端原始 payload; -- `Payload`:已经翻译到 provider 侧的 payload; -- `StorageJSON`、`AuthMetadata` 和 `AuthAttributes`:选中 auth 的状态; -- `HTTPClient`:宿主 HTTP bridge。 - -执行器访问上游 HTTP 时必须使用 `req.HTTPClient.Do` 或 `req.HTTPClient.DoStream`。不要在插件内部自行构造 proxy-aware client。宿主 bridge 会保持宿主传输策略,并且让 `request-log` 在插件转换响应前记录发往上游的请求和上游返回的原始响应。 - -示例方法刻意保持确定性: - -- `Execute` 返回一个 OpenAI 形态的 JSON 响应。 -- `ExecuteStream` 输出一个 stream chunk 后关闭 channel。 -- `CountTokens` 返回 0 token 统计。 -- `HttpRequest` 通过宿主 bridge 转发原始 HTTP。 - -真实 provider 中应使用 `req.Model` 做 provider 路由和模型改写判断。不要假设每种协议 payload 都有可信的顶层 `model` 字段。 - -## Translator、Normalizer 和 Thinking - -原生逻辑是权威实现。插件转换用于补齐空白,而不是替换内置 provider 支持。 - -请求和响应行为: - -- 请求 normalizer 按优先级从高到低链式执行。 -- 翻译前和翻译后的响应 normalizer 也遵循同样的优先级顺序。 -- 只有当某个格式转换不存在原生 translator 时,请求 translator 和响应 translator 才会运行。 -- 对于缺失的翻译路径,只会选择优先级最高的一个插件 translator。 - -Thinking 行为: - -- 宿主集中解析、规整并验证 thinking 配置。 -- `ThinkingApplier` 接收 canonical `pluginapi.ThinkingConfig`。 -- 插件 thinking applier 只会处理没有原生 thinking provider 拥有的 provider key。 -- 插件被禁用、从活动快照中移除或被 panic fuse 后,它的 thinking applier 会被移除。 - -示例会向 payload 写入这些 provider 专属字段: - -```json -{ - "plugin_example_thinking": { - "mode": "budget", - "budget": 1024, - "level": "" - } -} -``` - -## 命令行参数 - -示例声明了两个插件自有参数: - -```bash -./cli-proxy-api -config config.yaml -plugin-example-command -./cli-proxy-api -config config.yaml -plugin-example-command -plugin-example-message "custom message" -``` - -插件命令行参数会在正常 flag 解析前注册,因此会显示在 `-help` 中。 - -规则: - -- 支持的 flag 类型为 `bool`、`string`、`int`、`int64`、`float64` 和 `duration`。 -- flag 名称不能以 `-` 开头,不能包含空白字符,不能包含 `=`,也不能是 `help` / `h`。 -- 原生 flag 不能被替换。 -- 更高优先级插件的 flag 不能被低优先级插件替换。 -- 当提供了任意插件自有 flag 时,宿主会把所有参数、所有可见的已解析 flag,以及触发执行的插件自有 flag 传给 `ExecuteCommandLine`。 -- 如果最终配置禁用了全局插件或当前插件,flag 仍可能被解析,但插件执行会被跳过。 -- 如果 `ExecuteCommandLine` 返回 `Auths`,宿主会通过已配置的 auth store 持久化它们,并把保存路径追加到 stdout。 - -## Management API 路由 - -宿主提供原生插件管理接口: - -```text -GET /v0/management/plugins -PATCH /v0/management/plugins/{pluginID}/enabled -PUT /v0/management/plugins/{pluginID}/config -PATCH /v0/management/plugins/{pluginID}/config -``` - -`GET /v0/management/plugins` 会按宿主当前扫描规则列出插件目录中的 `.so` 文件,也会列出只存在于 `plugins.configs` 中的配置项。已成功注册的插件会返回 `logo`、`config_fields` 和 `supports_oauth`。 - -如果插件注册的 Management API 路由是 `GET` 方法,并且 `ManagementRoute.Menu` 不为空,`GET /v0/management/plugins` 会在该插件条目的 `menus` 数组中返回 `path`、`menu` 和 `description`。`Menu` 用作管理端菜单名称,`Description` 用作菜单说明。 - -`PATCH /v0/management/plugins/{pluginID}/enabled` 只更新 `plugins.configs..enabled`,不会隐式修改全局 `plugins.enabled`。因此当 `plugins.enabled=false` 时,单插件可以显示为启用,但实际运行时仍不会加载插件能力。 - -`PUT /v0/management/plugins/{pluginID}/config` 会替换整个插件配置子树。`PATCH /v0/management/plugins/{pluginID}/config` 会做浅层合并;请求中的 `null` 会删除对应字段。 - -示例路由: - -```text -GET /v0/management/plugins/example/status -GET /v0/management/plugins/example/capabilities -``` - -Management API 路由规则: - -- 路由是 `/v0/management/` 下按 method/path 精确匹配的路由。 -- 插件可以返回类似 `/plugins/example/status` 的相对路径;宿主会把它解析到 `/v0/management` 下。 -- 路径不能包含空白字符、`:` 或 `*`。 -- 原生 Management API 路由不能被替换。 -- 更高优先级插件的路由不能被低优先级插件替换。 -- 路由仍需要正常的 Management API 鉴权。 -- 当 Home 模式或 Management API 可用性禁用本地 Management 路由时,这些路由不可用。 -- 路由表会在配置热重载时重建。 - -## 前端鉴权 - -示例 `FrontendAuthProvider` 只接受带有以下 header 的请求: - -```text -X-Plugin-Example: allow -``` - -注册后的前端 provider key 会被宿主命名空间化: - -```text -plugin:: -``` - -这个示例的 provider identifier 是 `plugin-example`,因此下游 auth metadata 会与原生前端鉴权 provider 隔离。 - -## Usage 插件 - -`UsagePlugin.HandleUsage` 会在请求执行完成后收到 usage record。示例会递增内存计数器,并通过诊断 Management API status 路由展示。 - -Usage record 包含 provider、executor type、model、alias、选中 auth、source、请求的 reasoning effort、service tier、latency、TTFT、失败详情、token 计数和选定响应头。 - -这个 hook 应保持轻量。Usage 派发属于请求计费/统计路径,宿主会从 panic 中恢复并 fuse 插件。 - -## 优先级、原生优先和 panic fuse - -插件系统是增量扩展机制: - -- 原生 provider、executor、translator、thinking applier、flag 和 Management route 都优先于插件。 -- 插件用于补齐 provider 空白并增加插件自有能力面。 -- 高优先级插件先于低优先级插件被考虑。 -- 插件执行器不会覆盖原生执行器。 -- 插件 Management 路由和命令行 flag 不会覆盖原生路由或 flag。 - -每个生命周期调用和能力调用都带有 panic recovery。如果插件在 `Register`、`Reconfigure` 或任意能力方法中 panic,宿主会在当前进程生命周期内把该插件标记为 fused。fused 插件不会再被调用,即使后续配置热重载重新启用它也一样。重启服务后才会清除 fused 状态。 - -Go 插件是可信的进程内代码,不是沙箱。panic recovery 无法阻止插件调用 `os.Exit`、修改共享进程状态、启动后台任务或泄露 secret。请把插件二进制视为与服务二进制同等信任级别的代码。 - -## 扩展示例 - -把这个示例改造成真实 provider 插件时: - -1. 保留 `package main` 和导出的 `Register` / `Reconfigure` 函数。 -2. 统一修改 metadata、provider key、model ID、命令行 flag 和 Management path。 -3. 让 `.so` 文件名匹配期望的插件 ID。 -4. 选择最窄的 `ExecutorModelScope`。 -5. 所有上游 provider 调用都使用 `HostHTTPClient`。 -6. 当宿主已经负责登录或命令行持久化时,返回 `AuthData`,不要直接写 auth storage。 -7. 把 provider 专属 payload 改写保持在插件边界内。 -8. 不要记录 secret、token、原始 auth JSON 或签名请求头。 -9. 后台 goroutine 需要绑定 context 或显式生命周期状态,因为 Go 插件无法卸载。 -10. 添加插件本地测试,并使用与服务相同的工具链构建插件。 - -## 验证 - -编译示例插件: - -```bash -go build -buildmode=plugin -o /tmp/cliproxy-example-plugin.so ./examples/plugin && rm -f /tmp/cliproxy-example-plugin.so -``` - -编辑文档后检查 Markdown 空白问题: - -```bash -git diff --check -- examples/plugin/README.md examples/plugin/README_CN.md -``` - -如果插件实现过程中修改了 Go 代码,还需要执行仓库要求的服务端编译: - -```bash -go build -o test-output ./cmd/server && rm test-output -``` - -## 排障 - -`plugin.Open` 因类型或版本错误失败: - -请使用与服务二进制一致的 Go 版本、module path、build tags 和依赖版本构建插件。 - -插件没有被加载: - -确认 `plugins.enabled=true`,`.so` 文件位于被选中的插件目录下,插件 ID 合法,并且单插件配置没有禁用它。 - -插件加载了,但没有能力生效: - -确认 `Register` 或 `Reconfigure` 返回有效 metadata,并且至少有一个非 nil capability。 - -执行器没有被使用: - -确认存在匹配的 auth 记录,auth 的 `type` 匹配 provider key,执行器 scope 允许目标模型路径,并且没有原生执行器拥有该 provider 或模型。 - -命令行 flag 出现了但没有执行: - -确认最终加载的配置仍启用了全局插件和当前插件。CLI flag 会在最终配置分发之前注册,但执行时会检查最终活动插件快照。 - -Management 路由返回 404: - -确认本地 Management API 路由可用,路由路径完全匹配,插件处于启用状态,并且没有原生或更高优先级路由声明了同一个 method/path。 +`host-callback` 使用最小 Management API 路由承载,因为宿主回调只能从插件方法内部发起,不是独立能力。 diff --git a/examples/plugin/auth/c/CMakeLists.txt b/examples/plugin/auth/c/CMakeLists.txt new file mode 100644 index 000000000..3345be5b0 --- /dev/null +++ b/examples/plugin/auth/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/auth/c/src/plugin.c b/examples/plugin/auth/c/src/plugin.c new file mode 100644 index 000000000..8a4b88bec --- /dev/null +++ b/examples/plugin/auth/c/src/plugin.c @@ -0,0 +1,129 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/auth/go/go.mod b/examples/plugin/auth/go/go.mod new file mode 100644 index 000000000..f084d0a60 --- /dev/null +++ b/examples/plugin/auth/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/auth/go + +go 1.26 diff --git a/examples/plugin/auth/go/main.go b/examples/plugin/auth/go/main.go new file mode 100644 index 000000000..c349aaf32 --- /dev/null +++ b/examples/plugin/auth/go/main.go @@ -0,0 +1,181 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/auth/rust/Cargo.lock b/examples/plugin/auth/rust/Cargo.lock new file mode 100644 index 000000000..2fcbda318 --- /dev/null +++ b/examples/plugin/auth/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/auth/rust/Cargo.toml b/examples/plugin/auth/rust/Cargo.toml new file mode 100644 index 000000000..4ca835bcf --- /dev/null +++ b/examples/plugin/auth/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-auth-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/auth/rust/src/lib.rs b/examples/plugin/auth/rust/src/lib.rs new file mode 100644 index 000000000..9bbd6648e --- /dev/null +++ b/examples/plugin/auth/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/cli/c/CMakeLists.txt b/examples/plugin/cli/c/CMakeLists.txt new file mode 100644 index 000000000..06fbfc135 --- /dev/null +++ b/examples/plugin/cli/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/cli/c/src/plugin.c b/examples/plugin/cli/c/src/plugin.c new file mode 100644 index 000000000..115a38210 --- /dev/null +++ b/examples/plugin/cli/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/cli/go/go.mod b/examples/plugin/cli/go/go.mod new file mode 100644 index 000000000..d5061d1f6 --- /dev/null +++ b/examples/plugin/cli/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/cli/go + +go 1.26 diff --git a/examples/plugin/cli/go/main.go b/examples/plugin/cli/go/main.go new file mode 100644 index 000000000..e5ca6fc7a --- /dev/null +++ b/examples/plugin/cli/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/cli/rust/Cargo.lock b/examples/plugin/cli/rust/Cargo.lock new file mode 100644 index 000000000..664051509 --- /dev/null +++ b/examples/plugin/cli/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/cli/rust/Cargo.toml b/examples/plugin/cli/rust/Cargo.toml new file mode 100644 index 000000000..d628e854d --- /dev/null +++ b/examples/plugin/cli/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-cli-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/cli/rust/src/lib.rs b/examples/plugin/cli/rust/src/lib.rs new file mode 100644 index 000000000..d293b0df2 --- /dev/null +++ b/examples/plugin/cli/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/executor/c/CMakeLists.txt b/examples/plugin/executor/c/CMakeLists.txt new file mode 100644 index 000000000..243dd88ad --- /dev/null +++ b/examples/plugin/executor/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/executor/c/src/plugin.c b/examples/plugin/executor/c/src/plugin.c new file mode 100644 index 000000000..71e9bce0a --- /dev/null +++ b/examples/plugin/executor/c/src/plugin.c @@ -0,0 +1,129 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/executor/go/go.mod b/examples/plugin/executor/go/go.mod new file mode 100644 index 000000000..d0c0ce178 --- /dev/null +++ b/examples/plugin/executor/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/executor/go + +go 1.26 diff --git a/examples/plugin/executor/go/main.go b/examples/plugin/executor/go/main.go new file mode 100644 index 000000000..25b57e701 --- /dev/null +++ b/examples/plugin/executor/go/main.go @@ -0,0 +1,181 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/executor/rust/Cargo.lock b/examples/plugin/executor/rust/Cargo.lock new file mode 100644 index 000000000..a722d5bad --- /dev/null +++ b/examples/plugin/executor/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/executor/rust/Cargo.toml b/examples/plugin/executor/rust/Cargo.toml new file mode 100644 index 000000000..b34bd907f --- /dev/null +++ b/examples/plugin/executor/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-executor-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/executor/rust/src/lib.rs b/examples/plugin/executor/rust/src/lib.rs new file mode 100644 index 000000000..07acfd5de --- /dev/null +++ b/examples/plugin/executor/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/frontend-auth/c/CMakeLists.txt b/examples/plugin/frontend-auth/c/CMakeLists.txt new file mode 100644 index 000000000..85256642d --- /dev/null +++ b/examples/plugin/frontend-auth/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/frontend-auth/c/src/plugin.c b/examples/plugin/frontend-auth/c/src/plugin.c new file mode 100644 index 000000000..66c7b1a84 --- /dev/null +++ b/examples/plugin/frontend-auth/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/frontend-auth/go/go.mod b/examples/plugin/frontend-auth/go/go.mod new file mode 100644 index 000000000..62bbf528a --- /dev/null +++ b/examples/plugin/frontend-auth/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth/go + +go 1.26 diff --git a/examples/plugin/frontend-auth/go/main.go b/examples/plugin/frontend-auth/go/main.go new file mode 100644 index 000000000..6a9fd5ab9 --- /dev/null +++ b/examples/plugin/frontend-auth/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/frontend-auth/rust/Cargo.lock b/examples/plugin/frontend-auth/rust/Cargo.lock new file mode 100644 index 000000000..934e900ea --- /dev/null +++ b/examples/plugin/frontend-auth/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/frontend-auth/rust/Cargo.toml b/examples/plugin/frontend-auth/rust/Cargo.toml new file mode 100644 index 000000000..d5f9359ca --- /dev/null +++ b/examples/plugin/frontend-auth/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-frontend-auth-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/frontend-auth/rust/src/lib.rs b/examples/plugin/frontend-auth/rust/src/lib.rs new file mode 100644 index 000000000..9ee1b1cff --- /dev/null +++ b/examples/plugin/frontend-auth/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/host-callback/c/CMakeLists.txt b/examples/plugin/host-callback/c/CMakeLists.txt new file mode 100644 index 000000000..c56117d3e --- /dev/null +++ b/examples/plugin/host-callback/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/host-callback/c/src/plugin.c b/examples/plugin/host-callback/c/src/plugin.c new file mode 100644 index 000000000..6af0d598f --- /dev/null +++ b/examples/plugin/host-callback/c/src/plugin.c @@ -0,0 +1,120 @@ +#include +#include +#include + +#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\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-c/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}"); + 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\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stYyJ9\"}}"); + 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; +} diff --git a/examples/plugin/host-callback/go/go.mod b/examples/plugin/host-callback/go/go.mod new file mode 100644 index 000000000..73c4e0abd --- /dev/null +++ b/examples/plugin/host-callback/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback/go + +go 1.26 diff --git a/examples/plugin/host-callback/go/main.go b/examples/plugin/host-callback/go/main.go new file mode 100644 index 000000000..531da32af --- /dev/null +++ b/examples/plugin/host-callback/go/main.go @@ -0,0 +1,177 @@ +package main + +/* +#include +#include + +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("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-go/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}") + 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\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stZ28ifQ==\"}") + 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) + } +} diff --git a/examples/plugin/host-callback/rust/Cargo.lock b/examples/plugin/host-callback/rust/Cargo.lock new file mode 100644 index 000000000..9714e2dba --- /dev/null +++ b/examples/plugin/host-callback/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/host-callback/rust/Cargo.toml b/examples/plugin/host-callback/rust/Cargo.toml new file mode 100644 index 000000000..26c2995ad --- /dev/null +++ b/examples/plugin/host-callback/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-host-callback-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/host-callback/rust/src/lib.rs b/examples/plugin/host-callback/rust/src/lib.rs new file mode 100644 index 000000000..8a0ce3585 --- /dev/null +++ b/examples/plugin/host-callback/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-host-callback-rust/status\",\"Menu\":\"Host Callback\",\"Description\":\"Host callback example carried by a minimal Management API route.\"}]}}"); 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\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLWhvc3QtY2FsbGJhY2stcnVzdCJ9\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/main.go b/examples/plugin/main.go deleted file mode 100644 index 1ac082308..000000000 --- a/examples/plugin/main.go +++ /dev/null @@ -1,420 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "sync" - - "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" -) - -// Register is called once when the host first loads this .so file. -func Register(configYAML []byte) pluginapi.Plugin { - return buildPlugin(configYAML) -} - -// Reconfigure is called on config hot reload while this plugin remains enabled. -func Reconfigure(configYAML []byte) pluginapi.Plugin { - return buildPlugin(configYAML) -} - -func buildPlugin(configYAML []byte) pluginapi.Plugin { - example := &examplePlugin{configYAML: append([]byte(nil), configYAML...)} - return pluginapi.Plugin{ - Metadata: pluginapi.Metadata{ - Name: "example", - Version: "0.1.0", - Author: "router-for-me", - GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", - Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png", - ConfigFields: []pluginapi.ConfigField{ - { - Name: "config1", - Type: pluginapi.ConfigFieldTypeBoolean, - Description: "Enables the example boolean option.", - }, - { - Name: "config2", - Type: pluginapi.ConfigFieldTypeString, - Description: "Stores the example string option.", - }, - { - Name: "config3", - Type: pluginapi.ConfigFieldTypeInteger, - Description: "Stores the example integer option.", - }, - { - Name: "mode", - Type: pluginapi.ConfigFieldTypeEnum, - EnumValues: []string{"safe", "fast"}, - Description: "Selects the example execution mode.", - }, - }, - }, - Capabilities: pluginapi.Capabilities{ - ModelProvider: example, - AuthProvider: example, - FrontendAuthProvider: example, - Executor: example, - ExecutorModelScope: pluginapi.ExecutorModelScopeBoth, - RequestTranslator: example, - RequestNormalizer: example, - ResponseTranslator: example, - ResponseBeforeTranslator: example, - ResponseAfterTranslator: example, - ThinkingApplier: example, - UsagePlugin: example, - CommandLinePlugin: example, - ManagementAPI: example, - }, - } -} - -type examplePlugin struct { - configYAML []byte - mu sync.Mutex - usageCount int64 -} - -var _ pluginapi.AuthProvider = (*examplePlugin)(nil) -var _ pluginapi.ModelProvider = (*examplePlugin)(nil) -var _ pluginapi.ProviderExecutor = (*examplePlugin)(nil) -var _ pluginapi.ThinkingApplier = (*examplePlugin)(nil) - -// Native logic always has higher priority than plugin logic. -// Native model registration always runs before plugin model discovery. -// Executor-backed plugin models can be static, OAuth auth-bound, or both. -func (p *examplePlugin) StaticModels(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { - return pluginapi.ModelResponse{ - Provider: "plugin-example", - Models: []pluginapi.ModelInfo{{ - ID: "plugin-example-model", - Object: "model", - OwnedBy: "plugin-example", - Type: "chat", - DisplayName: "Plugin Example Model", - Name: "plugin-example-model", - Version: "0.1.0", - Description: "Deterministic example model provided by a Go dynamic plugin.", - InputTokenLimit: 4096, - OutputTokenLimit: 1024, - SupportedGenerationMethods: []string{"generateContent", "chat.completions"}, - ContextLength: 4096, - MaxCompletionTokens: 1024, - SupportedParameters: []string{"model", "messages", "stream", "thinking", "reasoning_effort"}, - SupportedInputModalities: []string{"text"}, - SupportedOutputModalities: []string{"text"}, - Thinking: &pluginapi.ThinkingSupport{ZeroAllowed: true, DynamicAllowed: true}, - UserDefined: true, - }}, - }, nil -} - -func (p *examplePlugin) ModelsForAuth(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { - return p.StaticModels(ctx, pluginapi.StaticModelRequest{Plugin: req.Plugin, Host: req.Host}) -} - -func (p *examplePlugin) Identifier() string { - return "plugin-example" -} - -func (p *examplePlugin) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { - if !strings.EqualFold(req.Provider, "plugin-example") { - return pluginapi.AuthParseResponse{}, nil - } - return pluginapi.AuthParseResponse{ - Handled: true, - Auth: pluginapi.AuthData{ - Provider: "plugin-example", - ID: req.FileName, - FileName: req.FileName, - Label: "Plugin Example", - StorageJSON: append([]byte(nil), req.RawJSON...), - Metadata: map[string]any{ - "type": "plugin-example", - }, - }, - }, nil -} - -func (p *examplePlugin) StartLogin(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) { - return pluginapi.AuthLoginStartResponse{}, fmt.Errorf("plugin-example login is not interactive") -} - -func (p *examplePlugin) PollLogin(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) { - return pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusError, Message: "plugin-example login is not interactive"}, nil -} - -func (p *examplePlugin) RefreshAuth(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { - return pluginapi.AuthRefreshResponse{ - Auth: pluginapi.AuthData{ - Provider: req.AuthProvider, - ID: req.AuthID, - StorageJSON: append([]byte(nil), req.StorageJSON...), - Metadata: cloneAnyMap(req.Metadata), - Attributes: cloneStringMap(req.Attributes), - }, - }, nil -} - -// A plugin can register multiple command-line flags. -// Flags are registered by priority. Existing native flags, reserved help/h flags, -// or higher-priority plugin flags win and cannot be registered again. -func (p *examplePlugin) RegisterCommandLine(context.Context, pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) { - return pluginapi.CommandLineRegistrationResponse{ - Flags: []pluginapi.CommandLineFlag{ - { - Name: "plugin-example-command", - Usage: "Run the example plugin command-line handler", - Type: "bool", - DefaultValue: "false", - }, - { - Name: "plugin-example-message", - Usage: "Message passed to the example plugin command-line handler", - Type: "string", - DefaultValue: "hello", - }, - }, - }, nil -} - -// Global plugins.enabled=false or per-plugin enabled=false skips command-line execution after reload. -// The host passes every command-line argument and all triggered plugin flags to ExecuteCommandLine. -func (p *examplePlugin) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) { - message := req.Flags["plugin-example-message"].Value - if triggeredMessage, ok := req.TriggeredFlags["plugin-example-message"]; ok { - message = triggeredMessage.Value - } - return pluginapi.CommandLineExecutionResponse{ - Stdout: []byte(fmt.Sprintf("example plugin command executed with %d argument(s), message=%q\n", len(req.Args), message)), - }, nil -} - -// A plugin can register multiple Management API routes. -// Management API routes are exact routes under /v0/management/ and cannot override -// native routes or higher-priority plugin routes that are already registered. -func (p *examplePlugin) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) { - return pluginapi.ManagementRegistrationResponse{ - Routes: []pluginapi.ManagementRoute{ - { - Method: http.MethodGet, - Path: "/plugins/example/status", - Menu: "Example Status", - Description: "Shows example plugin runtime status.", - Handler: p, - }, - { - Method: http.MethodGet, - Path: "/plugins/example/capabilities", - Menu: "Example Capabilities", - Description: "Shows example plugin capability details.", - Handler: p, - }, - }, - }, nil -} - -// Plugin Management API routes still require the normal Management API key, -// and are skipped when Home mode or Management API availability disables them. -func (p *examplePlugin) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { - p.mu.Lock() - usageCount := p.usageCount - p.mu.Unlock() - - body := []byte(fmt.Sprintf(`{"plugin":"example","usage_count":%d}`+"\n", usageCount)) - if strings.HasSuffix(req.Path, "/capabilities") { - body = []byte(`{"plugin":"example","capabilities":["command-line","management-api","auth-provider","model-provider","frontend-auth","executor","raw-http","request-translator","request-normalizer","response-translator","response-normalizer","thinking-applier","usage"]}` + "\n") - } - - return pluginapi.ManagementResponse{ - StatusCode: http.StatusOK, - Headers: http.Header{ - "Content-Type": []string{"application/json"}, - }, - Body: body, - }, nil -} - -// Global plugins.enabled=false or per-plugin enabled=false skips plugin execution after reload. -func (p *examplePlugin) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { - authenticated := req.Headers.Get("X-Plugin-Example") == "allow" - if !authenticated { - return pluginapi.FrontendAuthResponse{}, nil - } - - return pluginapi.FrontendAuthResponse{ - Authenticated: true, - Principal: "plugin-example-user", - Metadata: map[string]string{ - "provider": "plugin-example", - }, - }, nil -} - -// A plugin executor runs only for a matching auth when no native executor owns the provider. -func (p *examplePlugin) Execute(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { - return pluginapi.ExecutorResponse{ - Payload: []byte(`{"id":"plugin-example-response","object":"chat.completion","model":"plugin-example-model","choices":[{"index":0,"message":{"role":"assistant","content":"plugin example response"},"finish_reason":"stop"}]}`), - Headers: http.Header{ - "Content-Type": []string{"application/json"}, - }, - Metadata: map[string]any{ - "provider": "plugin-example", - }, - }, nil -} - -func (p *examplePlugin) ExecuteStream(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) { - chunks := make(chan pluginapi.ExecutorStreamChunk, 1) - chunks <- pluginapi.ExecutorStreamChunk{ - Payload: []byte(`{"id":"plugin-example-stream","object":"chat.completion.chunk","model":"plugin-example-model","choices":[{"index":0,"delta":{"content":"plugin example response"},"finish_reason":"stop"}]}`), - } - close(chunks) - - return pluginapi.ExecutorStreamResponse{ - Headers: http.Header{ - "Content-Type": []string{"application/json"}, - }, - Chunks: chunks, - }, nil -} - -func (p *examplePlugin) CountTokens(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { - return pluginapi.ExecutorResponse{ - Payload: []byte(`{"input_tokens":0,"output_tokens":0,"total_tokens":0}`), - Headers: http.Header{ - "Content-Type": []string{"application/json"}, - }, - }, nil -} - -func (p *examplePlugin) HttpRequest(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) { - resp, errDo := req.HTTPClient.Do(ctx, pluginapi.HTTPRequest{ - Method: req.Method, - URL: req.URL, - Headers: req.Headers, - Body: req.Body, - }) - if errDo != nil { - return pluginapi.ExecutorHTTPResponse{}, errDo - } - return pluginapi.ExecutorHTTPResponse{ - StatusCode: resp.StatusCode, - Headers: resp.Headers, - Body: resp.Body, - }, nil -} - -// Request/response translators run only when no native translator exists, and only the highest-priority plugin translator runs once. -func (p *examplePlugin) TranslateRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { - return payloadOrEmptyObject(req.Body), nil -} - -// Normalizers run from higher priority to lower priority and are chained. -func (p *examplePlugin) NormalizeRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { - return payloadOrEmptyObject(req.Body), nil -} - -func (p *examplePlugin) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { - return payloadOrEmptyObject(req.Body), nil -} - -func (p *examplePlugin) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { - return payloadOrEmptyObject(req.Body), nil -} - -func (p *examplePlugin) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) { - var payload map[string]any - if len(req.Body) == 0 { - payload = map[string]any{} - } else if errUnmarshal := json.Unmarshal(req.Body, &payload); errUnmarshal != nil { - return pluginapi.PayloadResponse{}, errUnmarshal - } - payload["plugin_example_thinking"] = map[string]any{ - "mode": req.Config.Mode, - "budget": req.Config.Budget, - "level": req.Config.Level, - } - out, errMarshal := json.Marshal(payload) - if errMarshal != nil { - return pluginapi.PayloadResponse{}, errMarshal - } - return pluginapi.PayloadResponse{Body: out}, nil -} - -// If any plugin method panics, host disables that plugin for current process lifetime and never calls it again until restart. -func (p *examplePlugin) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) { - p.mu.Lock() - defer p.mu.Unlock() - - p.usageCount++ -} - -func payloadOrEmptyObject(body []byte) pluginapi.PayloadResponse { - if len(body) == 0 { - return pluginapi.PayloadResponse{Body: []byte(`{}`)} - } - - return pluginapi.PayloadResponse{Body: append([]byte(nil), body...)} -} - -func cloneAnyMap(src map[string]any) map[string]any { - if len(src) == 0 { - return nil - } - dst := make(map[string]any, len(src)) - for key, value := range src { - dst[key] = cloneAnyValue(value) - } - return dst -} - -func cloneAnyValue(value any) any { - switch typed := value.(type) { - case map[string]any: - return cloneAnyMap(typed) - case map[string]string: - return cloneStringMap(typed) - case []any: - out := make([]any, len(typed)) - for i, item := range typed { - out[i] = cloneAnyValue(item) - } - return out - case []string: - return append([]string(nil), typed...) - case http.Header: - return typed.Clone() - case url.Values: - return cloneValues(typed) - default: - return value - } -} - -func cloneStringMap(src map[string]string) map[string]string { - if len(src) == 0 { - return nil - } - dst := make(map[string]string, len(src)) - for key, value := range src { - dst[key] = value - } - return dst -} - -func cloneValues(src url.Values) url.Values { - if len(src) == 0 { - return nil - } - dst := make(url.Values, len(src)) - for key, values := range src { - dst[key] = append([]string(nil), values...) - } - return dst -} diff --git a/examples/plugin/management-api/c/CMakeLists.txt b/examples/plugin/management-api/c/CMakeLists.txt new file mode 100644 index 000000000..14801f611 --- /dev/null +++ b/examples/plugin/management-api/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/management-api/c/src/plugin.c b/examples/plugin/management-api/c/src/plugin.c new file mode 100644 index 000000000..b7c739c55 --- /dev/null +++ b/examples/plugin/management-api/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-c/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); + return 0; + } + if (strcmp(method, "management.handle") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWMifQ==\"}}"); + 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; +} diff --git a/examples/plugin/management-api/go/go.mod b/examples/plugin/management-api/go/go.mod new file mode 100644 index 000000000..51f802bf9 --- /dev/null +++ b/examples/plugin/management-api/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/management-api/go + +go 1.26 diff --git a/examples/plugin/management-api/go/main.go b/examples/plugin/management-api/go/main.go new file mode 100644 index 000000000..d2d01818b --- /dev/null +++ b/examples/plugin/management-api/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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("{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-go/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}") + case "management.handle": + return okEnvelopeJSON("{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLWdvIn0=\"}") + 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) + } +} diff --git a/examples/plugin/management-api/rust/Cargo.lock b/examples/plugin/management-api/rust/Cargo.lock new file mode 100644 index 000000000..4dbc81dab --- /dev/null +++ b/examples/plugin/management-api/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/management-api/rust/Cargo.toml b/examples/plugin/management-api/rust/Cargo.toml new file mode 100644 index 000000000..1e41c3031 --- /dev/null +++ b/examples/plugin/management-api/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-management-api-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/management-api/rust/src/lib.rs b/examples/plugin/management-api/rust/src/lib.rs new file mode 100644 index 000000000..b16daf1d6 --- /dev/null +++ b/examples/plugin/management-api/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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\":{\"routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-management-api-rust/status\",\"Menu\":\"Management API\",\"Description\":\"Management API capability example.\"}]}}"); 0 },"management.handle" => { write_response(response, "{\"ok\":true,\"result\":{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"eyJwbHVnaW4iOiJleGFtcGxlLW1hbmFnZW1lbnQtYXBpLXJ1c3QifQ==\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/model/c/CMakeLists.txt b/examples/plugin/model/c/CMakeLists.txt new file mode 100644 index 000000000..a9113068c --- /dev/null +++ b/examples/plugin/model/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/model/c/src/plugin.c b/examples/plugin/model/c/src/plugin.c new file mode 100644 index 000000000..8457c3b3e --- /dev/null +++ b/examples/plugin/model/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/model/go/go.mod b/examples/plugin/model/go/go.mod new file mode 100644 index 000000000..fb459720e --- /dev/null +++ b/examples/plugin/model/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/model/go + +go 1.26 diff --git a/examples/plugin/model/go/main.go b/examples/plugin/model/go/main.go new file mode 100644 index 000000000..c8c486775 --- /dev/null +++ b/examples/plugin/model/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/model/rust/Cargo.lock b/examples/plugin/model/rust/Cargo.lock new file mode 100644 index 000000000..93f85bc31 --- /dev/null +++ b/examples/plugin/model/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/model/rust/Cargo.toml b/examples/plugin/model/rust/Cargo.toml new file mode 100644 index 000000000..f34ad11e3 --- /dev/null +++ b/examples/plugin/model/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-model-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/model/rust/src/lib.rs b/examples/plugin/model/rust/src/lib.rs new file mode 100644 index 000000000..4d4ff5163 --- /dev/null +++ b/examples/plugin/model/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/protocol-format/c/CMakeLists.txt b/examples/plugin/protocol-format/c/CMakeLists.txt new file mode 100644 index 000000000..a581ebd24 --- /dev/null +++ b/examples/plugin/protocol-format/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/protocol-format/c/src/plugin.c b/examples/plugin/protocol-format/c/src/plugin.c new file mode 100644 index 000000000..8a7cf0ab8 --- /dev/null +++ b/examples/plugin/protocol-format/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/protocol-format/go/go.mod b/examples/plugin/protocol-format/go/go.mod new file mode 100644 index 000000000..da2a1db32 --- /dev/null +++ b/examples/plugin/protocol-format/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/protocol-format/go + +go 1.26 diff --git a/examples/plugin/protocol-format/go/main.go b/examples/plugin/protocol-format/go/main.go new file mode 100644 index 000000000..610af9311 --- /dev/null +++ b/examples/plugin/protocol-format/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/protocol-format/rust/Cargo.lock b/examples/plugin/protocol-format/rust/Cargo.lock new file mode 100644 index 000000000..ea7ed52da --- /dev/null +++ b/examples/plugin/protocol-format/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/protocol-format/rust/Cargo.toml b/examples/plugin/protocol-format/rust/Cargo.toml new file mode 100644 index 000000000..a50dc2bb0 --- /dev/null +++ b/examples/plugin/protocol-format/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-protocol-format-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/protocol-format/rust/src/lib.rs b/examples/plugin/protocol-format/rust/src/lib.rs new file mode 100644 index 000000000..0b3fb5a76 --- /dev/null +++ b/examples/plugin/protocol-format/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/request-normalizer/c/CMakeLists.txt b/examples/plugin/request-normalizer/c/CMakeLists.txt new file mode 100644 index 000000000..c49308872 --- /dev/null +++ b/examples/plugin/request-normalizer/c/CMakeLists.txt @@ -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 "" +) diff --git a/examples/plugin/request-normalizer/c/src/plugin.c b/examples/plugin/request-normalizer/c/src/plugin.c new file mode 100644 index 000000000..85bd569a9 --- /dev/null +++ b/examples/plugin/request-normalizer/c/src/plugin.c @@ -0,0 +1,113 @@ +#include +#include +#include + +#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; +} diff --git a/examples/plugin/request-normalizer/go/go.mod b/examples/plugin/request-normalizer/go/go.mod new file mode 100644 index 000000000..8ccec1218 --- /dev/null +++ b/examples/plugin/request-normalizer/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/request-normalizer/go + +go 1.26 diff --git a/examples/plugin/request-normalizer/go/main.go b/examples/plugin/request-normalizer/go/main.go new file mode 100644 index 000000000..3cf45e452 --- /dev/null +++ b/examples/plugin/request-normalizer/go/main.go @@ -0,0 +1,173 @@ +package main + +/* +#include +#include + +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) + } +} diff --git a/examples/plugin/request-normalizer/rust/Cargo.lock b/examples/plugin/request-normalizer/rust/Cargo.lock new file mode 100644 index 000000000..bb5e2bcb6 --- /dev/null +++ b/examples/plugin/request-normalizer/rust/Cargo.lock @@ -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" diff --git a/examples/plugin/request-normalizer/rust/Cargo.toml b/examples/plugin/request-normalizer/rust/Cargo.toml new file mode 100644 index 000000000..6649a3f01 --- /dev/null +++ b/examples/plugin/request-normalizer/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-request-normalizer-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/request-normalizer/rust/src/lib.rs b/examples/plugin/request-normalizer/rust/src/lib.rs new file mode 100644 index 000000000..9acdaafd7 --- /dev/null +++ b/examples/plugin/request-normalizer/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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); + } + } + } +} diff --git a/examples/plugin/request-translator/c/CMakeLists.txt b/examples/plugin/request-translator/c/CMakeLists.txt new file mode 100644 index 000000000..3d2217d01 --- /dev/null +++ b/examples/plugin/request-translator/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_request_translator_c C) + +add_library(cliproxy_request_translator_c SHARED src/plugin.c) +set_target_properties(cliproxy_request_translator_c PROPERTIES + OUTPUT_NAME "request-translator-c" + PREFIX "" +) diff --git a/examples/plugin/request-translator/c/src/plugin.c b/examples/plugin/request-translator/c/src/plugin.c new file mode 100644 index 000000000..094022fbb --- /dev/null +++ b/examples/plugin/request-translator/c/src/plugin.c @@ -0,0 +1,113 @@ +#include +#include +#include + +#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-translator-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}}"); + return 0; + } + if (strcmp(method, "plugin.reconfigure") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-translator-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}}"); + return 0; + } + if (strcmp(method, "request.translate") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJ0cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LXRyYW5zbGF0b3ItYyJ9\"}}"); + 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; +} diff --git a/examples/plugin/request-translator/go/go.mod b/examples/plugin/request-translator/go/go.mod new file mode 100644 index 000000000..186b756cf --- /dev/null +++ b/examples/plugin/request-translator/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/request-translator/go + +go 1.26 diff --git a/examples/plugin/request-translator/go/main.go b/examples/plugin/request-translator/go/main.go new file mode 100644 index 000000000..5dc76a26b --- /dev/null +++ b/examples/plugin/request-translator/go/main.go @@ -0,0 +1,173 @@ +package main + +/* +#include +#include + +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-translator-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}") + case "plugin.reconfigure": + return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-translator-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}") + case "request.translate": + return okEnvelopeJSON("{\"Body\":\"eyJ0cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LXRyYW5zbGF0b3ItZ28ifQ==\"}") + 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) + } +} diff --git a/examples/plugin/request-translator/rust/Cargo.lock b/examples/plugin/request-translator/rust/Cargo.lock new file mode 100644 index 000000000..fb3095e18 --- /dev/null +++ b/examples/plugin/request-translator/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-request-translator-rust" +version = "0.1.0" diff --git a/examples/plugin/request-translator/rust/Cargo.toml b/examples/plugin/request-translator/rust/Cargo.toml new file mode 100644 index 000000000..d258c2cd8 --- /dev/null +++ b/examples/plugin/request-translator/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-request-translator-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/request-translator/rust/src/lib.rs b/examples/plugin/request-translator/rust/src/lib.rs new file mode 100644 index 000000000..eaa2c75f9 --- /dev/null +++ b/examples/plugin/request-translator/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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-translator-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-request-translator-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-request-translator-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"request_translator\":true}}}"); 0 },"request.translate" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJ0cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXF1ZXN0LXRyYW5zbGF0b3ItcnVzdCJ9\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/response-normalizer/c/CMakeLists.txt b/examples/plugin/response-normalizer/c/CMakeLists.txt new file mode 100644 index 000000000..c13ffe1a5 --- /dev/null +++ b/examples/plugin/response-normalizer/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_response_normalizer_c C) + +add_library(cliproxy_response_normalizer_c SHARED src/plugin.c) +set_target_properties(cliproxy_response_normalizer_c PROPERTIES + OUTPUT_NAME "response-normalizer-c" + PREFIX "" +) diff --git a/examples/plugin/response-normalizer/c/src/plugin.c b/examples/plugin/response-normalizer/c/src/plugin.c new file mode 100644 index 000000000..207d849cd --- /dev/null +++ b/examples/plugin/response-normalizer/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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-response-normalizer-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}}"); + return 0; + } + if (strcmp(method, "plugin.reconfigure") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-normalizer-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}}"); + return 0; + } + if (strcmp(method, "response.normalize_before") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2JlZm9yZV9ieSI6ImV4YW1wbGUtcmVzcG9uc2Utbm9ybWFsaXplci1jIn0=\"}}"); + return 0; + } + if (strcmp(method, "response.normalize_after") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2FmdGVyX2J5IjoiZXhhbXBsZS1yZXNwb25zZS1ub3JtYWxpemVyLWMifQ==\"}}"); + 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; +} diff --git a/examples/plugin/response-normalizer/go/go.mod b/examples/plugin/response-normalizer/go/go.mod new file mode 100644 index 000000000..cd2602166 --- /dev/null +++ b/examples/plugin/response-normalizer/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/response-normalizer/go + +go 1.26 diff --git a/examples/plugin/response-normalizer/go/main.go b/examples/plugin/response-normalizer/go/main.go new file mode 100644 index 000000000..ec6890f1e --- /dev/null +++ b/examples/plugin/response-normalizer/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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-response-normalizer-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}") + case "plugin.reconfigure": + return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-normalizer-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}") + case "response.normalize_before": + return okEnvelopeJSON("{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2JlZm9yZV9ieSI6ImV4YW1wbGUtcmVzcG9uc2Utbm9ybWFsaXplci1nbyJ9\"}") + case "response.normalize_after": + return okEnvelopeJSON("{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2FmdGVyX2J5IjoiZXhhbXBsZS1yZXNwb25zZS1ub3JtYWxpemVyLWdvIn0=\"}") + 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) + } +} diff --git a/examples/plugin/response-normalizer/rust/Cargo.lock b/examples/plugin/response-normalizer/rust/Cargo.lock new file mode 100644 index 000000000..f0ab39a43 --- /dev/null +++ b/examples/plugin/response-normalizer/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-response-normalizer-rust" +version = "0.1.0" diff --git a/examples/plugin/response-normalizer/rust/Cargo.toml b/examples/plugin/response-normalizer/rust/Cargo.toml new file mode 100644 index 000000000..b5663cc45 --- /dev/null +++ b/examples/plugin/response-normalizer/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-response-normalizer-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/response-normalizer/rust/src/lib.rs b/examples/plugin/response-normalizer/rust/src/lib.rs new file mode 100644 index 000000000..6371c9f24 --- /dev/null +++ b/examples/plugin/response-normalizer/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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-response-normalizer-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-normalizer-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-normalizer-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_before_translator\":true,\"response_after_translator\":true}}}"); 0 },"response.normalize_before" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2JlZm9yZV9ieSI6ImV4YW1wbGUtcmVzcG9uc2Utbm9ybWFsaXplci1ydXN0In0=\"}}"); 0 },"response.normalize_after" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV9ub3JtYWxpemVkX2FmdGVyX2J5IjoiZXhhbXBsZS1yZXNwb25zZS1ub3JtYWxpemVyLXJ1c3QifQ==\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/response-translator/c/CMakeLists.txt b/examples/plugin/response-translator/c/CMakeLists.txt new file mode 100644 index 000000000..ba2845eaa --- /dev/null +++ b/examples/plugin/response-translator/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_response_translator_c C) + +add_library(cliproxy_response_translator_c SHARED src/plugin.c) +set_target_properties(cliproxy_response_translator_c PROPERTIES + OUTPUT_NAME "response-translator-c" + PREFIX "" +) diff --git a/examples/plugin/response-translator/c/src/plugin.c b/examples/plugin/response-translator/c/src/plugin.c new file mode 100644 index 000000000..ca8313bf5 --- /dev/null +++ b/examples/plugin/response-translator/c/src/plugin.c @@ -0,0 +1,113 @@ +#include +#include +#include + +#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-response-translator-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}}"); + return 0; + } + if (strcmp(method, "plugin.reconfigure") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-translator-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}}"); + return 0; + } + if (strcmp(method, "response.translate") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV90cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXNwb25zZS10cmFuc2xhdG9yLWMifQ==\"}}"); + 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; +} diff --git a/examples/plugin/response-translator/go/go.mod b/examples/plugin/response-translator/go/go.mod new file mode 100644 index 000000000..5f53fd124 --- /dev/null +++ b/examples/plugin/response-translator/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/response-translator/go + +go 1.26 diff --git a/examples/plugin/response-translator/go/main.go b/examples/plugin/response-translator/go/main.go new file mode 100644 index 000000000..e0d8bf389 --- /dev/null +++ b/examples/plugin/response-translator/go/main.go @@ -0,0 +1,173 @@ +package main + +/* +#include +#include + +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-response-translator-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}") + case "plugin.reconfigure": + return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-translator-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}") + case "response.translate": + return okEnvelopeJSON("{\"Body\":\"eyJyZXNwb25zZV90cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXNwb25zZS10cmFuc2xhdG9yLWdvIn0=\"}") + 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) + } +} diff --git a/examples/plugin/response-translator/rust/Cargo.lock b/examples/plugin/response-translator/rust/Cargo.lock new file mode 100644 index 000000000..67f68a91d --- /dev/null +++ b/examples/plugin/response-translator/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-response-translator-rust" +version = "0.1.0" diff --git a/examples/plugin/response-translator/rust/Cargo.toml b/examples/plugin/response-translator/rust/Cargo.toml new file mode 100644 index 000000000..528f5a160 --- /dev/null +++ b/examples/plugin/response-translator/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-response-translator-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/response-translator/rust/src/lib.rs b/examples/plugin/response-translator/rust/src/lib.rs new file mode 100644 index 000000000..7f0fdaf4d --- /dev/null +++ b/examples/plugin/response-translator/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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-response-translator-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-response-translator-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-response-translator-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"response_translator\":true}}}"); 0 },"response.translate" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJyZXNwb25zZV90cmFuc2xhdGVkX2J5IjoiZXhhbXBsZS1yZXNwb25zZS10cmFuc2xhdG9yLXJ1c3QifQ==\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/scripts/generate_examples.py b/examples/plugin/scripts/generate_examples.py new file mode 100644 index 000000000..ca13082de --- /dev/null +++ b/examples/plugin/scripts/generate_examples.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from pathlib import Path +from typing import NamedTuple + + +ROOT = Path(__file__).resolve().parents[1] +ABI_VERSION = 1 +SCHEMA_VERSION = 1 + + +class Capability(NamedTuple): + slug: str + title: str + capability_json: str + methods: tuple[str, ...] + description_cn: str + description_en: str + + +CAPABILITIES = ( + Capability("model", "Model", '"model_provider":true', ("model.static", "model.for_auth"), "模型能力示例,只返回静态模型和按认证发现模型。", "Model capability example with static and auth-bound models."), + Capability("auth", "Auth", '"auth_provider":true', ("auth.identifier", "auth.parse", "auth.login.start", "auth.login.poll", "auth.refresh"), "认证能力示例,演示解析、登录、轮询和刷新。", "Auth capability example with parse, login, poll, and refresh."), + Capability("frontend-auth", "Frontend Auth", '"frontend_auth_provider":true', ("frontend_auth.identifier", "frontend_auth.authenticate"), "前端认证能力示例,演示代理入口前认证。", "Frontend auth capability example."), + Capability("executor", "Executor", '"executor":true,"executor_model_scope":"both","executor_input_formats":["chat-completions"],"executor_output_formats":["chat-completions"]', ("executor.identifier", "executor.execute", "executor.execute_stream", "executor.count_tokens", "executor.http_request"), "执行器能力示例,演示普通执行、流式执行、计数和 HTTP 请求。", "Executor capability example."), + Capability("protocol-format", "Protocol Format", '"executor":true,"executor_model_scope":"both","executor_input_formats":["chat-completions"],"executor_output_formats":["responses"]', ("executor.identifier", "executor.execute"), "协议格式适配示例,用最小执行器承载格式声明。", "Protocol format example carried by a minimal executor."), + Capability("request-translator", "Request Translator", '"request_translator":true', ("request.translate",), "请求转换能力示例。", "Request translator capability example."), + Capability("request-normalizer", "Request Normalizer", '"request_normalizer":true', ("request.normalize",), "请求规整能力示例。", "Request normalizer capability example."), + Capability("response-translator", "Response Translator", '"response_translator":true', ("response.translate",), "响应转换能力示例。", "Response translator capability example."), + Capability("response-normalizer", "Response Normalizer", '"response_before_translator":true,"response_after_translator":true', ("response.normalize_before", "response.normalize_after"), "响应规整能力示例。", "Response normalizer capability example."), + Capability("thinking", "Thinking", '"thinking_applier":true', ("thinking.identifier", "thinking.apply"), "Thinking 能力示例。", "Thinking applier capability example."), + Capability("usage", "Usage", '"usage_plugin":true', ("usage.handle",), "Usage 能力示例。", "Usage observer capability example."), + Capability("cli", "CLI", '"command_line_plugin":true', ("command_line.register", "command_line.execute"), "命令行扩展能力示例。", "Command-line capability example."), + Capability("management-api", "Management API", '"management_api":true', ("management.register", "management.handle"), "Management API 扩展能力示例。", "Management API capability example."), + Capability("host-callback", "Host Callback", '"management_api":true', ("management.register", "management.handle"), "Host callback 示例,用最小 Management API 入口触发宿主 HTTP 和日志回调。", "Host callback example carried by a minimal Management API route."), +) + + +def plugin_id(cap: Capability, lang: str) -> str: + return f"example-{cap.slug}-{lang}" + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def json_string(value: str) -> str: + return json.dumps(value) + + +def compact_json(value: object) -> str: + return json.dumps(value, separators=(",", ":")) + + +def c_ident(slug: str) -> str: + return slug.replace("-", "_") + + +def registration_result(cap: Capability, lang: str) -> str: + pid = plugin_id(cap, lang) + return ( + "{" + f'"schema_version":{SCHEMA_VERSION},' + '"metadata":{' + f'"Name":{json.dumps(pid)},' + '"Version":"0.1.0",' + '"Author":"router-for-me",' + '"GitHubRepository":"https://github.com/router-for-me/CLIProxyAPI",' + f'"Logo":"https://example.invalid/{pid}.png",' + '"ConfigFields":[]' + "}," + f'"capabilities":{{{cap.capability_json}}}' + "}" + ) + + +def model_result(cap: Capability, lang: str) -> str: + pid = plugin_id(cap, lang) + return ( + "{" + f'"Provider":{json.dumps(pid)},' + '"Models":[{' + f'"ID":{json.dumps(pid + "-model")},' + '"Object":"model",' + f'"OwnedBy":{json.dumps(pid)},' + f'"DisplayName":{json.dumps(cap.title + " Example Model")},' + '"SupportedGenerationMethods":["chat"],' + '"ContextLength":8192,' + '"MaxCompletionTokens":1024,' + '"UserDefined":true' + "}]" + "}" + ) + + +def auth_data_result(cap: Capability, lang: str) -> str: + pid = plugin_id(cap, lang) + return ( + "{" + f'"Provider":{json.dumps(pid)},' + f'"ID":{json.dumps(pid)},' + f'"FileName":{json.dumps(pid + ".json")},' + f'"Label":{json.dumps(cap.title + " Example")},' + f'"StorageJSON":{json.dumps(base64_json({"type": pid, "token": "example-token"}))},' + f'"Metadata":{{"type":{json.dumps(pid)}}}' + "}" + ) + + +def base64_json(value: object) -> str: + import base64 + + raw = json.dumps(value, separators=(",", ":")).encode() + return base64.b64encode(raw).decode() + + +def result_for_method(cap: Capability, lang: str, method: str) -> str: + pid = plugin_id(cap, lang) + if method in ("plugin.register", "plugin.reconfigure"): + return registration_result(cap, lang) + if method == "model.static" or method == "model.for_auth": + return model_result(cap, lang) + if method.endswith(".identifier"): + return f'{{"identifier":{json.dumps(pid)}}}' + if method == "auth.parse": + return f'{{"Handled":true,"Auth":{auth_data_result(cap, lang)}}}' + if method == "auth.login.start": + return f'{{"Provider":{json.dumps(pid)},"URL":"https://example.invalid/login","State":"example-state","ExpiresAt":"2030-01-01T00:00:00Z"}}' + if method == "auth.login.poll": + return f'{{"Status":"success","Message":"example login complete","Auth":{auth_data_result(cap, lang)}}}' + if method == "auth.refresh": + return f'{{"Auth":{auth_data_result(cap, lang)},"NextRefreshAfter":"2030-01-01T00:00:00Z"}}' + if method == "frontend_auth.authenticate": + return compact_json({"Authenticated": True, "Principal": pid, "Metadata": {"provider": pid}}) + if method == "executor.execute": + return compact_json({"Payload": base64_json({"id": pid, "object": "chat.completion"}), "Headers": {"content-type": ["application/json"]}}) + if method == "executor.execute_stream": + return compact_json({"headers": {"content-type": ["text/event-stream"]}, "chunks": [{"Payload": base64_json("data: " + pid + "\n\n")}]}) + if method == "executor.count_tokens": + return compact_json({"Payload": base64_json({"total_tokens": 0})}) + if method == "executor.http_request": + return compact_json({"StatusCode": 200, "Headers": {"content-type": ["application/json"]}, "Body": base64_json({"plugin": pid})}) + if method == "request.translate": + return compact_json({"Body": base64_json({"translated_by": pid})}) + if method == "request.normalize": + return compact_json({"Body": base64_json({"normalized_by": pid})}) + if method == "response.translate": + return compact_json({"Body": base64_json({"response_translated_by": pid})}) + if method == "response.normalize_before": + return compact_json({"Body": base64_json({"response_normalized_before_by": pid})}) + if method == "response.normalize_after": + return compact_json({"Body": base64_json({"response_normalized_after_by": pid})}) + if method == "thinking.apply": + return compact_json({"Body": base64_json({"thinking_applied_by": pid})}) + if method == "usage.handle": + return "{}" + if method == "command_line.register": + return f'{{"Flags":[{{"Name":{json.dumps(pid + "-command")},"Usage":"Run the example plugin command","Type":"bool"}}]}}' + if method == "command_line.execute": + return f'{{"Stdout":{json.dumps(base64_json(pid + " command executed\\n"))},"ExitCode":0}}' + if method == "management.register": + return f'{{"routes":[{{"Method":"GET","Path":"/plugins/{pid}/status","Menu":{json.dumps(cap.title)},"Description":{json.dumps(cap.description_en)}}}]}}' + if method == "management.handle": + return compact_json({"StatusCode": 200, "Headers": {"content-type": ["application/json"]}, "Body": base64_json({"plugin": pid})}) + raise ValueError(f"unsupported method {method}") + + +def envelope(result: str) -> str: + return f'{{"ok":true,"result":{result}}}' + + +def error_envelope(code: str, message: str) -> str: + return json.dumps({"ok": False, "error": {"code": code, "message": message}}, separators=(",", ":")) + + +def methods_for(cap: Capability) -> tuple[str, ...]: + return ("plugin.register", "plugin.reconfigure", *cap.methods) + + +def generate_go(cap: Capability) -> None: + slug = cap.slug + pid = plugin_id(cap, "go") + method_cases = [] + for method in methods_for(cap): + host_callback_call = "" + if slug == "host-callback" and method == "management.handle": + host_callback_call = f"""\t\tcallHost("host.log", []byte(`{{"level":"info","message":"{pid} host callback log","fields":{{"plugin":"{pid}"}}}}`)) +\t\tcallHost("host.http.do", []byte(`{{"method":"GET","url":"https://example.com","headers":{{"user-agent":["{pid}"]}}}}`)) +""" + method_cases.append(f'\tcase "{method}":\n{host_callback_call}\t\treturn okEnvelopeJSON({json.dumps(result_for_method(cap, "go", method))})') + go_mod = f"""module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/{slug}/go + +go 1.26 +""" + go_main = f"""package main + +/* +#include +#include + +typedef struct {{ +\tvoid* ptr; +\tsize_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 {{ +\tuint32_t abi_version; +\tvoid* host_ctx; +\tcliproxy_host_call_fn call; +\tcliproxy_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 {{ +\tuint32_t abi_version; +\tcliproxy_plugin_call_fn call; +\tcliproxy_plugin_free_fn free_buffer; +\tcliproxy_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) {{ +\tstored_host = host; +}} + +static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {{ +\tif (stored_host == NULL || stored_host->call == NULL) {{ +\t\treturn 1; +\t}} +\treturn stored_host->call(stored_host->host_ctx, method, request, request_len, response); +}} + +static void free_host_buffer(void* ptr, size_t len) {{ +\tif (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {{ +\t\tstored_host->free_buffer(ptr, len); +\t}} +}} +*/ +import "C" + +import ( +\t"encoding/json" +\t"net/http" +\t"time" +\t"unsafe" +) + +const abiVersion uint32 = {ABI_VERSION} + +type envelope struct {{ +\tOK bool `json:"ok"` +\tResult json.RawMessage `json:"result,omitempty"` +\tError *envelopeError `json:"error,omitempty"` +}} + +type envelopeError struct {{ +\tCode string `json:"code"` +\tMessage 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 {{ +\tif plugin == nil {{ +\t\treturn 1 +\t}} +\tC.store_host_api(host) +\tplugin.abi_version = C.uint32_t(abiVersion) +\tplugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) +\tplugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) +\tplugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) +\treturn 0 +}} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {{ +\tif response != nil {{ +\t\tresponse.ptr = nil +\t\tresponse.len = 0 +\t}} +\tif method == nil {{ +\t\twriteResponse(response, errorEnvelope("invalid_method", "method is required")) +\t\treturn 1 +\t}} +\traw, errHandle := handleMethod(C.GoString(method)) +\tif errHandle != nil {{ +\t\twriteResponse(response, errorEnvelope("plugin_error", errHandle.Error())) +\t\treturn 1 +\t}} +\twriteResponse(response, raw) +\t_ = request +\t_ = requestLen +\treturn 0 +}} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {{ +\tif ptr != nil {{ +\t\tC.free(ptr) +\t}} +\t_ = len +}} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() {{}} + +func handleMethod(method string) ([]byte, error) {{ +\t_ = http.StatusOK +\t_ = time.Second +\tswitch method {{ +{chr(10).join(method_cases)} +\tdefault: +\t\treturn errorEnvelope("unknown_method", "unknown method: "+method), nil +\t}} +}} + +func okEnvelopeJSON(result string) ([]byte, error) {{ +\treturn json.Marshal(envelope{{OK: true, Result: json.RawMessage(result)}}) +}} + +func errorEnvelope(code, message string) []byte {{ +\traw, _ := json.Marshal(envelope{{OK: false, Error: &envelopeError{{Code: code, Message: message}}}}) +\treturn raw +}} + +func writeResponse(response *C.cliproxy_buffer, raw []byte) {{ +\tif response == nil || len(raw) == 0 {{ +\t\treturn +\t}} +\tptr := C.CBytes(raw) +\tif ptr == nil {{ +\t\treturn +\t}} +\tresponse.ptr = ptr +\tresponse.len = C.size_t(len(raw)) +}} + +func callHost(method string, payload []byte) {{ +\tcMethod := C.CString(method) +\tdefer C.free(unsafe.Pointer(cMethod)) +\tvar response C.cliproxy_buffer +\tvar req *C.uint8_t +\tif len(payload) > 0 {{ +\t\treq = (*C.uint8_t)(C.CBytes(payload)) +\t\tdefer C.free(unsafe.Pointer(req)) +\t}} +\tif C.call_host_api(cMethod, req, C.size_t(len(payload)), &response) == 0 && response.ptr != nil {{ +\t\tC.free_host_buffer(response.ptr, response.len) +\t}} +}} +""" + write(ROOT / slug / "go" / "go.mod", go_mod) + write(ROOT / slug / "go" / "main.go", go_main) + + +def c_string(value: str) -> str: + return json.dumps(value) + + +def generate_c(cap: Capability) -> None: + slug = cap.slug + ident = c_ident(slug) + pid = plugin_id(cap, "c") + cases = [] + for method in methods_for(cap): + result = envelope(result_for_method(cap, "c", method)) + host_call = "" + if slug == "host-callback" and method == "management.handle": + host_call = f""" +\t\tcall_host("host.log", "{{\\\"level\\\":\\\"info\\\",\\\"message\\\":\\\"{pid} host callback log\\\",\\\"fields\\\":{{\\\"plugin\\\":\\\"{pid}\\\"}}}}"); +\t\tcall_host("host.http.do", "{{\\\"method\\\":\\\"GET\\\",\\\"url\\\":\\\"https://example.com\\\",\\\"headers\\\":{{\\\"user-agent\\\":[\\\"{pid}\\\"]}}}}"); +""" + cases.append(f"""\tif (strcmp(method, {c_string(method)}) == 0) {{{host_call} +\t\twrite_response(response, {c_string(result)}); +\t\treturn 0; +\t}}""") + cmake = f"""cmake_minimum_required(VERSION 3.16) +project(cliproxy_{ident}_c C) + +add_library(cliproxy_{ident}_c SHARED src/plugin.c) +set_target_properties(cliproxy_{ident}_c PROPERTIES + OUTPUT_NAME "{slug}-c" + PREFIX "" +) +""" + source = f"""#include +#include +#include + +#if defined(_WIN32) +#define CLIPROXY_EXPORT __declspec(dllexport) +#else +#define CLIPROXY_EXPORT __attribute__((visibility("default"))) +#endif + +#define ABI_VERSION {ABI_VERSION} + +typedef struct {{ +\tvoid* ptr; +\tsize_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 {{ +\tuint32_t abi_version; +\tvoid* host_ctx; +\tcliproxy_host_call_fn call; +\tcliproxy_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 {{ +\tuint32_t abi_version; +\tcliproxy_plugin_call_fn call; +\tcliproxy_plugin_free_fn free_buffer; +\tcliproxy_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) {{ +\tif (response == NULL || text == NULL) {{ +\t\treturn; +\t}} +\tsize_t len = strlen(text); +\tvoid* ptr = malloc(len); +\tif (ptr == NULL) {{ +\t\tresponse->ptr = NULL; +\t\tresponse->len = 0; +\t\treturn; +\t}} +\tmemcpy(ptr, text, len); +\tresponse->ptr = ptr; +\tresponse->len = len; +}} + +static void call_host(const char* method, const char* payload) {{ +\tif (stored_host == NULL || stored_host->call == NULL || method == NULL) {{ +\t\treturn; +\t}} +\tcliproxy_buffer response = {{0}}; +\tconst uint8_t* request = (const uint8_t*)payload; +\tsize_t request_len = payload == NULL ? 0 : strlen(payload); +\tif (stored_host->call(stored_host->host_ctx, method, request, request_len, &response) == 0 && response.ptr != NULL && stored_host->free_buffer != NULL) {{ +\t\tstored_host->free_buffer(response.ptr, response.len); +\t}} +}} + +static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {{ +\tif (response != NULL) {{ +\t\tresponse->ptr = NULL; +\t\tresponse->len = 0; +\t}} +\tif (method == NULL) {{ +\t\twrite_response(response, "{{\\"ok\\":false,\\"error\\":{{\\"code\\":\\"invalid_method\\",\\"message\\":\\"method is required\\"}}}}"); +\t\treturn 1; +\t}} +{chr(10).join(cases)} +\twrite_response(response, "{{\\"ok\\":false,\\"error\\":{{\\"code\\":\\"unknown_method\\",\\"message\\":\\"unknown method\\"}}}}"); +\t(void)request; +\t(void)request_len; +\treturn 0; +}} + +static void plugin_free(void* ptr, size_t len) {{ +\t(void)len; +\tfree(ptr); +}} + +static void plugin_shutdown(void) {{}} + +CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {{ +\tif (plugin == NULL) {{ +\t\treturn 1; +\t}} +\tstored_host = host; +\tplugin->abi_version = ABI_VERSION; +\tplugin->call = plugin_call; +\tplugin->free_buffer = plugin_free; +\tplugin->shutdown = plugin_shutdown; +\treturn 0; +}} +""" + write(ROOT / slug / "c" / "CMakeLists.txt", cmake) + write(ROOT / slug / "c" / "src" / "plugin.c", source) + + +def generate_rust(cap: Capability) -> None: + slug = cap.slug + ident = c_ident(slug) + pid = plugin_id(cap, "rust") + cases = [] + for method in methods_for(cap): + result = envelope(result_for_method(cap, "rust", method)) + host_call = "" + if slug == "host-callback" and method == "management.handle": + host_call = f""" + call_host("host.log", r#"{{"level":"info","message":"{pid} host callback log","fields":{{"plugin":"{pid}"}}}}"#); + call_host("host.http.do", r#"{{"method":"GET","url":"https://example.com","headers":{{"user-agent":["{pid}"]}}}}"#); +""" + cases.append(f'{json.dumps(method)} => {{{host_call} write_response(response, {json.dumps(result)}); 0 }}') + cargo = f"""[package] +name = "cliproxy-{slug}-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] +""" + cargo_lock = f"""# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-{slug}-rust" +version = "0.1.0" +""" + source = f"""use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; + +const ABI_VERSION: u32 = {ABI_VERSION}; + +#[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, + free_buffer: Option, +}} + +#[repr(C)] +pub struct CliproxyPluginApi {{ + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +}} + +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 {{ + {",".join(cases)}, + _ => {{ + 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); + }} + }} + }} +}} +""" + write(ROOT / slug / "rust" / "Cargo.toml", cargo) + write(ROOT / slug / "rust" / "Cargo.lock", cargo_lock) + write(ROOT / slug / "rust" / "src" / "lib.rs", source) + + +def main() -> None: + for cap in CAPABILITIES: + generate_go(cap) + generate_c(cap) + generate_rust(cap) + + +if __name__ == "__main__": + main() diff --git a/examples/plugin/simple/README.md b/examples/plugin/simple/README.md new file mode 100644 index 000000000..02b40fa78 --- /dev/null +++ b/examples/plugin/simple/README.md @@ -0,0 +1,211 @@ +# Example Standard Dynamic Library Plugin + +This is the full mixed-capability skeleton. For single-capability examples, see `../README.md`. + +This directory is the reference skeleton for the current standard dynamic library plugin ABI. The ABI is language-neutral: the host loads a native dynamic library, calls `cliproxy_plugin_init`, and then exchanges JSON envelopes through a stable C function table. + +This directory contains complete Go, C, and Rust implementations of the same mixed-capability sample. The Go sample uses `-buildmode=c-shared`; the C sample uses CMake; the Rust sample uses a `cdylib` crate. + +## Entry Point + +Every plugin must export: + +```c +int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin); +``` + +The plugin fills `cliproxy_plugin_api` with: + +```c +int call(char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response); +void free_buffer(void* ptr, size_t len); +void shutdown(void); +``` + +The host provides `cliproxy_host_api` with: + +```c +int call(void* host_ctx, char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response); +void free_buffer(void* ptr, size_t len); +``` + +The C ABI never passes Go interfaces, Go slices, Go maps, Go channels, `context.Context`, or Go errors. + +## JSON Envelope + +Successful responses use: + +```json +{ + "ok": true, + "result": {} +} +``` + +Errors use: + +```json +{ + "ok": false, + "error": { + "code": "invalid_request", + "message": "request is invalid" + } +} +``` + +Raw byte fields are encoded as base64 by JSON. + +## Capabilities + +`plugin.register` and `plugin.reconfigure` return metadata and capability flags. This sample declares the full provider-native surface: + +- model provider +- model registrar +- auth provider +- frontend auth provider +- executor +- request and response transforms +- thinking applier +- usage observer +- command-line plugin +- Management API plugin + +Executor plugins must declare `executor_input_formats` and `executor_output_formats` in their capability block. The host passes requests through directly when the client protocol is declared by the executor. Otherwise, the host translates the inbound request into one declared input format and translates the executor response back to the client protocol. This example declares `chat-completions` for both lists, so non-chat-completions protocols are translated by the host. The host also accepts the existing internal aliases `openai`, `openai-response`, and `claude` for Chat Completions, Responses, and Anthropic protocols. + +The host keeps the existing precedence rules: native logic wins, plugins fill gaps, and higher-priority plugins run before lower-priority plugins. + +## Layout + +- `go/`: full mixed-capability Go implementation. +- `c/`: full mixed-capability C implementation with no external dependencies. +- `rust/`: full mixed-capability Rust implementation with no external dependencies. + +All three implementations parse incoming JSON requests for the methods where request content matters. Auth methods persist the raw request payload as `StorageJSON`; request and response transforms echo the inbound `Body`; Thinking decodes `Body` and appends `plugin_example_thinking`; executor methods use request fields such as `Model`, `Format`, and `Payload`; Usage keeps an in-process count. + +## Build + +Build from the repository root. + +Build all plugin examples, including all three `simple` variants: + +```bash +make -C examples/plugin build +``` + +Artifacts are written to `examples/plugin/bin` as `simple-go`, `simple-c`, and `simple-rust` with the current platform dynamic-library extension. + +Manual Go build on macOS: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +go build -buildmode=c-shared -o plugins/darwin/$(go env GOARCH)/simple-go.dylib ./examples/plugin/simple/go +rm -f plugins/darwin/$(go env GOARCH)/simple-go.h +``` + +Manual C build on macOS: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +cmake -S examples/plugin/simple/c -B /tmp/cliproxy-simple-c-build -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PWD/plugins/darwin/$(go env GOARCH) +cmake --build /tmp/cliproxy-simple-c-build +``` + +Manual Rust build on macOS: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +cd examples/plugin/simple/rust +CARGO_TARGET_DIR=/tmp/cliproxy-simple-rust-target cargo build --release --locked +cp /tmp/cliproxy-simple-rust-target/release/libcliproxy_simple_rust.dylib ../../../../plugins/darwin/$(go env GOARCH)/simple-rust.dylib +``` + +For Linux, FreeBSD, or Windows, keep the same source directory and use the platform extension selected by `examples/plugin/Makefile`. + +The plugin ID is the dynamic library basename without the platform extension. Makefile-built artifacts map to `plugins.configs.simple-go`, `plugins.configs.simple-c`, and `plugins.configs.simple-rust`. + +## Discovery + +The host searches: + +```text +plugins//- +plugins// +plugins +``` + +Accepted extensions are: + +- `.so` on Linux and FreeBSD +- `.dylib` on macOS +- `.dll` on Windows + +Plugin IDs must match: + +```text +[A-Za-z0-9][A-Za-z0-9._-]{0,127} +``` + +## Configuration + +Dynamic plugins are disabled by default. + +```yaml +plugins: + enabled: true + dir: "plugins" + configs: + simple-go: + enabled: true + priority: 1 + config1: true + config2: "string" + config3: 3 + mode: "safe" +``` + +`plugins.configs.` is passed to `plugin.register` or `plugin.reconfigure` as normalized YAML bytes inside the JSON request. + +## Host HTTP Bridge + +Plugins can call host functionality through `host.call`. The HTTP bridge method is: + +```text +host.http.do +``` + +The host still performs the real HTTP request, so proxy handling, transport policy, auth context, and request logging stay under host control. + +## Management API + +The native plugin management endpoints remain: + +```text +GET /v0/management/plugins +PATCH /v0/management/plugins/{pluginID}/enabled +PUT /v0/management/plugins/{pluginID}/config +PATCH /v0/management/plugins/{pluginID}/config +``` + +Plugin-owned Management API routes are registered through `management.register` and handled through `management.handle`. + +## Trust Boundary + +Standard dynamic library plugins are trusted in-process code. Panic recovery can protect host-managed calls, but it cannot prevent a plugin from exiting the process, corrupting memory, mutating global process state, or leaking secrets. Install only plugins you trust as much as the service binary. + +## Verification + +Current platform sample builds: + +```bash +make -C examples/plugin list +make -C examples/plugin build +find examples/plugin/bin -maxdepth 1 -type f | wc -l +make -C examples/plugin clean +``` + +After changing Go code in this repository, also run: + +```bash +go build -o test-output ./cmd/server && rm test-output +``` diff --git a/examples/plugin/simple/README_CN.md b/examples/plugin/simple/README_CN.md new file mode 100644 index 000000000..7bb46e892 --- /dev/null +++ b/examples/plugin/simple/README_CN.md @@ -0,0 +1,209 @@ +# 标准动态库插件示例 + +这是混合全部能力的完整骨架示例。单能力示例请查看 `../README_CN.md`。 + +本目录是当前标准动态库插件 ABI 的参考骨架。ABI 与语言无关:宿主加载原生动态库,调用 `cliproxy_plugin_init`,然后通过稳定的 C 函数表交换 JSON 信封。 + +本目录包含同一个混合能力示例的 Go、C、Rust 三种完整实现。Go 示例使用 `-buildmode=c-shared`,C 示例使用 CMake,Rust 示例使用 `cdylib` crate。 + +## 入口 + +每个插件必须导出: + +```c +int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin); +``` + +插件填充 `cliproxy_plugin_api`: + +```c +int call(char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response); +void free_buffer(void* ptr, size_t len); +void shutdown(void); +``` + +宿主提供 `cliproxy_host_api`: + +```c +int call(void* host_ctx, char* method, uint8_t* request, size_t request_len, cliproxy_buffer* response); +void free_buffer(void* ptr, size_t len); +``` + +C ABI 不传递 Go interface、Go slice、Go map、Go channel、`context.Context` 或 Go error。 + +## JSON 信封 + +成功响应: + +```json +{ + "ok": true, + "result": {} +} +``` + +错误响应: + +```json +{ + "ok": false, + "error": { + "code": "invalid_request", + "message": "request is invalid" + } +} +``` + +原始字节字段通过 JSON 自动使用 base64 编码。 + +## 能力 + +`plugin.register` 和 `plugin.reconfigure` 返回 metadata 和能力开关。本示例声明完整的提供方插件能力: + +- 模型提供方 +- 模型注册器 +- 认证提供方 +- 前端认证提供方 +- 执行器 +- 请求和响应转换 +- 思考配置处理 +- 用量观察 +- 命令行插件 +- Management API 插件 + +宿主保留现有优先级规则:原生逻辑优先,插件补齐缺口,高优先级插件先于低优先级插件执行。 + +## 目录布局 + +- `go/`:完整混合能力 Go 实现。 +- `c/`:完整混合能力 C 实现,不依赖外部库。 +- `rust/`:完整混合能力 Rust 实现,不依赖外部库。 + +三种实现都会在需要请求内容的方法中解析传入 JSON。认证方法会把原始请求作为 `StorageJSON`,请求和响应转换会回显传入 `Body`,Thinking 会解码 `Body` 并追加 `plugin_example_thinking`,执行器方法会使用 `Model`、`Format`、`Payload` 等请求字段,Usage 会维护进程内计数。 + +## 构建 + +在仓库根目录构建。 + +构建全部插件示例,包括 `simple` 的三种语言实现: + +```bash +make -C examples/plugin build +``` + +产物会写入 `examples/plugin/bin`,当前平台扩展名下分别为 `simple-go`、`simple-c`、`simple-rust`。 + +macOS 手动构建 Go: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +go build -buildmode=c-shared -o plugins/darwin/$(go env GOARCH)/simple-go.dylib ./examples/plugin/simple/go +rm -f plugins/darwin/$(go env GOARCH)/simple-go.h +``` + +macOS 手动构建 C: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +cmake -S examples/plugin/simple/c -B /tmp/cliproxy-simple-c-build -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$PWD/plugins/darwin/$(go env GOARCH) +cmake --build /tmp/cliproxy-simple-c-build +``` + +macOS 手动构建 Rust: + +```bash +mkdir -p plugins/darwin/$(go env GOARCH) +cd examples/plugin/simple/rust +CARGO_TARGET_DIR=/tmp/cliproxy-simple-rust-target cargo build --release --locked +cp /tmp/cliproxy-simple-rust-target/release/libcliproxy_simple_rust.dylib ../../../../plugins/darwin/$(go env GOARCH)/simple-rust.dylib +``` + +Linux、FreeBSD 或 Windows 使用相同源码目录,平台扩展名以 `examples/plugin/Makefile` 的规则为准。 + +插件 ID 来自动态库文件名去掉平台扩展名。通过 Makefile 构建的产物分别对应 `plugins.configs.simple-go`、`plugins.configs.simple-c` 和 `plugins.configs.simple-rust`。 + +## 发现规则 + +宿主搜索: + +```text +plugins//- +plugins// +plugins +``` + +支持的扩展名: + +- Linux 和 FreeBSD 使用 `.so` +- macOS 使用 `.dylib` +- Windows 使用 `.dll` + +插件 ID 必须匹配: + +```text +[A-Za-z0-9][A-Za-z0-9._-]{0,127} +``` + +## 配置 + +动态插件默认关闭。 + +```yaml +plugins: + enabled: true + dir: "plugins" + configs: + simple-go: + enabled: true + priority: 1 + config1: true + config2: "string" + config3: 3 + mode: "safe" +``` + +`plugins.configs.` 会作为标准化 YAML 字节放进 JSON 请求,传给 `plugin.register` 或 `plugin.reconfigure`。 + +## 宿主 HTTP 桥接 + +插件可以通过 `host.call` 调用宿主能力。HTTP 桥接方法是: + +```text +host.http.do +``` + +真实 HTTP 请求仍由宿主执行,因此代理、传输策略、认证上下文和请求日志仍由宿主控制。 + +## Management API + +原生插件管理接口保持不变: + +```text +GET /v0/management/plugins +PATCH /v0/management/plugins/{pluginID}/enabled +PUT /v0/management/plugins/{pluginID}/config +PATCH /v0/management/plugins/{pluginID}/config +``` + +插件自有 Management API 路由通过 `management.register` 注册,通过 `management.handle` 处理。 + +## 信任边界 + +标准动态库插件是可信进程内代码。panic 恢复可以保护宿主管理的调用,但不能阻止插件退出进程、破坏内存、修改进程全局状态或泄露敏感数据。只安装你像信任服务二进制一样信任的插件。 + +## 验证 + +当前平台示例构建: + +```bash +make -C examples/plugin list +make -C examples/plugin build +find examples/plugin/bin -maxdepth 1 -type f | wc -l +make -C examples/plugin clean +``` + +如果修改了本仓库的 Go 代码,还需要运行: + +```bash +go build -o test-output ./cmd/server && rm test-output +``` diff --git a/examples/plugin/simple/c/CMakeLists.txt b/examples/plugin/simple/c/CMakeLists.txt new file mode 100644 index 000000000..7cc928849 --- /dev/null +++ b/examples/plugin/simple/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_simple_c C) + +add_library(cliproxy_simple_c SHARED src/plugin.c) +set_target_properties(cliproxy_simple_c PROPERTIES + OUTPUT_NAME "simple-c" + PREFIX "" +) diff --git a/examples/plugin/simple/c/src/plugin.c b/examples/plugin/simple/c/src/plugin.c new file mode 100644 index 000000000..5620f47fc --- /dev/null +++ b/examples/plugin/simple/c/src/plugin.c @@ -0,0 +1,615 @@ +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define CLIPROXY_EXPORT __declspec(dllexport) +#else +#define CLIPROXY_EXPORT __attribute__((visibility("default"))) +#endif + +#define ABI_VERSION 1 + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +static long usage_count = 0; + +static const char* REGISTRATION_RESPONSE = + "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-simple-c\"," + "\"Version\":\"0.1.0\",\"Author\":\"router-for-me\"," + "\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\"," + "\"Logo\":\"https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png\"," + "\"ConfigFields\":[" + "{\"Name\":\"config1\",\"Type\":\"boolean\",\"Description\":\"Enables the example boolean option.\"}," + "{\"Name\":\"config2\",\"Type\":\"string\",\"Description\":\"Stores the example string option.\"}," + "{\"Name\":\"config3\",\"Type\":\"integer\",\"Description\":\"Stores the example integer option.\"}," + "{\"Name\":\"mode\",\"Type\":\"enum\",\"EnumValues\":[\"safe\",\"fast\"]," + "\"Description\":\"Selects the example execution mode.\"}]}," + "\"capabilities\":{\"model_registrar\":true,\"model_provider\":true,\"auth_provider\":true," + "\"frontend_auth_provider\":true,\"executor\":true,\"executor_model_scope\":\"both\"," + "\"executor_input_formats\":[\"chat-completions\"]," + "\"executor_output_formats\":[\"chat-completions\"],\"request_translator\":true," + "\"request_normalizer\":true,\"response_translator\":true,\"response_before_translator\":true," + "\"response_after_translator\":true,\"thinking_applier\":true,\"usage_plugin\":true," + "\"command_line_plugin\":true,\"management_api\":true}}}"; + +static const char* MODEL_RESPONSE = + "{\"ok\":true,\"result\":{\"Provider\":\"plugin-example-c\",\"Models\":[{\"ID\":\"plugin-example-c-model\"," + "\"Object\":\"model\",\"OwnedBy\":\"plugin-example-c\",\"DisplayName\":\"Plugin Example C Model\"," + "\"SupportedGenerationMethods\":[\"chat\"],\"ContextLength\":8192," + "\"MaxCompletionTokens\":1024,\"UserDefined\":true}]}}"; + +static const char* IDENTIFIER_RESPONSE = "{\"ok\":true,\"result\":{\"identifier\":\"plugin-example-c\"}}"; +static const char* LOGIN_START_RESPONSE = + "{\"ok\":true,\"result\":{\"Provider\":\"plugin-example-c\",\"URL\":\"https://example.invalid/plugin-login\"," + "\"State\":\"example-state\",\"ExpiresAt\":\"2030-01-01T00:00:00Z\"}}"; +static const char* LOGIN_POLL_RESPONSE = + "{\"ok\":true,\"result\":{\"Status\":\"error\",\"Message\":\"example plugin has no interactive login\"}}"; +static const char* FRONTEND_AUTH_RESPONSE = + "{\"ok\":true,\"result\":{\"Authenticated\":true,\"Principal\":\"plugin-example-c\"," + "\"Metadata\":{\"provider\":\"plugin-example-c\"}}}"; +static const char* STREAM_RESPONSE = + "{\"ok\":true,\"result\":{\"headers\":{\"content-type\":[\"text/event-stream\"]}," + "\"chunks\":[{\"Payload\":\"cGx1Z2luLWV4YW1wbGUtYwo=\"}]}}"; +static const char* CLI_REGISTER_RESPONSE = + "{\"ok\":true,\"result\":{\"Flags\":[{\"Name\":\"plugin-example-c-command\"," + "\"Usage\":\"Run the example C ABI plugin command\",\"Type\":\"bool\"}]}}"; +static const char* CLI_EXECUTE_RESPONSE = + "{\"ok\":true,\"result\":{\"Stdout\":\"cGx1Z2luIGV4YW1wbGUgYyBjb21tYW5kCg==\",\"ExitCode\":0}}"; +static const char* MANAGEMENT_REGISTER_RESPONSE = + "{\"ok\":true,\"result\":{\"Routes\":[{\"Method\":\"GET\",\"Path\":\"/plugins/example-c/status\"," + "\"Menu\":\"Example C Plugin\",\"Description\":\"Shows example C plugin status.\"}]}}"; +static const char* UNKNOWN_METHOD_RESPONSE = + "{\"ok\":false,\"error\":{\"code\":\"unknown_method\",\"message\":\"unknown method\"}}"; +static const char* INVALID_METHOD_RESPONSE = + "{\"ok\":false,\"error\":{\"code\":\"invalid_method\",\"message\":\"method is required\"}}"; +static const char BASE64_TABLE[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static char* format_string(const char* format, ...) { + va_list args; + va_start(args, format); + va_list args_copy; + va_copy(args_copy, args); + int len = vsnprintf(NULL, 0, format, args); + va_end(args); + if (len < 0) { + va_end(args_copy); + return NULL; + } + char* out = (char*)malloc((size_t)len + 1); + if (out == NULL) { + va_end(args_copy); + return NULL; + } + vsnprintf(out, (size_t)len + 1, format, args_copy); + va_end(args_copy); + return out; +} + +static char* copy_request_string(const uint8_t* request, size_t request_len) { + char* out = (char*)malloc(request_len + 1); + if (out == NULL) { + return NULL; + } + if (request_len > 0 && request != NULL) { + memcpy(out, request, request_len); + } + out[request_len] = '\0'; + return out; +} + +static char* json_escape(const char* value) { + if (value == NULL) { + return format_string(""); + } + size_t len = strlen(value); + char* out = (char*)malloc((len * 2) + 1); + if (out == NULL) { + return NULL; + } + size_t pos = 0; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)value[i]; + if (c == '"' || c == '\\') { + out[pos++] = '\\'; + out[pos++] = (char)c; + } else if (c == '\n') { + out[pos++] = '\\'; + out[pos++] = 'n'; + } else if (c == '\r') { + out[pos++] = '\\'; + out[pos++] = 'r'; + } else if (c == '\t') { + out[pos++] = '\\'; + out[pos++] = 't'; + } else if (c < 0x20) { + out[pos++] = ' '; + } else { + out[pos++] = (char)c; + } + } + out[pos] = '\0'; + return out; +} + +static char* base64_encode(const uint8_t* data, size_t len) { + size_t out_len = ((len + 2) / 3) * 4; + char* out = (char*)malloc(out_len + 1); + if (out == NULL) { + return NULL; + } + size_t i = 0; + size_t j = 0; + while (i < len) { + uint32_t octet_a = i < len ? data[i++] : 0; + uint32_t octet_b = i < len ? data[i++] : 0; + uint32_t octet_c = i < len ? data[i++] : 0; + uint32_t triple = (octet_a << 16) | (octet_b << 8) | octet_c; + out[j++] = BASE64_TABLE[(triple >> 18) & 0x3F]; + out[j++] = BASE64_TABLE[(triple >> 12) & 0x3F]; + out[j++] = BASE64_TABLE[(triple >> 6) & 0x3F]; + out[j++] = BASE64_TABLE[triple & 0x3F]; + } + if (len % 3 == 1) { + out[out_len - 2] = '='; + out[out_len - 1] = '='; + } else if (len % 3 == 2) { + out[out_len - 1] = '='; + } + out[out_len] = '\0'; + return out; +} + +static int base64_value(char c) { + if (c >= 'A' && c <= 'Z') { + return c - 'A'; + } + if (c >= 'a' && c <= 'z') { + return c - 'a' + 26; + } + if (c >= '0' && c <= '9') { + return c - '0' + 52; + } + if (c == '+') { + return 62; + } + if (c == '/') { + return 63; + } + return -1; +} + +static uint8_t* base64_decode(const char* input, size_t* out_len) { + size_t len = input == NULL ? 0 : strlen(input); + uint8_t* out = (uint8_t*)malloc(((len * 3) / 4) + 4); + if (out == NULL) { + return NULL; + } + int value = 0; + int bits = -8; + size_t pos = 0; + for (size_t i = 0; i < len; i++) { + if (input[i] == '=') { + break; + } + int digit = base64_value(input[i]); + if (digit < 0) { + continue; + } + value = (value << 6) | digit; + bits += 6; + if (bits >= 0) { + out[pos++] = (uint8_t)((value >> bits) & 0xFF); + bits -= 8; + } + } + *out_len = pos; + return out; +} + +static char* extract_json_string(const char* json, const char* key) { + char* pattern = format_string("\"%s\"", key); + if (pattern == NULL || json == NULL) { + free(pattern); + return NULL; + } + const char* pos = json; + size_t pattern_len = strlen(pattern); + while ((pos = strstr(pos, pattern)) != NULL) { + const char* p = pos + pattern_len; + while (*p != '\0' && isspace((unsigned char)*p)) { + p++; + } + if (*p++ != ':') { + pos += pattern_len; + continue; + } + while (*p != '\0' && isspace((unsigned char)*p)) { + p++; + } + if (*p++ != '"') { + pos += pattern_len; + continue; + } + char* out = (char*)malloc(strlen(p) + 1); + if (out == NULL) { + free(pattern); + return NULL; + } + size_t out_pos = 0; + while (*p != '\0') { + if (*p == '"') { + out[out_pos] = '\0'; + free(pattern); + return out; + } + if (*p == '\\' && p[1] != '\0') { + p++; + if (*p == 'n') { + out[out_pos++] = '\n'; + } else if (*p == 'r') { + out[out_pos++] = '\r'; + } else if (*p == 't') { + out[out_pos++] = '\t'; + } else { + out[out_pos++] = *p; + } + } else { + out[out_pos++] = *p; + } + p++; + } + free(out); + pos += pattern_len; + } + free(pattern); + return NULL; +} + +static long extract_json_int(const char* json, const char* key, long fallback) { + char* pattern = format_string("\"%s\"", key); + if (pattern == NULL || json == NULL) { + free(pattern); + return fallback; + } + const char* pos = strstr(json, pattern); + free(pattern); + if (pos == NULL) { + return fallback; + } + const char* p = strchr(pos, ':'); + if (p == NULL) { + return fallback; + } + p++; + while (*p != '\0' && isspace((unsigned char)*p)) { + p++; + } + char* end = NULL; + long value = strtol(p, &end, 10); + return end == p ? fallback : value; +} + +static char* wrap_ok(const char* result_json) { + return format_string("{\"ok\":true,\"result\":%s}", result_json == NULL ? "{}" : result_json); +} + +static char* make_error(const char* code, const char* message) { + char* escaped = json_escape(message); + char* out = format_string("{\"ok\":false,\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", code, escaped == NULL ? "" : escaped); + free(escaped); + return out; +} + +static char* make_auth_data(const uint8_t* request, size_t request_len) { + char* storage = base64_encode(request == NULL ? (const uint8_t*)"" : request, request == NULL ? 0 : request_len); + char* out = format_string( + "{\"Provider\":\"plugin-example-c\",\"ID\":\"plugin-example-c\",\"FileName\":\"plugin-example-c.json\"," + "\"Label\":\"Plugin Example C\",\"StorageJSON\":\"%s\",\"Metadata\":{\"type\":\"plugin-example-c\"}}", + storage == NULL ? "" : storage); + free(storage); + return out; +} + +static char* make_auth_parse_response(const uint8_t* request, size_t request_len) { + char* auth = make_auth_data(request, request_len); + char* result = format_string("{\"Handled\":true,\"Auth\":%s}", auth == NULL ? "{}" : auth); + char* out = wrap_ok(result); + free(auth); + free(result); + return out; +} + +static char* make_auth_refresh_response(const uint8_t* request, size_t request_len) { + char* auth = make_auth_data(request, request_len); + char* result = format_string("{\"Auth\":%s}", auth == NULL ? "{}" : auth); + char* out = wrap_ok(result); + free(auth); + free(result); + return out; +} + +static char* make_payload_echo_response(const uint8_t* request, size_t request_len) { + char* json = copy_request_string(request, request_len); + char* body = extract_json_string(json, "Body"); + char* out = NULL; + if (body == NULL) { + out = make_error("invalid_request", "request body field is required"); + } else { + char* result = format_string("{\"Body\":\"%s\"}", body); + out = wrap_ok(result); + free(result); + } + free(json); + free(body); + return out; +} + +static char* make_executor_response(const uint8_t* request, size_t request_len) { + char* json = copy_request_string(request, request_len); + char* model = extract_json_string(json, "Model"); + char* format = extract_json_string(json, "Format"); + char* model_escaped = json_escape(model == NULL ? "plugin-example-c-model" : model); + char* format_escaped = json_escape(format == NULL ? "chat-completions" : format); + char* payload_json = format_string( + "{\"id\":\"plugin-example-c\",\"object\":\"chat.completion\",\"model\":\"%s\",\"format\":\"%s\"}", + model_escaped == NULL ? "" : model_escaped, + format_escaped == NULL ? "" : format_escaped); + char* payload = base64_encode((const uint8_t*)payload_json, payload_json == NULL ? 0 : strlen(payload_json)); + char* result = format_string("{\"Payload\":\"%s\",\"Headers\":{\"content-type\":[\"application/json\"]}}", payload == NULL ? "" : payload); + char* out = wrap_ok(result); + free(json); + free(model); + free(format); + free(model_escaped); + free(format_escaped); + free(payload_json); + free(payload); + free(result); + return out; +} + +static char* make_count_tokens_response(const uint8_t* request, size_t request_len) { + char* json = copy_request_string(request, request_len); + char* payload = extract_json_string(json, "Payload"); + size_t decoded_len = 0; + uint8_t* decoded = base64_decode(payload == NULL ? "" : payload, &decoded_len); + long tokens = decoded_len == 0 ? 0 : (long)((decoded_len + 3) / 4); + char* payload_json = format_string("{\"total_tokens\":%ld}", tokens); + char* payload_b64 = base64_encode((const uint8_t*)payload_json, payload_json == NULL ? 0 : strlen(payload_json)); + char* result = format_string("{\"Payload\":\"%s\",\"Headers\":{\"content-type\":[\"application/json\"]}}", payload_b64 == NULL ? "" : payload_b64); + char* out = wrap_ok(result); + free(json); + free(payload); + free(decoded); + free(payload_json); + free(payload_b64); + free(result); + return out; +} + +static char* make_http_response(const uint8_t* request, size_t request_len) { + char* json = copy_request_string(request, request_len); + char* method = extract_json_string(json, "Method"); + char* url = extract_json_string(json, "URL"); + char* path = extract_json_string(json, "Path"); + char* method_escaped = json_escape(method == NULL ? "GET" : method); + char* target_escaped = json_escape(url != NULL ? url : (path == NULL ? "/plugins/example-c/status" : path)); + char* body_json = format_string( + "{\"plugin\":\"example-c\",\"method\":\"%s\",\"target\":\"%s\"}", + method_escaped == NULL ? "" : method_escaped, + target_escaped == NULL ? "" : target_escaped); + char* body = base64_encode((const uint8_t*)body_json, body_json == NULL ? 0 : strlen(body_json)); + char* result = format_string( + "{\"StatusCode\":200,\"Headers\":{\"content-type\":[\"application/json\"]},\"Body\":\"%s\"}", + body == NULL ? "" : body); + char* out = wrap_ok(result); + free(json); + free(method); + free(url); + free(path); + free(method_escaped); + free(target_escaped); + free(body_json); + free(body); + free(result); + return out; +} + +static char* inject_thinking(const uint8_t* body, size_t body_len, const char* mode, long budget, const char* level) { + char* body_text = (char*)malloc(body_len + 1); + if (body_text == NULL) { + return NULL; + } + memcpy(body_text, body, body_len); + body_text[body_len] = '\0'; + char* mode_escaped = json_escape(mode == NULL ? "" : mode); + char* level_escaped = json_escape(level == NULL ? "" : level); + size_t start = 0; + while (body_text[start] != '\0' && isspace((unsigned char)body_text[start])) { + start++; + } + size_t end = strlen(body_text); + while (end > start && isspace((unsigned char)body_text[end - 1])) { + end--; + } + char* out = NULL; + if (end > start + 1 && body_text[start] == '{' && body_text[end - 1] == '}') { + int has_fields = 0; + for (size_t i = start + 1; i < end - 1; i++) { + if (!isspace((unsigned char)body_text[i])) { + has_fields = 1; + break; + } + } + out = format_string( + "%.*s%s\"plugin_example_thinking\":{\"mode\":\"%s\",\"budget\":%ld,\"level\":\"%s\"}}", + (int)(end - 1 - start), + body_text + start, + has_fields ? "," : "", + mode_escaped == NULL ? "" : mode_escaped, + budget, + level_escaped == NULL ? "" : level_escaped); + } else { + char* escaped_body = json_escape(body_text); + out = format_string( + "{\"original_body\":\"%s\",\"plugin_example_thinking\":{\"mode\":\"%s\",\"budget\":%ld,\"level\":\"%s\"}}", + escaped_body == NULL ? "" : escaped_body, + mode_escaped == NULL ? "" : mode_escaped, + budget, + level_escaped == NULL ? "" : level_escaped); + free(escaped_body); + } + free(body_text); + free(mode_escaped); + free(level_escaped); + return out; +} + +static char* make_thinking_response(const uint8_t* request, size_t request_len) { + char* json = copy_request_string(request, request_len); + char* body_b64 = extract_json_string(json, "Body"); + char* mode = extract_json_string(json, "Mode"); + char* level = extract_json_string(json, "Level"); + long budget = extract_json_int(json, "Budget", 0); + size_t body_len = 0; + uint8_t* body = base64_decode(body_b64 == NULL ? "e30=" : body_b64, &body_len); + char* body_json = inject_thinking(body == NULL ? (const uint8_t*)"{}" : body, body == NULL ? 2 : body_len, mode, budget, level); + char* out_b64 = base64_encode((const uint8_t*)body_json, body_json == NULL ? 0 : strlen(body_json)); + char* result = format_string("{\"Body\":\"%s\"}", out_b64 == NULL ? "" : out_b64); + char* out = wrap_ok(result); + free(json); + free(body_b64); + free(mode); + free(level); + free(body); + free(body_json); + free(out_b64); + free(result); + return out; +} + +static char* make_usage_response(void) { + usage_count++; + char* result = format_string("{\"Count\":%ld}", usage_count); + char* out = wrap_ok(result); + free(result); + return out; +} + +static void write_response(cliproxy_buffer* response, const char* text) { + if (response == NULL || text == NULL) { + return; + } + size_t len = strlen(text); + void* ptr = malloc(len); + if (ptr == NULL) { + response->ptr = NULL; + response->len = 0; + return; + } + memcpy(ptr, text, len); + response->ptr = ptr; + response->len = len; +} + +static int plugin_call(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + if (response != NULL) { + response->ptr = NULL; + response->len = 0; + } + if (method == NULL) { + write_response(response, INVALID_METHOD_RESPONSE); + return 1; + } + const char* static_response = NULL; + char* dynamic_response = NULL; + if (strcmp(method, "plugin.register") == 0 || strcmp(method, "plugin.reconfigure") == 0) { + static_response = REGISTRATION_RESPONSE; + } else if (strcmp(method, "model.register") == 0 || strcmp(method, "model.static") == 0 || strcmp(method, "model.for_auth") == 0) { + static_response = MODEL_RESPONSE; + } else if (strcmp(method, "auth.identifier") == 0 || strcmp(method, "frontend_auth.identifier") == 0 || strcmp(method, "executor.identifier") == 0 || strcmp(method, "thinking.identifier") == 0) { + static_response = IDENTIFIER_RESPONSE; + } else if (strcmp(method, "auth.parse") == 0) { + dynamic_response = make_auth_parse_response(request, request_len); + } else if (strcmp(method, "auth.login.start") == 0) { + static_response = LOGIN_START_RESPONSE; + } else if (strcmp(method, "auth.login.poll") == 0) { + static_response = LOGIN_POLL_RESPONSE; + } else if (strcmp(method, "auth.refresh") == 0) { + dynamic_response = make_auth_refresh_response(request, request_len); + } else if (strcmp(method, "frontend_auth.authenticate") == 0) { + static_response = FRONTEND_AUTH_RESPONSE; + } else if (strcmp(method, "executor.execute") == 0) { + dynamic_response = make_executor_response(request, request_len); + } else if (strcmp(method, "executor.execute_stream") == 0) { + static_response = STREAM_RESPONSE; + } else if (strcmp(method, "executor.count_tokens") == 0) { + dynamic_response = make_count_tokens_response(request, request_len); + } else if (strcmp(method, "executor.http_request") == 0 || strcmp(method, "management.handle") == 0) { + dynamic_response = make_http_response(request, request_len); + } else if (strcmp(method, "request.translate") == 0 || strcmp(method, "request.normalize") == 0 || strcmp(method, "response.translate") == 0 || strcmp(method, "response.normalize_before") == 0 || strcmp(method, "response.normalize_after") == 0) { + dynamic_response = make_payload_echo_response(request, request_len); + } else if (strcmp(method, "thinking.apply") == 0) { + dynamic_response = make_thinking_response(request, request_len); + } else if (strcmp(method, "usage.handle") == 0) { + dynamic_response = make_usage_response(); + } else if (strcmp(method, "command_line.register") == 0) { + static_response = CLI_REGISTER_RESPONSE; + } else if (strcmp(method, "command_line.execute") == 0) { + static_response = CLI_EXECUTE_RESPONSE; + } else if (strcmp(method, "management.register") == 0) { + static_response = MANAGEMENT_REGISTER_RESPONSE; + } else { + static_response = UNKNOWN_METHOD_RESPONSE; + } + write_response(response, dynamic_response != NULL ? dynamic_response : static_response); + free(dynamic_response); + return 0; +} + +static void plugin_free(void* ptr, size_t len) { + (void)len; + free(ptr); +} + +static void plugin_shutdown(void) {} + +CLIPROXY_EXPORT int cliproxy_plugin_init(const cliproxy_host_api* host, cliproxy_plugin_api* plugin) { + if (plugin == NULL) { + return 1; + } + (void)host; + plugin->abi_version = ABI_VERSION; + plugin->call = plugin_call; + plugin->free_buffer = plugin_free; + plugin->shutdown = plugin_shutdown; + return 0; +} diff --git a/examples/plugin/simple/go/go.mod b/examples/plugin/simple/go/go.mod new file mode 100644 index 000000000..7dd60e3f4 --- /dev/null +++ b/examples/plugin/simple/go/go.mod @@ -0,0 +1,7 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/simple/go + +go 1.26.0 + +require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0 + +replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../.. diff --git a/examples/plugin/simple/go/main.go b/examples/plugin/simple/go/main.go new file mode 100644 index 000000000..582cf93ba --- /dev/null +++ b/examples/plugin/simple/go/main.go @@ -0,0 +1,343 @@ +package main + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyPluginFree(void*, size_t); +extern void cliproxyPluginShutdown(void); +*/ +import "C" + +import ( + "encoding/json" + "net/http" + "sync/atomic" + "time" + "unsafe" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +var usageCount atomic.Int64 + +type envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *envelopeError `json:"error,omitempty"` +} + +type envelopeError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type lifecycleRequest struct { + ConfigYAML []byte `json:"config_yaml"` +} + +type registration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata pluginapi.Metadata `json:"metadata"` + Capabilities registrationCapability `json:"capabilities"` +} + +type registrationCapability struct { + ModelRegistrar bool `json:"model_registrar"` + ModelProvider bool `json:"model_provider"` + AuthProvider bool `json:"auth_provider"` + FrontendAuthProvider bool `json:"frontend_auth_provider"` + Executor bool `json:"executor"` + ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"` + ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` + ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` + RequestTranslator bool `json:"request_translator"` + RequestNormalizer bool `json:"request_normalizer"` + ResponseTranslator bool `json:"response_translator"` + ResponseBeforeTranslator bool `json:"response_before_translator"` + ResponseAfterTranslator bool `json:"response_after_translator"` + ThinkingApplier bool `json:"thinking_applier"` + UsagePlugin bool `json:"usage_plugin"` + CommandLinePlugin bool `json:"command_line_plugin"` + ManagementAPI bool `json:"management_api"` +} + +type identifierResponse struct { + Identifier string `json:"identifier"` +} + +type streamResponse struct { + Headers http.Header `json:"headers,omitempty"` + Chunks []pluginapi.ExecutorStreamChunk `json:"chunks,omitempty"` +} + +type managementRegistrationResponse struct { + Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` +} + +func main() {} + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + if plugin == nil { + return 1 + } + plugin.abi_version = C.uint32_t(pluginabi.ABIVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) + return 0 +} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeResponse(response, errorEnvelope("invalid_method", "method is required")) + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleMethod(C.GoString(method), requestBytes) + if errHandle != nil { + writeResponse(response, errorEnvelope("plugin_error", errHandle.Error())) + return 1 + } + writeResponse(response, raw) + return 0 +} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) { + if ptr != nil { + C.free(ptr) + } +} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() {} + +func handleMethod(method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure: + return okEnvelope(exampleRegistration()) + case pluginabi.MethodModelRegister: + return okEnvelope(pluginapi.ModelRegistrationResponse{Provider: "plugin-example", Models: exampleModels()}) + case pluginabi.MethodModelStatic, pluginabi.MethodModelForAuth: + return okEnvelope(pluginapi.ModelResponse{Provider: "plugin-example", Models: exampleModels()}) + case pluginabi.MethodAuthIdentifier: + return okEnvelope(identifierResponse{Identifier: "plugin-example"}) + case pluginabi.MethodAuthParse: + return okEnvelope(pluginapi.AuthParseResponse{Handled: true, Auth: exampleAuthData(request)}) + case pluginabi.MethodAuthLoginStart: + return okEnvelope(pluginapi.AuthLoginStartResponse{ + Provider: "plugin-example", + URL: "https://example.invalid/plugin-login", + State: "example-state", + ExpiresAt: time.Now().Add(5 * time.Minute).UTC(), + }) + case pluginabi.MethodAuthLoginPoll: + return okEnvelope(pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusError, Message: "example plugin has no interactive login"}) + case pluginabi.MethodAuthRefresh: + return okEnvelope(pluginapi.AuthRefreshResponse{Auth: exampleAuthData(request)}) + case pluginabi.MethodFrontendAuthIdentifier: + return okEnvelope(identifierResponse{Identifier: "plugin-example"}) + case pluginabi.MethodFrontendAuthAuthenticate: + return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: true, Principal: "plugin-example"}) + case pluginabi.MethodExecutorIdentifier: + return okEnvelope(identifierResponse{Identifier: "plugin-example"}) + case pluginabi.MethodExecutorExecute: + return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"id":"plugin-example","object":"chat.completion"}`)}) + case pluginabi.MethodExecutorExecuteStream: + return okEnvelope(streamResponse{Chunks: []pluginapi.ExecutorStreamChunk{{Payload: []byte("plugin-example")}}}) + case pluginabi.MethodExecutorCountTokens: + return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"total_tokens":0}`)}) + case pluginabi.MethodExecutorHTTPRequest: + return okEnvelope(pluginapi.ExecutorHTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)}) + case pluginabi.MethodRequestTranslate, pluginabi.MethodRequestNormalize: + return payloadEcho(request) + case pluginabi.MethodResponseTranslate, pluginabi.MethodResponseNormalizeBefore, pluginabi.MethodResponseNormalizeAfter: + return responsePayloadEcho(request) + case pluginabi.MethodThinkingIdentifier: + return okEnvelope(identifierResponse{Identifier: "plugin-example"}) + case pluginabi.MethodThinkingApply: + return applyThinking(request) + case pluginabi.MethodUsageHandle: + usageCount.Add(1) + return okEnvelope(map[string]any{}) + case pluginabi.MethodCommandLineRegister: + return okEnvelope(pluginapi.CommandLineRegistrationResponse{Flags: []pluginapi.CommandLineFlag{{ + Name: "plugin-example-command", + Usage: "Run the example C ABI plugin command", + Type: "bool", + }}}) + case pluginabi.MethodCommandLineExecute: + return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")}) + case pluginabi.MethodManagementRegister: + return okEnvelope(managementRegistrationResponse{Routes: []pluginapi.ManagementRoute{{ + Method: http.MethodGet, + Path: "/plugins/example/status", + Menu: "Example Plugin", + Description: "Shows example plugin status.", + }}}) + case pluginabi.MethodManagementHandle: + return okEnvelope(pluginapi.ManagementResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)}) + default: + return errorEnvelope("unknown_method", "unknown method: "+method), nil + } +} + +func exampleRegistration() registration { + return registration{ + SchemaVersion: pluginabi.SchemaVersion, + Metadata: pluginapi.Metadata{ + Name: "example", + Version: "0.1.0", + Author: "router-for-me", + GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", + Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png", + ConfigFields: []pluginapi.ConfigField{ + {Name: "config1", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Enables the example boolean option."}, + {Name: "config2", Type: pluginapi.ConfigFieldTypeString, Description: "Stores the example string option."}, + {Name: "config3", Type: pluginapi.ConfigFieldTypeInteger, Description: "Stores the example integer option."}, + {Name: "mode", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{"safe", "fast"}, Description: "Selects the example execution mode."}, + }, + }, + Capabilities: registrationCapability{ + ModelRegistrar: true, + ModelProvider: true, + AuthProvider: true, + FrontendAuthProvider: true, + Executor: true, + ExecutorModelScope: pluginapi.ExecutorModelScopeBoth, + ExecutorInputFormats: []string{"chat-completions"}, + ExecutorOutputFormats: []string{"chat-completions"}, + RequestTranslator: true, + RequestNormalizer: true, + ResponseTranslator: true, + ResponseBeforeTranslator: true, + ResponseAfterTranslator: true, + ThinkingApplier: true, + UsagePlugin: true, + CommandLinePlugin: true, + ManagementAPI: true, + }, + } +} + +func exampleModels() []pluginapi.ModelInfo { + return []pluginapi.ModelInfo{{ + ID: "plugin-example-model", + Object: "model", + OwnedBy: "plugin-example", + DisplayName: "Plugin Example Model", + SupportedGenerationMethods: []string{"chat"}, + ContextLength: 8192, + MaxCompletionTokens: 1024, + UserDefined: true, + }} +} + +func exampleAuthData(raw []byte) pluginapi.AuthData { + return pluginapi.AuthData{ + Provider: "plugin-example", + ID: "plugin-example", + FileName: "plugin-example.json", + Label: "Plugin Example", + StorageJSON: append([]byte(nil), raw...), + Metadata: map[string]any{"type": "plugin-example"}, + } +} + +func payloadEcho(raw []byte) ([]byte, error) { + var req pluginapi.RequestTransformRequest + if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil { + return nil, errUnmarshal + } + return okEnvelope(pluginapi.PayloadResponse{Body: req.Body}) +} + +func responsePayloadEcho(raw []byte) ([]byte, error) { + var req pluginapi.ResponseTransformRequest + if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil { + return nil, errUnmarshal + } + return okEnvelope(pluginapi.PayloadResponse{Body: req.Body}) +} + +func applyThinking(raw []byte) ([]byte, error) { + var req pluginapi.ThinkingApplyRequest + if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil { + return nil, errUnmarshal + } + body := map[string]any{} + _ = json.Unmarshal(req.Body, &body) + body["plugin_example_thinking"] = map[string]any{ + "mode": req.Config.Mode, + "budget": req.Config.Budget, + "level": req.Config.Level, + } + out, errMarshal := json.Marshal(body) + if errMarshal != nil { + return nil, errMarshal + } + return okEnvelope(pluginapi.PayloadResponse{Body: out}) +} + +func okEnvelope(v any) ([]byte, error) { + raw, errMarshal := json.Marshal(v) + if errMarshal != nil { + return nil, errMarshal + } + return json.Marshal(envelope{OK: true, Result: raw}) +} + +func errorEnvelope(code, message string) []byte { + raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}}) + return raw +} + +func writeResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} diff --git a/examples/plugin/simple/rust/Cargo.lock b/examples/plugin/simple/rust/Cargo.lock new file mode 100644 index 000000000..79c7ed8e0 --- /dev/null +++ b/examples/plugin/simple/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-simple-rust" +version = "0.1.0" diff --git a/examples/plugin/simple/rust/Cargo.toml b/examples/plugin/simple/rust/Cargo.toml new file mode 100644 index 000000000..ead9d1d79 --- /dev/null +++ b/examples/plugin/simple/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-simple-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/simple/rust/src/lib.rs b/examples/plugin/simple/rust/src/lib.rs new file mode 100644 index 000000000..90fe9bec5 --- /dev/null +++ b/examples/plugin/simple/rust/src/lib.rs @@ -0,0 +1,404 @@ +use std::borrow::Cow; +use std::ffi::CStr; +use std::os::raw::c_char; +use std::ptr; +use std::sync::atomic::{AtomicI64, Ordering}; + +const ABI_VERSION: u32 = 1; +const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static USAGE_COUNT: AtomicI64 = AtomicI64::new(0); + +const REGISTRATION_RESPONSE: &str = r#"{"ok":true,"result":{"schema_version":1,"metadata":{"Name":"example-simple-rust","Version":"0.1.0","Author":"router-for-me","GitHubRepository":"https://github.com/router-for-me/CLIProxyAPI","Logo":"https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png","ConfigFields":[{"Name":"config1","Type":"boolean","Description":"Enables the example boolean option."},{"Name":"config2","Type":"string","Description":"Stores the example string option."},{"Name":"config3","Type":"integer","Description":"Stores the example integer option."},{"Name":"mode","Type":"enum","EnumValues":["safe","fast"],"Description":"Selects the example execution mode."}]},"capabilities":{"model_registrar":true,"model_provider":true,"auth_provider":true,"frontend_auth_provider":true,"executor":true,"executor_model_scope":"both","executor_input_formats":["chat-completions"],"executor_output_formats":["chat-completions"],"request_translator":true,"request_normalizer":true,"response_translator":true,"response_before_translator":true,"response_after_translator":true,"thinking_applier":true,"usage_plugin":true,"command_line_plugin":true,"management_api":true}}}"#; +const MODEL_RESPONSE: &str = r#"{"ok":true,"result":{"Provider":"plugin-example-rust","Models":[{"ID":"plugin-example-rust-model","Object":"model","OwnedBy":"plugin-example-rust","DisplayName":"Plugin Example Rust Model","SupportedGenerationMethods":["chat"],"ContextLength":8192,"MaxCompletionTokens":1024,"UserDefined":true}]}}"#; +const IDENTIFIER_RESPONSE: &str = r#"{"ok":true,"result":{"identifier":"plugin-example-rust"}}"#; +const LOGIN_START_RESPONSE: &str = r#"{"ok":true,"result":{"Provider":"plugin-example-rust","URL":"https://example.invalid/plugin-login","State":"example-state","ExpiresAt":"2030-01-01T00:00:00Z"}}"#; +const LOGIN_POLL_RESPONSE: &str = r#"{"ok":true,"result":{"Status":"error","Message":"example plugin has no interactive login"}}"#; +const FRONTEND_AUTH_RESPONSE: &str = r#"{"ok":true,"result":{"Authenticated":true,"Principal":"plugin-example-rust","Metadata":{"provider":"plugin-example-rust"}}}"#; +const STREAM_RESPONSE: &str = r#"{"ok":true,"result":{"headers":{"content-type":["text/event-stream"]},"chunks":[{"Payload":"cGx1Z2luLWV4YW1wbGUtcnVzdAo="}]}}"#; +const CLI_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Flags":[{"Name":"plugin-example-rust-command","Usage":"Run the example Rust ABI plugin command","Type":"bool"}]}}"#; +const CLI_EXECUTE_RESPONSE: &str = r#"{"ok":true,"result":{"Stdout":"cGx1Z2luIGV4YW1wbGUgcnVzdCBjb21tYW5kCg==","ExitCode":0}}"#; +const MANAGEMENT_REGISTER_RESPONSE: &str = r#"{"ok":true,"result":{"Routes":[{"Method":"GET","Path":"/plugins/example-rust/status","Menu":"Example Rust Plugin","Description":"Shows example Rust plugin status."}]}}"#; +const UNKNOWN_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"unknown_method","message":"unknown method"}}"#; +const INVALID_METHOD_RESPONSE: &str = r#"{"ok":false,"error":{"code":"invalid_method","message":"method is required"}}"#; + +#[repr(C)] +pub struct CliproxyBuffer { + ptr: *mut u8, + len: usize, +} + +type HostCall = unsafe extern "C" fn(*mut std::ffi::c_void, *const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32; +type HostFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize); +type PluginCall = unsafe extern "C" fn(*const c_char, *const u8, usize, *mut CliproxyBuffer) -> i32; +type PluginFree = unsafe extern "C" fn(*mut std::ffi::c_void, usize); +type PluginShutdown = unsafe extern "C" fn(); + +#[repr(C)] +pub struct CliproxyHostApi { + abi_version: u32, + host_ctx: *mut std::ffi::c_void, + call: Option, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +#[no_mangle] +pub extern "C" fn cliproxy_plugin_init(host: *const CliproxyHostApi, plugin: *mut CliproxyPluginApi) -> i32 { + if plugin.is_null() { + return 1; + } + unsafe { + (*plugin).abi_version = ABI_VERSION; + (*plugin).call = Some(plugin_call); + (*plugin).free_buffer = Some(plugin_free); + (*plugin).shutdown = Some(plugin_shutdown); + } + let _ = host; + 0 +} + +unsafe extern "C" fn plugin_call(method: *const c_char, request: *const u8, request_len: usize, response: *mut CliproxyBuffer) -> i32 { + if !response.is_null() { + (*response).ptr = ptr::null_mut(); + (*response).len = 0; + } + if method.is_null() { + write_response(response, INVALID_METHOD_RESPONSE); + return 1; + } + let method = match CStr::from_ptr(method).to_str() { + Ok(value) => value, + Err(_) => { + write_response(response, r#"{"ok":false,"error":{"code":"invalid_method","message":"method is not utf-8"}}"#); + return 1; + } + }; + let request = if request.is_null() || request_len == 0 { + &[] + } else { + std::slice::from_raw_parts(request, request_len) + }; + let response_text = handle_method(method, request); + write_response(response, response_text.as_ref()); + 0 +} + +fn handle_method(method: &str, request: &[u8]) -> Cow<'static, str> { + match method { + "plugin.register" | "plugin.reconfigure" => Cow::Borrowed(REGISTRATION_RESPONSE), + "model.register" | "model.static" | "model.for_auth" => Cow::Borrowed(MODEL_RESPONSE), + "auth.identifier" | "frontend_auth.identifier" | "executor.identifier" | "thinking.identifier" => Cow::Borrowed(IDENTIFIER_RESPONSE), + "auth.parse" => Cow::Owned(make_auth_parse_response(request)), + "auth.login.start" => Cow::Borrowed(LOGIN_START_RESPONSE), + "auth.login.poll" => Cow::Borrowed(LOGIN_POLL_RESPONSE), + "auth.refresh" => Cow::Owned(make_auth_refresh_response(request)), + "frontend_auth.authenticate" => Cow::Borrowed(FRONTEND_AUTH_RESPONSE), + "executor.execute" => Cow::Owned(make_executor_response(request)), + "executor.execute_stream" => Cow::Borrowed(STREAM_RESPONSE), + "executor.count_tokens" => Cow::Owned(make_count_tokens_response(request)), + "executor.http_request" | "management.handle" => Cow::Owned(make_http_response(request)), + "request.translate" | "request.normalize" | "response.translate" | "response.normalize_before" | "response.normalize_after" => Cow::Owned(make_payload_echo_response(request)), + "thinking.apply" => Cow::Owned(make_thinking_response(request)), + "usage.handle" => Cow::Owned(make_usage_response()), + "command_line.register" => Cow::Borrowed(CLI_REGISTER_RESPONSE), + "command_line.execute" => Cow::Borrowed(CLI_EXECUTE_RESPONSE), + "management.register" => Cow::Borrowed(MANAGEMENT_REGISTER_RESPONSE), + _ => Cow::Borrowed(UNKNOWN_METHOD_RESPONSE), + } +} + +fn make_auth_data(request: &[u8]) -> String { + format!( + r#"{{"Provider":"plugin-example-rust","ID":"plugin-example-rust","FileName":"plugin-example-rust.json","Label":"Plugin Example Rust","StorageJSON":"{}","Metadata":{{"type":"plugin-example-rust"}}}}"#, + base64_encode(request), + ) +} + +fn make_auth_parse_response(request: &[u8]) -> String { + wrap_ok(&format!(r#"{{"Handled":true,"Auth":{}}}"#, make_auth_data(request))) +} + +fn make_auth_refresh_response(request: &[u8]) -> String { + wrap_ok(&format!(r#"{{"Auth":{}}}"#, make_auth_data(request))) +} + +fn make_payload_echo_response(request: &[u8]) -> String { + let json = String::from_utf8_lossy(request); + match extract_json_string(&json, "Body") { + Some(body) => wrap_ok(&format!(r#"{{"Body":"{}"}}"#, body)), + None => make_error("invalid_request", "request body field is required"), + } +} + +fn make_executor_response(request: &[u8]) -> String { + let json = String::from_utf8_lossy(request); + let model = extract_json_string(&json, "Model").unwrap_or_else(|| "plugin-example-rust-model".to_string()); + let format = extract_json_string(&json, "Format").unwrap_or_else(|| "chat-completions".to_string()); + let payload = format!( + r#"{{"id":"plugin-example-rust","object":"chat.completion","model":"{}","format":"{}"}}"#, + json_escape(&model), + json_escape(&format), + ); + wrap_ok(&format!( + r#"{{"Payload":"{}","Headers":{{"content-type":["application/json"]}}}}"#, + base64_encode(payload.as_bytes()), + )) +} + +fn make_count_tokens_response(request: &[u8]) -> String { + let json = String::from_utf8_lossy(request); + let payload = extract_json_string(&json, "Payload").unwrap_or_default(); + let decoded = base64_decode(&payload); + let tokens = if decoded.is_empty() { 0 } else { (decoded.len() + 3) / 4 }; + let payload_json = format!(r#"{{"total_tokens":{}}}"#, tokens); + wrap_ok(&format!( + r#"{{"Payload":"{}","Headers":{{"content-type":["application/json"]}}}}"#, + base64_encode(payload_json.as_bytes()), + )) +} + +fn make_http_response(request: &[u8]) -> String { + let json = String::from_utf8_lossy(request); + let method = extract_json_string(&json, "Method").unwrap_or_else(|| "GET".to_string()); + let target = extract_json_string(&json, "URL") + .or_else(|| extract_json_string(&json, "Path")) + .unwrap_or_else(|| "/plugins/example-rust/status".to_string()); + let body = format!( + r#"{{"plugin":"example-rust","method":"{}","target":"{}"}}"#, + json_escape(&method), + json_escape(&target), + ); + wrap_ok(&format!( + r#"{{"StatusCode":200,"Headers":{{"content-type":["application/json"]}},"Body":"{}"}}"#, + base64_encode(body.as_bytes()), + )) +} + +fn make_thinking_response(request: &[u8]) -> String { + let json = String::from_utf8_lossy(request); + let body_b64 = extract_json_string(&json, "Body").unwrap_or_else(|| "e30=".to_string()); + let body = base64_decode(&body_b64); + let mode = extract_json_string(&json, "Mode").unwrap_or_default(); + let level = extract_json_string(&json, "Level").unwrap_or_default(); + let budget = extract_json_int(&json, "Budget").unwrap_or(0); + let rewritten = inject_thinking(&body, &mode, budget, &level); + wrap_ok(&format!(r#"{{"Body":"{}"}}"#, base64_encode(rewritten.as_bytes()))) +} + +fn make_usage_response() -> String { + let count = USAGE_COUNT.fetch_add(1, Ordering::SeqCst) + 1; + wrap_ok(&format!(r#"{{"Count":{}}}"#, count)) +} + +fn inject_thinking(body: &[u8], mode: &str, budget: i64, level: &str) -> String { + let body_text = String::from_utf8_lossy(body); + let trimmed = body_text.trim(); + let thinking = format!( + r#""plugin_example_thinking":{{"mode":"{}","budget":{},"level":"{}"}}"#, + json_escape(mode), + budget, + json_escape(level), + ); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + let inner = &trimmed[1..trimmed.len() - 1]; + if inner.trim().is_empty() { + format!("{{{}}}", thinking) + } else { + format!("{{{},{} }}", inner, thinking) + } + } else { + format!( + r#"{{"original_body":"{}","plugin_example_thinking":{{"mode":"{}","budget":{},"level":"{}"}}}}"#, + json_escape(&body_text), + json_escape(mode), + budget, + json_escape(level), + ) + } +} + +fn wrap_ok(result_json: &str) -> String { + format!(r#"{{"ok":true,"result":{}}}"#, result_json) +} + +fn make_error(code: &str, message: &str) -> String { + format!( + r#"{{"ok":false,"error":{{"code":"{}","message":"{}"}}}}"#, + json_escape(code), + json_escape(message), + ) +} + +fn extract_json_string(json: &str, key: &str) -> Option { + let pattern = format!(r#""{}""#, key); + let bytes = json.as_bytes(); + let mut start = 0; + while let Some(relative) = json[start..].find(&pattern) { + let mut i = start + relative + pattern.len(); + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() || bytes[i] != b':' { + start = i.saturating_add(1); + continue; + } + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() || bytes[i] != b'"' { + start = i.saturating_add(1); + continue; + } + i += 1; + let mut out = Vec::new(); + while i < bytes.len() { + if bytes[i] == b'"' { + return Some(String::from_utf8_lossy(&out).into_owned()); + } + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 1; + match bytes[i] { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + other => out.push(other), + } + } else { + out.push(bytes[i]); + } + i += 1; + } + start = i; + } + None +} + +fn extract_json_int(json: &str, key: &str) -> Option { + let pattern = format!(r#""{}""#, key); + let idx = json.find(&pattern)?; + let bytes = json.as_bytes(); + let mut i = idx + pattern.len(); + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() || bytes[i] != b':' { + return None; + } + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let start = i; + if i < bytes.len() && bytes[i] == b'-' { + i += 1; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + json[start..i].parse().ok() +} + +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if ch.is_control() => out.push(' '), + ch => out.push(ch), + } + } + out +} + +fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(((data.len() + 2) / 3) * 4); + let mut i = 0; + while i < data.len() { + let a = data[i] as u32; + i += 1; + let b = if i < data.len() { data[i] as u32 } else { 0 }; + i += 1; + let c = if i < data.len() { data[i] as u32 } else { 0 }; + i += 1; + let triple = (a << 16) | (b << 8) | c; + out.push(BASE64_TABLE[((triple >> 18) & 0x3F) as usize] as char); + out.push(BASE64_TABLE[((triple >> 12) & 0x3F) as usize] as char); + out.push(BASE64_TABLE[((triple >> 6) & 0x3F) as usize] as char); + out.push(BASE64_TABLE[(triple & 0x3F) as usize] as char); + } + match data.len() % 3 { + 1 => { + out.pop(); + out.pop(); + out.push('='); + out.push('='); + } + 2 => { + out.pop(); + out.push('='); + } + _ => {} + } + out +} + +fn base64_decode(input: &str) -> Vec { + let mut out = Vec::with_capacity((input.len() * 3) / 4); + let mut value: i32 = 0; + let mut bits = -8; + for byte in input.bytes() { + if byte == b'=' { + break; + } + let digit = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => continue, + } as i32; + value = (value << 6) | digit; + bits += 6; + if bits >= 0 { + out.push(((value >> bits) & 0xFF) as u8); + bits -= 8; + } + } + out +} + +unsafe extern "C" fn plugin_free(ptr: *mut std::ffi::c_void, len: usize) { + if !ptr.is_null() { + let _ = Vec::from_raw_parts(ptr as *mut u8, len, len); + } +} + +unsafe extern "C" fn plugin_shutdown() {} + +fn write_response(response: *mut CliproxyBuffer, text: &str) { + if response.is_null() { + return; + } + let mut bytes = text.as_bytes().to_vec(); + let len = bytes.len(); + let ptr = bytes.as_mut_ptr(); + std::mem::forget(bytes); + unsafe { + (*response).ptr = ptr; + (*response).len = len; + } +} diff --git a/examples/plugin/thinking/c/CMakeLists.txt b/examples/plugin/thinking/c/CMakeLists.txt new file mode 100644 index 000000000..5fbe222f9 --- /dev/null +++ b/examples/plugin/thinking/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_thinking_c C) + +add_library(cliproxy_thinking_c SHARED src/plugin.c) +set_target_properties(cliproxy_thinking_c PROPERTIES + OUTPUT_NAME "thinking-c" + PREFIX "" +) diff --git a/examples/plugin/thinking/c/src/plugin.c b/examples/plugin/thinking/c/src/plugin.c new file mode 100644 index 000000000..89e10d6f0 --- /dev/null +++ b/examples/plugin/thinking/c/src/plugin.c @@ -0,0 +1,117 @@ +#include +#include +#include + +#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-thinking-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}}"); + return 0; + } + if (strcmp(method, "plugin.reconfigure") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-thinking-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}}"); + return 0; + } + if (strcmp(method, "thinking.identifier") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-thinking-c\"}}"); + return 0; + } + if (strcmp(method, "thinking.apply") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJ0aGlua2luZ19hcHBsaWVkX2J5IjoiZXhhbXBsZS10aGlua2luZy1jIn0=\"}}"); + 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; +} diff --git a/examples/plugin/thinking/go/go.mod b/examples/plugin/thinking/go/go.mod new file mode 100644 index 000000000..940ed3e18 --- /dev/null +++ b/examples/plugin/thinking/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/thinking/go + +go 1.26 diff --git a/examples/plugin/thinking/go/main.go b/examples/plugin/thinking/go/main.go new file mode 100644 index 000000000..bb16e62f8 --- /dev/null +++ b/examples/plugin/thinking/go/main.go @@ -0,0 +1,175 @@ +package main + +/* +#include +#include + +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-thinking-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}") + case "plugin.reconfigure": + return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-thinking-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}") + case "thinking.identifier": + return okEnvelopeJSON("{\"identifier\":\"example-thinking-go\"}") + case "thinking.apply": + return okEnvelopeJSON("{\"Body\":\"eyJ0aGlua2luZ19hcHBsaWVkX2J5IjoiZXhhbXBsZS10aGlua2luZy1nbyJ9\"}") + 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) + } +} diff --git a/examples/plugin/thinking/rust/Cargo.lock b/examples/plugin/thinking/rust/Cargo.lock new file mode 100644 index 000000000..0b30df7bb --- /dev/null +++ b/examples/plugin/thinking/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-thinking-rust" +version = "0.1.0" diff --git a/examples/plugin/thinking/rust/Cargo.toml b/examples/plugin/thinking/rust/Cargo.toml new file mode 100644 index 000000000..0eacb546a --- /dev/null +++ b/examples/plugin/thinking/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-thinking-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/thinking/rust/src/lib.rs b/examples/plugin/thinking/rust/src/lib.rs new file mode 100644 index 000000000..ab080d887 --- /dev/null +++ b/examples/plugin/thinking/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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-thinking-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-thinking-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-thinking-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"thinking_applier\":true}}}"); 0 },"thinking.identifier" => { write_response(response, "{\"ok\":true,\"result\":{\"identifier\":\"example-thinking-rust\"}}"); 0 },"thinking.apply" => { write_response(response, "{\"ok\":true,\"result\":{\"Body\":\"eyJ0aGlua2luZ19hcHBsaWVkX2J5IjoiZXhhbXBsZS10aGlua2luZy1ydXN0In0=\"}}"); 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); + } + } + } +} diff --git a/examples/plugin/usage/c/CMakeLists.txt b/examples/plugin/usage/c/CMakeLists.txt new file mode 100644 index 000000000..e18b8aca6 --- /dev/null +++ b/examples/plugin/usage/c/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) +project(cliproxy_usage_c C) + +add_library(cliproxy_usage_c SHARED src/plugin.c) +set_target_properties(cliproxy_usage_c PROPERTIES + OUTPUT_NAME "usage-c" + PREFIX "" +) diff --git a/examples/plugin/usage/c/src/plugin.c b/examples/plugin/usage/c/src/plugin.c new file mode 100644 index 000000000..b623170d7 --- /dev/null +++ b/examples/plugin/usage/c/src/plugin.c @@ -0,0 +1,113 @@ +#include +#include +#include + +#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-usage-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}}"); + return 0; + } + if (strcmp(method, "plugin.reconfigure") == 0) { + write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-usage-c\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-c.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}}"); + return 0; + } + if (strcmp(method, "usage.handle") == 0) { + write_response(response, "{\"ok\":true,\"result\":{}}"); + 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; +} diff --git a/examples/plugin/usage/go/go.mod b/examples/plugin/usage/go/go.mod new file mode 100644 index 000000000..fb86bf690 --- /dev/null +++ b/examples/plugin/usage/go/go.mod @@ -0,0 +1,3 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/usage/go + +go 1.26 diff --git a/examples/plugin/usage/go/main.go b/examples/plugin/usage/go/main.go new file mode 100644 index 000000000..80f8197e2 --- /dev/null +++ b/examples/plugin/usage/go/main.go @@ -0,0 +1,173 @@ +package main + +/* +#include +#include + +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-usage-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}") + case "plugin.reconfigure": + return okEnvelopeJSON("{\"schema_version\":1,\"metadata\":{\"Name\":\"example-usage-go\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-go.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}") + case "usage.handle": + return okEnvelopeJSON("{}") + 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) + } +} diff --git a/examples/plugin/usage/rust/Cargo.lock b/examples/plugin/usage/rust/Cargo.lock new file mode 100644 index 000000000..96ca6d8ac --- /dev/null +++ b/examples/plugin/usage/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cliproxy-usage-rust" +version = "0.1.0" diff --git a/examples/plugin/usage/rust/Cargo.toml b/examples/plugin/usage/rust/Cargo.toml new file mode 100644 index 000000000..76c1605a5 --- /dev/null +++ b/examples/plugin/usage/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cliproxy-usage-rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] diff --git a/examples/plugin/usage/rust/src/lib.rs b/examples/plugin/usage/rust/src/lib.rs new file mode 100644 index 000000000..6739318dd --- /dev/null +++ b/examples/plugin/usage/rust/src/lib.rs @@ -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, + free_buffer: Option, +} + +#[repr(C)] +pub struct CliproxyPluginApi { + abi_version: u32, + call: Option, + free_buffer: Option, + shutdown: Option, +} + +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-usage-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}}"); 0 },"plugin.reconfigure" => { write_response(response, "{\"ok\":true,\"result\":{\"schema_version\":1,\"metadata\":{\"Name\":\"example-usage-rust\",\"Version\":\"0.1.0\",\"Author\":\"router-for-me\",\"GitHubRepository\":\"https://github.com/router-for-me/CLIProxyAPI\",\"Logo\":\"https://example.invalid/example-usage-rust.png\",\"ConfigFields\":[]},\"capabilities\":{\"usage_plugin\":true}}}"); 0 },"usage.handle" => { write_response(response, "{\"ok\":true,\"result\":{}}"); 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); + } + } + } +} diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index 4cb44d696..cff9c0639 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -218,13 +218,24 @@ func writeManagementPluginFile(t *testing.T, id string) string { if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil { t.Fatalf("MkdirAll() error = %v", errMkdirAll) } - path := filepath.Join(archDir, id+".so") + path := filepath.Join(archDir, id+managementPluginExtension(runtime.GOOS)) if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) } return root } +func managementPluginExtension(goos string) string { + switch goos { + case "darwin": + return ".dylib" + case "windows": + return ".dll" + default: + return ".so" + } +} + func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig { t.Helper() var item config.PluginInstanceConfig diff --git a/internal/pluginhost/abi.go b/internal/pluginhost/abi.go new file mode 100644 index 000000000..44d75cd52 --- /dev/null +++ b/internal/pluginhost/abi.go @@ -0,0 +1,18 @@ +package pluginhost + +import ( + "context" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" +) + +const pluginHostABIVersion = pluginabi.ABIVersion + +type pluginClient interface { + Call(ctx context.Context, method string, request []byte) ([]byte, error) + Shutdown() +} + +type pluginLoader interface { + Open(path string, host *Host) (pluginClient, error) +} diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go index 4d8c73c07..ac9989818 100644 --- a/internal/pluginhost/adapters.go +++ b/internal/pluginhost/adapters.go @@ -20,6 +20,7 @@ import ( coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + _ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator/builtin" log "github.com/sirupsen/logrus" ) @@ -71,6 +72,54 @@ func executorScopeAllowsOAuthModels(caps pluginapi.Capabilities) bool { return scope == pluginapi.ExecutorModelScopeOAuth || scope == pluginapi.ExecutorModelScopeBoth } +func normalizeExecutorFormats(raw []string) []sdktranslator.Format { + if len(raw) == 0 { + return nil + } + out := make([]sdktranslator.Format, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, item := range raw { + format := normalizeExecutorFormatName(item) + if format == "" { + continue + } + key := format.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, format) + } + return out +} + +func normalizeExecutorFormatName(raw string) sdktranslator.Format { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "none": + return "" + case "chat-completions", "chat_completions", "openai-chat-completions", "openai_chat_completions": + return sdktranslator.FormatOpenAI + case "responses", "openai-responses", "openai_responses": + return sdktranslator.FormatOpenAIResponse + case "anthropic": + return sdktranslator.FormatClaude + default: + return sdktranslator.FromString(strings.TrimSpace(raw)) + } +} + +func executorFormatContains(formats []sdktranslator.Format, target sdktranslator.Format) bool { + if target == "" { + return false + } + for _, format := range formats { + if format == target { + return true + } + } + return false +} + type AuthModelResult struct { Provider string Models []*registry.ModelInfo @@ -665,10 +714,12 @@ func newExecutorAdapterRegistration(h *Host, record capabilityRecord, provider s return executorRegistration{ provider: provider, adapter: &executorAdapter{ - host: h, - pluginID: record.id, - provider: provider, - executor: executor, + host: h, + pluginID: record.id, + provider: provider, + executor: executor, + inputFormats: normalizeExecutorFormats(record.plugin.Capabilities.ExecutorInputFormats), + outputFormats: normalizeExecutorFormats(record.plugin.Capabilities.ExecutorOutputFormats), }, } } @@ -1030,10 +1081,12 @@ func (a *accessAdapter) Authenticate(ctx context.Context, r *http.Request) (resu } type executorAdapter struct { - host *Host - pluginID string - provider string - executor pluginapi.ProviderExecutor + host *Host + pluginID string + provider string + executor pluginapi.ProviderExecutor + inputFormats []sdktranslator.Format + outputFormats []sdktranslator.Format } func (a *executorAdapter) Identifier() string { @@ -1043,6 +1096,208 @@ func (a *executorAdapter) Identifier() string { return a.provider } +type preparedExecutorCall struct { + req coreexecutor.Request + opts coreexecutor.Options + requestedFormat sdktranslator.Format + inputFormat sdktranslator.Format + outputFormat sdktranslator.Format +} + +func (a *executorAdapter) prepareExecutorCall(req coreexecutor.Request, opts coreexecutor.Options) (preparedExecutorCall, error) { + requestedFormat := executorRequestedFormat(req, opts) + inputFormat, errInput := a.selectExecutorInputFormat(requestedFormat) + if errInput != nil { + return preparedExecutorCall{}, errInput + } + outputFormat, errOutput := a.selectExecutorOutputFormat(requestedFormat, inputFormat) + if errOutput != nil { + return preparedExecutorCall{}, errOutput + } + + nativeReq := req + nativeOpts := opts + if requestedFormat != "" && requestedFormat != inputFormat { + nativeReq.Payload = sdktranslator.TranslateRequest(requestedFormat, inputFormat, req.Model, req.Payload, opts.Stream) + } + nativeReq.Format = outputFormat + nativeOpts.SourceFormat = inputFormat + + return preparedExecutorCall{ + req: nativeReq, + opts: nativeOpts, + requestedFormat: requestedFormat, + inputFormat: inputFormat, + outputFormat: outputFormat, + }, nil +} + +func executorRequestedFormat(req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format { + if opts.SourceFormat != "" { + return normalizeExecutorFormatName(opts.SourceFormat.String()) + } + if req.Format != "" { + return normalizeExecutorFormatName(req.Format.String()) + } + return sdktranslator.FormatOpenAI +} + +func (a *executorAdapter) selectExecutorInputFormat(requested sdktranslator.Format) (sdktranslator.Format, error) { + if len(a.inputFormats) == 0 { + return "", fmt.Errorf("plugin executor %s declares no input formats", a.Identifier()) + } + if executorFormatContains(a.inputFormats, requested) { + return requested, nil + } + for _, format := range a.inputFormats { + if requested == "" || sdktranslator.HasRequestTransformer(requested, format) { + return format, nil + } + } + return "", fmt.Errorf("plugin executor %s does not support input format %q", a.Identifier(), requested) +} + +func (a *executorAdapter) selectExecutorOutputFormat(requested, inputFormat sdktranslator.Format) (sdktranslator.Format, error) { + if len(a.outputFormats) == 0 { + return "", fmt.Errorf("plugin executor %s declares no output formats", a.Identifier()) + } + if executorFormatContains(a.outputFormats, requested) { + return requested, nil + } + if executorFormatContains(a.outputFormats, inputFormat) && executorResponseTranslatorExists(inputFormat, requested) { + return inputFormat, nil + } + for _, format := range a.outputFormats { + if requested == "" || executorResponseTranslatorExists(format, requested) { + return format, nil + } + } + return "", fmt.Errorf("plugin executor %s does not support output format %q", a.Identifier(), requested) +} + +func executorResponseTranslatorExists(from, to sdktranslator.Format) bool { + if from == "" || to == "" || from == to { + return true + } + return sdktranslator.HasResponseTransformer(to, from) +} + +func (a *executorAdapter) translateExecutorResponse(ctx context.Context, prepared preparedExecutorCall, payload []byte, stream bool, param *any) []byte { + if prepared.requestedFormat == "" || prepared.outputFormat == prepared.requestedFormat { + return bytes.Clone(payload) + } + originalRequest := prepared.opts.OriginalRequest + if len(originalRequest) == 0 { + originalRequest = prepared.req.Payload + } + if stream { + frames := a.translateExecutorStreamPayload(ctx, prepared, payload, param) + if len(frames) == 0 { + return nil + } + if len(frames) == 1 { + return bytes.Clone(frames[0]) + } + return bytes.Join(frames, nil) + } + return sdktranslator.TranslateNonStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param) +} + +func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, prepared preparedExecutorCall, in <-chan pluginapi.ExecutorStreamChunk) <-chan pluginapi.ExecutorStreamChunk { + if prepared.requestedFormat == "" || prepared.outputFormat == prepared.requestedFormat { + return in + } + if in == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + out := make(chan pluginapi.ExecutorStreamChunk) + go func() { + defer close(out) + var param any + for { + select { + case <-ctx.Done(): + return + case chunk, ok := <-in: + if !ok { + a.emitTranslatedExecutorStreamTail(ctx, prepared, out, ¶m) + return + } + if chunk.Err != nil { + _ = sendExecutorPluginStreamChunk(ctx, out, chunk) + continue + } + frames := a.translateExecutorStreamPayload(ctx, prepared, chunk.Payload, ¶m) + for _, frame := range frames { + if !sendExecutorPluginStreamChunk(ctx, out, pluginapi.ExecutorStreamChunk{Payload: frame}) { + return + } + } + } + } + }() + return out +} + +func (a *executorAdapter) translateExecutorStreamPayload(ctx context.Context, prepared preparedExecutorCall, payload []byte, param *any) [][]byte { + originalRequest := prepared.opts.OriginalRequest + if len(originalRequest) == 0 { + originalRequest = prepared.req.Payload + } + frames := sdktranslator.TranslateStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param) + if executorStreamTranslationFellBack(prepared, payload, frames) { + return nil + } + return frames +} + +func executorStreamTranslationFellBack(prepared preparedExecutorCall, payload []byte, frames [][]byte) bool { + if prepared.requestedFormat == "" || prepared.outputFormat == "" || prepared.outputFormat == prepared.requestedFormat { + return false + } + if len(frames) != 1 || !bytes.Equal(frames[0], payload) { + return false + } + // A plugin executor only reaches this path after host-side response translation + // has been selected. An unchanged single frame is the SDK registry fallback, + // not a valid translated frame to send to the client. + return executorResponseTranslatorExists(prepared.outputFormat, prepared.requestedFormat) +} + +func (a *executorAdapter) emitTranslatedExecutorStreamTail(ctx context.Context, prepared preparedExecutorCall, out chan<- pluginapi.ExecutorStreamChunk, param *any) { + tail := executorStreamDonePayload(prepared.outputFormat) + if len(tail) == 0 { + return + } + frames := a.translateExecutorStreamPayload(ctx, prepared, tail, param) + for _, frame := range frames { + if !sendExecutorPluginStreamChunk(ctx, out, pluginapi.ExecutorStreamChunk{Payload: frame}) { + return + } + } +} + +func executorStreamDonePayload(format sdktranslator.Format) []byte { + switch format { + case sdktranslator.FormatOpenAI: + return []byte("data: [DONE]") + default: + return nil + } +} + +func sendExecutorPluginStreamChunk(ctx context.Context, out chan<- pluginapi.ExecutorStreamChunk, chunk pluginapi.ExecutorStreamChunk) bool { + select { + case out <- pluginapi.ExecutorStreamChunk{Payload: bytes.Clone(chunk.Payload), Err: chunk.Err}: + return true + case <-ctx.Done(): + return false + } +} + func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) { if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) @@ -1055,12 +1310,16 @@ func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req } }() - pluginResp, errExecute := a.executor.Execute(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + prepared, errPrepare := a.prepareExecutorCall(req, opts) + if errPrepare != nil { + return coreexecutor.Response{}, errPrepare + } + pluginResp, errExecute := a.executor.Execute(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts)) if errExecute != nil { return coreexecutor.Response{}, errExecute } return coreexecutor.Response{ - Payload: bytes.Clone(pluginResp.Payload), + Payload: a.translateExecutorResponse(ctx, prepared, pluginResp.Payload, false, nil), Metadata: cloneAnyMap(pluginResp.Metadata), Headers: cloneHeader(pluginResp.Headers), }, nil @@ -1078,13 +1337,17 @@ func (a *executorAdapter) ExecuteStream(ctx context.Context, auth *coreauth.Auth } }() - pluginResp, errExecuteStream := a.executor.ExecuteStream(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + prepared, errPrepare := a.prepareExecutorCall(req, opts) + if errPrepare != nil { + return nil, errPrepare + } + pluginResp, errExecuteStream := a.executor.ExecuteStream(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts)) if errExecuteStream != nil { return nil, errExecuteStream } return &coreexecutor.StreamResult{ Headers: cloneHeader(pluginResp.Headers), - Chunks: mapExecutorStreamChunks(ctx, pluginResp.Chunks), + Chunks: mapExecutorStreamChunks(ctx, a.translateExecutorStreamChunks(ctx, prepared, pluginResp.Chunks)), }, nil } @@ -1173,12 +1436,16 @@ func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth, } }() - pluginResp, errCountTokens := a.executor.CountTokens(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + prepared, errPrepare := a.prepareExecutorCall(req, opts) + if errPrepare != nil { + return coreexecutor.Response{}, errPrepare + } + pluginResp, errCountTokens := a.executor.CountTokens(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts)) if errCountTokens != nil { return coreexecutor.Response{}, errCountTokens } return coreexecutor.Response{ - Payload: bytes.Clone(pluginResp.Payload), + Payload: a.translateExecutorResponse(ctx, prepared, pluginResp.Payload, false, nil), Metadata: cloneAnyMap(pluginResp.Metadata), Headers: cloneHeader(pluginResp.Headers), }, nil diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go index df73ddd1d..9a22968f3 100644 --- a/internal/pluginhost/adapters_test.go +++ b/internal/pluginhost/adapters_test.go @@ -1793,6 +1793,58 @@ func TestExecutorAdapterMethods(t *testing.T) { } } +func TestExecutorAdapterConsumesTranslatedStreamChunksWithoutOutput(t *testing.T) { + adapter := &executorAdapter{} + request := []byte(`{"model":"qmodel_latest","stream":true,"tool_choice":"auto","parallel_tool_calls":true}`) + prepared := preparedExecutorCall{ + req: coreexecutor.Request{ + Model: "qmodel_latest", + Payload: request, + }, + opts: coreexecutor.Options{ + OriginalRequest: request, + }, + requestedFormat: sdktranslator.FormatOpenAIResponse, + outputFormat: sdktranslator.FormatOpenAI, + } + var param any + + startPayload := []byte(`{"choices":[{"delta":{"content":"","tool_calls":[{"function":{"arguments":"","name":"get_weather"},"id":"call_69755759d70640e3b7a42805","index":0,"type":"function"}]},"index":0}],"created":1780767281,"id":"chatcmpl-ba492ed2-2901-9d1f-80e7-b6dfe97fefaa","model":"auto","object":"chat.completion.chunk"}`) + if got := adapter.translateExecutorStreamPayload(context.Background(), prepared, startPayload, ¶m); len(got) == 0 { + t.Fatal("tool call start payload was not translated") + } + + emptyArgumentsPayload := []byte(`{"choices":[{"delta":{"content":"","tool_calls":[{"function":{"arguments":""},"id":"","index":0,"type":"function"}]},"index":0}],"created":1780767281,"id":"chatcmpl-ba492ed2-2901-9d1f-80e7-b6dfe97fefaa","model":"auto","object":"chat.completion.chunk"}`) + if got := adapter.translateExecutorStreamPayload(context.Background(), prepared, emptyArgumentsPayload, ¶m); len(got) != 0 { + t.Fatalf("empty arguments payload leaked through translation fallback: %q", got[0]) + } + + finishPayload := []byte(`{"choices":[{"delta":{},"finish_reason":"tool_calls","index":0}],"created":1780767281,"id":"chatcmpl-ba492ed2-2901-9d1f-80e7-b6dfe97fefaa","model":"auto","object":"chat.completion.chunk"}`) + if got := adapter.translateExecutorStreamPayload(context.Background(), prepared, finishPayload, ¶m); len(got) == 0 { + t.Fatal("finish payload was not translated") + } + + usagePayload := []byte(`{"choices":[],"created":1780767281,"id":"chatcmpl-ba492ed2-2901-9d1f-80e7-b6dfe97fefaa","model":"auto","object":"chat.completion.chunk","usage":{"completion_tokens":179,"completion_tokens_details":{"reasoning_tokens":121},"prompt_tokens":331,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":510}}`) + if got := adapter.translateExecutorStreamPayload(context.Background(), prepared, usagePayload, ¶m); len(got) != 0 { + t.Fatalf("usage-only payload leaked through translation fallback: %q", got[0]) + } + + donePayload := []byte(`data: [DONE]`) + doneFrames := adapter.translateExecutorStreamPayload(context.Background(), prepared, donePayload, ¶m) + if len(doneFrames) != 1 { + t.Fatalf("done payload translated to %d frames, want 1", len(doneFrames)) + } + if !bytes.Contains(doneFrames[0], []byte("response.completed")) { + t.Fatalf("done payload did not produce response.completed: %q", doneFrames[0]) + } + if !bytes.Contains(doneFrames[0], []byte(`"input_tokens":331`)) || + !bytes.Contains(doneFrames[0], []byte(`"output_tokens":179`)) || + !bytes.Contains(doneFrames[0], []byte(`"reasoning_tokens":121`)) || + !bytes.Contains(doneFrames[0], []byte(`"total_tokens":510`)) { + t.Fatalf("completed payload did not preserve usage: %q", doneFrames[0]) + } +} + func TestExecutorAdapterPanicFusesAndReturnsError(t *testing.T) { host := New() calls := 0 diff --git a/internal/pluginhost/callback_contexts.go b/internal/pluginhost/callback_contexts.go new file mode 100644 index 000000000..b3e07d9f1 --- /dev/null +++ b/internal/pluginhost/callback_contexts.go @@ -0,0 +1,73 @@ +package pluginhost + +import ( + "context" + "strconv" + "sync" + "sync/atomic" +) + +type callbackContextRegistry struct { + next atomic.Uint64 + mu sync.RWMutex + contexts map[string]context.Context +} + +func newCallbackContextRegistry() *callbackContextRegistry { + return &callbackContextRegistry{contexts: make(map[string]context.Context)} +} + +func (r *callbackContextRegistry) open(ctx context.Context) (string, func()) { + if r == nil { + return "", func() {} + } + if ctx == nil { + ctx = context.Background() + } + id := strconv.FormatUint(r.next.Add(1), 10) + r.mu.Lock() + r.contexts[id] = ctx + r.mu.Unlock() + + var once sync.Once + return id, func() { + once.Do(func() { + r.mu.Lock() + delete(r.contexts, id) + r.mu.Unlock() + }) + } +} + +func (r *callbackContextRegistry) resolve(id string, fallback context.Context) context.Context { + if fallback == nil { + fallback = context.Background() + } + if r == nil || id == "" { + return fallback + } + r.mu.RLock() + ctx := r.contexts[id] + r.mu.RUnlock() + if ctx == nil { + return fallback + } + return ctx +} + +func (h *Host) openCallbackContext(ctx context.Context) (string, func()) { + if h == nil || h.callbackContexts == nil { + return "", func() {} + } + return h.callbackContexts.open(ctx) +} + +func (h *Host) resolveCallbackContext(id string, fallback context.Context) context.Context { + if h == nil || h.callbackContexts == nil { + if fallback == nil { + return context.Background() + } + return fallback + } + return h.callbackContexts.resolve(id, fallback) +} diff --git a/internal/pluginhost/client_guard.go b/internal/pluginhost/client_guard.go new file mode 100644 index 000000000..7637bc3aa --- /dev/null +++ b/internal/pluginhost/client_guard.go @@ -0,0 +1,79 @@ +package pluginhost + +import ( + "context" + "fmt" + "sync" +) + +type guardedPluginClient struct { + mu sync.Mutex + cond *sync.Cond + inner pluginClient + calls int + closed bool +} + +func newGuardedPluginClient(inner pluginClient) pluginClient { + client := &guardedPluginClient{inner: inner} + client.cond = sync.NewCond(&client.mu) + return client +} + +func (c *guardedPluginClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) { + inner, errAcquire := c.acquire() + if errAcquire != nil { + return nil, errAcquire + } + defer c.release() + return inner.Call(ctx, method, request) +} + +func (c *guardedPluginClient) acquire() (pluginClient, error) { + if c == nil { + return nil, fmt.Errorf("plugin client is closed") + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.inner == nil { + return nil, fmt.Errorf("plugin client is closed") + } + c.calls++ + return c.inner, nil +} + +func (c *guardedPluginClient) release() { + c.mu.Lock() + c.calls-- + if c.calls == 0 { + c.cond.Broadcast() + } + c.mu.Unlock() +} + +func (c *guardedPluginClient) Shutdown() { + if c == nil { + return + } + + var inner pluginClient + c.mu.Lock() + if c.closed { + for c.calls > 0 { + c.cond.Wait() + } + c.mu.Unlock() + return + } + c.closed = true + for c.calls > 0 { + c.cond.Wait() + } + inner = c.inner + c.inner = nil + c.mu.Unlock() + + if inner != nil { + inner.Shutdown() + } +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 7e39ae221..ba73f9079 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -3,30 +3,27 @@ package pluginhost import ( "context" "fmt" - "reflect" "runtime/debug" "strings" "sync" "sync/atomic" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" log "github.com/sirupsen/logrus" ) -type registerFunc func([]byte) pluginapi.Plugin - type loadedPlugin struct { - id string - path string - registered bool - register registerFunc - reconfigure registerFunc + id string + path string + registered bool + client pluginClient } type Host struct { mu sync.Mutex - loader symbolLoader + loader pluginLoader loaded map[string]*loadedPlugin fused map[string]string runtimeConfig *config.Config @@ -40,12 +37,15 @@ type Host struct { commandLineFlags map[string]commandLineFlagRecord commandLineHits map[string]struct{} managementRoutes map[string]managementRouteRecord + streams *streamBridge + httpStreams *hostHTTPStreamBridge + callbackContexts *callbackContextRegistry snapshot atomic.Value } func New() *Host { h := &Host{ - loader: defaultSymbolLoader(), + loader: defaultPluginLoader(), loaded: make(map[string]*loadedPlugin), fused: make(map[string]string), modelClientIDs: make(map[string]struct{}), @@ -58,12 +58,15 @@ func New() *Host { commandLineFlags: make(map[string]commandLineFlagRecord), commandLineHits: make(map[string]struct{}), managementRoutes: make(map[string]managementRouteRecord), + streams: newStreamBridge(), + httpStreams: newHostHTTPStreamBridge(), + callbackContexts: newCallbackContextRegistry(), } h.snapshot.Store(emptySnapshot()) return h } -func NewForTest(loader symbolLoader) *Host { +func NewForTest(loader pluginLoader) *Host { h := New() h.loader = loader return h @@ -148,51 +151,69 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { } func (h *Host) loadLocked(file pluginFile) (*loadedPlugin, error) { - lookup, errOpen := h.loader.Open(file.Path) + client, errOpen := h.loader.Open(file.Path, h) if errOpen != nil { return nil, errOpen } - rawRegister, errRegister := lookup.Lookup("Register") - if errRegister != nil { - return nil, errRegister - } - register, okRegister := rawRegister.(func([]byte) pluginapi.Plugin) - if !okRegister { - return nil, fmt.Errorf("Register has unsupported signature %s", typeName(rawRegister)) - } - - rawReconfigure, errLookup := lookup.Lookup("Reconfigure") - if errLookup != nil { - return nil, fmt.Errorf("Reconfigure lookup failed: %w", errLookup) - } - reconfigure, okReconfigure := rawReconfigure.(func([]byte) pluginapi.Plugin) - if !okReconfigure { - return nil, fmt.Errorf("Reconfigure has unsupported signature %s", typeName(rawReconfigure)) - } - return &loadedPlugin{ - id: file.ID, - path: file.Path, - register: register, - reconfigure: reconfigure, + id: file.ID, + path: file.Path, + client: newGuardedPluginClient(client), }, nil } +// ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries. +func (h *Host) ShutdownAll() { + if h == nil { + return + } + + clients := make([]pluginClient, 0) + h.mu.Lock() + for _, lp := range h.loaded { + if lp == nil || lp.client == nil { + continue + } + clients = append(clients, lp.client) + } + h.loaded = make(map[string]*loadedPlugin) + h.modelClientIDs = make(map[string]struct{}) + h.executorModelClientIDs = make(map[string]struct{}) + h.modelProviders = make(map[string]string) + h.modelRegistrations = make(map[string]pluginModelRegistration) + h.providerModels = make(map[string][]*registryModelInfo) + h.executorProviders = make(map[string]struct{}) + h.commandLineFlags = make(map[string]commandLineFlagRecord) + h.commandLineHits = make(map[string]struct{}) + h.managementRoutes = make(map[string]managementRouteRecord) + h.snapshot.Store(emptySnapshot()) + h.mu.Unlock() + + h.refreshThinkingProviders(nil) + h.RegisterFrontendAuthProviders() + for _, client := range clients { + client.Shutdown() + } +} + func (h *Host) callRegisterLocked(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) { if lp == nil { return pluginapi.Plugin{}, false } - method := "Register" - fn := lp.register + method := pluginabi.MethodPluginRegister if lp.registered { - method = "Reconfigure" - fn = lp.reconfigure + method = pluginabi.MethodPluginReconfigure } plugin, okCall := h.safePluginCallLocked(ctx, lp.id, method, func() pluginapi.Plugin { - return fn(item.ConfigYAML) + plugin, errRegister := registerRPCPlugin(ctx, h, lp.id, lp.client, method, item.ConfigYAML) + if errRegister != nil { + log.Warnf("pluginhost: plugin %s %s failed: %v", lp.id, method, errRegister) + return pluginapi.Plugin{} + } + return plugin }) if !okCall { return pluginapi.Plugin{}, false @@ -256,8 +277,5 @@ func validPlugin(plugin pluginapi.Plugin) bool { } func typeName(v any) string { - if v == nil { - return "" - } - return reflect.TypeOf(v).String() + return fmt.Sprintf("%T", v) } diff --git a/internal/pluginhost/host_callbacks.go b/internal/pluginhost/host_callbacks.go new file mode 100644 index 000000000..ab76256b1 --- /dev/null +++ b/internal/pluginhost/host_callbacks.go @@ -0,0 +1,244 @@ +package pluginhost + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + log "github.com/sirupsen/logrus" +) + +type rpcHostHTTPRequest struct { + HTTPClientID string `json:"http_client_id,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Headers httpHeader `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` + Request *httpRequest `json:"request,omitempty"` +} + +type httpHeader map[string][]string + +type httpRequest struct { + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Headers httpHeader `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type rpcHostHTTPStreamResponse struct { + StatusCode int `json:"status_code"` + Headers httpHeader `json:"headers,omitempty"` + StreamID string `json:"stream_id,omitempty"` + Chunks []pluginapi.HTTPStreamChunk `json:"chunks,omitempty"` +} + +type rpcHostHTTPStreamReadRequest struct { + StreamID string `json:"stream_id"` +} + +type rpcHostHTTPStreamReadResponse struct { + Payload []byte `json:"payload,omitempty"` + Error string `json:"error,omitempty"` + Done bool `json:"done,omitempty"` +} + +type rpcHostHTTPStreamCloseRequest struct { + StreamID string `json:"stream_id"` +} + +type rpcHostLogRequest struct { + HostCallbackID string `json:"host_callback_id,omitempty"` + Level string `json:"level,omitempty"` + Message string `json:"message,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +func (h *Host) callFromPlugin(ctx context.Context, method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodHostHTTPDo: + return h.callHostHTTPDo(ctx, request) + case pluginabi.MethodHostHTTPDoStream: + return h.callHostHTTPDoStream(ctx, request) + case pluginabi.MethodHostHTTPStreamRead: + return h.callHostHTTPStreamRead(ctx, request) + case pluginabi.MethodHostHTTPStreamClose: + return h.callHostHTTPStreamClose(request) + case pluginabi.MethodHostStreamEmit: + return h.callHostStreamEmit(ctx, request) + case pluginabi.MethodHostStreamClose: + return h.callHostStreamClose(request) + case pluginabi.MethodHostLog: + return h.callHostLog(ctx, request) + default: + return nil, fmt.Errorf("unsupported host callback %s", method) + } +} + +func (h *Host) callHostHTTPDo(ctx context.Context, request []byte) ([]byte, error) { + httpReq, callbackID, errDecode := decodeHostHTTPRequestWithCallbackID(request) + if errDecode != nil { + return nil, errDecode + } + ctx = h.resolveCallbackContext(callbackID, ctx) + resp, errDo := h.newHTTPClient(nil).Do(ctx, httpReq) + if errDo != nil { + return nil, errDo + } + return marshalRPCResult(resp) +} + +func (h *Host) callHostHTTPDoStream(ctx context.Context, request []byte) ([]byte, error) { + httpReq, callbackID, errDecode := decodeHostHTTPRequestWithCallbackID(request) + if errDecode != nil { + return nil, errDecode + } + ctx = h.resolveCallbackContext(callbackID, ctx) + if ctx == nil { + ctx = context.Background() + } + streamCtx, cancel := context.WithCancel(ctx) + resp, errDo := h.newHTTPClient(nil).DoStream(streamCtx, httpReq) + if errDo != nil { + cancel() + return nil, errDo + } + streamID := "" + if h != nil && h.httpStreams != nil { + streamID = h.httpStreams.open(resp.Chunks, cancel) + } + if streamID == "" { + cancel() + return nil, fmt.Errorf("host http stream bridge is unavailable") + } + return marshalRPCResult(rpcHostHTTPStreamResponse{ + StatusCode: resp.StatusCode, + Headers: httpHeader(resp.Headers), + StreamID: streamID, + }) +} + +func (h *Host) callHostHTTPStreamRead(ctx context.Context, request []byte) ([]byte, error) { + var req rpcHostHTTPStreamReadRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host http stream read request: %w", errUnmarshal) + } + if h == nil || h.httpStreams == nil { + return nil, fmt.Errorf("host http stream bridge is unavailable") + } + chunk, done, errRead := h.httpStreams.read(ctx, req.StreamID) + if errRead != nil { + return nil, errRead + } + resp := rpcHostHTTPStreamReadResponse{ + Payload: append([]byte(nil), chunk.Payload...), + Done: done, + } + if chunk.Err != nil { + resp.Error = chunk.Err.Error() + resp.Done = true + } + return marshalRPCResult(resp) +} + +func (h *Host) callHostHTTPStreamClose(request []byte) ([]byte, error) { + var req rpcHostHTTPStreamCloseRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host http stream close request: %w", errUnmarshal) + } + if h != nil && h.httpStreams != nil { + h.httpStreams.close(req.StreamID) + } + return marshalRPCResult(rpcEmptyResponse{}) +} + +func decodeHostHTTPRequest(raw []byte) (pluginapi.HTTPRequest, error) { + httpReq, _, errDecode := decodeHostHTTPRequestWithCallbackID(raw) + return httpReq, errDecode +} + +func decodeHostHTTPRequestWithCallbackID(raw []byte) (pluginapi.HTTPRequest, string, error) { + var req rpcHostHTTPRequest + if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil { + return pluginapi.HTTPRequest{}, "", fmt.Errorf("decode host http request: %w", errUnmarshal) + } + if req.Request != nil { + return pluginapi.HTTPRequest{ + Method: req.Request.Method, + URL: req.Request.URL, + Headers: map[string][]string(req.Request.Headers), + Body: append([]byte(nil), req.Request.Body...), + }, req.HostCallbackID, nil + } + return pluginapi.HTTPRequest{ + Method: req.Method, + URL: req.URL, + Headers: map[string][]string(req.Headers), + Body: append([]byte(nil), req.Body...), + }, req.HostCallbackID, nil +} + +func (h *Host) callHostStreamEmit(ctx context.Context, request []byte) ([]byte, error) { + var req rpcStreamEmitRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode stream emit request: %w", errUnmarshal) + } + chunk := pluginapi.ExecutorStreamChunk{Payload: append([]byte(nil), req.Payload...)} + if req.Error != "" { + chunk.Err = fmt.Errorf("%s", req.Error) + } + if errEmit := h.streams.emit(ctx, req.StreamID, chunk); errEmit != nil { + return nil, errEmit + } + return marshalRPCResult(rpcEmptyResponse{}) +} + +func (h *Host) callHostStreamClose(request []byte) ([]byte, error) { + var req rpcStreamCloseRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode stream close request: %w", errUnmarshal) + } + h.streams.close(req.StreamID, req.Error) + return marshalRPCResult(rpcEmptyResponse{}) +} + +func (h *Host) callHostLog(ctx context.Context, request []byte) ([]byte, error) { + var req rpcHostLogRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, fmt.Errorf("decode host log request: %w", errUnmarshal) + } + ctx = h.resolveCallbackContext(req.HostCallbackID, ctx) + message := strings.TrimSpace(req.Message) + if message == "" { + message = "plugin log" + } + fields := log.Fields{} + for key, value := range req.Fields { + key = strings.TrimSpace(key) + if key != "" { + fields[key] = value + } + } + if requestID := logging.GetRequestID(ctx); requestID != "" { + fields["request_id"] = requestID + } + entry := log.WithFields(fields) + switch strings.ToLower(strings.TrimSpace(req.Level)) { + case "trace": + entry.Trace(message) + case "info": + entry.Info(message) + case "warn", "warning": + entry.Warn(message) + case "error": + entry.Error(message) + default: + entry.Debug(message) + } + return marshalRPCResult(rpcEmptyResponse{}) +} diff --git a/internal/pluginhost/host_callbacks_test.go b/internal/pluginhost/host_callbacks_test.go new file mode 100644 index 000000000..50e58c760 --- /dev/null +++ b/internal/pluginhost/host_callbacks_test.go @@ -0,0 +1,215 @@ +package pluginhost + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +func TestHostHTTPDoCallbackUsesHostHTTPClient(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + w.Header().Set("X-Test", "ok") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + req := pluginapi.HTTPRequest{ + Method: http.MethodPost, + URL: server.URL, + Body: []byte(`{"request":true}`), + } + rawReq, errMarshal := json.Marshal(req) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + + rawResp, errCall := New().callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDo, rawReq) + if errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + + resp, errDecode := decodeRPCEnvelope[pluginapi.HTTPResponse](rawResp) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if resp.StatusCode != http.StatusOK || string(resp.Body) != `{"ok":true}` { + t.Fatalf("response = %#v, want status 200 body", resp) + } + if resp.Headers.Get("X-Test") != "ok" { + t.Fatalf("X-Test = %q, want ok", resp.Headers.Get("X-Test")) + } +} + +func TestHostHTTPDoCallbackRestoresRegisteredRequestContext(t *testing.T) { + gin.SetMode(gin.TestMode) + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx := context.WithValue(context.Background(), "gin", ginCtx) + + host := New() + host.mu.Lock() + host.runtimeConfig = &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}} + host.mu.Unlock() + callbackID, closeCallback := host.openCallbackContext(ctx) + defer closeCallback() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Context().Err() != nil { + t.Fatalf("request context error = %v", r.Context().Err()) + } + w.Header().Set("X-Upstream", "ok") + _, _ = w.Write([]byte("upstream-body")) + })) + defer server.Close() + + rawReq, errMarshal := json.Marshal(rpcHostHTTPRequest{ + HostCallbackID: callbackID, + Method: http.MethodPost, + URL: server.URL, + Body: []byte(`{"request":true}`), + }) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDo, rawReq); errCall != nil { + t.Fatalf("callFromPlugin() error = %v", errCall) + } + + rawAPIRequest, okRequest := ginCtx.Get("API_REQUEST") + if !okRequest { + t.Fatal("API_REQUEST was not captured on the original Gin context") + } + apiRequest, _ := rawAPIRequest.([]byte) + if !bytes.Contains(apiRequest, []byte("=== API REQUEST 1 ===")) || !bytes.Contains(apiRequest, []byte(`{"request":true}`)) { + t.Fatalf("API_REQUEST = %q, want upstream request details", apiRequest) + } + + rawAPIResponse, okResponse := ginCtx.Get("API_RESPONSE") + if !okResponse { + t.Fatal("API_RESPONSE was not captured on the original Gin context") + } + apiResponse, _ := rawAPIResponse.([]byte) + if !bytes.Contains(apiResponse, []byte("=== API RESPONSE 1 ===")) || !bytes.Contains(apiResponse, []byte("upstream-body")) { + t.Fatalf("API_RESPONSE = %q, want upstream response details", apiResponse) + } +} + +func TestHostHTTPDoStreamCallbackReturnsBeforeUpstreamCompletes(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("first")) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-release + _, _ = w.Write([]byte("second")) + })) + defer server.Close() + defer close(release) + + rawReq, errMarshal := json.Marshal(pluginapi.HTTPRequest{ + Method: http.MethodGet, + URL: server.URL, + }) + if errMarshal != nil { + t.Fatalf("marshal request: %v", errMarshal) + } + + type callResult struct { + raw []byte + err error + } + done := make(chan callResult, 1) + host := New() + go func() { + rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDoStream, rawReq) + done <- callResult{raw: rawResp, err: errCall} + }() + + var result callResult + select { + case result = <-done: + case <-time.After(time.Second): + t.Fatal("host.http.do_stream waited for the whole upstream response") + } + if result.err != nil { + t.Fatalf("callFromPlugin() error = %v", result.err) + } + + resp, errDecode := decodeRPCEnvelope[rpcHostHTTPStreamResponse](result.raw) + if errDecode != nil { + t.Fatalf("decode response: %v", errDecode) + } + if resp.StreamID == "" { + t.Fatalf("stream id is empty: %#v", resp) + } + readReq, errMarshal := json.Marshal(rpcHostHTTPStreamReadRequest{StreamID: resp.StreamID}) + if errMarshal != nil { + t.Fatalf("marshal read request: %v", errMarshal) + } + rawRead, errRead := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPStreamRead, readReq) + if errRead != nil { + t.Fatalf("read callback error = %v", errRead) + } + chunk, errDecode := decodeRPCEnvelope[rpcHostHTTPStreamReadResponse](rawRead) + if errDecode != nil { + t.Fatalf("decode read response: %v", errDecode) + } + if string(chunk.Payload) != "first" || chunk.Done || chunk.Error != "" { + t.Fatalf("read chunk = %#v, want first payload", chunk) + } + + closeReq, errMarshal := json.Marshal(rpcHostHTTPStreamCloseRequest{StreamID: resp.StreamID}) + if errMarshal != nil { + t.Fatalf("marshal close request: %v", errMarshal) + } + if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPStreamClose, closeReq); errClose != nil { + t.Fatalf("close callback error = %v", errClose) + } +} + +func TestHostStreamCallbacksEmitAndClose(t *testing.T) { + host := New() + streamID, chunks, cleanup := host.streams.open(context.Background()) + defer cleanup() + + emitReq, errMarshal := json.Marshal(rpcStreamEmitRequest{StreamID: streamID, Payload: []byte("chunk")}) + if errMarshal != nil { + t.Fatalf("marshal emit request: %v", errMarshal) + } + if _, errEmit := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamEmit, emitReq); errEmit != nil { + t.Fatalf("emit callback error = %v", errEmit) + } + + closeReq, errMarshal := json.Marshal(rpcStreamCloseRequest{StreamID: streamID}) + if errMarshal != nil { + t.Fatalf("marshal close request: %v", errMarshal) + } + if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamClose, closeReq); errClose != nil { + t.Fatalf("close callback error = %v", errClose) + } + + chunk, ok := <-chunks + if !ok { + t.Fatalf("stream closed before chunk") + } + if string(chunk.Payload) != "chunk" || chunk.Err != nil { + t.Fatalf("chunk = %#v, want payload chunk", chunk) + } + if _, ok = <-chunks; ok { + t.Fatalf("stream remains open after close") + } +} diff --git a/internal/pluginhost/host_callbacks_unix.go b/internal/pluginhost/host_callbacks_unix.go new file mode 100644 index 000000000..1f624cd2c --- /dev/null +++ b/internal/pluginhost/host_callbacks_unix.go @@ -0,0 +1,64 @@ +//go:build cgo && (linux || darwin || freebsd) + +package pluginhost + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; +*/ +import "C" + +import ( + "context" + "unsafe" +) + +//export cliproxyHostCall +func cliproxyHostCall(hostCtx unsafe.Pointer, 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 hostCtx == nil || method == nil { + return 1 + } + id := uintptr(*(*C.uintptr_t)(hostCtx)) + rawHost, okHost := hostCallbackEntries.Load(id) + if !okHost { + return 1 + } + host, okHost := rawHost.(*Host) + if !okHost || host == nil { + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + resp, errCall := host.callFromPlugin(context.Background(), C.GoString(method), requestBytes) + if errCall != nil { + resp = marshalRPCError("host_call_failed", errCall.Error()) + } + if len(resp) == 0 || response == nil { + return 0 + } + ptr := C.CBytes(resp) + if ptr == nil { + return 1 + } + response.ptr = ptr + response.len = C.size_t(len(resp)) + return 0 +} + +//export cliproxyHostFree +func cliproxyHostFree(ptr unsafe.Pointer, len C.size_t) { + if ptr != nil { + C.free(ptr) + } +} diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go index 19fe7c23a..8569119ef 100644 --- a/internal/pluginhost/host_test.go +++ b/internal/pluginhost/host_test.go @@ -178,7 +178,7 @@ func TestHostApplyConfig_InvalidMetadataOrNoCapabilitiesSkipped(t *testing.T) { registerResult: validTestPlugin("no-caps"), reconfigureResult: validTestPlugin("no-caps"), }) - loader.lookups["no-caps"].symbols["Register"] = func([]byte) pluginapi.Plugin { + loader.lookups["no-caps"].registerOverride = func([]byte) pluginapi.Plugin { return pluginapi.Plugin{Metadata: pluginapi.Metadata{ Name: "no-caps", Version: "1.0.0", diff --git a/internal/pluginhost/http_stream_bridge.go b/internal/pluginhost/http_stream_bridge.go new file mode 100644 index 000000000..48b065384 --- /dev/null +++ b/internal/pluginhost/http_stream_bridge.go @@ -0,0 +1,83 @@ +package pluginhost + +import ( + "context" + "fmt" + "strconv" + "sync" + "sync/atomic" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type hostHTTPStreamBridge struct { + next atomic.Uint64 + mu sync.Mutex + streams map[string]hostHTTPStreamEntry +} + +type hostHTTPStreamEntry struct { + chunks <-chan pluginapi.HTTPStreamChunk + cancel context.CancelFunc +} + +func newHostHTTPStreamBridge() *hostHTTPStreamBridge { + return &hostHTTPStreamBridge{streams: make(map[string]hostHTTPStreamEntry)} +} + +func (b *hostHTTPStreamBridge) open(chunks <-chan pluginapi.HTTPStreamChunk, cancel context.CancelFunc) string { + if b == nil || chunks == nil { + if cancel != nil { + cancel() + } + return "" + } + id := strconv.FormatUint(b.next.Add(1), 10) + b.mu.Lock() + b.streams[id] = hostHTTPStreamEntry{chunks: chunks, cancel: cancel} + b.mu.Unlock() + return id +} + +func (b *hostHTTPStreamBridge) read(ctx context.Context, id string) (pluginapi.HTTPStreamChunk, bool, error) { + if b == nil || id == "" { + return pluginapi.HTTPStreamChunk{}, true, fmt.Errorf("http stream id is required") + } + b.mu.Lock() + entry := b.streams[id] + b.mu.Unlock() + if entry.chunks == nil { + return pluginapi.HTTPStreamChunk{}, true, fmt.Errorf("http stream %s is not open", id) + } + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + b.close(id) + return pluginapi.HTTPStreamChunk{}, true, ctx.Err() + case chunk, ok := <-entry.chunks: + if !ok { + b.close(id) + return pluginapi.HTTPStreamChunk{}, true, nil + } + if chunk.Err != nil { + b.close(id) + return chunk, true, nil + } + return chunk, false, nil + } +} + +func (b *hostHTTPStreamBridge) close(id string) { + if b == nil || id == "" { + return + } + b.mu.Lock() + entry := b.streams[id] + delete(b.streams, id) + b.mu.Unlock() + if entry.cancel != nil { + entry.cancel() + } +} diff --git a/internal/pluginhost/loader_plugin.go b/internal/pluginhost/loader_plugin.go deleted file mode 100644 index 421307cd8..000000000 --- a/internal/pluginhost/loader_plugin.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build linux || darwin || freebsd - -package pluginhost - -import "plugin" - -type symbolLoader interface { - Open(path string) (symbolLookup, error) -} - -type symbolLookup interface { - Lookup(name string) (any, error) -} - -type goPluginLoader struct{} - -func (goPluginLoader) Open(path string) (symbolLookup, error) { - opened, errOpen := plugin.Open(path) - if errOpen != nil { - return nil, errOpen - } - return goPluginLookup{plugin: opened}, nil -} - -type goPluginLookup struct { - plugin *plugin.Plugin -} - -func (l goPluginLookup) Lookup(name string) (any, error) { - return l.plugin.Lookup(name) -} - -func defaultSymbolLoader() symbolLoader { - return goPluginLoader{} -} diff --git a/internal/pluginhost/loader_unix.go b/internal/pluginhost/loader_unix.go new file mode 100644 index 000000000..a44ab7e35 --- /dev/null +++ b/internal/pluginhost/loader_unix.go @@ -0,0 +1,229 @@ +//go:build cgo && (linux || darwin || freebsd) + +package pluginhost + +/* +#cgo linux LDFLAGS: -ldl +#cgo freebsd LDFLAGS: -ldl +#include +#include +#include + +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; + +typedef int (*cliproxy_plugin_init_fn)(const cliproxy_host_api*, cliproxy_plugin_api*); + +extern int cliproxyHostCall(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyHostFree(void*, size_t); + +static void* cliproxy_dlopen(const char* path) { + return dlopen(path, RTLD_NOW | RTLD_LOCAL); +} + +static void* cliproxy_dlsym(void* handle, const char* name) { + return dlsym(handle, name); +} + +static const char* cliproxy_dlerror(void) { + return dlerror(); +} + +static int cliproxy_dlclose(void* handle) { + return dlclose(handle); +} + +static int cliproxy_call_init(void* fn, const cliproxy_host_api* host, cliproxy_plugin_api* plugin) { + return ((cliproxy_plugin_init_fn)fn)(host, plugin); +} + +static int cliproxy_call_plugin(cliproxy_plugin_call_fn fn, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + return fn(method, request, request_len, response); +} + +static void cliproxy_free_plugin_buffer(cliproxy_plugin_free_fn fn, void* ptr, size_t len) { + fn(ptr, len); +} + +static void cliproxy_shutdown_plugin(cliproxy_plugin_shutdown_fn fn) { + fn(); +} + +static void cliproxy_set_host_api(cliproxy_host_api* api, uint32_t abi_version, void* host_ctx) { + api->abi_version = abi_version; + api->host_ctx = host_ctx; + api->call = cliproxyHostCall; + api->free_buffer = cliproxyHostFree; +} + +*/ +import "C" + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "unsafe" +) + +var ( + hostCallbackID atomic.Uintptr + hostCallbackEntries sync.Map +) + +type dynamicLibraryLoader struct{} + +type dynamicLibraryClient struct { + handle unsafe.Pointer + hostAPI *C.cliproxy_host_api + hostCtx unsafe.Pointer + api C.cliproxy_plugin_api +} + +func defaultPluginLoader() pluginLoader { + return dynamicLibraryLoader{} +} + +func (dynamicLibraryLoader) Open(path string, host *Host) (pluginClient, error) { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + handle := C.cliproxy_dlopen(cPath) + if handle == nil { + return nil, fmt.Errorf("dlopen %s: %s", path, dlerrorString()) + } + + cSymbol := C.CString("cliproxy_plugin_init") + initSymbol := C.cliproxy_dlsym(handle, cSymbol) + C.free(unsafe.Pointer(cSymbol)) + if initSymbol == nil { + C.cliproxy_dlclose(handle) + return nil, fmt.Errorf("missing cliproxy_plugin_init: %s", dlerrorString()) + } + + hostAPI := (*C.cliproxy_host_api)(C.malloc(C.size_t(unsafe.Sizeof(C.cliproxy_host_api{})))) + if hostAPI == nil { + C.cliproxy_dlclose(handle) + return nil, fmt.Errorf("allocate host api") + } + hostCtx := C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0)))) + if hostCtx == nil { + C.free(unsafe.Pointer(hostAPI)) + C.cliproxy_dlclose(handle) + return nil, fmt.Errorf("allocate host context") + } + id := hostCallbackID.Add(1) + *(*C.uintptr_t)(hostCtx) = C.uintptr_t(id) + hostCallbackEntries.Store(id, host) + C.cliproxy_set_host_api(hostAPI, C.uint32_t(pluginHostABIVersion), hostCtx) + + client := &dynamicLibraryClient{ + handle: handle, + hostAPI: hostAPI, + hostCtx: hostCtx, + } + rc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api) + if rc != 0 { + client.Shutdown() + return nil, fmt.Errorf("cliproxy_plugin_init returned %d", int(rc)) + } + if uint32(client.api.abi_version) != pluginHostABIVersion { + client.Shutdown() + return nil, fmt.Errorf("plugin ABI version %d is not supported", uint32(client.api.abi_version)) + } + if client.api.call == nil || client.api.free_buffer == nil { + client.Shutdown() + return nil, fmt.Errorf("plugin function table is incomplete") + } + return client, nil +} + +func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) { + if c == nil || c.api.call == nil { + return nil, fmt.Errorf("plugin client is closed") + } + if ctx != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + } + + cMethod := C.CString(method) + defer C.free(unsafe.Pointer(cMethod)) + var cRequest unsafe.Pointer + if len(request) > 0 { + cRequest = C.CBytes(request) + defer C.free(cRequest) + } + var response C.cliproxy_buffer + rc := C.cliproxy_call_plugin(c.api.call, cMethod, (*C.uint8_t)(cRequest), C.size_t(len(request)), &response) + var out []byte + if response.ptr != nil && response.len > 0 { + out = C.GoBytes(response.ptr, C.int(response.len)) + } + if response.ptr != nil { + C.cliproxy_free_plugin_buffer(c.api.free_buffer, response.ptr, response.len) + } + if rc != 0 { + return nil, fmt.Errorf("plugin call %s returned %d: %s", method, int(rc), string(out)) + } + return out, nil +} + +func (c *dynamicLibraryClient) Shutdown() { + if c == nil { + return + } + if c.api.shutdown != nil { + C.cliproxy_shutdown_plugin(c.api.shutdown) + c.api.shutdown = nil + } + if c.hostCtx != nil { + id := uintptr(*(*C.uintptr_t)(c.hostCtx)) + hostCallbackEntries.Delete(id) + C.free(c.hostCtx) + c.hostCtx = nil + } + if c.hostAPI != nil { + C.free(unsafe.Pointer(c.hostAPI)) + c.hostAPI = nil + } + if c.handle != nil { + C.cliproxy_dlclose(c.handle) + c.handle = nil + } +} + +func dlerrorString() string { + errText := C.cliproxy_dlerror() + if errText == nil { + return "" + } + return C.GoString(errText) +} diff --git a/internal/pluginhost/loader_unsupported.go b/internal/pluginhost/loader_unsupported.go index d1d6c3433..eb2567a2b 100644 --- a/internal/pluginhost/loader_unsupported.go +++ b/internal/pluginhost/loader_unsupported.go @@ -1,23 +1,15 @@ -//go:build !(linux || darwin || freebsd) +//go:build !cgo && !windows package pluginhost import "fmt" -type symbolLoader interface { - Open(path string) (symbolLookup, error) -} - -type symbolLookup interface { - Lookup(name string) (any, error) -} - type unsupportedLoader struct{} -func (unsupportedLoader) Open(path string) (symbolLookup, error) { - return nil, fmt.Errorf("go plugin loading is not supported on this platform") +func (unsupportedLoader) Open(path string, host *Host) (pluginClient, error) { + return nil, fmt.Errorf("standard dynamic library plugin loading requires cgo on this platform: %s", path) } -func defaultSymbolLoader() symbolLoader { +func defaultPluginLoader() pluginLoader { return unsupportedLoader{} } diff --git a/internal/pluginhost/loader_windows.go b/internal/pluginhost/loader_windows.go new file mode 100644 index 000000000..61954a164 --- /dev/null +++ b/internal/pluginhost/loader_windows.go @@ -0,0 +1,213 @@ +//go:build windows + +package pluginhost + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "syscall" + "unsafe" +) + +type windowsBuffer struct { + ptr uintptr + len uintptr +} + +type windowsHostAPI struct { + abiVersion uint32 + hostCtx uintptr + call uintptr + freeBuffer uintptr +} + +type windowsPluginAPI struct { + abiVersion uint32 + call uintptr + freeBuffer uintptr + shutdown uintptr +} + +var ( + windowsHostCallbackID atomic.Uintptr + windowsHostCallbackEntries sync.Map + windowsHostCallCallback = syscall.NewCallback(windowsHostCall) + windowsHostFreeCallback = syscall.NewCallback(windowsHostFree) +) + +type dynamicLibraryLoader struct{} + +type dynamicLibraryClient struct { + dll *syscall.DLL + hostAPI *windowsHostAPI + hostCtx *uintptr + api windowsPluginAPI +} + +func defaultPluginLoader() pluginLoader { + return dynamicLibraryLoader{} +} + +func (dynamicLibraryLoader) Open(path string, host *Host) (pluginClient, error) { + dll, errLoad := syscall.LoadDLL(path) + if errLoad != nil { + return nil, errLoad + } + proc, errProc := dll.FindProc("cliproxy_plugin_init") + if errProc != nil { + _ = dll.Release() + return nil, errProc + } + id := windowsHostCallbackID.Add(1) + hostCtx := new(uintptr) + *hostCtx = id + windowsHostCallbackEntries.Store(id, host) + client := &dynamicLibraryClient{ + dll: dll, + hostCtx: hostCtx, + hostAPI: &windowsHostAPI{ + abiVersion: pluginHostABIVersion, + hostCtx: uintptr(unsafe.Pointer(hostCtx)), + call: windowsHostCallCallback, + freeBuffer: windowsHostFreeCallback, + }, + } + rc, _, errCall := proc.Call(uintptr(unsafe.Pointer(client.hostAPI)), uintptr(unsafe.Pointer(&client.api))) + if rc != 0 { + client.Shutdown() + return nil, fmt.Errorf("cliproxy_plugin_init returned %d: %v", rc, errCall) + } + if client.api.abiVersion != pluginHostABIVersion { + client.Shutdown() + return nil, fmt.Errorf("plugin ABI version %d is not supported", client.api.abiVersion) + } + if client.api.call == 0 || client.api.freeBuffer == 0 { + client.Shutdown() + return nil, fmt.Errorf("plugin function table is incomplete") + } + return client, nil +} + +func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) { + if c == nil || c.api.call == 0 { + return nil, fmt.Errorf("plugin client is closed") + } + if ctx != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + } + methodBytes, errMethod := syscall.BytePtrFromString(method) + if errMethod != nil { + return nil, errMethod + } + var requestPtr uintptr + if len(request) > 0 { + requestPtr = uintptr(unsafe.Pointer(&request[0])) + } + var response windowsBuffer + rc, _, _ := syscall.SyscallN( + c.api.call, + uintptr(unsafe.Pointer(methodBytes)), + requestPtr, + uintptr(len(request)), + uintptr(unsafe.Pointer(&response)), + ) + var out []byte + if response.ptr != 0 && response.len > 0 { + out = unsafe.Slice((*byte)(unsafe.Pointer(response.ptr)), response.len) + out = append([]byte(nil), out...) + } + if response.ptr != 0 { + _, _, _ = syscall.SyscallN(c.api.freeBuffer, response.ptr, response.len) + } + if rc != 0 { + return nil, fmt.Errorf("plugin call %s returned %d: %s", method, rc, string(out)) + } + return out, nil +} + +func (c *dynamicLibraryClient) Shutdown() { + if c == nil { + return + } + if c.api.shutdown != 0 { + _, _, _ = syscall.SyscallN(c.api.shutdown) + c.api.shutdown = 0 + } + if c.hostCtx != nil { + windowsHostCallbackEntries.Delete(*c.hostCtx) + c.hostCtx = nil + } + if c.dll != nil { + _ = c.dll.Release() + c.dll = nil + } +} + +func windowsHostCall(hostCtx uintptr, methodPtr uintptr, requestPtr uintptr, requestLen uintptr, responsePtr uintptr) uintptr { + if responsePtr != 0 { + response := (*windowsBuffer)(unsafe.Pointer(responsePtr)) + response.ptr = 0 + response.len = 0 + } + if hostCtx == 0 || methodPtr == 0 { + return 1 + } + id := *(*uintptr)(unsafe.Pointer(hostCtx)) + rawHost, okHost := windowsHostCallbackEntries.Load(id) + if !okHost { + return 1 + } + host, okHost := rawHost.(*Host) + if !okHost || host == nil { + return 1 + } + var request []byte + if requestPtr != 0 && requestLen > 0 { + request = unsafe.Slice((*byte)(unsafe.Pointer(requestPtr)), requestLen) + request = append([]byte(nil), request...) + } + resp, errCall := host.callFromPlugin(context.Background(), windowsString(methodPtr), request) + if errCall != nil { + resp = marshalRPCError("host_call_failed", errCall.Error()) + } + if len(resp) == 0 || responsePtr == 0 { + return 0 + } + mem, errAlloc := syscall.LocalAlloc(0, uint32(len(resp))) + if errAlloc != nil || mem == 0 { + return 1 + } + copy(unsafe.Slice((*byte)(unsafe.Pointer(mem)), len(resp)), resp) + response := (*windowsBuffer)(unsafe.Pointer(responsePtr)) + response.ptr = mem + response.len = uintptr(len(resp)) + return 0 +} + +func windowsHostFree(ptr uintptr, len uintptr) uintptr { + if ptr != 0 { + _, _ = syscall.LocalFree(syscall.Handle(ptr)) + } + return 0 +} + +func windowsString(ptr uintptr) string { + if ptr == 0 { + return "" + } + bytes := make([]byte, 0) + for offset := uintptr(0); ; offset++ { + b := *(*byte)(unsafe.Pointer(ptr + offset)) + if b == 0 { + break + } + bytes = append(bytes, b) + } + return string(bytes) +} diff --git a/internal/pluginhost/platform.go b/internal/pluginhost/platform.go index 25c6e0c25..4ea9b86e6 100644 --- a/internal/pluginhost/platform.go +++ b/internal/pluginhost/platform.go @@ -35,12 +35,26 @@ func validPluginID(id string) bool { func pluginIDFromPath(path string) string { base := filepath.Base(path) - if strings.HasSuffix(strings.ToLower(base), ".so") { - return base[:len(base)-len(".so")] + lowerBase := strings.ToLower(base) + for _, extension := range []string{".so", ".dylib", ".dll"} { + if strings.HasSuffix(lowerBase, extension) { + return base[:len(base)-len(extension)] + } } return base } +func pluginExtension(goos string) string { + switch goos { + case "darwin": + return ".dylib" + case "windows": + return ".dll" + default: + return ".so" + } +} + func selectPluginFiles(root string) ([]pluginFile, error) { root = strings.TrimSpace(root) if root == "" { @@ -48,6 +62,7 @@ func selectPluginFiles(root string) ([]pluginFile, error) { } candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant()) + extension := pluginExtension(runtime.GOOS) selected := make([]pluginFile, 0) seen := make(map[string]struct{}) for _, dir := range candidates { @@ -63,7 +78,7 @@ func selectPluginFiles(root string) ([]pluginFile, error) { if entry == nil || !entry.Type().IsRegular() { continue } - if strings.HasSuffix(strings.ToLower(entry.Name()), ".so") { + if strings.HasSuffix(strings.ToLower(entry.Name()), extension) { files = append(files, filepath.Join(dir, entry.Name())) } } diff --git a/internal/pluginhost/platform_test.go b/internal/pluginhost/platform_test.go index da4657efd..b2f640eb8 100644 --- a/internal/pluginhost/platform_test.go +++ b/internal/pluginhost/platform_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -40,6 +41,39 @@ func TestCandidateDirsOmitsEmptyVariant(t *testing.T) { } } +func TestPluginExtensionForPlatform(t *testing.T) { + cases := []struct { + goos string + want string + }{ + {goos: "linux", want: ".so"}, + {goos: "freebsd", want: ".so"}, + {goos: "darwin", want: ".dylib"}, + {goos: "windows", want: ".dll"}, + } + + for _, tc := range cases { + if got := pluginExtension(tc.goos); got != tc.want { + t.Fatalf("pluginExtension(%q) = %q, want %q", tc.goos, got, tc.want) + } + } +} + +func TestPluginIDFromDynamicLibraryPath(t *testing.T) { + cases := map[string]string{ + "plugins/example.so": "example", + "plugins/example.dylib": "example", + "plugins/example.dll": "example", + "plugins/example.custom": "example.custom", + } + + for path, want := range cases { + if got := pluginIDFromPath(path); got != want { + t.Fatalf("pluginIDFromPath(%q) = %q, want %q", path, got, want) + } + } +} + func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) { root := t.TempDir() archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH) @@ -47,12 +81,13 @@ func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) { t.Fatalf("MkdirAll() error = %v", errMkdirAll) } + extension := pluginExtension(runtime.GOOS) paths := []string{ - filepath.Join(root, "sample.so"), - filepath.Join(archDir, "sample.so"), - filepath.Join(archDir, "bad name.so"), - filepath.Join(archDir, "-bad.so"), - filepath.Join(archDir, "another.SO"), + filepath.Join(root, "sample"+extension), + filepath.Join(archDir, "sample"+extension), + filepath.Join(archDir, "bad name"+extension), + filepath.Join(archDir, "-bad"+extension), + filepath.Join(archDir, "another"+strings.ToUpper(extension)), filepath.Join(archDir, "ignored.txt"), } for _, path := range paths { @@ -60,7 +95,7 @@ func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) } } - if errMkdir := os.Mkdir(filepath.Join(archDir, "dir.so"), 0o755); errMkdir != nil { + if errMkdir := os.Mkdir(filepath.Join(archDir, "dir"+extension), 0o755); errMkdir != nil { t.Fatalf("Mkdir() error = %v", errMkdir) } @@ -70,8 +105,8 @@ func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) { } want := []pluginFile{ - {ID: "another", Path: filepath.Join(archDir, "another.SO")}, - {ID: "sample", Path: filepath.Join(archDir, "sample.so")}, + {ID: "another", Path: filepath.Join(archDir, "another"+strings.ToUpper(extension))}, + {ID: "sample", Path: filepath.Join(archDir, "sample"+extension)}, } if len(files) != len(want) { t.Fatalf("selectPluginFiles() = %v, want %v", files, want) @@ -90,8 +125,9 @@ func TestSelectPluginFilesPrefersPlatformDirOverRootFallback(t *testing.T) { t.Fatalf("MkdirAll() error = %v", errMkdirAll) } - platformPath := filepath.Join(archDir, "alpha.so") - rootPath := filepath.Join(root, "alpha.so") + extension := pluginExtension(runtime.GOOS) + platformPath := filepath.Join(archDir, "alpha"+extension) + rootPath := filepath.Join(root, "alpha"+extension) for _, path := range []string{rootPath, platformPath} { if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) @@ -137,8 +173,9 @@ func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) { } } - genericPath := filepath.Join(archDir, "alpha.so") - variantPath := filepath.Join(variantDir, "alpha.so") + extension := pluginExtension(runtime.GOOS) + genericPath := filepath.Join(archDir, "alpha"+extension) + variantPath := filepath.Join(variantDir, "alpha"+extension) for _, path := range []string{genericPath, variantPath} { if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go new file mode 100644 index 000000000..f8ed06676 --- /dev/null +++ b/internal/pluginhost/rpc_client.go @@ -0,0 +1,404 @@ +package pluginhost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type rpcPluginAdapter struct { + id string + host *Host + client pluginClient +} + +type rpcAuthProvider struct { + *rpcPluginAdapter +} + +type rpcFrontendAuthProvider struct { + *rpcPluginAdapter +} + +type rpcProviderExecutor struct { + *rpcPluginAdapter +} + +type rpcThinkingApplier struct { + *rpcPluginAdapter +} + +type rpcResponseNormalizer struct { + *rpcPluginAdapter + method string +} + +func registerRPCPlugin(ctx context.Context, host *Host, id string, client pluginClient, method string, configYAML []byte) (pluginapi.Plugin, error) { + if client == nil { + return pluginapi.Plugin{}, fmt.Errorf("plugin client is nil") + } + resp, errCall := callPlugin[rpcRegistration](ctx, client, method, rpcLifecycleRequest{ConfigYAML: bytes.Clone(configYAML)}) + if errCall != nil { + return pluginapi.Plugin{}, errCall + } + adapter := &rpcPluginAdapter{id: id, host: host, client: client} + plugin := pluginapi.Plugin{ + Metadata: resp.Metadata, + Capabilities: pluginapi.Capabilities{ + ExecutorModelScope: resp.Capabilities.ExecutorModelScope, + ExecutorInputFormats: append([]string(nil), resp.Capabilities.ExecutorInputFormats...), + ExecutorOutputFormats: append([]string(nil), resp.Capabilities.ExecutorOutputFormats...), + }, + } + if resp.Capabilities.ModelRegistrar { + plugin.Capabilities.ModelRegistrar = adapter + } + if resp.Capabilities.ModelProvider { + plugin.Capabilities.ModelProvider = adapter + } + if resp.Capabilities.AuthProvider { + plugin.Capabilities.AuthProvider = rpcAuthProvider{rpcPluginAdapter: adapter} + } + if resp.Capabilities.FrontendAuthProvider { + plugin.Capabilities.FrontendAuthProvider = rpcFrontendAuthProvider{rpcPluginAdapter: adapter} + } + if resp.Capabilities.Executor { + plugin.Capabilities.Executor = rpcProviderExecutor{rpcPluginAdapter: adapter} + } + if resp.Capabilities.RequestTranslator { + plugin.Capabilities.RequestTranslator = adapter + } + if resp.Capabilities.RequestNormalizer { + plugin.Capabilities.RequestNormalizer = adapter + } + if resp.Capabilities.ResponseTranslator { + plugin.Capabilities.ResponseTranslator = adapter + } + if resp.Capabilities.ResponseBeforeTranslator { + plugin.Capabilities.ResponseBeforeTranslator = rpcResponseNormalizer{rpcPluginAdapter: adapter, method: pluginabi.MethodResponseNormalizeBefore} + } + if resp.Capabilities.ResponseAfterTranslator { + plugin.Capabilities.ResponseAfterTranslator = rpcResponseNormalizer{rpcPluginAdapter: adapter, method: pluginabi.MethodResponseNormalizeAfter} + } + if resp.Capabilities.ThinkingApplier { + plugin.Capabilities.ThinkingApplier = rpcThinkingApplier{rpcPluginAdapter: adapter} + } + if resp.Capabilities.UsagePlugin { + plugin.Capabilities.UsagePlugin = adapter + } + if resp.Capabilities.CommandLinePlugin { + plugin.Capabilities.CommandLinePlugin = adapter + } + if resp.Capabilities.ManagementAPI { + plugin.Capabilities.ManagementAPI = adapter + } + return plugin, nil +} + +func callPlugin[T any](ctx context.Context, client pluginClient, method string, request any) (T, error) { + var zero T + rawRequest, errMarshal := json.Marshal(sanitizePluginRequest(request)) + if errMarshal != nil { + return zero, fmt.Errorf("marshal plugin request %s: %w", method, errMarshal) + } + rawResp, errCall := client.Call(ctx, method, rawRequest) + if errCall != nil { + return zero, errCall + } + var envelope pluginabi.Envelope + if errUnmarshal := json.Unmarshal(rawResp, &envelope); errUnmarshal != nil { + return zero, fmt.Errorf("decode plugin envelope %s: %w", method, errUnmarshal) + } + out, errDecode := decodeEnvelopeResult[T](envelope) + if errDecode != nil { + return zero, fmt.Errorf("decode plugin result %s: %w", method, errDecode) + } + return out, nil +} + +func sanitizePluginRequest(request any) any { + switch req := request.(type) { + case pluginapi.AuthLoginStartRequest: + req.HTTPClient = nil + return req + case pluginapi.AuthLoginPollRequest: + req.HTTPClient = nil + return req + case pluginapi.AuthRefreshRequest: + req.HTTPClient = nil + return req + case pluginapi.AuthModelRequest: + req.HTTPClient = nil + return req + case pluginapi.ExecutorRequest: + req.HTTPClient = nil + return req + case pluginapi.ExecutorHTTPRequest: + req.HTTPClient = nil + return req + case rpcExecutorRequest: + req.HTTPClient = nil + return req + default: + return request + } +} + +func decodeRPCEnvelope[T any](raw []byte) (T, error) { + var zero T + var envelope pluginabi.Envelope + if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil { + return zero, errUnmarshal + } + return decodeEnvelopeResult[T](envelope) +} + +func decodeEnvelopeResult[T any](envelope pluginabi.Envelope) (T, error) { + var zero T + if !envelope.OK { + if envelope.Error != nil { + return zero, fmt.Errorf("%s", envelope.Error.Message) + } + return zero, fmt.Errorf("plugin call failed") + } + if len(envelope.Result) == 0 { + return zero, nil + } + var out T + if errDecode := json.Unmarshal(envelope.Result, &out); errDecode != nil { + return zero, errDecode + } + return out, nil +} + +func marshalRPCEnvelope(result json.RawMessage) ([]byte, error) { + if result == nil { + result = json.RawMessage(`{}`) + } + return json.Marshal(pluginabi.Envelope{OK: true, Result: result}) +} + +func marshalRPCError(code, message string) []byte { + raw, _ := json.Marshal(pluginabi.Envelope{ + OK: false, + Error: &pluginabi.Error{ + Code: code, + Message: message, + }, + }) + return raw +} + +func (a *rpcPluginAdapter) openHostCallbackContext(ctx context.Context) (string, func()) { + if a == nil || a.host == nil { + return "", func() {} + } + return a.host.openCallbackContext(ctx) +} + +func (a *rpcPluginAdapter) RegisterModels(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return callPlugin[pluginapi.ModelRegistrationResponse](ctx, a.client, pluginabi.MethodModelRegister, req) +} + +func (a *rpcPluginAdapter) StaticModels(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + return callPlugin[pluginapi.ModelResponse](ctx, a.client, pluginabi.MethodModelStatic, req) +} + +func (a *rpcPluginAdapter) ModelsForAuth(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.ModelResponse](ctx, a.client, pluginabi.MethodModelForAuth, rpcAuthModelRequest{ + AuthModelRequest: req, + HostCallbackID: callbackID, + }) +} + +func callPluginIdentifier(client pluginClient, method string) string { + resp, errCall := callPlugin[rpcIdentifierResponse](context.Background(), client, method, rpcEmptyResponse{}) + if errCall != nil { + return "" + } + return strings.TrimSpace(resp.Identifier) +} + +func (a rpcAuthProvider) Identifier() string { + return callPluginIdentifier(a.client, pluginabi.MethodAuthIdentifier) +} + +func (a rpcFrontendAuthProvider) Identifier() string { + return callPluginIdentifier(a.client, pluginabi.MethodFrontendAuthIdentifier) +} + +func (a rpcProviderExecutor) Identifier() string { + return callPluginIdentifier(a.client, pluginabi.MethodExecutorIdentifier) +} + +func (a rpcThinkingApplier) Identifier() string { + return callPluginIdentifier(a.client, pluginabi.MethodThinkingIdentifier) +} + +func (a *rpcPluginAdapter) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { + return callPlugin[pluginapi.AuthParseResponse](ctx, a.client, pluginabi.MethodAuthParse, req) +} + +func (a *rpcPluginAdapter) StartLogin(ctx context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.AuthLoginStartResponse](ctx, a.client, pluginabi.MethodAuthLoginStart, rpcAuthLoginStartRequest{ + AuthLoginStartRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) PollLogin(ctx context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.AuthLoginPollResponse](ctx, a.client, pluginabi.MethodAuthLoginPoll, rpcAuthLoginPollRequest{ + AuthLoginPollRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) RefreshAuth(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.AuthRefreshResponse](ctx, a.client, pluginabi.MethodAuthRefresh, rpcAuthRefreshRequest{ + AuthRefreshRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + return callPlugin[pluginapi.FrontendAuthResponse](ctx, a.client, pluginabi.MethodFrontendAuthAuthenticate, req) +} + +func (a *rpcPluginAdapter) Execute(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.ExecutorResponse](ctx, a.client, pluginabi.MethodExecutorExecute, rpcExecutorRequest{ + ExecutorRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) ExecuteStream(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) { + if a == nil || a.host == nil || a.host.streams == nil { + return pluginapi.ExecutorStreamResponse{}, fmt.Errorf("plugin stream bridge is unavailable") + } + streamID, chunks, cleanup := a.host.streams.open(ctx) + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + rpcReq := rpcExecutorRequest{ + ExecutorRequest: req, + StreamID: streamID, + HostCallbackID: callbackID, + } + resp, errCall := callPlugin[rpcExecutorStreamResponse](ctx, a.client, pluginabi.MethodExecutorExecuteStream, rpcReq) + if errCall != nil { + cleanup() + return pluginapi.ExecutorStreamResponse{}, errCall + } + if len(resp.Chunks) > 0 { + cleanup() + out := make(chan pluginapi.ExecutorStreamChunk, len(resp.Chunks)) + for _, chunk := range resp.Chunks { + out <- chunk + } + close(out) + return pluginapi.ExecutorStreamResponse{Headers: resp.Headers, Chunks: out}, nil + } + return pluginapi.ExecutorStreamResponse{Headers: resp.Headers, Chunks: chunks}, nil +} + +func (a *rpcPluginAdapter) CountTokens(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.ExecutorResponse](ctx, a.client, pluginabi.MethodExecutorCountTokens, rpcExecutorRequest{ + ExecutorRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) HttpRequest(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.ExecutorHTTPResponse](ctx, a.client, pluginabi.MethodExecutorHTTPRequest, rpcExecutorHTTPRequest{ + ExecutorHTTPRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) TranslateRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodRequestTranslate, req) +} + +func (a *rpcPluginAdapter) NormalizeRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodRequestNormalize, req) +} + +func (a *rpcPluginAdapter) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodResponseTranslate, req) +} + +func (a rpcResponseNormalizer) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return callPlugin[pluginapi.PayloadResponse](ctx, a.client, a.method, req) +} + +func (a rpcThinkingApplier) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) { + callbackID, closeCallback := a.openHostCallbackContext(ctx) + defer closeCallback() + return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodThinkingApply, rpcThinkingApplyRequest{ + ThinkingApplyRequest: req, + HostCallbackID: callbackID, + }) +} + +func (a *rpcPluginAdapter) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) { + _, _ = callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodUsageHandle, record) +} + +func (a *rpcPluginAdapter) RegisterCommandLine(ctx context.Context, req pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) { + return callPlugin[pluginapi.CommandLineRegistrationResponse](ctx, a.client, pluginabi.MethodCommandLineRegister, req) +} + +func (a *rpcPluginAdapter) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) { + return callPlugin[pluginapi.CommandLineExecutionResponse](ctx, a.client, pluginabi.MethodCommandLineExecute, req) +} + +func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) { + resp, errCall := callPlugin[rpcManagementRegistrationResponse](ctx, a.client, pluginabi.MethodManagementRegister, req) + if errCall != nil { + return pluginapi.ManagementRegistrationResponse{}, errCall + } + routes := make([]pluginapi.ManagementRoute, 0, len(resp.Routes)) + for _, route := range resp.Routes { + route.Handler = a + routes = append(routes, route) + } + return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil +} + +func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) { + return callPlugin[pluginapi.ManagementResponse](ctx, a.client, pluginabi.MethodManagementHandle, req) +} + +func httpResponseFromPlugin(resp pluginapi.ExecutorHTTPResponse, req *http.Request) *http.Response { + status := resp.StatusCode + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Header: cloneHeader(resp.Headers), + Body: io.NopCloser(bytes.NewReader(bytes.Clone(resp.Body))), + Request: req, + } +} diff --git a/internal/pluginhost/rpc_schema.go b/internal/pluginhost/rpc_schema.go new file mode 100644 index 000000000..49d227597 --- /dev/null +++ b/internal/pluginhost/rpc_schema.go @@ -0,0 +1,120 @@ +package pluginhost + +import ( + "encoding/json" + "net/http" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type rpcLifecycleRequest struct { + ConfigYAML []byte `json:"config_yaml"` +} + +type rpcRegistration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata pluginapi.Metadata `json:"metadata"` + Capabilities rpcCapabilities `json:"capabilities"` +} + +type rpcCapabilities struct { + ModelRegistrar bool `json:"model_registrar"` + ModelProvider bool `json:"model_provider"` + AuthProvider bool `json:"auth_provider"` + FrontendAuthProvider bool `json:"frontend_auth_provider"` + Executor bool `json:"executor"` + ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"` + ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` + ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` + RequestTranslator bool `json:"request_translator"` + RequestNormalizer bool `json:"request_normalizer"` + ResponseTranslator bool `json:"response_translator"` + ResponseBeforeTranslator bool `json:"response_before_translator"` + ResponseAfterTranslator bool `json:"response_after_translator"` + ThinkingApplier bool `json:"thinking_applier"` + UsagePlugin bool `json:"usage_plugin"` + CommandLinePlugin bool `json:"command_line_plugin"` + ManagementAPI bool `json:"management_api"` +} + +type rpcIdentifierResponse struct { + Identifier string `json:"identifier"` +} + +type rpcExecutorStreamResponse struct { + Headers http.Header `json:"headers,omitempty"` + Chunks []pluginapi.ExecutorStreamChunk `json:"chunks,omitempty"` +} + +type rpcAuthLoginStartRequest struct { + pluginapi.AuthLoginStartRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcAuthLoginPollRequest struct { + pluginapi.AuthLoginPollRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcAuthRefreshRequest struct { + pluginapi.AuthRefreshRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcAuthModelRequest struct { + pluginapi.AuthModelRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcExecutorRequest struct { + pluginapi.ExecutorRequest + StreamID string `json:"stream_id,omitempty"` + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcExecutorHTTPRequest struct { + pluginapi.ExecutorHTTPRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcThinkingApplyRequest struct { + pluginapi.ThinkingApplyRequest + HostCallbackID string `json:"host_callback_id,omitempty"` +} + +type rpcManagementRegistrationResponse struct { + Routes []pluginapi.ManagementRoute `json:"routes,omitempty"` +} + +type rpcEmptyResponse struct{} + +func rpcCapabilitiesFromPlugin(plugin pluginapi.Plugin) rpcCapabilities { + caps := plugin.Capabilities + return rpcCapabilities{ + ModelRegistrar: caps.ModelRegistrar != nil, + ModelProvider: caps.ModelProvider != nil, + AuthProvider: caps.AuthProvider != nil, + FrontendAuthProvider: caps.FrontendAuthProvider != nil, + Executor: caps.Executor != nil, + ExecutorModelScope: normalizedExecutorModelScope(caps), + ExecutorInputFormats: append([]string(nil), caps.ExecutorInputFormats...), + ExecutorOutputFormats: append([]string(nil), caps.ExecutorOutputFormats...), + RequestTranslator: caps.RequestTranslator != nil, + RequestNormalizer: caps.RequestNormalizer != nil, + ResponseTranslator: caps.ResponseTranslator != nil, + ResponseBeforeTranslator: caps.ResponseBeforeTranslator != nil, + ResponseAfterTranslator: caps.ResponseAfterTranslator != nil, + ThinkingApplier: caps.ThinkingApplier != nil, + UsagePlugin: caps.UsagePlugin != nil, + CommandLinePlugin: caps.CommandLinePlugin != nil, + ManagementAPI: caps.ManagementAPI != nil, + } +} + +func marshalRPCResult(v any) ([]byte, error) { + result, errMarshal := json.Marshal(v) + if errMarshal != nil { + return nil, errMarshal + } + return marshalRPCEnvelope(json.RawMessage(result)) +} diff --git a/internal/pluginhost/stream_bridge.go b/internal/pluginhost/stream_bridge.go new file mode 100644 index 000000000..632cc2bc2 --- /dev/null +++ b/internal/pluginhost/stream_bridge.go @@ -0,0 +1,93 @@ +package pluginhost + +import ( + "context" + "fmt" + "strconv" + "sync" + "sync/atomic" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type streamBridge struct { + next atomic.Uint64 + mu sync.Mutex + streams map[string]chan pluginapi.ExecutorStreamChunk +} + +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 newStreamBridge() *streamBridge { + return &streamBridge{streams: make(map[string]chan pluginapi.ExecutorStreamChunk)} +} + +func (b *streamBridge) open(ctx context.Context) (string, <-chan pluginapi.ExecutorStreamChunk, func()) { + if b == nil { + chunks := make(chan pluginapi.ExecutorStreamChunk) + close(chunks) + return "", chunks, func() {} + } + id := strconv.FormatUint(b.next.Add(1), 10) + chunks := make(chan pluginapi.ExecutorStreamChunk, 16) + b.mu.Lock() + b.streams[id] = chunks + b.mu.Unlock() + cleanup := func() { + b.close(id, "") + } + if ctx != nil && ctx.Done() != nil { + go func() { + <-ctx.Done() + b.close(id, ctx.Err().Error()) + }() + } + return id, chunks, cleanup +} + +func (b *streamBridge) emit(ctx context.Context, id string, chunk pluginapi.ExecutorStreamChunk) error { + if b == nil || id == "" { + return fmt.Errorf("stream id is required") + } + b.mu.Lock() + chunks := b.streams[id] + b.mu.Unlock() + if chunks == nil { + return fmt.Errorf("stream %s is not open", id) + } + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return ctx.Err() + case chunks <- chunk: + return nil + } +} + +func (b *streamBridge) close(id string, errorMessage string) { + if b == nil || id == "" { + return + } + b.mu.Lock() + chunks := b.streams[id] + delete(b.streams, id) + b.mu.Unlock() + if chunks == nil { + return + } + if errorMessage != "" { + chunks <- pluginapi.ExecutorStreamChunk{Err: fmt.Errorf("%s", errorMessage)} + } + close(chunks) +} diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go index 2990c158f..321aece63 100644 --- a/internal/pluginhost/test_helpers_test.go +++ b/internal/pluginhost/test_helpers_test.go @@ -9,6 +9,7 @@ import ( "runtime" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" ) @@ -21,7 +22,7 @@ func newTestSymbolLoader() *testSymbolLoader { return &testSymbolLoader{lookups: make(map[string]*testSymbolLookup)} } -func (l *testSymbolLoader) Open(path string) (symbolLookup, error) { +func (l *testSymbolLoader) Open(path string, host *Host) (pluginClient, error) { l.openCalls++ lookup := l.lookups[pluginIDFromPath(path)] if lookup == nil { @@ -31,24 +32,84 @@ func (l *testSymbolLoader) Open(path string) (symbolLookup, error) { } type testSymbolLookup struct { - symbols map[string]any + plugin *testPlugin + active pluginapi.Plugin + registerOverride func([]byte) pluginapi.Plugin + reconfigureOverride func([]byte) pluginapi.Plugin } func newTestSymbolLookup(plugin *testPlugin) *testSymbolLookup { - return &testSymbolLookup{ - symbols: map[string]any{ - "Register": plugin.Register, - "Reconfigure": plugin.Reconfigure, - }, + return &testSymbolLookup{plugin: plugin} +} + +func (l *testSymbolLookup) Call(ctx context.Context, method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodPluginRegister: + return l.callLifecycle(request, false) + case pluginabi.MethodPluginReconfigure: + return l.callLifecycle(request, true) + case pluginabi.MethodThinkingIdentifier: + if l.active.Capabilities.ThinkingApplier == nil { + return nil, fmt.Errorf("missing thinking applier") + } + return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.ThinkingApplier.Identifier()}) + case pluginabi.MethodThinkingApply: + var req pluginapi.ThinkingApplyRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, errUnmarshal + } + resp, errApply := l.active.Capabilities.ThinkingApplier.ApplyThinking(ctx, req) + if errApply != nil { + return nil, errApply + } + return marshalRPCResult(resp) + case pluginabi.MethodAuthIdentifier: + if l.active.Capabilities.AuthProvider == nil { + return nil, fmt.Errorf("missing auth provider") + } + return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.AuthProvider.Identifier()}) + case pluginabi.MethodUsageHandle: + if l.active.Capabilities.UsagePlugin == nil { + return marshalRPCResult(rpcEmptyResponse{}) + } + var record pluginapi.UsageRecord + if errUnmarshal := json.Unmarshal(request, &record); errUnmarshal != nil { + return nil, errUnmarshal + } + l.active.Capabilities.UsagePlugin.HandleUsage(ctx, record) + return marshalRPCResult(rpcEmptyResponse{}) + default: + return nil, fmt.Errorf("missing test method %s", method) } } -func (l *testSymbolLookup) Lookup(name string) (any, error) { - symbol, ok := l.symbols[name] - if !ok { - return nil, fmt.Errorf("missing symbol %s", name) +func (l *testSymbolLookup) Shutdown() {} + +func (l *testSymbolLookup) callLifecycle(request []byte, reload bool) ([]byte, error) { + var req rpcLifecycleRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return nil, errUnmarshal } - return symbol, nil + var plugin pluginapi.Plugin + if reload { + if l.reconfigureOverride != nil { + plugin = l.reconfigureOverride(req.ConfigYAML) + } else { + plugin = l.plugin.Reconfigure(req.ConfigYAML) + } + } else { + if l.registerOverride != nil { + plugin = l.registerOverride(req.ConfigYAML) + } else { + plugin = l.plugin.Register(req.ConfigYAML) + } + } + l.active = plugin + return marshalRPCResult(rpcRegistration{ + SchemaVersion: pluginabi.SchemaVersion, + Metadata: plugin.Metadata, + Capabilities: rpcCapabilitiesFromPlugin(plugin), + }) } type testPlugin struct { @@ -124,7 +185,7 @@ func makePluginDir(t *testing.T, ids ...string) string { t.Fatalf("MkdirAll() error = %v", errMkdirAll) } for _, id := range ids { - path := filepath.Join(archDir, id+".so") + path := filepath.Join(archDir, id+pluginExtension(runtime.GOOS)) if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil { t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile) } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 52f8d990d..de2e604ee 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -333,11 +333,27 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma var config ThinkingConfig if suffixResult.HasSuffix { config = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID) + log.WithFields(log.Fields{ + "provider": toFormat, + "model": modelID, + "mode": config.Mode, + "budget": config.Budget, + "level": config.Level, + }).Debug("thinking: config from model suffix |") } else { config = extractThinkingConfig(body, fromFormat) if !hasThinkingConfig(config) && fromFormat != toFormat { config = extractThinkingConfig(body, toFormat) } + if hasThinkingConfig(config) { + log.WithFields(log.Fields{ + "provider": toFormat, + "model": modelID, + "mode": config.Mode, + "budget": config.Budget, + "level": config.Level, + }).Debug("thinking: original config from request |") + } } if !hasThinkingConfig(config) { @@ -357,15 +373,14 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma return body, nil } + config = normalizeUserDefinedConfig(config, fromFormat, toFormat) log.WithFields(log.Fields{ "provider": toFormat, "model": modelID, "mode": config.Mode, "budget": config.Budget, "level": config.Level, - }).Debug("thinking: applying config for user-defined model (skip validation)") - - config = normalizeUserDefinedConfig(config, fromFormat, toFormat) + }).Debug("thinking: processed config to apply |") return applier.Apply(body, config, modelInfo) } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 159eb7a65..87fb18f8d 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1598,6 +1598,24 @@ func (s *Service) Shutdown(ctx context.Context) error { } } + if s.pluginHost != nil { + sdktranslator.SetPluginHooks(nil) + sdkAuth.RegisterPluginAuthParser(nil) + if s.watcher != nil { + s.watcher.SetPluginAuthParser(nil) + } + s.pluginHost.ApplyConfig(ctx, &config.Config{}) + s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry()) + if s.coreManager != nil { + s.pluginHost.RegisterExecutors(s.coreManager, registry.GetGlobalRegistry()) + } + s.pluginHost.RegisterFrontendAuthProviders() + s.pluginHost.ShutdownAll() + if s.accessManager != nil { + s.accessManager.SetProviders(sdkaccess.RegisteredProviders()) + } + } + usage.StopDefault() }) return shutdownErr diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go new file mode 100644 index 000000000..3d3462aba --- /dev/null +++ b/sdk/pluginabi/types.go @@ -0,0 +1,71 @@ +package pluginabi + +import "encoding/json" + +const ( + ABIVersion uint32 = 1 + SchemaVersion uint32 = 1 +) + +const ( + MethodPluginRegister = "plugin.register" + MethodPluginReconfigure = "plugin.reconfigure" + MethodPluginShutdown = "plugin.shutdown" + + MethodModelRegister = "model.register" + MethodModelStatic = "model.static" + MethodModelForAuth = "model.for_auth" + + MethodAuthIdentifier = "auth.identifier" + MethodAuthParse = "auth.parse" + MethodAuthLoginStart = "auth.login.start" + MethodAuthLoginPoll = "auth.login.poll" + MethodAuthRefresh = "auth.refresh" + + MethodFrontendAuthIdentifier = "frontend_auth.identifier" + MethodFrontendAuthAuthenticate = "frontend_auth.authenticate" + + MethodExecutorIdentifier = "executor.identifier" + MethodExecutorExecute = "executor.execute" + MethodExecutorExecuteStream = "executor.execute_stream" + MethodExecutorCountTokens = "executor.count_tokens" + MethodExecutorHTTPRequest = "executor.http_request" + + MethodRequestTranslate = "request.translate" + MethodRequestNormalize = "request.normalize" + + MethodResponseTranslate = "response.translate" + MethodResponseNormalizeBefore = "response.normalize_before" + MethodResponseNormalizeAfter = "response.normalize_after" + + MethodThinkingIdentifier = "thinking.identifier" + MethodThinkingApply = "thinking.apply" + + MethodUsageHandle = "usage.handle" + + MethodCommandLineRegister = "command_line.register" + MethodCommandLineExecute = "command_line.execute" + + MethodManagementRegister = "management.register" + MethodManagementHandle = "management.handle" + + MethodHostHTTPDo = "host.http.do" + MethodHostHTTPDoStream = "host.http.do_stream" + MethodHostHTTPStreamRead = "host.http.stream_read" + MethodHostHTTPStreamClose = "host.http.stream_close" + MethodHostStreamEmit = "host.stream.emit" + MethodHostStreamClose = "host.stream.close" + MethodHostLog = "host.log" +) + +type Envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable,omitempty"` +} diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go new file mode 100644 index 000000000..ee9cd9ac6 --- /dev/null +++ b/sdk/pluginabi/types_test.go @@ -0,0 +1,42 @@ +package pluginabi + +import ( + "encoding/json" + "testing" +) + +func TestEnvelopeRoundTrip(t *testing.T) { + payload := json.RawMessage(`{"name":"example"}`) + env := Envelope{ + OK: true, + Result: payload, + } + + raw, errMarshal := json.Marshal(env) + if errMarshal != nil { + t.Fatalf("marshal envelope: %v", errMarshal) + } + + var decoded Envelope + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("unmarshal envelope: %v", errUnmarshal) + } + if !decoded.OK || string(decoded.Result) != string(payload) { + t.Fatalf("decoded envelope = %#v, want ok payload", decoded) + } +} + +func TestMethodNamesAreStable(t *testing.T) { + if MethodPluginRegister != "plugin.register" { + t.Fatalf("MethodPluginRegister = %q", MethodPluginRegister) + } + if MethodHostHTTPDo != "host.http.do" { + t.Fatalf("MethodHostHTTPDo = %q", MethodHostHTTPDo) + } + if MethodHostHTTPStreamRead != "host.http.stream_read" { + t.Fatalf("MethodHostHTTPStreamRead = %q", MethodHostHTTPStreamRead) + } + if MethodExecutorExecuteStream != "executor.execute_stream" { + t.Fatalf("MethodExecutorExecuteStream = %q", MethodExecutorExecuteStream) + } +} diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index 9eb59ab39..326d7f646 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -1,4 +1,4 @@ -// Package pluginapi defines the stable ABI used by Go dynamic plugins. +// Package pluginapi defines host-side plugin capability schemas and adapters. package pluginapi import ( @@ -8,7 +8,7 @@ import ( "time" ) -// Plugin is the exported plugin entrypoint returned by dynamic plugin binaries. +// Plugin is the host-side representation produced from a dynamic plugin registration. type Plugin struct { // Metadata identifies the plugin binary and its published source. Metadata Metadata @@ -79,6 +79,10 @@ type Capabilities struct { // ExecutorModelScope declares whether Executor serves static models, OAuth auth models, or both. // Empty defaults to ExecutorModelScopeBoth for backward compatibility. ExecutorModelScope ExecutorModelScope + // ExecutorInputFormats lists request protocols accepted directly by Executor. Executors must declare at least one. + ExecutorInputFormats []string + // ExecutorOutputFormats lists response protocols emitted directly by Executor. Executors must declare at least one. + ExecutorOutputFormats []string // RequestTranslator converts canonical requests into provider-specific payloads. RequestTranslator RequestTranslator // RequestNormalizer converts provider-specific requests into canonical payloads. @@ -255,7 +259,7 @@ type AuthLoginStartRequest struct { // Host contains relevant host configuration. Host HostConfigSummary // HTTPClient executes upstream HTTP requests through host transport policy. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` // Metadata carries plugin-defined login context. Metadata map[string]any } @@ -283,7 +287,7 @@ type AuthLoginPollRequest struct { // Host contains relevant host configuration. Host HostConfigSummary // HTTPClient executes upstream HTTP requests through host transport policy. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` // Metadata carries plugin-defined polling context. Metadata map[string]any } @@ -325,7 +329,7 @@ type AuthRefreshRequest struct { // Host contains relevant host configuration. Host HostConfigSummary // HTTPClient executes upstream HTTP requests through host transport policy. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` } // AuthRefreshResponse returns refreshed provider auth data. @@ -386,7 +390,7 @@ type AuthModelRequest struct { // Host contains relevant host configuration. Host HostConfigSummary // HTTPClient executes upstream HTTP requests through host transport policy. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` } // ModelResponse returns provider and model metadata discovered by a plugin. @@ -507,7 +511,7 @@ type ExecutorHTTPRequest struct { // Attributes contains immutable routing and provider attributes. Attributes map[string]string // HTTPClient executes upstream HTTP requests through host transport policy and request-log capture. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` } // ExecutorHTTPResponse describes an executor-owned HTTP response. @@ -553,7 +557,7 @@ type ExecutorRequest struct { // AuthAttributes contains immutable routing and provider attributes. AuthAttributes map[string]string // HTTPClient executes upstream HTTP requests through host transport policy and request-log capture. - HTTPClient HostHTTPClient + HTTPClient HostHTTPClient `json:"-"` } // ExecutorResponse returns a non-streaming executor result. diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go index 8b4e6c757..813f56754 100644 --- a/sdk/pluginapi/types_test.go +++ b/sdk/pluginapi/types_test.go @@ -2,6 +2,8 @@ package pluginapi import ( "context" + "encoding/json" + "strings" "testing" ) @@ -55,6 +57,59 @@ func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) { } } +func TestHostInjectedHTTPClientIsNotEncodedInPluginJSON(t *testing.T) { + requests := []struct { + name string + req any + dst any + }{ + { + name: "auth login start", + req: AuthLoginStartRequest{Provider: "plugin-example", HTTPClient: compileTimePlugin{}}, + dst: &AuthLoginStartRequest{}, + }, + { + name: "auth login poll", + req: AuthLoginPollRequest{Provider: "plugin-example", HTTPClient: compileTimePlugin{}}, + dst: &AuthLoginPollRequest{}, + }, + { + name: "auth refresh", + req: AuthRefreshRequest{AuthID: "auth-1", HTTPClient: compileTimePlugin{}}, + dst: &AuthRefreshRequest{}, + }, + { + name: "auth model", + req: AuthModelRequest{AuthID: "auth-1", HTTPClient: compileTimePlugin{}}, + dst: &AuthModelRequest{}, + }, + { + name: "executor request", + req: ExecutorRequest{Model: "model-1", HTTPClient: compileTimePlugin{}}, + dst: &ExecutorRequest{}, + }, + { + name: "executor http request", + req: ExecutorHTTPRequest{AuthID: "auth-1", HTTPClient: compileTimePlugin{}}, + dst: &ExecutorHTTPRequest{}, + }, + } + + for _, tt := range requests { + raw, errMarshal := json.Marshal(tt.req) + if errMarshal != nil { + t.Fatalf("%s marshal error = %v", tt.name, errMarshal) + } + if strings.Contains(string(raw), "HTTPClient") { + t.Fatalf("%s JSON contains host HTTPClient: %s", tt.name, raw) + } + withLegacyHTTPClient := append(raw[:len(raw)-1], []byte(`,"HTTPClient":{}}`)...) + if errUnmarshal := json.Unmarshal(withLegacyHTTPClient, tt.dst); errUnmarshal != nil { + t.Fatalf("%s unmarshal with legacy HTTPClient object error = %v", tt.name, errUnmarshal) + } + } +} + func (compileTimePlugin) RegisterModels(context.Context, ModelRegistrationRequest) (ModelRegistrationResponse, error) { return ModelRegistrationResponse{}, nil }