feat(pluginhost): add capabilities for command-line flag handling and plugin execution

- Implemented command-line flag registration and execution for plugins with priority-based conflict resolution.
- Enabled plugin-owned command-line flag execution and persistence of plugin-auth data.
- Added new `Host` methods to support command-line capabilities, including flag normalization, validation, and execution state management.
- Introduced unit tests to ensure coverage for command-line plugin functionality, including auth data persistence.
- Updated configs to normalize plugins during initialization.
This commit is contained in:
Luis Pater
2026-06-06 18:35:17 +08:00
parent 5753d1a089
commit d625caddd9
64 changed files with 12424 additions and 191 deletions
+1
View File
@@ -12,6 +12,7 @@ logs/*
conv/*
temp/*
refs/*
plugins/*
# Storage backends
pgstore/*
+74 -2
View File
@@ -25,6 +25,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
@@ -126,6 +127,12 @@ func main() {
})
}
pluginHost := pluginhost.New()
if bootstrapCfg := loadPluginBootstrapConfig(pluginBootstrapConfigPath(os.Args[1:], DefaultConfigPath)); bootstrapCfg != nil {
pluginHost.ApplyConfig(context.Background(), bootstrapCfg)
pluginHost.RegisterCommandLineFlags(context.Background(), flag.CommandLine)
}
// Parse the command-line flags.
flag.Parse()
@@ -525,6 +532,15 @@ func main() {
// Register built-in access providers before constructing services.
configaccess.Register(&cfg.SDKConfig)
pluginHost.ApplyConfig(context.Background(), cfg)
if pluginHost.HasTriggeredCommandLineFlags() {
if exitCode, handled := pluginHost.ExecuteCommandLine(context.Background(), os.Args[0], os.Args[1:], configFilePath, flag.CommandLine); handled {
if exitCode != 0 {
os.Exit(exitCode)
}
return
}
}
// Handle different command modes based on the provided flags.
@@ -599,7 +615,7 @@ func main() {
password = localMgmtPassword
}
cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)
cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost)
client := tui.NewClient(cfg.Port, password)
ready := false
@@ -648,7 +664,63 @@ func main() {
} else if cfg.Home.Enabled {
log.Info("Home mode: remote model updates disabled")
}
cmd.StartService(cfg, configFilePath, password)
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost)
}
}
}
func pluginBootstrapConfigPath(args []string, defaultPath string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--":
return defaultPluginBootstrapConfigPath(defaultPath)
case arg == "-config" || arg == "--config":
if i+1 < len(args) {
return args[i+1]
}
return defaultPluginBootstrapConfigPath(defaultPath)
case strings.HasPrefix(arg, "-config="):
return strings.TrimPrefix(arg, "-config=")
case strings.HasPrefix(arg, "--config="):
return strings.TrimPrefix(arg, "--config=")
}
}
return defaultPluginBootstrapConfigPath(defaultPath)
}
func defaultPluginBootstrapConfigPath(defaultPath string) string {
if strings.TrimSpace(defaultPath) != "" {
return defaultPath
}
wd, errGetwd := os.Getwd()
if errGetwd != nil {
return "config.yaml"
}
return filepath.Join(wd, "config.yaml")
}
func loadPluginBootstrapConfig(path string) *config.Config {
raw, errReadFile := os.ReadFile(path)
if errReadFile != nil {
if !errors.Is(errReadFile, os.ErrNotExist) {
log.Warnf("failed to read plugin bootstrap config: %v", errReadFile)
}
cfg := &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
if len(strings.TrimSpace(string(raw))) == 0 {
cfg := &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
cfg, errParseConfig := config.ParseConfigBytes(raw)
if errParseConfig != nil {
log.Warnf("failed to parse plugin bootstrap config: %v", errParseConfig)
cfg = &config.Config{}
cfg.NormalizePluginsConfig()
return cfg
}
return cfg
}
+23
View File
@@ -49,6 +49,26 @@ 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.
# 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.
# Existing native flags/routes and higher-priority plugin flags/routes cannot be replaced.
# 插件列表 Management API 会读取插件 Metadata 中的 Logo 和 ConfigFields,用于管理端展示。
# 单插件 enabled 只控制 plugins.configs.<pluginID>.enabled,不会隐式修改全局 plugins.enabled。
plugins:
enabled: false
dir: "plugins"
configs:
example:
enabled: true
priority: 1
config1: true
config2: "string"
config3: 3
mode: "safe" # enum example: safe, fast
# When true, disable high-overhead request logging and HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false
@@ -371,6 +391,9 @@ nonstream-keepalive-interval: 0
# xai:
# - name: "grok-4.3"
# alias: "grok-latest"
# qoder: # plugin provider keys are supported for OAuth plugins
# - name: "qmodel_latest"
# alias: "qlatest"
# OAuth provider excluded models
# oauth-excluded-models:
+416
View File
@@ -0,0 +1,416 @@
# Example Go Dynamic Plugin
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.
The example uses the provider key `plugin-example` and the plugin ID `example`.
## What the sample implements
`examples/plugin/main.go` exports the required Go plugin entrypoints:
```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:
```bash
go build -buildmode=plugin
```
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.
Go plugin ABI compatibility is strict. Build the plugin for the target service binary with the same:
- `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
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/<GOOS>/<GOARCH>-<variant>/*.so
plugins/<GOOS>/<GOARCH>/*.so
plugins/*.so
```
For `amd64`, `<variant>` 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.<pluginID>` 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.<pluginID>` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下:
```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.<pluginID>.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:<pluginID>:<providerIdentifier>
```
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.
+416
View File
@@ -0,0 +1,416 @@
# Go 动态插件示例
这个目录是基于当前 `sdk/pluginapi` ABI 编写 provider 插件的参考骨架。它保持确定性和小规模实现,但覆盖真实 provider 插件通常需要接入的宿主能力:provider 自有 auth 解析、模型发现、执行器、HTTP bridge、请求/响应转换、thinking 配置、usage 观察、命令行参数和诊断 Management API 路由。
示例使用 provider key `plugin-example`,插件 ID 为 `example`。
## 示例实现内容
`examples/plugin/main.go` 导出了 Go 插件必须提供的入口函数:
```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
```
Go 标准库 `plugin` 包支持 Linux、FreeBSD 和 macOS。在不支持的平台上,插件加载会被禁用,服务会继续使用原生逻辑运行。
Go plugin ABI 兼容性非常严格。请使用与目标服务二进制一致的环境构建插件:
- `GOOS` 和 `GOARCH`
- 使用 CPU 专属目录时的 CPU feature target
- Go 工具链版本
- build tags 和 CGO 设置
- module path
- 共享依赖版本
如果这些条件不一致,`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/<GOOS>/<GOARCH>-<variant>/*.so
plugins/<GOOS>/<GOARCH>/*.so
plugins/*.so
```
对于 `amd64`,`<variant>` 会根据 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.<pluginID>` 是传给 `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.<pluginID>` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下:
```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.<pluginID>.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:<pluginID>:<providerIdentifier>
```
这个示例的 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。
+420
View File
@@ -0,0 +1,420 @@
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
}
+137 -2
View File
@@ -34,6 +34,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"golang.org/x/oauth2"
@@ -236,6 +237,81 @@ func (h *Handler) managementCallbackURL(path string) (string, error) {
return fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, h.cfg.Port, path), nil
}
func pluginAuthProviderFromPath(path string) (string, bool) {
path = strings.TrimSpace(path)
const prefix = "/v0/management/"
const suffix = "-auth-url"
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
return "", false
}
provider := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return "", false
}
for _, r := range provider {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '-':
default:
return "", false
}
}
return provider, true
}
func (h *Handler) ServePluginAuthURL(c *gin.Context) bool {
if h == nil || c == nil || c.Request == nil || c.Request.URL == nil {
return false
}
h.mu.Lock()
host := h.pluginHost
h.mu.Unlock()
if host == nil {
return false
}
provider, ok := pluginAuthProviderFromPath(c.Request.URL.Path)
if !ok || !host.HasAuthProvider(provider) {
return false
}
ctx := PopulateAuthContext(context.Background(), c)
baseURL, errBaseURL := h.managementCallbackURL("/v0/management/oauth-callback")
if errBaseURL != nil {
log.WithError(errBaseURL).Error("failed to compute plugin auth callback URL")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return true
}
resp, handled, errStart := host.StartLogin(ctx, provider, baseURL)
if !handled {
return false
}
if errStart != nil {
log.WithError(errStart).Error("failed to start plugin auth login")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
return true
}
state := strings.TrimSpace(resp.State)
if state == "" {
log.WithField("provider", provider).Error("plugin auth provider returned empty state")
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid oauth state"})
return true
}
if errState := ValidateOAuthState(state); errState != nil {
log.WithError(errState).WithField("provider", provider).Error("plugin auth provider returned invalid state")
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid oauth state"})
return true
}
if errRegister := RegisterPluginOAuthSession(state, provider, resp.Metadata); errRegister != nil {
log.WithError(errRegister).WithField("provider", provider).Error("failed to register plugin oauth session")
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to generate authorization url"})
return true
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": resp.URL, "state": state})
return true
}
func (h *Handler) ListAuthFiles(c *gin.Context) {
if h == nil {
c.JSON(500, gin.H{"error": "handler not initialized"})
@@ -1618,7 +1694,16 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s
return "", fmt.Errorf("post-auth hook failed: %w", err)
}
}
return store.Save(ctx, record)
savedPath, errSave := store.Save(ctx, record)
if errSave != nil {
return "", errSave
}
if h.postAuthPersistHook != nil {
if errHook := h.postAuthPersistHook(ctx, record); errHook != nil {
return savedPath, fmt.Errorf("post-auth persist hook failed: %w", errHook)
}
}
return savedPath, nil
}
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
@@ -2980,7 +3065,7 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
return
}
_, status, ok := GetOAuthSession(state)
provider, status, isPlugin, metadata, ok := GetOAuthSessionDetails(state)
if !ok {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
@@ -2989,6 +3074,56 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
return
}
h.mu.Lock()
host := h.pluginHost
h.mu.Unlock()
if isPlugin && host != nil && host.HasAuthProvider(provider) {
ctx := PopulateAuthContext(context.Background(), c)
resp, handled, errPoll := host.PollLogin(ctx, provider, state, metadata)
if handled {
if errPoll != nil {
message := strings.TrimSpace(errPoll.Error())
if message == "" {
message = "Authentication failed"
}
SetOAuthSessionError(state, message)
c.JSON(http.StatusOK, gin.H{"status": "error", "error": message})
return
}
switch resp.Status {
case "", pluginapi.AuthLoginStatusPending:
c.JSON(http.StatusOK, gin.H{"status": "wait"})
return
case pluginapi.AuthLoginStatusError:
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "Authentication failed"
}
SetOAuthSessionError(state, message)
c.JSON(http.StatusOK, gin.H{"status": "error", "error": message})
return
case pluginapi.AuthLoginStatusSuccess:
record := host.AuthDataToCoreAuth(resp.Auth, "", "")
if record == nil {
SetOAuthSessionError(state, "Authentication failed")
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authentication failed"})
return
}
if _, errSave := h.saveTokenRecord(ctx, record); errSave != nil {
log.WithError(errSave).WithField("provider", provider).Error("failed to save plugin auth tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"})
return
}
CompleteOAuthSession(state)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
default:
c.JSON(http.StatusOK, gin.H{"status": "wait"})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}
@@ -15,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"golang.org/x/crypto/bcrypt"
@@ -46,6 +47,8 @@ type Handler struct {
envSecret string
logDir string
postAuthHook coreauth.PostAuthHook
postAuthPersistHook coreauth.PostAuthHook
pluginHost *pluginhost.Host
}
// NewHandler creates a new management handler instance.
@@ -121,6 +124,16 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
h.mu.Unlock()
}
// SetPluginHost updates the plugin host used by plugin-backed management endpoints.
func (h *Handler) SetPluginHost(host *pluginhost.Host) {
if h == nil {
return
}
h.mu.Lock()
h.pluginHost = host
h.mu.Unlock()
}
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
@@ -142,6 +155,11 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
h.postAuthHook = hook
}
// SetPostAuthPersistHook registers a hook to be called after auth persistence.
func (h *Handler) SetPostAuthPersistHook(hook coreauth.PostAuthHook) {
h.postAuthPersistHook = hook
}
// Middleware enforces access control for management endpoints.
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
@@ -16,15 +16,23 @@ const (
maxOAuthStateLength = 128
)
const (
oauthSessionSourceBuiltin = "builtin"
oauthSessionSourcePlugin = "plugin"
)
var (
errInvalidOAuthState = errors.New("invalid oauth state")
errUnsupportedOAuthFlow = errors.New("unsupported oauth provider")
errOAuthSessionNotPending = errors.New("oauth session is not pending")
errOAuthSessionExists = errors.New("oauth session already exists")
)
type oauthSession struct {
Provider string
Status string
Source string
Metadata map[string]any
CreatedAt time.Time
ExpiresAt time.Time
}
@@ -68,11 +76,41 @@ func (s *oauthSessionStore) Register(state, provider string) {
s.sessions[state] = oauthSession{
Provider: provider,
Status: "",
Source: oauthSessionSourceBuiltin,
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
}
}
func (s *oauthSessionStore) RegisterPlugin(state, provider string, metadata map[string]any) error {
state = strings.TrimSpace(state)
provider = strings.ToLower(strings.TrimSpace(provider))
if state == "" || provider == "" {
return fmt.Errorf("%w: empty state or provider", errInvalidOAuthState)
}
if errState := ValidateOAuthState(state); errState != nil {
return errState
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
if _, ok := s.sessions[state]; ok {
return errOAuthSessionExists
}
s.sessions[state] = oauthSession{
Provider: provider,
Status: "",
Source: oauthSessionSourcePlugin,
Metadata: cloneOAuthSessionMetadata(metadata),
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
}
return nil
}
func (s *oauthSessionStore) SetError(state, message string) {
state = strings.TrimSpace(state)
message = strings.TrimSpace(message)
@@ -111,11 +149,12 @@ func (s *oauthSessionStore) Complete(state string) {
delete(s.sessions, state)
}
func (s *oauthSessionStore) CompleteProvider(provider string) int {
func (s *oauthSessionStore) CompleteProvider(provider string, source string) int {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return 0
}
source = strings.TrimSpace(source)
now := time.Now()
s.mu.Lock()
@@ -124,7 +163,7 @@ func (s *oauthSessionStore) CompleteProvider(provider string) int {
s.purgeExpiredLocked(now)
removed := 0
for state, session := range s.sessions {
if strings.EqualFold(session.Provider, provider) {
if strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
delete(s.sessions, state)
removed++
}
@@ -141,6 +180,7 @@ func (s *oauthSessionStore) Get(state string) (oauthSession, bool) {
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
session.Metadata = cloneOAuthSessionMetadata(session.Metadata)
return session, ok
}
@@ -160,22 +200,44 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool {
if session.Status != "" {
return false
}
if session.Source == oauthSessionSourcePlugin {
return false
}
if provider == "" {
return true
}
return strings.EqualFold(session.Provider, provider)
}
func cloneOAuthSessionMetadata(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
var oauthSessions = newOAuthSessionStore(oauthSessionTTL)
func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }
func RegisterPluginOAuthSession(state, provider string, metadata map[string]any) error {
return oauthSessions.RegisterPlugin(state, provider, metadata)
}
func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }
func CompleteOAuthSession(state string) { oauthSessions.Complete(state) }
func CompleteOAuthSessionsByProvider(provider string) int {
return oauthSessions.CompleteProvider(provider)
return oauthSessions.CompleteProvider(provider, oauthSessionSourceBuiltin)
}
func CompletePluginOAuthSessionsByProvider(provider string) int {
return oauthSessions.CompleteProvider(provider, oauthSessionSourcePlugin)
}
func GetOAuthSession(state string) (provider string, status string, ok bool) {
@@ -186,6 +248,14 @@ func GetOAuthSession(state string) (provider string, status string, ok bool) {
return session.Provider, session.Status, true
}
func GetOAuthSessionDetails(state string) (provider string, status string, isPlugin bool, metadata map[string]any, ok bool) {
session, ok := oauthSessions.Get(state)
if !ok {
return "", "", false, nil, false
}
return session.Provider, session.Status, session.Source == oauthSessionSourcePlugin, cloneOAuthSessionMetadata(session.Metadata), true
}
func IsOAuthSessionPending(state, provider string) bool {
return oauthSessions.IsPending(state, provider)
}
+459
View File
@@ -0,0 +1,459 @@
package management
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
type pluginListResponse struct {
PluginsEnabled bool `json:"plugins_enabled"`
PluginsDir string `json:"plugins_dir"`
Plugins []pluginListEntry `json:"plugins"`
}
type pluginListEntry struct {
ID string `json:"id"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
SupportsOAuth bool `json:"supports_oauth"`
Logo string `json:"logo"`
ConfigFields []pluginConfigFieldInfo `json:"config_fields"`
Menus []pluginMenuInfo `json:"menus"`
Metadata *pluginMetadataInfo `json:"metadata"`
}
type pluginMetadataInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
GitHubRepository string `json:"github_repository"`
Logo string `json:"logo"`
ConfigFields []pluginConfigFieldInfo `json:"config_fields"`
}
type pluginConfigFieldInfo struct {
Name string `json:"name"`
Type string `json:"type"`
EnumValues []string `json:"enum_values"`
Description string `json:"description"`
}
type pluginMenuInfo struct {
Path string `json:"path"`
Menu string `json:"menu"`
Description string `json:"description"`
}
// ListPlugins returns discovered, configured, and registered plugin entries.
func (h *Handler) ListPlugins(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(http.StatusOK, pluginListResponse{
PluginsDir: "plugins",
Plugins: []pluginListEntry{},
})
return
}
h.mu.Lock()
pluginsEnabled := h.cfg.Plugins.Enabled
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs))
for id, item := range h.cfg.Plugins.Configs {
configs[id] = item
}
host := h.pluginHost
h.mu.Unlock()
entries := make(map[string]pluginListEntry)
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
if errDiscover != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()})
return
}
for _, file := range files {
entries[file.ID] = pluginListEntry{
ID: file.ID,
Path: file.Path,
Enabled: true,
ConfigFields: []pluginConfigFieldInfo{},
Menus: []pluginMenuInfo{},
}
}
for id, item := range configs {
entry := entries[id]
entry.ID = id
entry.Configured = true
entry.Enabled = pluginInstanceEnabled(item)
if entry.ConfigFields == nil {
entry.ConfigFields = []pluginConfigFieldInfo{}
}
if entry.Menus == nil {
entry.Menus = []pluginMenuInfo{}
}
entries[id] = entry
}
if host != nil {
for _, info := range host.RegisteredPlugins() {
entry := entries[info.ID]
entry.ID = info.ID
entry.Registered = true
entry.SupportsOAuth = info.SupportsOAuth
entry.Logo = info.Metadata.Logo
entry.ConfigFields = pluginConfigFields(info.Metadata.ConfigFields)
entry.Menus = pluginMenus(info.Menus)
entry.Metadata = pluginMetadata(info.Metadata)
_, configured := configs[info.ID]
if !configured && !entry.Enabled {
entry.Enabled = true
}
entries[info.ID] = entry
}
}
ids := make([]string, 0, len(entries))
for id := range entries {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]pluginListEntry, 0, len(ids))
for _, id := range ids {
entry := entries[id]
entry.EffectiveEnabled = pluginsEnabled && entry.Enabled && entry.Registered
if entry.ConfigFields == nil {
entry.ConfigFields = []pluginConfigFieldInfo{}
}
if entry.Menus == nil {
entry.Menus = []pluginMenuInfo{}
}
out = append(out, entry)
}
c.JSON(http.StatusOK, pluginListResponse{
PluginsEnabled: pluginsEnabled,
PluginsDir: pluginsDir,
Plugins: out,
})
}
// PatchPluginEnabled updates plugins.configs.<id>.enabled without touching plugins.enabled.
func (h *Handler) PatchPluginEnabled(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
if !okID {
return
}
var body struct {
Enabled *bool `json:"enabled"`
}
if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": "enabled is required"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
ensurePluginConfigMap(h.cfg)
item := h.cfg.Plugins.Configs[id]
node := pluginConfigNode(item)
setYAMLMappingValue(node, "enabled", boolYAMLNode(*body.Enabled))
updated, errConfig := pluginInstanceConfigFromNode(node)
if errConfig != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()})
return
}
h.cfg.Plugins.Configs[id] = updated
h.persistLocked(c)
}
// PutPluginConfig replaces plugins.configs.<id> with the request object.
func (h *Handler) PutPluginConfig(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
if !okID {
return
}
body, okBody := readPluginConfigObject(c)
if !okBody {
return
}
node, errNode := yamlNodeFromJSONObject(body)
if errNode != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errNode.Error()})
return
}
updated, errConfig := pluginInstanceConfigFromNode(node)
if errConfig != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()})
return
}
h.mu.Lock()
defer h.mu.Unlock()
ensurePluginConfigMap(h.cfg)
h.cfg.Plugins.Configs[id] = updated
h.persistLocked(c)
}
// PatchPluginConfig shallow-merges plugins.configs.<id> with the request object.
func (h *Handler) PatchPluginConfig(c *gin.Context) {
id, okID := pluginIDFromRequest(c)
if !okID {
return
}
body, okBody := readPluginConfigObject(c)
if !okBody {
return
}
h.mu.Lock()
defer h.mu.Unlock()
ensurePluginConfigMap(h.cfg)
node := pluginConfigNode(h.cfg.Plugins.Configs[id])
keys := make([]string, 0, len(body))
for key := range body {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
value := body[key]
if value == nil {
deleteYAMLMappingKey(node, key)
continue
}
valueNode, errNode := yamlNodeFromJSONValue(value)
if errNode != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errNode.Error()})
return
}
setYAMLMappingValue(node, key, valueNode)
}
updated, errConfig := pluginInstanceConfigFromNode(node)
if errConfig != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()})
return
}
h.cfg.Plugins.Configs[id] = updated
h.persistLocked(c)
}
func normalizedPluginsDir(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return "plugins"
}
return dir
}
func pluginInstanceEnabled(item config.PluginInstanceConfig) bool {
if item.Enabled == nil {
return true
}
return *item.Enabled
}
func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo {
out := make([]pluginConfigFieldInfo, 0, len(fields))
for _, field := range fields {
enumValues := append([]string{}, field.EnumValues...)
out = append(out, pluginConfigFieldInfo{
Name: field.Name,
Type: string(field.Type),
EnumValues: enumValues,
Description: field.Description,
})
}
return out
}
func pluginMenus(menus []pluginhost.RegisteredPluginMenu) []pluginMenuInfo {
out := make([]pluginMenuInfo, 0, len(menus))
for _, menu := range menus {
out = append(out, pluginMenuInfo{
Path: menu.Path,
Menu: menu.Menu,
Description: menu.Description,
})
}
return out
}
func pluginMetadata(meta pluginapi.Metadata) *pluginMetadataInfo {
return &pluginMetadataInfo{
Name: meta.Name,
Version: meta.Version,
Author: meta.Author,
GitHubRepository: meta.GitHubRepository,
Logo: meta.Logo,
ConfigFields: pluginConfigFields(meta.ConfigFields),
}
}
func pluginIDFromRequest(c *gin.Context) (string, bool) {
id := strings.TrimSpace(c.Param("id"))
if !pluginhost.ValidatePluginID(id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_plugin_id", "message": "invalid plugin id"})
return "", false
}
return id, true
}
func readPluginConfigObject(c *gin.Context) (map[string]any, bool) {
decoder := json.NewDecoder(c.Request.Body)
decoder.UseNumber()
var body map[string]any
if errDecode := decoder.Decode(&body); errDecode != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errDecode.Error()})
return nil, false
}
if body == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": "body must be a JSON object"})
return nil, false
}
return body, true
}
func ensurePluginConfigMap(cfg *config.Config) {
if cfg == nil {
return
}
cfg.NormalizePluginsConfig()
}
func pluginConfigNode(item config.PluginInstanceConfig) *yaml.Node {
if item.Raw.Kind == yaml.MappingNode {
return cloneYAMLNode(&item.Raw)
}
node := emptyYAMLMappingNode()
if item.Enabled != nil {
setYAMLMappingValue(node, "enabled", boolYAMLNode(*item.Enabled))
}
if item.Priority != 0 {
setYAMLMappingValue(node, "priority", intYAMLNode(item.Priority))
}
return node
}
func pluginInstanceConfigFromNode(node *yaml.Node) (config.PluginInstanceConfig, error) {
if node == nil {
node = emptyYAMLMappingNode()
}
var item config.PluginInstanceConfig
if errDecode := node.Decode(&item); errDecode != nil {
return config.PluginInstanceConfig{}, errDecode
}
return item, nil
}
func yamlNodeFromJSONObject(body map[string]any) (*yaml.Node, error) {
node := emptyYAMLMappingNode()
keys := make([]string, 0, len(body))
for key := range body {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
valueNode, errNode := yamlNodeFromJSONValue(body[key])
if errNode != nil {
return nil, fmt.Errorf("%s: %w", key, errNode)
}
setYAMLMappingValue(node, key, valueNode)
}
return node, nil
}
func yamlNodeFromJSONValue(value any) (*yaml.Node, error) {
switch typed := value.(type) {
case nil:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
case string:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: typed}, nil
case bool:
return boolYAMLNode(typed), nil
case json.Number:
if _, errInt64 := typed.Int64(); errInt64 == nil {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: typed.String()}, nil
}
if _, errFloat64 := typed.Float64(); errFloat64 == nil {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: typed.String()}, nil
}
return nil, fmt.Errorf("invalid number %q", typed.String())
case float64:
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: strconv.FormatFloat(typed, 'f', -1, 64)}, nil
case []any:
node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, item := range typed {
child, errChild := yamlNodeFromJSONValue(item)
if errChild != nil {
return nil, errChild
}
node.Content = append(node.Content, child)
}
return node, nil
case map[string]any:
return yamlNodeFromJSONObject(typed)
default:
return nil, fmt.Errorf("unsupported value type %T", value)
}
}
func emptyYAMLMappingNode() *yaml.Node {
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
}
func boolYAMLNode(value bool) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(value)}
}
func intYAMLNode(value int) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)}
}
func setYAMLMappingValue(mapping *yaml.Node, key string, value *yaml.Node) {
if mapping.Kind != yaml.MappingNode {
*mapping = *emptyYAMLMappingNode()
}
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index] != nil && mapping.Content[index].Value == key {
mapping.Content[index+1] = value
return
}
}
mapping.Content = append(mapping.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value)
}
func deleteYAMLMappingKey(mapping *yaml.Node, key string) {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return
}
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index] != nil && mapping.Content[index].Value == key {
mapping.Content = append(mapping.Content[:index], mapping.Content[index+2:]...)
return
}
}
}
func cloneYAMLNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}
out := *node
if len(node.Content) > 0 {
out.Content = make([]*yaml.Node, 0, len(node.Content))
for _, child := range node.Content {
out.Content = append(out.Content, cloneYAMLNode(child))
}
}
return &out
}
@@ -0,0 +1,244 @@
package management
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
)
func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
pluginsDir := writeManagementPluginFile(t, "scanned")
disabled := false
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: false,
Dir: pluginsDir,
Configs: map[string]config.PluginInstanceConfig{
"configured-only": {Enabled: &disabled},
},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
h.ListPlugins(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body struct {
PluginsEnabled bool `json:"plugins_enabled"`
Plugins []struct {
ID string `json:"id"`
Path string `json:"path"`
Configured bool `json:"configured"`
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
EffectiveEnabled bool `json:"effective_enabled"`
SupportsOAuth bool `json:"supports_oauth"`
Logo string `json:"logo"`
ConfigFields []any `json:"config_fields"`
Menus []any `json:"menus"`
} `json:"plugins"`
}
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
}
if body.PluginsEnabled {
t.Fatal("plugins_enabled = true, want false")
}
entries := map[string]struct {
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
Path string
}{}
for _, item := range body.Plugins {
entries[item.ID] = struct {
Configured bool
Registered bool
Enabled bool
EffectiveEnabled bool
Path string
}{
Configured: item.Configured,
Registered: item.Registered,
Enabled: item.Enabled,
EffectiveEnabled: item.EffectiveEnabled,
Path: item.Path,
}
if item.Registered || item.SupportsOAuth || item.Logo != "" || len(item.ConfigFields) != 0 || len(item.Menus) != 0 {
t.Fatalf("unregistered plugin entry has runtime fields: %#v", item)
}
}
if got, ok := entries["scanned"]; !ok || got.Configured || !got.Enabled || got.EffectiveEnabled || got.Path == "" {
t.Fatalf("scanned entry = %#v, exists=%v", got, ok)
}
if got, ok := entries["configured-only"]; !ok || !got.Configured || got.Enabled || got.EffectiveEnabled || got.Path != "" {
t.Fatalf("configured-only entry = %#v, exists=%v", got, ok)
}
}
func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Enabled: false,
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, "enabled: false\npriority: 2\nmode: safe\n"),
},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample"}}
c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/enabled", strings.NewReader(`{"enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
h.PatchPluginEnabled(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if h.cfg.Plugins.Enabled {
t.Fatal("global Plugins.Enabled changed to true")
}
item := h.cfg.Plugins.Configs["sample"]
if item.Enabled == nil || !*item.Enabled {
t.Fatalf("sample enabled = %#v, want true", item.Enabled)
}
raw := marshalPluginRaw(t, item)
if !strings.Contains(raw, "mode: safe") {
t.Fatalf("raw config lost custom field:\n%s", raw)
}
}
func TestPutPluginConfigReplacesPluginConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, "enabled: false\nmode: safe\nold: true\n"),
},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample"}}
c.Request = httptest.NewRequest(http.MethodPut, "/v0/management/plugins/sample/config", bytes.NewBufferString(`{"enabled":true,"priority":7,"mode":"fast"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.PutPluginConfig(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
item := h.cfg.Plugins.Configs["sample"]
if item.Enabled == nil || !*item.Enabled || item.Priority != 7 {
t.Fatalf("plugin host fields = enabled %#v priority %d, want true priority 7", item.Enabled, item.Priority)
}
raw := marshalPluginRaw(t, item)
if !strings.Contains(raw, "mode: fast") || strings.Contains(raw, "old:") {
t.Fatalf("raw config =\n%s", raw)
}
}
func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
h := &Handler{
cfg: &config.Config{
Plugins: config.PluginsConfig{
Configs: map[string]config.PluginInstanceConfig{
"sample": pluginConfigFromYAML(t, "enabled: false\npriority: 3\nmode: safe\nremove: yes\n"),
},
},
},
configFilePath: writeTestConfigFile(t),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: "sample"}}
c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/plugins/sample/config", strings.NewReader(`{"mode":"fast","remove":null,"count":3}`))
c.Request.Header.Set("Content-Type", "application/json")
h.PatchPluginConfig(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
item := h.cfg.Plugins.Configs["sample"]
if item.Enabled == nil || *item.Enabled || item.Priority != 3 {
t.Fatalf("plugin host fields = enabled %#v priority %d, want false priority 3", item.Enabled, item.Priority)
}
raw := marshalPluginRaw(t, item)
if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "count: 3") || strings.Contains(raw, "remove:") {
t.Fatalf("raw config =\n%s", raw)
}
}
func writeManagementPluginFile(t *testing.T, id string) string {
t.Helper()
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
path := filepath.Join(archDir, id+".so")
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
return root
}
func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig {
t.Helper()
var item config.PluginInstanceConfig
if errUnmarshal := yaml.Unmarshal([]byte(text), &item); errUnmarshal != nil {
t.Fatalf("unmarshal plugin config: %v", errUnmarshal)
}
return item
}
func marshalPluginRaw(t *testing.T, item config.PluginInstanceConfig) string {
t.Helper()
data, errMarshal := yaml.Marshal(&item.Raw)
if errMarshal != nil {
t.Fatalf("marshal plugin raw: %v", errMarshal)
}
return string(data)
}
+109 -10
View File
@@ -33,6 +33,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
@@ -59,6 +60,8 @@ type serverOptionConfig struct {
keepAliveTimeout time.Duration
keepAliveOnTimeout func()
postAuthHook auth.PostAuthHook
postAuthPersistHook auth.PostAuthHook
pluginHost *pluginhost.Host
}
// ServerOption customises HTTP server construction.
@@ -137,6 +140,20 @@ func WithPostAuthHook(hook auth.PostAuthHook) ServerOption {
}
}
// WithPostAuthPersistHook registers a hook to be called after auth persistence.
func WithPostAuthPersistHook(hook auth.PostAuthHook) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.postAuthPersistHook = hook
}
}
// WithPluginHost registers dynamic plugin HTTP adapters with the server.
func WithPluginHost(host *pluginhost.Host) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.pluginHost = host
}
}
// Server represents the main API server.
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
type Server struct {
@@ -187,6 +204,9 @@ type Server struct {
// ampModule is the Amp routing module for model mapping hot-reload
ampModule *ampmodule.AmpModule
// pluginHost owns dynamic plugin Management API route dispatch.
pluginHost *pluginhost.Host
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
managementRoutesRegistered atomic.Bool
// managementRoutesEnabled controls whether management endpoints serve real handlers.
@@ -277,6 +297,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
currentPath: wd,
envManagementSecret: envManagementSecret,
wsRoutes: make(map[string]struct{}),
pluginHost: optionState.pluginHost,
}
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
// Save initial YAML snapshot
@@ -290,6 +311,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
applySignatureCacheConfig(nil, cfg)
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
s.mgmt.SetPluginHost(optionState.pluginHost)
if optionState.localPassword != "" {
s.mgmt.SetLocalPassword(optionState.localPassword)
}
@@ -298,6 +320,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
if optionState.postAuthHook != nil {
s.mgmt.SetPostAuthHook(optionState.postAuthHook)
}
if optionState.postAuthPersistHook != nil {
s.mgmt.SetPostAuthPersistHook(optionState.postAuthPersistHook)
}
s.localPassword = optionState.localPassword
// Home heartbeat gate: when home is enabled, block all endpoints with 503 until the
@@ -332,6 +357,8 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
if hasManagementSecret {
s.registerManagementRoutes()
}
s.refreshPluginManagementRoutes()
engine.NoRoute(s.pluginManagementNoRoute)
if optionState.keepAliveEnabled {
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
@@ -571,6 +598,10 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
mgmt.GET("/latest-version", s.mgmt.GetLatestVersion)
mgmt.GET("/plugins", s.mgmt.ListPlugins)
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig)
mgmt.GET("/debug", s.mgmt.GetDebug)
mgmt.PUT("/debug", s.mgmt.PutDebug)
@@ -723,22 +754,88 @@ func (s *Server) registerManagementRoutes() {
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if s == nil || s.cfg == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if s.cfg.Home.Enabled {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !s.managementRoutesEnabled.Load() {
c.AbortWithStatus(http.StatusNotFound)
if !s.managementAvailable(c) {
return
}
c.Next()
}
}
func (s *Server) managementAvailable(c *gin.Context) bool {
if s == nil || s.cfg == nil {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if s.cfg.Home.Enabled {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if !s.managementRoutesEnabled.Load() {
c.AbortWithStatus(http.StatusNotFound)
return false
}
return true
}
func (s *Server) refreshPluginManagementRoutes() {
if s == nil || s.pluginHost == nil || s.engine == nil {
return
}
s.pluginHost.RegisterManagementRoutes(context.Background(), s.registeredManagementRouteKeys())
}
// RefreshPluginManagementRoutes rebuilds plugin-owned Management API routes.
func (s *Server) RefreshPluginManagementRoutes() {
s.refreshPluginManagementRoutes()
}
func (s *Server) registeredManagementRouteKeys() map[string]struct{} {
out := make(map[string]struct{})
if s == nil || s.engine == nil {
return out
}
for _, route := range s.engine.Routes() {
if strings.HasPrefix(route.Path, "/v0/management/") || route.Path == "/v0/management" {
out[strings.ToUpper(strings.TrimSpace(route.Method))+" "+route.Path] = struct{}{}
}
}
return out
}
func (s *Server) pluginManagementNoRoute(c *gin.Context) {
if s == nil || c == nil || c.Request == nil || c.Request.URL == nil {
if c != nil {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
path := c.Request.URL.Path
if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") {
c.AbortWithStatus(http.StatusNotFound)
return
}
if s.pluginHost == nil || s.mgmt == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !s.managementAvailable(c) {
return
}
s.mgmt.Middleware()(c)
if c.IsAborted() {
return
}
if s.mgmt.ServePluginAuthURL(c) {
c.Abort()
return
}
if s.pluginHost.ServeManagementHTTP(c.Writer, c.Request) {
c.Abort()
return
}
c.AbortWithStatus(http.StatusNotFound)
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
@@ -1469,7 +1566,9 @@ func (s *Server) UpdateClients(cfg *config.Config) {
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)
s.mgmt.SetPluginHost(s.pluginHost)
}
s.refreshPluginManagementRoutes()
// Notify Amp module only when Amp config has changed.
ampConfigChanged := oldCfg == nil || !reflect.DeepEqual(oldCfg.AmpCode, cfg.AmpCode)
+26
View File
@@ -148,6 +148,32 @@ func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) {
}
}
func TestManagementPluginsRouteRegistered(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
server := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
req.Header.Set("Authorization", "Bearer test-management-key")
rr := httptest.NewRecorder()
server.engine.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var payload struct {
PluginsEnabled bool `json:"plugins_enabled"`
Plugins []any `json:"plugins"`
}
if errUnmarshal := json.Unmarshal(rr.Body.Bytes(), &payload); errUnmarshal != nil {
t.Fatalf("unmarshal response: %v body=%s", errUnmarshal, rr.Body.String())
}
if payload.Plugins == nil {
t.Fatalf("plugins field = nil, want array; body=%s", rr.Body.String())
}
}
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
+17
View File
@@ -12,6 +12,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
log "github.com/sirupsen/logrus"
)
@@ -25,10 +26,18 @@ import (
// - configPath: The path to the configuration file
// - localPassword: Optional password accepted for local management requests
func StartService(cfg *config.Config, configPath string, localPassword string) {
StartServiceWithPluginHost(cfg, configPath, localPassword, nil)
}
// StartServiceWithPluginHost builds and runs the proxy service with a shared plugin host.
func StartServiceWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host) {
builder := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath(configPath).
WithLocalManagementPassword(localPassword)
if host != nil {
builder = builder.WithPluginHost(host)
}
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
@@ -58,10 +67,18 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
// StartServiceBackground starts the proxy service in a background goroutine
// and returns a cancel function for shutdown and a done channel.
func StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) {
return StartServiceBackgroundWithPluginHost(cfg, configPath, localPassword, nil)
}
// StartServiceBackgroundWithPluginHost starts the proxy service with a shared plugin host.
func StartServiceBackgroundWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host) (cancel func(), done <-chan struct{}) {
builder := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath(configPath).
WithLocalManagementPassword(localPassword)
if host != nil {
builder = builder.WithPluginHost(host)
}
ctx, cancelFn := context.WithCancel(context.Background())
doneCh := make(chan struct{})
+111 -3
View File
@@ -43,6 +43,9 @@ type Config struct {
// RemoteManagement nests management-related options under 'remote-management'.
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
// Plugins configures dynamic plugin discovery and per-plugin settings.
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
// AuthDir is the directory where authentication token files are stored.
AuthDir string `yaml:"auth-dir" json:"-"`
@@ -152,6 +155,87 @@ type Config struct {
legacyMigrationPending bool `yaml:"-" json:"-"`
}
// PluginsConfig holds dynamic plugin system settings.
type PluginsConfig struct {
// Enabled toggles dynamic plugin loading.
Enabled bool `yaml:"enabled" json:"enabled"`
// Dir is the plugin discovery directory.
Dir string `yaml:"dir" json:"dir"`
// Configs stores per-plugin instance configuration by plugin ID.
Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"`
}
// PluginInstanceConfig stores host-owned plugin settings and the original plugin YAML subtree.
type PluginInstanceConfig struct {
// Enabled toggles this plugin instance. Nil is normalized to true during YAML parsing.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Priority controls plugin startup and routing order.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
// Raw preserves the full original plugin configuration YAML subtree.
Raw yaml.Node `yaml:"-" json:"-"`
}
// UnmarshalYAML extracts host-owned fields while preserving the full original YAML node.
func (c *PluginInstanceConfig) UnmarshalYAML(value *yaml.Node) error {
if c == nil {
return nil
}
c.Priority = 0
defaultEnabled := true
c.Enabled = &defaultEnabled
if value == nil || value.Kind == 0 {
c.Raw = *defaultPluginInstanceConfigNode()
return nil
}
c.Raw = *deepCopyNode(value)
if value.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(value.Content); i += 2 {
key := value.Content[i]
node := value.Content[i+1]
if key == nil {
continue
}
switch key.Value {
case "enabled":
var enabled bool
if errDecodeEnabled := node.Decode(&enabled); errDecodeEnabled != nil {
return fmt.Errorf("parse plugin enabled: %w", errDecodeEnabled)
}
c.Enabled = &enabled
case "priority":
var priority int
if errDecodePriority := node.Decode(&priority); errDecodePriority != nil {
return fmt.Errorf("parse plugin priority: %w", errDecodePriority)
}
c.Priority = priority
}
}
return nil
}
// MarshalYAML returns the preserved raw plugin YAML subtree for lossless config output.
func (c PluginInstanceConfig) MarshalYAML() (any, error) {
if c.Raw.Kind == 0 {
return defaultPluginInstanceConfigNode(), nil
}
return deepCopyNode(&c.Raw), nil
}
func defaultPluginInstanceConfigNode() *yaml.Node {
return &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{},
}
}
// ClaudeHeaderDefaults configures default header values injected into Claude API requests.
// In legacy mode, UserAgent/PackageVersion/RuntimeVersion/Timeout act as fallbacks when
// the client omits them, while OS/Arch remain runtime-derived. When stabilized device
@@ -628,7 +712,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if optional {
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
// Missing and optional: return empty config (cloud deploy standby).
return &Config{}, nil
cfg := &Config{}
cfg.NormalizePluginsConfig()
return cfg, nil
}
}
return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -636,7 +722,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
if optional && len(data) == 0 {
return &Config{}, nil
cfg := &Config{}
cfg.NormalizePluginsConfig()
return cfg, nil
}
// Unmarshal the YAML data into the Config struct.
@@ -657,7 +745,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
return &Config{}, nil
cfgOptional := &Config{}
cfgOptional.NormalizePluginsConfig()
return cfgOptional, nil
}
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
@@ -721,6 +811,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.MaxRetryCredentials = 0
}
cfg.NormalizePluginsConfig()
// Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys()
@@ -770,6 +862,20 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
return &cfg, nil
}
// NormalizePluginsConfig applies default plugin configuration values.
func (cfg *Config) NormalizePluginsConfig() {
if cfg == nil {
return
}
cfg.Plugins.Dir = strings.TrimSpace(cfg.Plugins.Dir)
if cfg.Plugins.Dir == "" {
cfg.Plugins.Dir = "plugins"
}
if cfg.Plugins.Configs == nil {
cfg.Plugins.Configs = map[string]PluginInstanceConfig{}
}
}
// SanitizePayloadRules validates raw JSON payload rule params and drops invalid rules.
func (cfg *Config) SanitizePayloadRules() {
if cfg == nil {
@@ -1390,6 +1496,8 @@ func isKnownDefaultValue(path []string, node *yaml.Node) bool {
return node.Value == DefaultPprofAddr
case "remote-management.panel-github-repository":
return node.Value == DefaultPanelGitHubRepository
case "plugins.dir":
return node.Value == "plugins"
case "routing.strategy":
return node.Value == "round-robin"
}
+2
View File
@@ -73,6 +73,8 @@ func ParseConfigBytes(data []byte) (*Config, error) {
cfg.MaxRetryCredentials = 0
}
cfg.NormalizePluginsConfig()
// Apply the same sanitization pipeline.
cfg.SanitizeGeminiKeys()
cfg.SanitizeVertexCompatKeys()
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestParseConfigBytes_PluginsDefaults(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins: {}
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
if cfg.Plugins.Enabled {
t.Fatal("Plugins.Enabled = true, want false")
}
if cfg.Plugins.Dir != "plugins" {
t.Fatalf("Plugins.Dir = %q, want plugins", cfg.Plugins.Dir)
}
if cfg.Plugins.Configs == nil {
t.Fatal("Plugins.Configs = nil, want empty map")
}
if len(cfg.Plugins.Configs) != 0 {
t.Fatalf("len(Plugins.Configs) = %d, want 0", len(cfg.Plugins.Configs))
}
}
func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
configs:
sample: {}
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
plugin, ok := cfg.Plugins.Configs["sample"]
if !ok {
t.Fatal("Plugins.Configs[\"sample\"] missing")
}
if plugin.Enabled == nil {
t.Fatal("Plugin.Enabled = nil, want true pointer")
}
if !*plugin.Enabled {
t.Fatal("Plugin.Enabled = false, want true")
}
if plugin.Priority != 0 {
t.Fatalf("Plugin.Priority = %d, want 0", plugin.Priority)
}
raw, errMarshal := yaml.Marshal(&plugin.Raw)
if errMarshal != nil {
t.Fatalf("yaml.Marshal(Raw) error = %v", errMarshal)
}
rawText := string(raw)
if strings.Contains(rawText, "enabled:") {
t.Fatalf("Raw YAML contains enabled default:\n%s", rawText)
}
if strings.Contains(rawText, "priority:") {
t.Fatalf("Raw YAML contains priority default:\n%s", rawText)
}
marshaled, errMarshalPlugin := yaml.Marshal(plugin)
if errMarshalPlugin != nil {
t.Fatalf("yaml.Marshal(plugin) error = %v", errMarshalPlugin)
}
marshaledText := string(marshaled)
if strings.Contains(marshaledText, "enabled:") {
t.Fatalf("Plugin YAML contains enabled default:\n%s", marshaledText)
}
if strings.Contains(marshaledText, "priority:") {
t.Fatalf("Plugin YAML contains priority default:\n%s", marshaledText)
}
}
func TestSaveConfigPreserveComments_PrunesDefaultPluginsDir(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(configPath, []byte("debug: true\n"), 0o600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
cfg := &Config{
Debug: true,
Plugins: PluginsConfig{
Dir: "plugins",
Configs: map[string]PluginInstanceConfig{},
},
}
if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
}
data, errRead := os.ReadFile(configPath)
if errRead != nil {
t.Fatalf("os.ReadFile() error = %v", errRead)
}
text := string(data)
if strings.Contains(text, "plugins:") {
t.Fatalf("saved config contains plugins default section:\n%s", text)
}
if strings.Contains(text, "dir: plugins") {
t.Fatalf("saved config contains default plugins dir:\n%s", text)
}
}
func TestParseConfigBytes_PluginInstanceRawYAML(t *testing.T) {
cfg, errParse := ParseConfigBytes([]byte(`
plugins:
enabled: true
dir: custom-plugins
configs:
sample:
enabled: false
priority: 7
config1: value1
config2:
nested: value2
`))
if errParse != nil {
t.Fatalf("ParseConfigBytes() error = %v", errParse)
}
plugin, ok := cfg.Plugins.Configs["sample"]
if !ok {
t.Fatal("Plugins.Configs[\"sample\"] missing")
}
if plugin.Enabled == nil {
t.Fatal("Plugin.Enabled = nil, want false pointer")
}
if *plugin.Enabled {
t.Fatal("Plugin.Enabled = true, want false")
}
if plugin.Priority != 7 {
t.Fatalf("Plugin.Priority = %d, want 7", plugin.Priority)
}
raw, errMarshal := yaml.Marshal(&plugin.Raw)
if errMarshal != nil {
t.Fatalf("yaml.Marshal(Raw) error = %v", errMarshal)
}
rawText := string(raw)
for _, want := range []string{
"enabled: false",
"priority: 7",
"config1: value1",
"config2:",
"nested: value2",
} {
if !strings.Contains(rawText, want) {
t.Fatalf("Raw YAML missing %q in:\n%s", want, rawText)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
package pluginhost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func (h *Host) hostConfigSummaryLocked() pluginapi.HostConfigSummary {
if h == nil || h.runtimeConfig == nil {
return pluginapi.HostConfigSummary{}
}
cfg := h.runtimeConfig
return pluginapi.HostConfigSummary{
AuthDir: strings.TrimSpace(cfg.AuthDir),
ProxyURL: strings.TrimSpace(cfg.ProxyURL),
ForceModelPrefix: cfg.ForceModelPrefix,
OAuthModelAlias: pluginOAuthModelAliases(cfg.OAuthModelAlias),
ExcludedModels: cloneStringSliceMap(cfg.OAuthExcludedModels),
}
}
func (h *Host) hostConfigSummary() pluginapi.HostConfigSummary {
if h == nil {
return pluginapi.HostConfigSummary{}
}
h.mu.Lock()
defer h.mu.Unlock()
return h.hostConfigSummaryLocked()
}
func pluginOAuthModelAliases(in map[string][]config.OAuthModelAlias) map[string][]pluginapi.ModelAlias {
if len(in) == 0 {
return nil
}
out := make(map[string][]pluginapi.ModelAlias, len(in))
for provider, aliases := range in {
key := normalizeProviderID(provider)
if key == "" {
continue
}
for _, alias := range aliases {
name := strings.TrimSpace(alias.Name)
value := strings.TrimSpace(alias.Alias)
if name == "" || value == "" {
continue
}
out[key] = append(out[key], pluginapi.ModelAlias{Name: name, Alias: value})
}
}
if len(out) == 0 {
return nil
}
return out
}
func cloneStringSliceMap(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
cleanKey := normalizeProviderID(key)
if cleanKey == "" {
continue
}
out[cleanKey] = cloneStringSlice(values)
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeProviderID(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
func authIDForPath(path, authDir string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
id := path
if authDir = strings.TrimSpace(authDir); authDir != "" {
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" && !strings.HasPrefix(rel, "..") {
id = rel
}
}
id = filepath.ToSlash(filepath.Clean(id))
if runtime.GOOS == "windows" {
id = strings.ToLower(id)
}
return id
}
func (h *Host) AuthProviderIdentifiers() []string {
if h == nil {
return nil
}
out := make([]string, 0)
for _, record := range h.Snapshot().records {
provider := record.plugin.Capabilities.AuthProvider
if provider == nil || h.isPluginFused(record.id) {
continue
}
identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, provider)
if okIdentifier && identifier != "" {
out = append(out, identifier)
}
}
return out
}
func (h *Host) HasAuthProvider(provider string) bool {
return h.authProviderRecord(provider) != nil
}
func (h *Host) authProviderRecord(provider string) *capabilityRecord {
provider = normalizeProviderID(provider)
if h == nil || provider == "" {
return nil
}
for _, record := range h.Snapshot().records {
authProvider := record.plugin.Capabilities.AuthProvider
if authProvider == nil || h.isPluginFused(record.id) {
continue
}
identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider)
if okIdentifier && identifier == provider {
copyRecord := record
return &copyRecord
}
}
return nil
}
func (h *Host) callAuthProviderIdentifier(pluginID string, provider pluginapi.AuthProvider) (identifier string, ok bool) {
if h == nil || provider == nil || h.isPluginFused(pluginID) {
return "", false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(pluginID, "AuthProvider.Identifier", recovered)
identifier = ""
ok = false
}
}()
return normalizeProviderID(provider.Identifier()), true
}
func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
if h == nil {
return nil, false, nil
}
if strings.TrimSpace(req.Provider) != "" {
record := h.authProviderRecord(req.Provider)
if record == nil {
return nil, false, nil
}
return h.callParseAuth(ctx, *record, req)
}
for _, record := range h.Snapshot().records {
if record.plugin.Capabilities.AuthProvider == nil || h.isPluginFused(record.id) {
continue
}
auth, handled, errParse := h.callParseAuth(ctx, record, req)
if errParse != nil || handled {
return auth, handled, errParse
}
}
return nil, false, nil
}
func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auth *coreauth.Auth, handled bool, err error) {
provider := record.plugin.Capabilities.AuthProvider
if h == nil || provider == nil || h.isPluginFused(record.id) {
return nil, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.ParseAuth", recovered)
auth = nil
handled = false
err = fmt.Errorf("auth provider panic: %v", recovered)
}
}()
if req.Host.AuthDir == "" {
req.Host = h.hostConfigSummary()
}
req.Provider = normalizeProviderID(req.Provider)
if req.Provider == "" {
req.Provider = normalizeProviderID(provider.Identifier())
}
req.RawJSON = bytes.Clone(req.RawJSON)
resp, errParse := provider.ParseAuth(ctx, req)
if errParse != nil {
return nil, false, errParse
}
if !resp.Handled {
return nil, false, nil
}
data := resp.Auth
if strings.TrimSpace(data.Provider) == "" {
data.Provider = req.Provider
}
if strings.TrimSpace(data.Provider) == "" {
data.Provider = normalizeProviderID(provider.Identifier())
}
if normalizeProviderID(data.Provider) == "" {
return nil, true, fmt.Errorf("auth provider %s returned auth without provider", record.id)
}
parsed := h.AuthDataToCoreAuth(data, req.Path, req.FileName)
if parsed == nil {
return nil, true, fmt.Errorf("auth provider %s returned invalid auth data", record.id)
}
return parsed, true, nil
}
func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string) (pluginapi.AuthLoginStartResponse, bool, error) {
record := h.authProviderRecord(provider)
if record == nil {
return pluginapi.AuthLoginStartResponse{}, false, nil
}
return h.callStartLogin(ctx, *record, provider, baseURL)
}
func (h *Host) callStartLogin(ctx context.Context, record capabilityRecord, provider string, baseURL string) (resp pluginapi.AuthLoginStartResponse, handled bool, err error) {
authProvider := record.plugin.Capabilities.AuthProvider
if h == nil || authProvider == nil || h.isPluginFused(record.id) {
return pluginapi.AuthLoginStartResponse{}, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.StartLogin", recovered)
resp = pluginapi.AuthLoginStartResponse{}
handled = false
err = fmt.Errorf("auth provider start login panic: %v", recovered)
}
}()
req := pluginapi.AuthLoginStartRequest{
Provider: normalizeProviderID(provider),
BaseURL: strings.TrimSpace(baseURL),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(nil),
}
resp, errStart := authProvider.StartLogin(ctx, req)
if errStart != nil {
return pluginapi.AuthLoginStartResponse{}, true, errStart
}
return resp, true, nil
}
func (h *Host) PollLogin(ctx context.Context, provider, state string, metadata ...map[string]any) (pluginapi.AuthLoginPollResponse, bool, error) {
record := h.authProviderRecord(provider)
if record == nil {
return pluginapi.AuthLoginPollResponse{}, false, nil
}
var pollMetadata map[string]any
if len(metadata) > 0 {
pollMetadata = metadata[0]
}
return h.callPollLogin(ctx, *record, provider, state, pollMetadata)
}
func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provider, state string, metadata map[string]any) (resp pluginapi.AuthLoginPollResponse, handled bool, err error) {
authProvider := record.plugin.Capabilities.AuthProvider
if h == nil || authProvider == nil || h.isPluginFused(record.id) {
return pluginapi.AuthLoginPollResponse{}, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.PollLogin", recovered)
resp = pluginapi.AuthLoginPollResponse{}
handled = false
err = fmt.Errorf("auth provider poll login panic: %v", recovered)
}
}()
req := pluginapi.AuthLoginPollRequest{
Provider: normalizeProviderID(provider),
State: strings.TrimSpace(state),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(nil),
Metadata: cloneAnyMap(metadata),
}
resp, errPoll := authProvider.PollLogin(ctx, req)
if errPoll != nil {
return pluginapi.AuthLoginPollResponse{}, true, errPoll
}
return resp, true, nil
}
func (h *Host) AuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string) *coreauth.Auth {
authDir := ""
if h != nil {
authDir = h.hostConfigSummary().AuthDir
}
return pluginAuthDataToCoreAuth(data, path, fileName, authDir)
}
type pluginTokenStorage struct {
provider string
rawJSON []byte
meta map[string]any
}
func (s *pluginTokenStorage) SetMetadata(meta map[string]any) {
if s == nil {
return
}
s.meta = cloneAnyMap(meta)
}
func (s *pluginTokenStorage) RawJSON() []byte {
if s == nil {
return nil
}
payload, errPayload := mergedStorageJSON(s.rawJSON, s.meta, s.provider)
if errPayload != nil {
return nil
}
return payload
}
func (s *pluginTokenStorage) SaveTokenToFile(path string) error {
if s == nil {
return fmt.Errorf("plugin token storage is nil")
}
payload, errPayload := mergedStorageJSON(s.rawJSON, s.meta, s.provider)
if errPayload != nil {
return errPayload
}
if len(bytes.TrimSpace(payload)) == 0 {
return fmt.Errorf("plugin token storage payload is empty")
}
if pluginTokenStorageFileCurrent(path, payload) {
return nil
}
return atomicWriteFile(path, payload)
}
func pluginTokenStorageFileCurrent(path string, payload []byte) bool {
if strings.TrimSpace(path) == "" || len(bytes.TrimSpace(payload)) == 0 {
return false
}
current, errRead := os.ReadFile(path)
if errRead != nil {
return false
}
return jsonPayloadEqual(current, payload)
}
func jsonPayloadEqual(left, right []byte) bool {
var leftValue any
if errUnmarshalLeft := json.Unmarshal(left, &leftValue); errUnmarshalLeft != nil {
return false
}
var rightValue any
if errUnmarshalRight := json.Unmarshal(right, &rightValue); errUnmarshalRight != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func mergedStorageJSON(raw []byte, metadata map[string]any, provider string) ([]byte, error) {
out := make(map[string]any)
if len(bytes.TrimSpace(raw)) > 0 {
if errUnmarshal := json.Unmarshal(raw, &out); errUnmarshal != nil {
return nil, fmt.Errorf("decode plugin token storage: %w", errUnmarshal)
}
if out == nil {
out = make(map[string]any)
}
}
for key, value := range metadata {
out[key] = value
}
provider = normalizeProviderID(provider)
if provider != "" {
out["type"] = provider
}
if len(out) == 0 {
return nil, fmt.Errorf("plugin token storage payload is empty")
}
payload, errMarshal := json.Marshal(out)
if errMarshal != nil {
return nil, fmt.Errorf("encode plugin token storage: %w", errMarshal)
}
return payload, nil
}
func atomicWriteFile(path string, data []byte) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("path is empty")
}
dir := filepath.Dir(path)
if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
return fmt.Errorf("create auth directory: %w", errMkdir)
}
tmp, errCreate := os.CreateTemp(dir, ".plugin-auth-*.tmp")
if errCreate != nil {
return fmt.Errorf("create temp auth file: %w", errCreate)
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, errWrite := tmp.Write(data); errWrite != nil {
if errClose := tmp.Close(); errClose != nil {
errWrite = fmt.Errorf("%w; close temp auth file: %v", errWrite, errClose)
}
return fmt.Errorf("write temp auth file: %w", errWrite)
}
if errClose := tmp.Close(); errClose != nil {
return fmt.Errorf("close temp auth file: %w", errClose)
}
if errRename := os.Rename(tmpPath, path); errRename != nil {
return fmt.Errorf("rename temp auth file: %w", errRename)
}
return nil
}
func pluginAuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string, authDir string) *coreauth.Auth {
provider := normalizeProviderID(data.Provider)
if provider == "" {
return nil
}
metadata := cloneAnyMap(data.Metadata)
if metadata == nil {
metadata = make(map[string]any)
}
if provider != "" {
metadata["type"] = provider
}
attributes := cloneStringMap(data.Attributes)
if attributes == nil {
attributes = make(map[string]string)
}
path = strings.TrimSpace(path)
if path != "" {
attributes["path"] = path
attributes["source"] = path
}
fileName = strings.TrimSpace(firstNonEmpty(data.FileName, fileName))
if fileName != "" && attributes["source"] == "" {
attributes["source"] = fileName
}
id := strings.TrimSpace(data.ID)
if id == "" {
id = authIDForPath(firstNonEmpty(path, fileName), authDir)
}
status := coreauth.StatusActive
if data.Disabled {
status = coreauth.StatusDisabled
}
now := time.Now().UTC()
auth := &coreauth.Auth{
Provider: provider,
ID: id,
FileName: fileName,
Label: strings.TrimSpace(data.Label),
Prefix: strings.TrimSpace(data.Prefix),
ProxyURL: strings.TrimSpace(data.ProxyURL),
Disabled: data.Disabled,
Status: status,
Storage: &pluginTokenStorage{provider: provider, rawJSON: bytes.Clone(data.StorageJSON), meta: metadata},
Metadata: metadata,
Attributes: attributes,
CreatedAt: now,
UpdatedAt: now,
NextRefreshAfter: data.NextRefreshAfter,
}
return auth
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
+317
View File
@@ -0,0 +1,317 @@
package pluginhost
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestAuthProviderDiscovery(t *testing.T) {
host := newHostWithRecords(
capabilityRecord{
id: "high",
priority: 20,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{identifier: " High-Provider "},
}},
},
capabilityRecord{
id: "low",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{identifier: "low-provider"},
}},
},
capabilityRecord{
id: "missing-auth-provider",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRegistrar: staticModelRegistrar("provider", "model"),
}},
},
)
identifiers := host.AuthProviderIdentifiers()
if len(identifiers) != 2 || identifiers[0] != "high-provider" || identifiers[1] != "low-provider" {
t.Fatalf("AuthProviderIdentifiers() = %#v, want sorted normalized providers", identifiers)
}
if !host.HasAuthProvider(" HIGH-PROVIDER ") {
t.Fatal("HasAuthProvider(high-provider) = false, want true")
}
if host.HasAuthProvider("missing-provider") {
t.Fatal("HasAuthProvider(missing-provider) = true, want false")
}
}
func TestParseAuthDefaultsProviderFromRequest(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
return pluginapi.AuthParseResponse{
Handled: true,
Auth: pluginapi.AuthData{
ID: "auth-1",
},
}, nil
},
},
},
},
})
auth, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{Provider: "plugin-provider"})
if errParse != nil {
t.Fatalf("ParseAuth() error = %v", errParse)
}
if !handled || auth == nil {
t.Fatalf("ParseAuth() handled=%t auth=%#v, want parsed auth", handled, auth)
}
if auth.Provider != "plugin-provider" || auth.Metadata["type"] != "plugin-provider" {
t.Fatalf("ParseAuth() auth = %#v, want plugin-provider defaults", auth)
}
}
func TestParseAuthDefaultsProviderFromAuthProviderIdentifier(t *testing.T) {
seenProvider := ""
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "Plugin-Provider",
parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
seenProvider = req.Provider
return pluginapi.AuthParseResponse{
Handled: true,
Auth: pluginapi.AuthData{
ID: "auth-1",
},
}, nil
},
},
},
},
})
auth, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{})
if errParse != nil {
t.Fatalf("ParseAuth() error = %v", errParse)
}
if !handled || auth == nil {
t.Fatalf("ParseAuth() handled=%t auth=%#v, want parsed auth", handled, auth)
}
if seenProvider != "plugin-provider" {
t.Fatalf("plugin parse request provider = %q, want plugin-provider", seenProvider)
}
if auth.Provider != "plugin-provider" || auth.Metadata["type"] != "plugin-provider" {
t.Fatalf("ParseAuth() auth = %#v, want identifier provider fallback", auth)
}
}
func TestStartLoginPassesProviderBaseURLHostAndHTTPClient(t *testing.T) {
authDir := t.TempDir()
expiresAt := time.Now().Add(time.Minute).UTC()
called := false
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
startLogin: func(ctx context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
called = true
if req.Provider != "plugin-provider" || req.BaseURL != "http://localhost:8080/login" {
t.Fatalf("StartLogin request = %#v, want provider/baseURL", req)
}
if req.Host.AuthDir != authDir || req.Host.ProxyURL != "http://proxy.local" || !req.Host.ForceModelPrefix {
t.Fatalf("StartLogin host = %#v, want configured summary", req.Host)
}
if req.HTTPClient == nil {
t.Fatal("StartLogin HTTPClient = nil, want host HTTP bridge")
}
return pluginapi.AuthLoginStartResponse{
Provider: req.Provider,
URL: "http://provider/login",
State: "state-1",
ExpiresAt: expiresAt,
}, nil
},
},
},
},
})
host.runtimeConfig = &config.Config{
SDKConfig: config.SDKConfig{
ProxyURL: "http://proxy.local",
ForceModelPrefix: true,
},
AuthDir: authDir,
}
resp, handled, errStart := host.StartLogin(context.Background(), " Plugin-Provider ", "http://localhost:8080/login")
if errStart != nil {
t.Fatalf("StartLogin() error = %v", errStart)
}
if !handled || !called {
t.Fatalf("StartLogin() handled=%t called=%t, want handled call", handled, called)
}
if resp.Provider != "plugin-provider" || resp.URL != "http://provider/login" || resp.State != "state-1" || !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("StartLogin() response = %#v, want plugin response", resp)
}
}
func TestPollLoginPassesProviderStateHostAndHTTPClient(t *testing.T) {
authDir := t.TempDir()
called := false
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
pollLogin: func(ctx context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
called = true
if req.Provider != "plugin-provider" || req.State != "state-1" {
t.Fatalf("PollLogin request = %#v, want provider/state", req)
}
if req.Host.AuthDir != authDir || req.Host.ProxyURL != "http://proxy.local" || !req.Host.ForceModelPrefix {
t.Fatalf("PollLogin host = %#v, want configured summary", req.Host)
}
if req.HTTPClient == nil {
t.Fatal("PollLogin HTTPClient = nil, want host HTTP bridge")
}
return pluginapi.AuthLoginPollResponse{
Status: pluginapi.AuthLoginStatusSuccess,
Message: "done",
Auth: pluginapi.AuthData{
Provider: "plugin-provider",
ID: "auth-1",
},
}, nil
},
},
},
},
})
host.runtimeConfig = &config.Config{
SDKConfig: config.SDKConfig{
ProxyURL: "http://proxy.local",
ForceModelPrefix: true,
},
AuthDir: authDir,
}
resp, handled, errPoll := host.PollLogin(context.Background(), " Plugin-Provider ", " state-1 ")
if errPoll != nil {
t.Fatalf("PollLogin() error = %v", errPoll)
}
if !handled || !called {
t.Fatalf("PollLogin() handled=%t called=%t, want handled call", handled, called)
}
if resp.Status != pluginapi.AuthLoginStatusSuccess || resp.Message != "done" || resp.Auth.ID != "auth-1" {
t.Fatalf("PollLogin() response = %#v, want plugin response", resp)
}
}
func TestHostAuthDataToCoreAuthRejectsMissingProviderAndUsesAuthDir(t *testing.T) {
authDir := t.TempDir()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
path := filepath.Join(authDir, "nested", "auth.json")
if auth := host.AuthDataToCoreAuth(pluginapi.AuthData{ID: "auth-1"}, path, "auth.json"); auth != nil {
t.Fatalf("AuthDataToCoreAuth() = %#v, want nil for missing provider", auth)
}
auth := host.AuthDataToCoreAuth(pluginapi.AuthData{Provider: "Plugin-Provider"}, path, "")
if auth == nil {
t.Fatal("AuthDataToCoreAuth() = nil, want auth")
}
if auth.Provider != "plugin-provider" || auth.ID != "nested/auth.json" {
t.Fatalf("AuthDataToCoreAuth() auth = %#v, want normalized provider and relative ID", auth)
}
if auth.Metadata["type"] != "plugin-provider" || auth.Attributes["path"] != path || auth.Attributes["source"] != path {
t.Fatalf("AuthDataToCoreAuth() metadata=%#v attributes=%#v, want path/source/type", auth.Metadata, auth.Attributes)
}
}
func TestPluginTokenStorageMergesRawMetadataAndProviderType(t *testing.T) {
storage := &pluginTokenStorage{
provider: "plugin-provider",
rawJSON: []byte(`{"old":"value","type":"old-provider"}`),
}
storage.SetMetadata(map[string]any{
"new": "value",
"old": "override",
})
raw := storage.RawJSON()
var decoded map[string]any
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
t.Fatalf("RawJSON() decode error = %v", errUnmarshal)
}
if decoded["old"] != "override" || decoded["new"] != "value" || decoded["type"] != "plugin-provider" {
t.Fatalf("RawJSON() decoded = %#v, want merged metadata and provider type", decoded)
}
path := filepath.Join(t.TempDir(), "auth.json")
if errSave := storage.SaveTokenToFile(path); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
saved, errReadFile := os.ReadFile(path)
if errReadFile != nil {
t.Fatalf("ReadFile(saved token) error = %v", errReadFile)
}
decoded = nil
if errUnmarshal := json.Unmarshal(saved, &decoded); errUnmarshal != nil {
t.Fatalf("saved token decode error = %v", errUnmarshal)
}
if decoded["old"] != "override" || decoded["new"] != "value" || decoded["type"] != "plugin-provider" {
t.Fatalf("saved token decoded = %#v, want merged metadata and provider type", decoded)
}
}
func TestPluginTokenStorageSkipsUnchangedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
if errWriteFile := os.WriteFile(path, []byte(`{"disabled":false,"token":"secret","type":"plugin-provider"}`), 0o600); errWriteFile != nil {
t.Fatalf("WriteFile() error = %v", errWriteFile)
}
before, errStatBefore := os.Stat(path)
if errStatBefore != nil {
t.Fatalf("Stat(before) error = %v", errStatBefore)
}
storage := &pluginTokenStorage{
provider: "plugin-provider",
rawJSON: []byte(`{"token":"secret"}`),
}
storage.SetMetadata(map[string]any{"disabled": false})
if errSave := storage.SaveTokenToFile(path); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
after, errStatAfter := os.Stat(path)
if errStatAfter != nil {
t.Fatalf("Stat(after) error = %v", errStatAfter)
}
if !os.SameFile(before, after) {
t.Fatal("SaveTokenToFile() replaced unchanged auth file, want write skipped")
}
}
func TestPluginTokenStorageRejectsEmptyPayload(t *testing.T) {
storage := &pluginTokenStorage{}
if raw := storage.RawJSON(); raw != nil {
t.Fatalf("RawJSON() = %q, want nil for empty payload", raw)
}
if errSave := storage.SaveTokenToFile(filepath.Join(t.TempDir(), "auth.json")); errSave == nil {
t.Fatal("SaveTokenToFile() error = nil, want empty payload error")
}
}
+420
View File
@@ -0,0 +1,420 @@
package pluginhost
import (
"context"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type commandLineFlagRecord struct {
pluginID string
flag pluginapi.CommandLineFlag
value string
set bool
}
// RegisterCommandLineFlags exposes plugin-declared flags on the provided FlagSet.
func (h *Host) RegisterCommandLineFlags(ctx context.Context, flagSet *flag.FlagSet) {
if h == nil || flagSet == nil {
return
}
for _, record := range h.Snapshot().records {
plugin := record.plugin.Capabilities.CommandLinePlugin
if plugin == nil || h.isPluginFused(record.id) {
continue
}
resp, errRegister := h.callCommandLineRegistrar(ctx, record, plugin)
if errRegister != nil {
log.Warnf("pluginhost: command-line registrar %s failed: %v", record.id, errRegister)
continue
}
for _, item := range resp.Flags {
h.registerCommandLineFlag(flagSet, record.id, item)
}
}
}
func (h *Host) callCommandLineRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin) (resp pluginapi.CommandLineRegistrationResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) {
return pluginapi.CommandLineRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "CommandLinePlugin.RegisterCommandLine", recovered)
resp = pluginapi.CommandLineRegistrationResponse{}
err = fmt.Errorf("command-line registrar panic: %v", recovered)
}
}()
return plugin.RegisterCommandLine(ctx, pluginapi.CommandLineRegistrationRequest{Plugin: record.meta})
}
func (h *Host) registerCommandLineFlag(flagSet *flag.FlagSet, pluginID string, item pluginapi.CommandLineFlag) {
name := strings.TrimSpace(item.Name)
if !validCommandLineFlagName(name) {
log.Warnf("pluginhost: plugin %s declared invalid command-line flag %q", pluginID, item.Name)
return
}
kind := normalizeCommandLineFlagType(item.Type)
if kind == "" {
log.Warnf("pluginhost: plugin %s declared unsupported command-line flag type %q for %s", pluginID, item.Type, name)
return
}
value, okDefault := normalizeCommandLineFlagValue(kind, item.DefaultValue)
if !okDefault {
log.Warnf("pluginhost: plugin %s declared invalid default value %q for %s", pluginID, item.DefaultValue, name)
return
}
if flagSet.Lookup(name) != nil {
log.Warnf("pluginhost: plugin %s command-line flag %s conflicts with an existing flag and was skipped", pluginID, name)
return
}
h.mu.Lock()
if _, exists := h.commandLineFlags[name]; exists {
h.mu.Unlock()
log.Warnf("pluginhost: plugin %s command-line flag %s conflicts with a higher-priority plugin and was skipped", pluginID, name)
return
}
h.commandLineFlags[name] = commandLineFlagRecord{
pluginID: pluginID,
flag: pluginapi.CommandLineFlag{
Name: name,
Usage: item.Usage,
Type: kind,
DefaultValue: value,
},
value: value,
}
h.mu.Unlock()
flagSet.Var(&commandLineFlagValue{
host: h,
name: name,
kind: kind,
}, name, item.Usage)
}
func validCommandLineFlagName(name string) bool {
return name != "" &&
!strings.HasPrefix(name, "-") &&
name != "help" &&
name != "h" &&
!strings.ContainsAny(name, " \t\r\n=")
}
func normalizeCommandLineFlagType(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "", "bool":
return "bool"
case "string":
return "string"
case "int":
return "int"
case "int64":
return "int64"
case "float64":
return "float64"
case "duration":
return "duration"
default:
return ""
}
}
func normalizeCommandLineFlagValue(kind, value string) (string, bool) {
switch kind {
case "bool":
if strings.TrimSpace(value) == "" {
return "false", true
}
parsed, errParse := strconv.ParseBool(value)
if errParse != nil {
return "", false
}
return strconv.FormatBool(parsed), true
case "string":
return value, true
case "int":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.Atoi(value)
if errParse != nil {
return "", false
}
return strconv.Itoa(parsed), true
case "int64":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.ParseInt(value, 10, 64)
if errParse != nil {
return "", false
}
return strconv.FormatInt(parsed, 10), true
case "float64":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.ParseFloat(value, 64)
if errParse != nil {
return "", false
}
return strconv.FormatFloat(parsed, 'g', -1, 64), true
case "duration":
if strings.TrimSpace(value) == "" {
return "0s", true
}
parsed, errParse := time.ParseDuration(value)
if errParse != nil {
return "", false
}
return parsed.String(), true
default:
return "", false
}
}
type commandLineFlagValue struct {
host *Host
name string
kind string
}
func (v *commandLineFlagValue) String() string {
if v == nil || v.host == nil {
return ""
}
v.host.mu.Lock()
defer v.host.mu.Unlock()
return v.host.commandLineFlags[v.name].value
}
func (v *commandLineFlagValue) Set(raw string) error {
if v == nil || v.host == nil {
return nil
}
normalized, okValue := normalizeCommandLineFlagValue(v.kind, raw)
if !okValue {
return fmt.Errorf("invalid %s value %q", v.kind, raw)
}
v.host.mu.Lock()
record, okRecord := v.host.commandLineFlags[v.name]
if okRecord {
record.value = normalized
record.set = true
v.host.commandLineFlags[v.name] = record
v.host.commandLineHits[v.name] = struct{}{}
}
v.host.mu.Unlock()
return nil
}
func (v *commandLineFlagValue) IsBoolFlag() bool {
return v != nil && v.kind == "bool"
}
// HasTriggeredCommandLineFlags reports whether any plugin-owned flag was provided.
func (h *Host) HasTriggeredCommandLineFlags() bool {
if h == nil {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
return len(h.commandLineHits) > 0
}
// ExecuteCommandLine runs all enabled plugins whose command-line flags were provided.
func (h *Host) ExecuteCommandLine(ctx context.Context, program string, args []string, configPath string, flagSet *flag.FlagSet) (int, bool) {
if h == nil {
return 0, false
}
triggeredByPlugin, allFlags := h.commandLineExecutionState(flagSet)
if len(triggeredByPlugin) == 0 {
return 0, false
}
exitCode := 0
handled := false
for _, record := range h.Snapshot().records {
plugin := record.plugin.Capabilities.CommandLinePlugin
if plugin == nil || h.isPluginFused(record.id) {
continue
}
triggered := triggeredByPlugin[record.id]
if len(triggered) == 0 {
continue
}
handled = true
resp, errExecute := h.callCommandLineExecutor(ctx, record, plugin, pluginapi.CommandLineExecutionRequest{
Plugin: record.meta,
Program: program,
Args: append([]string(nil), args...),
ConfigPath: configPath,
Host: h.hostConfigSummary(),
Flags: cloneCommandLineFlagValues(allFlags),
TriggeredFlags: cloneCommandLineFlagValues(triggered),
})
if errExecute != nil {
log.Warnf("pluginhost: command-line plugin %s failed: %v", record.id, errExecute)
if exitCode == 0 {
exitCode = 1
}
continue
}
if resp.ExitCode == 0 && len(resp.Auths) > 0 {
savedPaths, errPersist := h.persistCommandLineAuths(ctx, resp.Auths)
if errPersist != nil {
writeCommandLineOutput(os.Stdout, resp.Stdout)
writeCommandLineOutput(os.Stderr, resp.Stderr)
writeCommandLineOutput(os.Stderr, []byte(errPersist.Error()+"\n"))
if exitCode == 0 {
exitCode = 1
}
continue
}
resp.Stdout = appendCommandLineSavedPaths(resp.Stdout, savedPaths)
}
writeCommandLineOutput(os.Stdout, resp.Stdout)
writeCommandLineOutput(os.Stderr, resp.Stderr)
if resp.ExitCode != 0 && exitCode == 0 {
exitCode = resp.ExitCode
}
}
return exitCode, handled
}
func (h *Host) commandLineExecutionState(flagSet *flag.FlagSet) (map[string]map[string]pluginapi.CommandLineFlagValue, map[string]pluginapi.CommandLineFlagValue) {
triggeredByPlugin := make(map[string]map[string]pluginapi.CommandLineFlagValue)
allFlags := make(map[string]pluginapi.CommandLineFlagValue)
setFlags := make(map[string]struct{})
if flagSet != nil {
flagSet.Visit(func(f *flag.Flag) {
setFlags[f.Name] = struct{}{}
})
flagSet.VisitAll(func(f *flag.Flag) {
allFlags[f.Name] = pluginapi.CommandLineFlagValue{
Name: f.Name,
Type: "",
Value: f.Value.String(),
Set: false,
}
})
}
h.mu.Lock()
defer h.mu.Unlock()
for name, record := range h.commandLineFlags {
value := pluginapi.CommandLineFlagValue{
Name: name,
Type: record.flag.Type,
Value: record.value,
Set: record.set,
}
if _, set := setFlags[name]; set {
value.Set = true
}
allFlags[name] = value
if _, hit := h.commandLineHits[name]; !hit {
continue
}
if triggeredByPlugin[record.pluginID] == nil {
triggeredByPlugin[record.pluginID] = make(map[string]pluginapi.CommandLineFlagValue)
}
triggeredByPlugin[record.pluginID][name] = value
}
return triggeredByPlugin, allFlags
}
func cloneCommandLineFlagValues(in map[string]pluginapi.CommandLineFlagValue) map[string]pluginapi.CommandLineFlagValue {
if len(in) == 0 {
return nil
}
out := make(map[string]pluginapi.CommandLineFlagValue, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func (h *Host) callCommandLineExecutor(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin, req pluginapi.CommandLineExecutionRequest) (resp pluginapi.CommandLineExecutionResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) {
return pluginapi.CommandLineExecutionResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "CommandLinePlugin.ExecuteCommandLine", recovered)
resp = pluginapi.CommandLineExecutionResponse{}
err = fmt.Errorf("command-line execution panic: %v", recovered)
}
}()
return plugin.ExecuteCommandLine(ctx, req)
}
func (h *Host) persistCommandLineAuths(ctx context.Context, auths []pluginapi.AuthData) ([]string, error) {
if len(auths) == 0 {
return nil, nil
}
store := sdkAuth.GetTokenStore()
if store == nil {
return nil, fmt.Errorf("pluginhost: token store unavailable")
}
summary := h.hostConfigSummary()
if summary.AuthDir != "" {
if setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter {
setter.SetBaseDir(summary.AuthDir)
}
}
savedPaths := make([]string, 0, len(auths))
for index, authData := range auths {
record := h.AuthDataToCoreAuth(authData, "", "")
if record == nil {
return savedPaths, fmt.Errorf("pluginhost: command-line auth %d is invalid", index+1)
}
savedPath, errSave := store.Save(ctx, record)
if errSave != nil {
return savedPaths, fmt.Errorf("pluginhost: save command-line auth %s: %w", record.ID, errSave)
}
if strings.TrimSpace(savedPath) != "" {
savedPaths = append(savedPaths, savedPath)
}
}
return savedPaths, nil
}
func appendCommandLineSavedPaths(stdout []byte, savedPaths []string) []byte {
if len(savedPaths) == 0 {
return stdout
}
out := append([]byte(nil), stdout...)
if len(out) > 0 && out[len(out)-1] != '\n' {
out = append(out, '\n')
}
for _, savedPath := range savedPaths {
if strings.TrimSpace(savedPath) == "" {
continue
}
out = append(out, []byte(fmt.Sprintf("Authentication saved to %s\n", savedPath))...)
}
return out
}
func writeCommandLineOutput(w io.Writer, data []byte) {
if w == nil || len(data) == 0 {
return
}
if _, errWrite := w.Write(data); errWrite != nil {
log.Warnf("pluginhost: failed to write command-line plugin output: %v", errWrite)
}
}
+212
View File
@@ -0,0 +1,212 @@
package pluginhost
import (
"bytes"
"context"
"flag"
"path/filepath"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRegisterCommandLineFlagsSkipsNativeAndUsesPriority(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
flagSet.Bool("native", false, "native flag")
high := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "native", Type: "bool", Usage: "conflicting native flag"},
{Name: "help", Type: "bool", Usage: "reserved help flag"},
{Name: "h", Type: "bool", Usage: "reserved short help flag"},
{Name: "shared", Type: "string", Usage: "shared flag"},
},
}
low := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "shared", Type: "string", Usage: "lower priority shared flag"},
{Name: "low-only", Type: "int", Usage: "low priority flag"},
},
}
host := newHostWithRecords(
capabilityRecord{id: "low", priority: 1, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: low}}},
capabilityRecord{id: "high", priority: 10, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: high}}},
)
host.RegisterCommandLineFlags(context.Background(), flagSet)
if flagSet.Lookup("native") == nil {
t.Fatal("native flag missing")
}
if flagSet.Lookup("shared") == nil {
t.Fatal("shared plugin flag missing")
}
if flagSet.Lookup("low-only") == nil {
t.Fatal("low-only plugin flag missing")
}
if got := host.commandLineFlags["shared"].pluginID; got != "high" {
t.Fatalf("shared owner = %q, want high", got)
}
if _, exists := host.commandLineFlags["native"]; exists {
t.Fatal("native flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["help"]; exists {
t.Fatal("reserved help flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["h"]; exists {
t.Fatal("reserved h flag was claimed by plugin")
}
}
func TestExecuteCommandLinePassesAllArgsAndTriggeredFlags(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-command",
Type: "bool",
}},
}
host := newHostWithRecords(capabilityRecord{
id: "alpha",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: "/tmp/plugin-auth"}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-command", "tail"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
if !host.HasTriggeredCommandLineFlags() {
t.Fatal("HasTriggeredCommandLineFlags() = false, want true")
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-command", "tail"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if len(plugin.execRequests) != 1 {
t.Fatalf("execute calls = %d, want 1", len(plugin.execRequests))
}
req := plugin.execRequests[0]
if req.Program != "cliproxy" || req.ConfigPath != "/tmp/config.yaml" {
t.Fatalf("execution request = %#v, want program and config path", req)
}
if req.Host.AuthDir != "/tmp/plugin-auth" {
t.Fatalf("execution request host = %#v, want auth dir", req.Host)
}
if len(req.Args) != 2 || req.Args[0] != "-plugin-command" || req.Args[1] != "tail" {
t.Fatalf("Args = %#v, want full args", req.Args)
}
if got := req.TriggeredFlags["plugin-command"]; !got.Set || got.Value != "true" {
t.Fatalf("TriggeredFlags[plugin-command] = %#v, want set true", got)
}
}
func TestExecuteCommandLinePersistsReturnedAuths(t *testing.T) {
authDir := t.TempDir()
store := &commandLineAuthStore{}
origStore := sdkAuth.GetTokenStore()
sdkAuth.RegisterTokenStore(store)
defer sdkAuth.RegisterTokenStore(origStore)
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-login",
Type: "bool",
}},
response: pluginapi.CommandLineExecutionResponse{
Stdout: []byte("login ok\n"),
Auths: []pluginapi.AuthData{{
Provider: "Qoder",
ID: "qoder.json",
FileName: "qoder.json",
Label: "Luis",
StorageJSON: []byte(`{"token":"secret"}`),
}},
},
}
host := newHostWithRecords(capabilityRecord{
id: "qoder",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-login"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-login"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if store.baseDir != authDir {
t.Fatalf("store baseDir = %q, want %q", store.baseDir, authDir)
}
if len(store.saved) != 1 {
t.Fatalf("saved auths = %d, want 1", len(store.saved))
}
saved := store.saved[0]
if saved.Provider != "qoder" || saved.ID != "qoder.json" || saved.FileName != "qoder.json" {
t.Fatalf("saved auth = %#v, want normalized qoder auth", saved)
}
if saved.Storage == nil {
t.Fatal("saved auth storage = nil, want plugin token storage")
}
if store.paths[0] != filepath.Join(authDir, "qoder.json") {
t.Fatalf("saved path = %q, want auth dir path", store.paths[0])
}
}
type commandLinePluginDouble struct {
flags []pluginapi.CommandLineFlag
execRequests []pluginapi.CommandLineExecutionRequest
response pluginapi.CommandLineExecutionResponse
}
func (p *commandLinePluginDouble) RegisterCommandLine(context.Context, pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
return pluginapi.CommandLineRegistrationResponse{Flags: p.flags}, nil
}
func (p *commandLinePluginDouble) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) {
p.execRequests = append(p.execRequests, req)
return p.response, nil
}
type commandLineAuthStore struct {
baseDir string
saved []*coreauth.Auth
paths []string
}
func (s *commandLineAuthStore) List(context.Context) ([]*coreauth.Auth, error) {
return nil, nil
}
func (s *commandLineAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {
s.saved = append(s.saved, auth.Clone())
path := filepath.Join(s.baseDir, auth.FileName)
s.paths = append(s.paths, path)
return path, nil
}
func (s *commandLineAuthStore) Delete(context.Context, string) error {
return nil
}
func (s *commandLineAuthStore) SetBaseDir(dir string) {
s.baseDir = dir
}
+156
View File
@@ -0,0 +1,156 @@
package pluginhost
import (
"bytes"
"sort"
"strconv"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
)
var defaultRuntimeConfigYAML = []byte("enabled: true\npriority: 0\n")
type runtimeConfig struct {
Enabled bool
Dir string
Items map[string]runtimeItemConfig
}
type runtimeItemConfig struct {
ID string
Enabled bool
Priority int
ConfigYAML []byte
}
func runtimeConfigFromConfig(cfg *config.Config) runtimeConfig {
out := runtimeConfig{
Dir: "plugins",
Items: make(map[string]runtimeItemConfig),
}
if cfg == nil {
return out
}
out.Enabled = cfg.Plugins.Enabled
out.Dir = strings.TrimSpace(cfg.Plugins.Dir)
if out.Dir == "" {
out.Dir = "plugins"
}
ids := make([]string, 0, len(cfg.Plugins.Configs))
for id := range cfg.Plugins.Configs {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
item := cfg.Plugins.Configs[id]
enabled := true
if item.Enabled != nil {
enabled = *item.Enabled
}
out.Items[id] = runtimeItemConfig{
ID: id,
Enabled: enabled,
Priority: item.Priority,
ConfigYAML: runtimeConfigYAML(item, enabled),
}
}
return out
}
func defaultRuntimeItemConfig(id string) runtimeItemConfig {
return runtimeItemConfig{
ID: id,
Enabled: true,
Priority: 0,
ConfigYAML: append([]byte(nil), defaultRuntimeConfigYAML...),
}
}
func runtimeConfigYAML(item config.PluginInstanceConfig, enabled bool) []byte {
rawNode := normalizedConfigNode(item, enabled)
rawYAML := bytes.TrimSpace(mustMarshalYAML(rawNode))
if len(rawYAML) == 0 {
return append([]byte(nil), defaultRuntimeConfigYAML...)
}
return append(append([]byte(nil), rawYAML...), '\n')
}
func normalizedConfigNode(item config.PluginInstanceConfig, enabled bool) *yaml.Node {
if item.Raw.Kind == 0 {
return defaultRuntimeConfigNode(enabled, item.Priority)
}
node := deepCopyYAMLNode(&item.Raw)
if node.Kind != yaml.MappingNode {
return node
}
ensureMappingScalar(node, "enabled", boolYAMLValue(enabled), "!!bool")
ensureMappingScalar(node, "priority", intYAMLValue(item.Priority), "!!int")
return node
}
func defaultRuntimeConfigNode(enabled bool, priority int) *yaml.Node {
return &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolYAMLValue(enabled)},
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "priority"},
{Kind: yaml.ScalarNode, Tag: "!!int", Value: intYAMLValue(priority)},
},
}
}
func ensureMappingScalar(node *yaml.Node, key, value, tag string) {
if node == nil || node.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i] != nil && node.Content[i].Value == key {
return
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value},
)
}
func boolYAMLValue(v bool) string {
if v {
return "true"
}
return "false"
}
func intYAMLValue(v int) string {
return strconv.Itoa(v)
}
func deepCopyYAMLNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}
copyNode := *node
if len(node.Content) > 0 {
copyNode.Content = make([]*yaml.Node, 0, len(node.Content))
for _, child := range node.Content {
copyNode.Content = append(copyNode.Content, deepCopyYAMLNode(child))
}
}
return &copyNode
}
func mustMarshalYAML(v any) []byte {
raw, errMarshal := yaml.Marshal(v)
if errMarshal != nil {
return append([]byte(nil), defaultRuntimeConfigYAML...)
}
return raw
}
+35
View File
@@ -0,0 +1,35 @@
package pluginhost
import (
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
)
func TestRuntimeConfigYAMLAddsHostDefaultsToRawPluginConfig(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("config1: true\nconfig2: value\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
if len(node.Content) != 1 {
t.Fatalf("yaml node content length = %d, want 1", len(node.Content))
}
item := config.PluginInstanceConfig{
Priority: 3,
Raw: *node.Content[0],
}
got := string(runtimeConfigYAML(item, true))
for _, want := range []string{
"config1: true",
"config2: value",
"enabled: true",
"priority: 3",
} {
if !strings.Contains(got, want) {
t.Fatalf("runtimeConfigYAML() missing %q in:\n%s", want, got)
}
}
}
+263
View File
@@ -0,0 +1,263 @@
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/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
}
type Host struct {
mu sync.Mutex
loader symbolLoader
loaded map[string]*loadedPlugin
fused map[string]string
runtimeConfig *config.Config
modelClientIDs map[string]struct{}
executorModelClientIDs map[string]struct{}
modelProviders map[string]string
modelRegistrations map[string]pluginModelRegistration
providerModels map[string][]*registryModelInfo
executorProviders map[string]struct{}
accessProviderKeys map[string]struct{}
commandLineFlags map[string]commandLineFlagRecord
commandLineHits map[string]struct{}
managementRoutes map[string]managementRouteRecord
snapshot atomic.Value
}
func New() *Host {
h := &Host{
loader: defaultSymbolLoader(),
loaded: make(map[string]*loadedPlugin),
fused: make(map[string]string),
modelClientIDs: make(map[string]struct{}),
executorModelClientIDs: make(map[string]struct{}),
modelProviders: make(map[string]string),
modelRegistrations: make(map[string]pluginModelRegistration),
providerModels: make(map[string][]*registryModelInfo),
executorProviders: make(map[string]struct{}),
accessProviderKeys: make(map[string]struct{}),
commandLineFlags: make(map[string]commandLineFlagRecord),
commandLineHits: make(map[string]struct{}),
managementRoutes: make(map[string]managementRouteRecord),
}
h.snapshot.Store(emptySnapshot())
return h
}
func NewForTest(loader symbolLoader) *Host {
h := New()
h.loader = loader
return h
}
func (h *Host) Snapshot() *Snapshot {
if h == nil {
return emptySnapshot()
}
raw := h.snapshot.Load()
if snap, ok := raw.(*Snapshot); ok && snap != nil {
return snap
}
return emptySnapshot()
}
func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if h == nil {
return
}
rc := runtimeConfigFromConfig(cfg)
h.mu.Lock()
h.runtimeConfig = cfg
if !rc.Enabled {
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
files, errSelect := selectPluginFiles(rc.Dir)
if errSelect != nil {
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
records := make([]capabilityRecord, 0, len(files))
for _, file := range files {
item, ok := rc.Items[file.ID]
if !ok {
item = defaultRuntimeItemConfig(file.ID)
}
if !item.Enabled {
continue
}
if _, disabled := h.fused[file.ID]; disabled {
continue
}
lp := h.loaded[file.ID]
if lp == nil {
loaded, errLoad := h.loadLocked(file)
if errLoad != nil {
log.Warnf("pluginhost: failed to load plugin %s from %s: %v", file.ID, file.Path, errLoad)
continue
}
lp = loaded
h.loaded[file.ID] = lp
}
plugin, okCall := h.callRegisterLocked(ctx, lp, item)
if !okCall {
continue
}
records = append(records, capabilityRecord{
id: file.ID,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
})
}
sortRecords(records)
h.snapshot.Store(&Snapshot{enabled: true, records: records})
h.mu.Unlock()
h.refreshThinkingProviders(records)
}
func (h *Host) loadLocked(file pluginFile) (*loadedPlugin, error) {
lookup, errOpen := h.loader.Open(file.Path)
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,
}, nil
}
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
if lp.registered {
method = "Reconfigure"
fn = lp.reconfigure
}
plugin, okCall := h.safePluginCallLocked(ctx, lp.id, method, func() pluginapi.Plugin {
return fn(item.ConfigYAML)
})
if !okCall {
return pluginapi.Plugin{}, false
}
lp.registered = true
if !validPlugin(plugin) {
log.Warnf("pluginhost: plugin %s returned invalid metadata or no capabilities", lp.id)
return pluginapi.Plugin{}, false
}
return plugin, true
}
func (h *Host) safePluginCallLocked(ctx context.Context, id, method string, fn func() pluginapi.Plugin) (out pluginapi.Plugin, ok bool) {
defer func() {
if recovered := recover(); recovered != nil {
h.fused[id] = fmt.Sprintf("%s panic: %v", method, recovered)
log.WithField("plugin_id", id).WithField("method", method).Errorf("pluginhost: plugin panic recovered: %v\n%s", recovered, debug.Stack())
out = pluginapi.Plugin{}
ok = false
}
}()
if ctx != nil {
select {
case <-ctx.Done():
return pluginapi.Plugin{}, false
default:
}
}
return fn(), true
}
func validPlugin(plugin pluginapi.Plugin) bool {
if strings.TrimSpace(plugin.Metadata.Name) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Version) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Author) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.GitHubRepository) == "" {
return false
}
caps := plugin.Capabilities
return caps.ModelRegistrar != nil ||
caps.ModelProvider != nil ||
caps.AuthProvider != nil ||
caps.FrontendAuthProvider != nil ||
caps.Executor != nil ||
caps.RequestTranslator != nil ||
caps.RequestNormalizer != nil ||
caps.ResponseTranslator != nil ||
caps.ResponseBeforeTranslator != nil ||
caps.ResponseAfterTranslator != nil ||
caps.ThinkingApplier != nil ||
caps.UsagePlugin != nil ||
caps.CommandLinePlugin != nil ||
caps.ManagementAPI != nil
}
func typeName(v any) string {
if v == nil {
return "<nil>"
}
return reflect.TypeOf(v).String()
}
+250
View File
@@ -0,0 +1,250 @@
package pluginhost
import (
"context"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"github.com/tidwall/gjson"
)
func TestHostApplyConfig_DisabledGlobalSkipsSnapshot(t *testing.T) {
loader := newTestSymbolLoader()
h := NewForTest(loader)
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: false,
Dir: makePluginDir(t, "alpha"),
},
})
if loader.openCalls != 0 {
t.Fatalf("Open calls = %d, want 0", loader.openCalls)
}
snap := h.Snapshot()
if snap.enabled || len(snap.records) != 0 {
t.Fatalf("Snapshot() = %+v, want empty disabled snapshot", snap)
}
}
func TestHostApplyConfig_DisabledPluginSkipsCapability(t *testing.T) {
enabled := false
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha"),
Configs: map[string]config.PluginInstanceConfig{
"alpha": {Enabled: &enabled},
},
},
})
if plugin.registerCalls != 0 || plugin.reconfigureCalls != 0 {
t.Fatalf("calls = register %d reconfigure %d, want 0", plugin.registerCalls, plugin.reconfigureCalls)
}
if loader.openCalls != 0 {
t.Fatalf("Open calls = %d, want 0", loader.openCalls)
}
if len(h.Snapshot().records) != 0 {
t.Fatalf("Snapshot records = %d, want 0", len(h.Snapshot().records))
}
}
func TestHostApplyConfigRegistersPluginThinkingApplier(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
plugin.registerResult.Capabilities.ThinkingApplier = testThinkingCapability{provider: "plugin-thinking"}
plugin.reconfigureResult.Capabilities.ThinkingApplier = testThinkingCapability{provider: "plugin-thinking"}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha"),
},
}
t.Cleanup(func() {
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: false,
Dir: cfg.Plugins.Dir,
},
})
})
h.ApplyConfig(context.Background(), cfg)
out, errApply := thinking.ApplyThinking([]byte(`{"model":"plugin-model"}`), "plugin-model(10240)", "openai", "plugin-thinking", "plugin-thinking")
if errApply != nil {
t.Fatalf("ApplyThinking() error = %v", errApply)
}
if got := gjson.GetBytes(out, "thinking_budget").Int(); got != 10240 {
t.Fatalf("thinking_budget = %d, want 10240; body=%s", got, string(out))
}
if got := gjson.GetBytes(out, "plugin").String(); got != "plugin-thinking" {
t.Fatalf("plugin = %q, want plugin-thinking; body=%s", got, string(out))
}
}
func TestHostApplyConfig_ReconfigureCalledOnReload(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha"),
},
}
h.ApplyConfig(context.Background(), cfg)
h.ApplyConfig(context.Background(), cfg)
if plugin.registerCalls != 1 {
t.Fatalf("Register calls = %d, want 1", plugin.registerCalls)
}
if plugin.reconfigureCalls != 1 {
t.Fatalf("Reconfigure calls = %d, want 1", plugin.reconfigureCalls)
}
if loader.openCalls != 1 {
t.Fatalf("Open calls = %d, want 1", loader.openCalls)
}
if len(h.Snapshot().records) != 1 {
t.Fatalf("Snapshot records = %d, want 1", len(h.Snapshot().records))
}
}
func TestRegisteredPluginsIncludesMetadataAndOAuthCapability(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
}
plugin.registerResult.Metadata.Logo = "https://example.com/logo.svg"
plugin.registerResult.Metadata.ConfigFields = []pluginapi.ConfigField{{
Name: "mode",
Type: pluginapi.ConfigFieldTypeEnum,
EnumValues: []string{"safe", "fast"},
Description: "Execution mode.",
}}
plugin.registerResult.Capabilities.AuthProvider = fakeAuthProvider{identifier: "alpha"}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha"),
},
})
infos := h.RegisteredPlugins()
if len(infos) != 1 {
t.Fatalf("RegisteredPlugins() len = %d, want 1; infos=%#v", len(infos), infos)
}
if !infos[0].SupportsOAuth {
t.Fatalf("RegisteredPlugins()[0].SupportsOAuth = false, want true; infos=%#v", infos)
}
if infos[0].Metadata.Logo == "" || len(infos[0].Metadata.ConfigFields) != 1 {
t.Fatalf("RegisteredPlugins()[0].Metadata = %#v, want logo and config fields", infos[0].Metadata)
}
}
func TestHostApplyConfig_InvalidMetadataOrNoCapabilitiesSkipped(t *testing.T) {
loader := newTestSymbolLoader()
loader.lookups["empty-name"] = newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin(""),
reconfigureResult: validTestPlugin(""),
})
loader.lookups["no-caps"] = newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin("no-caps"),
reconfigureResult: validTestPlugin("no-caps"),
})
loader.lookups["no-caps"].symbols["Register"] = func([]byte) pluginapi.Plugin {
return pluginapi.Plugin{Metadata: pluginapi.Metadata{
Name: "no-caps",
Version: "1.0.0",
Author: "test",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
}}
}
h := NewForTest(loader)
h.ApplyConfig(context.Background(), &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "empty-name", "no-caps"),
},
})
if len(h.Snapshot().records) != 0 {
t.Fatalf("Snapshot records = %d, want 0", len(h.Snapshot().records))
}
}
func TestHostApplyConfig_PanicFusesPluginForProcessLifetime(t *testing.T) {
loader := newTestSymbolLoader()
plugin := &testPlugin{
registerResult: validTestPlugin("alpha"),
reconfigureResult: validTestPlugin("alpha"),
panicOnReload: true,
}
loader.lookups["alpha"] = newTestSymbolLookup(plugin)
h := NewForTest(loader)
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Dir: makePluginDir(t, "alpha"),
},
}
h.ApplyConfig(context.Background(), cfg)
h.ApplyConfig(context.Background(), cfg)
plugin.panicOnReload = false
h.ApplyConfig(context.Background(), cfg)
if plugin.registerCalls != 1 {
t.Fatalf("Register calls = %d, want 1", plugin.registerCalls)
}
if plugin.reconfigureCalls != 1 {
t.Fatalf("Reconfigure calls = %d, want 1", plugin.reconfigureCalls)
}
if len(h.Snapshot().records) != 0 {
t.Fatalf("Snapshot records = %d, want 0 after fuse", len(h.Snapshot().records))
}
}
func TestSortRecordsPriorityDescendingAndIDTieBreak(t *testing.T) {
records := []capabilityRecord{
{id: "charlie", priority: 1},
{id: "bravo", priority: 2},
{id: "alpha", priority: 2},
}
sortRecords(records)
want := []string{"alpha", "bravo", "charlie"}
for index, id := range want {
if records[index].id != id {
t.Fatalf("records[%d].id = %q, want %q", index, records[index].id, id)
}
}
}
+172
View File
@@ -0,0 +1,172 @@
package pluginhost
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type hostHTTPClient struct {
host *Host
auth *coreauth.Auth
provider string
}
func (h *Host) newHTTPClient(auth *coreauth.Auth, providers ...string) pluginapi.HostHTTPClient {
provider := ""
if len(providers) > 0 {
provider = providers[0]
}
return &hostHTTPClient{host: h, auth: auth, provider: provider}
}
func (c *hostHTTPClient) Do(ctx context.Context, req pluginapi.HTTPRequest) (pluginapi.HTTPResponse, error) {
if ctx == nil {
ctx = context.Background()
}
resp, cfg, errDo := c.doHTTP(ctx, req)
if errDo != nil {
return pluginapi.HTTPResponse{}, errDo
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Warnf("pluginhost: response body close error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, cfg, resp.StatusCode, resp.Header.Clone())
body, errReadAll := io.ReadAll(resp.Body)
if len(body) > 0 {
helps.AppendAPIResponseChunk(ctx, cfg, body)
}
if errReadAll != nil {
helps.RecordAPIResponseError(ctx, cfg, errReadAll)
return pluginapi.HTTPResponse{}, fmt.Errorf("read host http response: %w", errReadAll)
}
return pluginapi.HTTPResponse{
StatusCode: resp.StatusCode,
Headers: cloneHeader(resp.Header),
Body: body,
}, nil
}
func (c *hostHTTPClient) DoStream(ctx context.Context, req pluginapi.HTTPRequest) (pluginapi.HTTPStreamResponse, error) {
if ctx == nil {
ctx = context.Background()
}
resp, cfg, errDo := c.doHTTP(ctx, req)
if errDo != nil {
return pluginapi.HTTPStreamResponse{}, errDo
}
helps.RecordAPIResponseMetadata(ctx, cfg, resp.StatusCode, resp.Header.Clone())
chunks := make(chan pluginapi.HTTPStreamChunk)
go func() {
defer close(chunks)
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Warnf("pluginhost: stream response body close error: %v", errClose)
}
}()
buf := make([]byte, 32*1024)
for {
n, errRead := resp.Body.Read(buf)
if n > 0 {
payload := bytes.Clone(buf[:n])
helps.AppendAPIResponseChunk(ctx, cfg, payload)
select {
case <-ctx.Done():
return
case chunks <- pluginapi.HTTPStreamChunk{Payload: payload}:
}
}
if errRead != nil {
if errRead != io.EOF {
helps.RecordAPIResponseError(ctx, cfg, errRead)
select {
case <-ctx.Done():
case chunks <- pluginapi.HTTPStreamChunk{Err: errRead}:
}
}
return
}
}
}()
return pluginapi.HTTPStreamResponse{
StatusCode: resp.StatusCode,
Headers: cloneHeader(resp.Header),
Chunks: chunks,
}, nil
}
func (c *hostHTTPClient) doHTTP(ctx context.Context, req pluginapi.HTTPRequest) (*http.Response, *config.Config, error) {
if c == nil || c.host == nil {
return nil, nil, fmt.Errorf("host http client is unavailable")
}
if ctx == nil {
ctx = context.Background()
}
cfg := c.host.currentRuntimeConfig()
method := req.Method
if method == "" {
method = http.MethodGet
}
httpReq, errNewRequest := http.NewRequestWithContext(ctx, method, req.URL, bytes.NewReader(bytes.Clone(req.Body)))
if errNewRequest != nil {
return nil, cfg, fmt.Errorf("create host http request: %w", errNewRequest)
}
httpReq.Header = cloneHeader(req.Headers)
c.recordHTTPRequest(ctx, cfg, httpReq, req.Body)
client := helps.NewProxyAwareHTTPClient(ctx, cfg, c.auth, 0)
if client == nil {
client = &http.Client{}
}
resp, errDo := client.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, cfg, errDo)
return nil, cfg, fmt.Errorf("execute host http request: %w", errDo)
}
return resp, cfg, nil
}
func (c *hostHTTPClient) recordHTTPRequest(ctx context.Context, cfg *config.Config, req *http.Request, body []byte) {
if req == nil {
return
}
provider := c.provider
var authID, authLabel, authType, authValue string
if c.auth != nil {
authID = c.auth.ID
authLabel = c.auth.Label
authType, authValue = c.auth.AccountInfo()
if provider == "" {
provider = c.auth.Provider
}
}
helps.RecordAPIRequest(ctx, cfg, helps.UpstreamRequestLog{
URL: req.URL.String(),
Method: req.Method,
Headers: req.Header.Clone(),
Body: bytes.Clone(body),
Provider: provider,
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
}
func (h *Host) currentRuntimeConfig() *config.Config {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
return h.runtimeConfig
}
+35
View File
@@ -0,0 +1,35 @@
//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{}
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !(linux || darwin || freebsd)
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 defaultSymbolLoader() symbolLoader {
return unsupportedLoader{}
}
+193
View File
@@ -0,0 +1,193 @@
package pluginhost
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
const managementBasePath = "/v0/management"
type managementRouteRecord struct {
pluginID string
route pluginapi.ManagementRoute
}
// RegisterManagementRoutes rebuilds the plugin-owned Management API route table.
func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
if h == nil {
return
}
nextRoutes := make(map[string]managementRouteRecord)
for _, record := range h.Snapshot().records {
plugin := record.plugin.Capabilities.ManagementAPI
if plugin == nil || h.isPluginFused(record.id) {
continue
}
resp, errRegister := h.callManagementRegistrar(ctx, record, plugin)
if errRegister != nil {
log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
continue
}
for _, item := range resp.Routes {
method, path, okRoute := normalizeManagementRoute(item)
if !okRoute {
log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
continue
}
key := managementRouteKey(method, path)
if _, exists := reserved[key]; exists {
log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)
continue
}
if _, exists := nextRoutes[key]; exists {
log.Warnf("pluginhost: plugin %s management route %s conflicts with a higher-priority plugin and was skipped", record.id, key)
continue
}
item.Method = method
item.Path = path
nextRoutes[key] = managementRouteRecord{
pluginID: record.id,
route: item,
}
}
}
h.mu.Lock()
h.managementRoutes = nextRoutes
h.mu.Unlock()
}
func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.ManagementAPI) (resp pluginapi.ManagementRegistrationResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) {
return pluginapi.ManagementRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ManagementAPI.RegisterManagement", recovered)
resp = pluginapi.ManagementRegistrationResponse{}
err = fmt.Errorf("management registrar panic: %v", recovered)
}
}()
return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
Plugin: record.meta,
BasePath: managementBasePath,
})
}
func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, bool) {
if item.Handler == nil {
return "", "", false
}
method := strings.ToUpper(strings.TrimSpace(item.Method))
if method == "" {
method = http.MethodGet
}
if strings.ContainsAny(method, " \t\r\n") {
return "", "", false
}
path := strings.TrimSpace(item.Path)
if path == "" {
return "", "", false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasPrefix(path, managementBasePath+"/") {
path = strings.TrimPrefix(path, managementBasePath)
}
path = strings.TrimRight(path, "/")
if path == "" {
return "", "", false
}
fullPath := managementBasePath + path
if !strings.HasPrefix(fullPath, managementBasePath+"/") {
return "", "", false
}
if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") {
return "", "", false
}
return method, fullPath, true
}
func managementRouteKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
// ServeManagementHTTP dispatches an authenticated Management API request to a plugin route.
func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool {
if h == nil || w == nil || r == nil || r.URL == nil {
return false
}
key := managementRouteKey(r.Method, r.URL.Path)
h.mu.Lock()
record, okRoute := h.managementRoutes[key]
h.mu.Unlock()
if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return false
}
var body []byte
if r.Body != nil {
var errRead error
body, errRead = io.ReadAll(r.Body)
if errRead != nil {
http.Error(w, "failed to read plugin management request body", http.StatusBadRequest)
return true
}
if errClose := r.Body.Close(); errClose != nil {
log.Warnf("pluginhost: failed to close plugin management request body: %v", errClose)
}
}
r.Body = io.NopCloser(bytes.NewReader(body))
resp, errHandle := h.callManagementHandler(r.Context(), record, pluginapi.ManagementRequest{
Method: r.Method,
Path: r.URL.Path,
Headers: cloneHeader(r.Header),
Query: cloneValues(r.URL.Query()),
Body: bytes.Clone(body),
})
if errHandle != nil {
log.Warnf("pluginhost: management handler %s failed: %v", record.pluginID, errHandle)
http.Error(w, "plugin management handler failed", http.StatusBadGateway)
return true
}
for keyHeader, values := range resp.Headers {
for _, value := range values {
w.Header().Add(keyHeader, value)
}
}
statusCode := resp.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
w.WriteHeader(statusCode)
if _, errWrite := w.Write(resp.Body); errWrite != nil {
log.Warnf("pluginhost: failed to write plugin management response: %v", errWrite)
}
return true
}
func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return pluginapi.ManagementResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.pluginID, "ManagementHandler.HandleManagement", recovered)
resp = pluginapi.ManagementResponse{}
err = fmt.Errorf("management handler panic: %v", recovered)
}
}()
return record.route.Handler.HandleManagement(ctx, req)
}
+156
View File
@@ -0,0 +1,156 @@
package pluginhost
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRegisterManagementRoutesSkipsReservedAndUsesPriority(t *testing.T) {
high := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{Method: http.MethodGet, Path: "/config", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("reserved")}, nil
})},
{Method: http.MethodGet, Path: "/plugins/shared/status", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("high")}, nil
})},
},
}
low := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{Method: http.MethodGet, Path: "/plugins/shared/status", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("low")}, nil
})},
{Method: http.MethodPost, Path: "plugins/low/run", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{StatusCode: http.StatusAccepted, Body: []byte("low-only")}, nil
})},
},
}
host := newHostWithRecords(
capabilityRecord{id: "low", priority: 1, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: low}}},
capabilityRecord{id: "high", priority: 10, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: high}}},
)
host.RegisterManagementRoutes(context.Background(), map[string]struct{}{
"GET /v0/management/config": {},
})
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/shared/status", nil)
rec := httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() = false, want true")
}
if rec.Body.String() != "high" {
t.Fatalf("Body = %q, want high", rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v0/management/plugins/low/run", nil)
rec = httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() for low route = false, want true")
}
if rec.Code != http.StatusAccepted || rec.Body.String() != "low-only" {
t.Fatalf("response = %d %q, want 202 low-only", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
rec = httptest.NewRecorder()
if host.ServeManagementHTTP(rec, req) {
t.Fatal("reserved route was served by plugin")
}
}
func TestManagementHandlerPanicFusesPlugin(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "panic",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/panic",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
panic("boom")
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/panic", nil)
rec := httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() = false, want true")
}
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rec.Code)
}
if !host.isPluginFused("panic") {
t.Fatal("plugin was not fused after panic")
}
}
func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
plugin := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{
Method: http.MethodGet,
Path: "/plugins/menu/status",
Menu: "Status",
Description: "Shows plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
},
{
Method: http.MethodGet,
Path: "/plugins/menu/hidden",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
},
{
Method: http.MethodPost,
Path: "/plugins/menu/run",
Menu: "Run",
Description: "Runs a plugin action.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
},
},
}
host := newHostWithRecords(capabilityRecord{
id: "menu",
meta: pluginapi.Metadata{Name: "menu", Version: "1.0.0", Author: "test", GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI"},
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: plugin}},
})
host.RegisterManagementRoutes(context.Background(), nil)
plugins := host.RegisteredPlugins()
if len(plugins) != 1 {
t.Fatalf("RegisteredPlugins() len = %d, want 1", len(plugins))
}
if len(plugins[0].Menus) != 1 {
t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
}
menu := plugins[0].Menus[0]
if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
t.Fatalf("menu = %#v, want normalized status menu", menu)
}
}
type managementPluginDouble struct {
routes []pluginapi.ManagementRoute
}
func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil
}
type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)
func (f managementHandlerFunc) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return f(ctx, req)
}
+126
View File
@@ -0,0 +1,126 @@
package pluginhost
import (
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"golang.org/x/sys/cpu"
)
var pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
type pluginFile struct {
ID string
Path string
}
// PluginFileInfo describes a plugin binary selected by the host discovery rules.
type PluginFileInfo struct {
ID string
Path string
}
// ValidatePluginID reports whether id can be used as a plugin configuration key.
func ValidatePluginID(id string) bool {
return validPluginID(id)
}
func validPluginID(id string) bool {
return pluginIDPattern.MatchString(id)
}
func pluginIDFromPath(path string) string {
base := filepath.Base(path)
if strings.HasSuffix(strings.ToLower(base), ".so") {
return base[:len(base)-len(".so")]
}
return base
}
func selectPluginFiles(root string) ([]pluginFile, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH, cpuVariant())
selected := make([]pluginFile, 0)
seen := make(map[string]struct{})
for _, dir := range candidates {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if os.IsNotExist(errReadDir) {
continue
}
return nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == nil || !entry.Type().IsRegular() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), ".so") {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
sort.Strings(files)
for _, path := range files {
id := pluginIDFromPath(path)
if !validPluginID(id) {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
selected = append(selected, pluginFile{ID: id, Path: path})
}
}
return selected, nil
}
// DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules.
func DiscoverPluginFiles(root string) ([]PluginFileInfo, error) {
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
return nil, errSelect
}
out := make([]PluginFileInfo, 0, len(files))
for _, file := range files {
out = append(out, PluginFileInfo{
ID: file.ID,
Path: file.Path,
})
}
return out, nil
}
func candidateDirs(root, goos, goarch, variant string) []string {
dirs := make([]string, 0, 3)
if variant != "" {
dirs = append(dirs, filepath.Join(root, goos, goarch+"-"+variant))
}
dirs = append(dirs, filepath.Join(root, goos, goarch))
dirs = append(dirs, root)
return dirs
}
func cpuVariant() string {
if runtime.GOARCH != "amd64" {
return ""
}
if cpu.X86.HasAVX512F && cpu.X86.HasAVX512BW && cpu.X86.HasAVX512CD && cpu.X86.HasAVX512DQ && cpu.X86.HasAVX512VL {
return "v4"
}
if cpu.X86.HasAVX && cpu.X86.HasAVX2 && cpu.X86.HasBMI1 && cpu.X86.HasBMI2 && cpu.X86.HasFMA {
return "v3"
}
if cpu.X86.HasSSE3 && cpu.X86.HasSSSE3 && cpu.X86.HasSSE41 && cpu.X86.HasSSE42 && cpu.X86.HasPOPCNT {
return "v2"
}
return "v1"
}
+158
View File
@@ -0,0 +1,158 @@
package pluginhost
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestCandidateDirs(t *testing.T) {
got := candidateDirs("plugins", "darwin", "arm64", "v3")
want := []string{
filepath.Join("plugins", "darwin", "arm64-v3"),
filepath.Join("plugins", "darwin", "arm64"),
"plugins",
}
if len(got) != len(want) {
t.Fatalf("len(candidateDirs) = %d, want %d", len(got), len(want))
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("candidateDirs[%d] = %q, want %q", index, got[index], want[index])
}
}
}
func TestCandidateDirsOmitsEmptyVariant(t *testing.T) {
got := candidateDirs("plugins", "linux", "arm64", "")
want := []string{
filepath.Join("plugins", "linux", "arm64"),
"plugins",
}
if len(got) != len(want) {
t.Fatalf("len(candidateDirs) = %d, want %d", len(got), len(want))
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("candidateDirs[%d] = %q, want %q", index, got[index], want[index])
}
}
}
func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) {
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
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(archDir, "ignored.txt"),
}
for _, path := range paths {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
if errMkdir := os.Mkdir(filepath.Join(archDir, "dir.so"), 0o755); errMkdir != nil {
t.Fatalf("Mkdir() error = %v", errMkdir)
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
want := []pluginFile{
{ID: "another", Path: filepath.Join(archDir, "another.SO")},
{ID: "sample", Path: filepath.Join(archDir, "sample.so")},
}
if len(files) != len(want) {
t.Fatalf("selectPluginFiles() = %v, want %v", files, want)
}
for index := range want {
if files[index] != want[index] {
t.Fatalf("selectPluginFiles()[%d] = %v, want %v", index, files[index], want[index])
}
}
}
func TestSelectPluginFilesPrefersPlatformDirOverRootFallback(t *testing.T) {
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
platformPath := filepath.Join(archDir, "alpha.so")
rootPath := filepath.Join(root, "alpha.so")
for _, path := range []string{rootPath, platformPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: platformPath}) {
t.Fatalf("selectPluginFiles()[0] = %v, want platform plugin %s", files[0], platformPath)
}
}
func TestDiscoverPluginFilesReturnsSelectedPluginFiles(t *testing.T) {
root := makePluginDir(t, "alpha")
files, errDiscover := DiscoverPluginFiles(root)
if errDiscover != nil {
t.Fatalf("DiscoverPluginFiles() error = %v", errDiscover)
}
if len(files) != 1 || files[0].ID != "alpha" || files[0].Path == "" {
t.Fatalf("DiscoverPluginFiles() = %#v, want alpha file", files)
}
}
func TestSelectPluginFilesPrefersCPUVariantOverGenericArchDir(t *testing.T) {
variant := cpuVariant()
if variant == "" {
t.Skip("current GOARCH has no plugin CPU variant")
}
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
variantDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH+"-"+variant)
for _, dir := range []string{archDir, variantDir} {
if errMkdirAll := os.MkdirAll(dir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll(%s) error = %v", dir, errMkdirAll)
}
}
genericPath := filepath.Join(archDir, "alpha.so")
variantPath := filepath.Join(variantDir, "alpha.so")
for _, path := range []string{genericPath, variantPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: variantPath}) {
t.Fatalf("selectPluginFiles()[0] = %v, want CPU variant plugin %s", files[0], variantPath)
}
}
+99
View File
@@ -0,0 +1,99 @@
package pluginhost
import (
"net/http"
"sort"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type capabilityRecord struct {
id string
priority int
meta pluginapi.Metadata
plugin pluginapi.Plugin
}
type Snapshot struct {
enabled bool
records []capabilityRecord
}
// RegisteredPluginInfo describes a plugin that is active in the current runtime snapshot.
type RegisteredPluginInfo struct {
ID string
Priority int
Metadata pluginapi.Metadata
SupportsOAuth bool
Menus []RegisteredPluginMenu
}
// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry.
type RegisteredPluginMenu struct {
Path string
Menu string
Description string
}
func emptySnapshot() *Snapshot {
return &Snapshot{}
}
// RegisteredPlugins returns a stable copy of plugin metadata in the current runtime snapshot.
func (h *Host) RegisteredPlugins() []RegisteredPluginInfo {
snap := h.Snapshot()
if snap == nil || len(snap.records) == 0 {
return nil
}
menusByPlugin := h.registeredPluginMenus()
out := make([]RegisteredPluginInfo, 0, len(snap.records))
for _, record := range snap.records {
out = append(out, RegisteredPluginInfo{
ID: record.id,
Priority: record.priority,
Metadata: record.meta,
SupportsOAuth: record.plugin.Capabilities.AuthProvider != nil,
Menus: menusByPlugin[record.id],
})
}
return out
}
func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu {
out := make(map[string][]RegisteredPluginMenu)
if h == nil {
return out
}
h.mu.Lock()
defer h.mu.Unlock()
for _, record := range h.managementRoutes {
if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) {
continue
}
menu := strings.TrimSpace(record.route.Menu)
if menu == "" {
continue
}
out[record.pluginID] = append(out[record.pluginID], RegisteredPluginMenu{
Path: strings.TrimSpace(record.route.Path),
Menu: menu,
Description: strings.TrimSpace(record.route.Description),
})
}
for pluginID := range out {
sort.SliceStable(out[pluginID], func(i, j int) bool {
return out[pluginID][i].Path < out[pluginID][j].Path
})
}
return out
}
func sortRecords(records []capabilityRecord) {
sort.SliceStable(records, func(i, j int) bool {
if records[i].priority == records[j].priority {
return records[i].id < records[j].id
}
return records[i].priority > records[j].priority
})
}
+133
View File
@@ -0,0 +1,133 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type testSymbolLoader struct {
openCalls int
lookups map[string]*testSymbolLookup
}
func newTestSymbolLoader() *testSymbolLoader {
return &testSymbolLoader{lookups: make(map[string]*testSymbolLookup)}
}
func (l *testSymbolLoader) Open(path string) (symbolLookup, error) {
l.openCalls++
lookup := l.lookups[pluginIDFromPath(path)]
if lookup == nil {
return nil, fmt.Errorf("missing test plugin for %s", path)
}
return lookup, nil
}
type testSymbolLookup struct {
symbols map[string]any
}
func newTestSymbolLookup(plugin *testPlugin) *testSymbolLookup {
return &testSymbolLookup{
symbols: map[string]any{
"Register": plugin.Register,
"Reconfigure": plugin.Reconfigure,
},
}
}
func (l *testSymbolLookup) Lookup(name string) (any, error) {
symbol, ok := l.symbols[name]
if !ok {
return nil, fmt.Errorf("missing symbol %s", name)
}
return symbol, nil
}
type testPlugin struct {
registerCalls int
reconfigureCalls int
registerResult pluginapi.Plugin
reconfigureResult pluginapi.Plugin
panicOnRegister bool
panicOnReload bool
}
func (p *testPlugin) Register([]byte) pluginapi.Plugin {
p.registerCalls++
if p.panicOnRegister {
panic("register panic")
}
return p.registerResult
}
func (p *testPlugin) Reconfigure([]byte) pluginapi.Plugin {
p.reconfigureCalls++
if p.panicOnReload {
panic("reconfigure panic")
}
return p.reconfigureResult
}
func validTestPlugin(name string) pluginapi.Plugin {
return pluginapi.Plugin{
Metadata: pluginapi.Metadata{
Name: name,
Version: "1.0.0",
Author: "test",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
},
Capabilities: pluginapi.Capabilities{
UsagePlugin: testUsageCapability{},
},
}
}
type testUsageCapability struct{}
func (testUsageCapability) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {}
type testThinkingCapability struct {
provider string
}
func (c testThinkingCapability) Identifier() string {
return c.provider
}
func (c testThinkingCapability) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {
var payload map[string]any
if errUnmarshal := json.Unmarshal(req.Body, &payload); errUnmarshal != nil {
return pluginapi.PayloadResponse{}, errUnmarshal
}
payload["plugin"] = c.provider
payload["thinking_budget"] = req.Config.Budget
out, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return pluginapi.PayloadResponse{}, errMarshal
}
return pluginapi.PayloadResponse{Body: out}, nil
}
func makePluginDir(t *testing.T, ids ...string) string {
t.Helper()
root := t.TempDir()
archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
t.Fatalf("MkdirAll() error = %v", errMkdirAll)
}
for _, id := range ids {
path := filepath.Join(archDir, id+".so")
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
return root
}
+1 -1
View File
@@ -439,7 +439,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
r.invalidateAvailableModelsCacheLocked()
r.triggerModelsRegistered(provider, clientID, models)
if len(added) == 0 && len(removed) == 0 && !providerChanged {
// Only metadata (e.g., display name) changed; skip separator when no log output.
// Only metadata (e.g., display name) changed; keep no-op re-registration quiet.
return
}
+81 -4
View File
@@ -3,14 +3,23 @@ package thinking
import (
"strings"
"sync"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
// providerAppliers maps provider names to their ProviderApplier implementations.
var providerAppliers = map[string]ProviderApplier{
type pluginProviderApplier struct {
owner string
priority int
applier ProviderApplier
}
var providerAppliersMu sync.RWMutex
// nativeProviderAppliers maps built-in provider names to their implementations.
var nativeProviderAppliers = map[string]ProviderApplier{
"gemini": nil,
"gemini-cli": nil,
"claude": nil,
@@ -21,15 +30,83 @@ var providerAppliers = map[string]ProviderApplier{
"xai": nil,
}
// pluginProviderAppliers maps plugin-owned provider names to their implementations.
var pluginProviderAppliers = map[string]pluginProviderApplier{}
// GetProviderApplier returns the ProviderApplier for the given provider name.
// Returns nil if the provider is not registered.
func GetProviderApplier(provider string) ProviderApplier {
return providerAppliers[provider]
provider = normalizedProviderName(provider)
if provider == "" {
return nil
}
providerAppliersMu.RLock()
defer providerAppliersMu.RUnlock()
if nativeApplier, okNative := nativeProviderAppliers[provider]; okNative {
return nativeApplier
}
return pluginProviderAppliers[provider].applier
}
// RegisterProvider registers a provider applier by name.
func RegisterProvider(name string, applier ProviderApplier) {
providerAppliers[name] = applier
name = normalizedProviderName(name)
if name == "" {
return
}
providerAppliersMu.Lock()
defer providerAppliersMu.Unlock()
nativeProviderAppliers[name] = applier
}
// RegisterPluginProvider registers a plugin-owned provider applier.
func RegisterPluginProvider(owner string, name string, priority int, applier ProviderApplier) bool {
owner = strings.TrimSpace(owner)
name = normalizedProviderName(name)
if owner == "" || name == "" || applier == nil {
return false
}
providerAppliersMu.Lock()
defer providerAppliersMu.Unlock()
if _, native := nativeProviderAppliers[name]; native {
return false
}
current, exists := pluginProviderAppliers[name]
if exists && (current.priority > priority || (current.priority == priority && current.owner <= owner)) {
return false
}
pluginProviderAppliers[name] = pluginProviderApplier{
owner: owner,
priority: priority,
applier: applier,
}
return true
}
// UnregisterPluginProviders removes all provider appliers owned by one plugin.
func UnregisterPluginProviders(owner string) {
owner = strings.TrimSpace(owner)
if owner == "" {
return
}
providerAppliersMu.Lock()
defer providerAppliersMu.Unlock()
for provider, record := range pluginProviderAppliers {
if record.owner == owner {
delete(pluginProviderAppliers, provider)
}
}
}
// ClearPluginProviders removes all plugin-owned provider appliers.
func ClearPluginProviders() {
providerAppliersMu.Lock()
defer providerAppliersMu.Unlock()
pluginProviderAppliers = map[string]pluginProviderApplier{}
}
func normalizedProviderName(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
// IsUserDefinedModel reports whether the model is a user-defined model that should
+1 -1
View File
@@ -339,7 +339,7 @@ func normalizeLevels(levels []string) []string {
// These providers may also support level-based thinking (hybrid models).
func isBudgetCapableProvider(provider string) bool {
switch provider {
case "gemini", "gemini-cli", "antigravity", "claude":
case "gemini", "gemini-cli", "antigravity", "claude", "qoder":
return true
default:
return false
+63 -29
View File
@@ -72,16 +72,19 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
}
if rescanAuth {
w.clientsMutex.Lock()
w.lastAuthHashes = make(map[string]string)
w.authRescanMu.Lock()
cacheAuthContents := log.IsLevelEnabled(log.DebugLevel)
newAuthHashes := make(map[string]string)
var newAuthContents map[string]*coreauth.Auth
if cacheAuthContents {
w.lastAuthContents = make(map[string]*coreauth.Auth)
} else {
w.lastAuthContents = nil
newAuthContents = make(map[string]*coreauth.Auth)
}
w.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)
newFileAuthsByPath := make(map[string]map[string]*coreauth.Auth)
w.clientsMutex.RLock()
parser := w.pluginAuthParser
w.clientsMutex.RUnlock()
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
} else if resolvedAuthDir != "" {
@@ -101,30 +104,36 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
if data, errReadFile := os.ReadFile(fullPath); errReadFile == nil && len(data) > 0 {
sum := sha256.Sum256(data)
normalizedPath := w.normalizeAuthPath(fullPath)
w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
newAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
// Parse and cache auth content for future diff comparisons (debug only).
if cacheAuthContents {
var auth coreauth.Auth
if errParse := json.Unmarshal(data, &auth); errParse == nil {
w.lastAuthContents[normalizedPath] = &auth
newAuthContents[normalizedPath] = &auth
}
}
ctx := &synthesizer.SynthesisContext{
Config: cfg,
AuthDir: resolvedAuthDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
Config: cfg,
AuthDir: resolvedAuthDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
PluginAuthParser: parser,
}
if generated := synthesizer.SynthesizeAuthFile(ctx, fullPath, data); len(generated) > 0 {
if pathAuths := authSliceToMap(generated); len(pathAuths) > 0 {
w.fileAuthsByPath[normalizedPath] = authIDSet(pathAuths)
newFileAuthsByPath[normalizedPath] = authIDSet(pathAuths)
}
}
}
}
}
}
w.clientsMutex.Lock()
w.lastAuthHashes = newAuthHashes
w.lastAuthContents = newAuthContents
w.fileAuthsByPath = newFileAuthsByPath
w.clientsMutex.Unlock()
w.authRescanMu.Unlock()
}
totalNewClients := authFileCount + geminiAPIKeyCount + vertexCompatAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
@@ -149,6 +158,13 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
}
func (w *Watcher) addOrUpdateClient(path string) {
w.authRescanMu.Lock()
defer w.authRescanMu.Unlock()
w.addOrUpdateClientLocked(path)
}
func (w *Watcher) addOrUpdateClientLocked(path string) {
data, errRead := os.ReadFile(path)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
@@ -170,12 +186,16 @@ func (w *Watcher) addOrUpdateClient(path string) {
return
}
cacheAuthContents := log.IsLevelEnabled(log.DebugLevel)
w.clientsMutex.Lock()
if w.config == nil {
log.Error("config is nil, cannot add or update client")
w.clientsMutex.Unlock()
return
}
cfg := w.config
authDir := w.authDir
parser := w.pluginAuthParser
if w.fileAuthsByPath == nil {
w.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)
}
@@ -186,23 +206,17 @@ func (w *Watcher) addOrUpdateClient(path string) {
}
// Get old auth for diff comparison
cacheAuthContents := log.IsLevelEnabled(log.DebugLevel)
var oldAuth *coreauth.Auth
if cacheAuthContents && w.lastAuthContents != nil {
oldAuth = w.lastAuthContents[normalized]
}
// Compute and log field changes
if cacheAuthContents {
if changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {
log.Debugf("auth field changes for %s:", filepath.Base(path))
for _, c := range changes {
log.Debugf(" %s", c)
}
if cached := w.lastAuthContents[normalized]; cached != nil {
oldAuth = cached.Clone()
}
}
// Update caches
if w.lastAuthHashes == nil {
w.lastAuthHashes = make(map[string]string)
}
w.lastAuthHashes[normalized] = curHash
if cacheAuthContents {
if w.lastAuthContents == nil {
@@ -215,16 +229,29 @@ func (w *Watcher) addOrUpdateClient(path string) {
for id, a := range w.fileAuthsByPath[normalized] {
oldByID[id] = a
}
w.clientsMutex.Unlock()
// Compute and log field changes
if cacheAuthContents {
if changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {
log.Debugf("auth field changes for %s:", filepath.Base(path))
for _, c := range changes {
log.Debugf(" %s", c)
}
}
}
// Build synthesized auth entries for this single file only.
sctx := &synthesizer.SynthesisContext{
Config: w.config,
AuthDir: w.authDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
Config: cfg,
AuthDir: authDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
PluginAuthParser: parser,
}
generated := synthesizer.SynthesizeAuthFile(sctx, path, data)
newByID := authSliceToMap(generated)
w.clientsMutex.Lock()
if len(newByID) > 0 {
w.fileAuthsByPath[normalized] = authIDSet(newByID)
} else {
@@ -239,6 +266,13 @@ func (w *Watcher) addOrUpdateClient(path string) {
}
func (w *Watcher) removeClient(path string) {
w.authRescanMu.Lock()
defer w.authRescanMu.Unlock()
w.removeClientLocked(path)
}
func (w *Watcher) removeClientLocked(path string) {
normalized := w.normalizeAuthPath(path)
w.clientsMutex.Lock()
oldByID := make(map[string]*coreauth.Auth, len(w.fileAuthsByPath[normalized]))
+67 -8
View File
@@ -77,12 +77,58 @@ func (w *Watcher) dispatchRuntimeAuthUpdate(update AuthUpdate) bool {
return true
}
func (w *Watcher) dispatchPersistedAuthUpdate(update AuthUpdate) bool {
if w == nil {
return false
}
if update.Auth == nil || update.Auth.ID == "" {
return false
}
path := ""
if update.Auth.Attributes != nil {
path = update.Auth.Attributes["path"]
if path == "" {
path = update.Auth.Attributes["source"]
}
}
normalized := w.normalizeAuthPath(path)
if normalized == "" {
return false
}
clone := update.Auth.Clone()
w.clientsMutex.Lock()
if w.fileAuthsByPath == nil {
w.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)
}
pathAuths := w.fileAuthsByPath[normalized]
if pathAuths == nil {
pathAuths = make(map[string]*coreauth.Auth)
w.fileAuthsByPath[normalized] = pathAuths
}
pathAuths[clone.ID] = nil
if w.currentAuths == nil {
w.currentAuths = make(map[string]*coreauth.Auth)
}
w.currentAuths[clone.ID] = clone
w.clientsMutex.Unlock()
if w.getAuthQueue() == nil {
return false
}
if update.ID == "" {
update.ID = clone.ID
}
update.Auth = clone.Clone()
w.dispatchAuthUpdates([]AuthUpdate{update})
return true
}
func (w *Watcher) refreshAuthState(force bool) {
w.clientsMutex.RLock()
cfg := w.config
authDir := w.authDir
parser := w.pluginAuthParser
w.clientsMutex.RUnlock()
auths := snapshotCoreAuthsFunc(cfg, authDir)
auths := snapshotCoreAuthsFunc(cfg, authDir, parser)
w.clientsMutex.Lock()
if len(w.runtimeAuths) > 0 {
for _, a := range w.runtimeAuths {
@@ -98,10 +144,14 @@ func (w *Watcher) refreshAuthState(force bool) {
func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) []AuthUpdate {
newState := make(map[string]*coreauth.Auth, len(auths))
orderedIDs := make([]string, 0, len(auths))
for _, auth := range auths {
if auth == nil || auth.ID == "" {
continue
}
if _, exists := newState[auth.ID]; !exists {
orderedIDs = append(orderedIDs, auth.ID)
}
newState[auth.ID] = auth.Clone()
}
if w.currentAuths == nil {
@@ -110,7 +160,11 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) [
return nil
}
updates := make([]AuthUpdate, 0, len(newState))
for id, auth := range newState {
for _, id := range orderedIDs {
auth := newState[id]
if auth == nil {
continue
}
updates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})
}
return updates
@@ -120,7 +174,11 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) [
return nil
}
updates := make([]AuthUpdate, 0, len(newState)+len(w.currentAuths))
for id, auth := range newState {
for _, id := range orderedIDs {
auth := newState[id]
if auth == nil {
continue
}
if existing, ok := w.currentAuths[id]; !ok {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})
} else if force || !authEqual(existing, auth) {
@@ -255,12 +313,13 @@ func normalizeAuth(a *coreauth.Auth) *coreauth.Auth {
return clone
}
func snapshotCoreAuths(cfg *config.Config, authDir string) []*coreauth.Auth {
func snapshotCoreAuths(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
ctx := &synthesizer.SynthesisContext{
Config: cfg,
AuthDir: authDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
Config: cfg,
AuthDir: authDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
PluginAuthParser: parser,
}
var out []*coreauth.Auth
+6 -3
View File
@@ -89,6 +89,9 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
}
// Handle auth directory changes incrementally (.json only)
w.authRescanMu.Lock()
defer w.authRescanMu.Unlock()
if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {
if w.shouldDebounceRemove(normalizedName, now) {
log.Debugf("debouncing remove event for %s", filepath.Base(event.Name))
@@ -103,7 +106,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
return
}
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
w.addOrUpdateClient(event.Name)
w.addOrUpdateClientLocked(event.Name)
return
}
if !w.isKnownAuthFile(event.Name) {
@@ -111,7 +114,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
return
}
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
w.removeClient(event.Name)
w.removeClientLocked(event.Name)
return
}
if event.Op&(fsnotify.Create|fsnotify.Write) != 0 {
@@ -120,7 +123,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
return
}
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
w.addOrUpdateClient(event.Name)
w.addOrUpdateClientLocked(event.Name)
}
}
+10
View File
@@ -1,11 +1,19 @@
package synthesizer
import (
"context"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// PluginAuthParser parses auth JSON owned by plugin providers.
type PluginAuthParser interface {
ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error)
}
// SynthesisContext provides the context needed for auth synthesis.
type SynthesisContext struct {
// Config is the current configuration
@@ -16,4 +24,6 @@ type SynthesisContext struct {
Now time.Time
// IDGenerator generates stable IDs for auth entries
IDGenerator *StableIDGenerator
// PluginAuthParser parses plugin-owned auth files
PluginAuthParser PluginAuthParser
}
+25 -2
View File
@@ -1,6 +1,7 @@
package synthesizer
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// FileSynthesizer generates Auth entries from OAuth JSON files.
@@ -76,10 +78,31 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) []
return nil
}
t, _ := metadata["type"].(string)
if t == "" {
provider := strings.ToLower(strings.TrimSpace(t))
if ctx.PluginAuthParser != nil {
auth, handled, errParse := ctx.PluginAuthParser.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
Provider: provider,
Path: fullPath,
FileName: filepath.Base(fullPath),
RawJSON: data,
})
if errParse == nil && handled && auth != nil {
auth.CreatedAt = now
auth.UpdatedAt = now
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["path"] = fullPath
auth.Attributes["source"] = fullPath
perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth")
coreauth.ApplyCustomHeadersFromMetadata(auth)
return []*coreauth.Auth{auth}
}
}
if provider == "" {
return nil
}
provider := strings.ToLower(t)
if provider == "gemini" {
provider = "gemini-cli"
}
+19 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
"gopkg.in/yaml.v3"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
@@ -34,6 +35,7 @@ type Watcher struct {
authDir string
config *config.Config
clientsMutex sync.RWMutex
authRescanMu sync.Mutex
configReloadMu sync.Mutex
configReloadTimer *time.Timer
serverUpdateMu sync.Mutex
@@ -57,6 +59,7 @@ type Watcher struct {
pendingOrder []string
dispatchCancel context.CancelFunc
storePersister storePersister
pluginAuthParser synthesizer.PluginAuthParser
mirroredAuthDir string
oldConfigYaml []byte
}
@@ -138,6 +141,13 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
w.oldConfigYaml, _ = yaml.Marshal(cfg)
}
// SetPluginAuthParser updates the plugin auth parser used for file auth synthesis.
func (w *Watcher) SetPluginAuthParser(parser synthesizer.PluginAuthParser) {
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
w.pluginAuthParser = parser
}
// SetAuthUpdateQueue sets the queue used to emit auth updates.
func (w *Watcher) SetAuthUpdateQueue(queue chan<- AuthUpdate) {
w.setAuthUpdateQueue(queue)
@@ -150,10 +160,18 @@ func (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool {
return w.dispatchRuntimeAuthUpdate(update)
}
// DispatchPersistedAuthUpdate pushes already-persisted file auth updates through the watcher queue.
// Returns true if the update was enqueued; false if no queue is configured.
func (w *Watcher) DispatchPersistedAuthUpdate(update AuthUpdate) bool {
return w.dispatchPersistedAuthUpdate(update)
}
// SnapshotCoreAuths converts current clients snapshot into core auth entries.
func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
w.clientsMutex.RLock()
cfg := w.config
authDir := w.authDir
parser := w.pluginAuthParser
w.clientsMutex.RUnlock()
return snapshotCoreAuths(cfg, w.authDir)
return snapshotCoreAuths(cfg, authDir, parser)
}
+2 -2
View File
@@ -479,9 +479,9 @@ func TestAuthFileEventsDoNotInvokeSnapshotCoreAuths(t *testing.T) {
origSnapshot := snapshotCoreAuthsFunc
var snapshotCalls int32
snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string) []*coreauth.Auth {
snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
atomic.AddInt32(&snapshotCalls, 1)
return origSnapshot(cfg, authDir)
return origSnapshot(cfg, authDir, parser)
}
defer func() { snapshotCoreAuthsFunc = origSnapshot }()
+57 -3
View File
@@ -13,11 +13,41 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// PluginAuthParser parses auth JSON owned by plugin providers.
type PluginAuthParser interface {
ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error)
}
type pluginAuthParserHolder struct {
parser PluginAuthParser
}
var pluginAuthParserValue atomic.Value
// RegisterPluginAuthParser registers the current plugin auth parser.
func RegisterPluginAuthParser(parser PluginAuthParser) {
pluginAuthParserValue.Store(pluginAuthParserHolder{parser: parser})
}
func currentPluginAuthParser() PluginAuthParser {
value := pluginAuthParserValue.Load()
if value == nil {
return nil
}
holder, ok := value.(pluginAuthParserHolder)
if !ok {
return nil
}
return holder.parser
}
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
type FileTokenStore struct {
mu sync.Mutex
@@ -198,6 +228,30 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
return nil, fmt.Errorf("unmarshal auth json: %w", err)
}
provider, _ := metadata["type"].(string)
provider = strings.TrimSpace(provider)
info, errStat := os.Stat(path)
if errStat != nil {
return nil, fmt.Errorf("stat file: %w", errStat)
}
if parser := currentPluginAuthParser(); parser != nil {
auth, handled, errParse := parser.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
Provider: provider,
Path: path,
FileName: s.idFor(path, baseDir),
RawJSON: data,
})
if errParse == nil && handled && auth != nil {
auth.CreatedAt = info.ModTime()
auth.UpdatedAt = info.ModTime()
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["path"] = path
auth.Attributes["source"] = path
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
return auth, nil
}
}
if provider == "" {
provider = "unknown"
}
@@ -231,9 +285,9 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
}
}
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
info, errStat = os.Stat(path)
if errStat != nil {
return nil, fmt.Errorf("stat file: %w", errStat)
}
id := s.idFor(path, baseDir)
disabled, _ := metadata["disabled"].(bool)
+16
View File
@@ -272,6 +272,22 @@ func (m *Manager) RefreshSchedulerEntry(authID string) {
m.scheduler.upsertAuth(snapshot)
}
// RefreshSchedulerAll rebuilds scheduler entries for every known auth.
func (m *Manager) RefreshSchedulerAll() {
if m == nil {
return
}
m.mu.RLock()
ids := make([]string, 0, len(m.auths))
for id := range m.auths {
ids = append(ids, id)
}
m.mu.RUnlock()
for _, id := range ids {
m.RefreshSchedulerEntry(id)
}
}
// ReconcileRegistryModelStates aligns per-model runtime state with the current
// registry snapshot for one auth.
//
+17 -12
View File
@@ -265,33 +265,38 @@ func modelAliasChannel(auth *Auth) string {
// and auth kind. Returns empty string if the provider/authKind combination doesn't support
// OAuth model alias (e.g., API key authentication).
//
// Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi.
// Built-in channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi.
// Plugin OAuth providers use their normalized provider key as the channel.
func OAuthModelAliasChannel(provider, authKind string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
authKind = strings.ToLower(strings.TrimSpace(authKind))
authKind = normalizeOAuthModelAliasAuthKind(authKind)
if authKind == "apikey" {
return ""
}
switch provider {
case "gemini":
// gemini provider uses gemini-api-key config, not oauth-model-alias.
// OAuth-based gemini auth is converted to "gemini-cli" by the synthesizer.
return ""
case "vertex":
if authKind == "apikey" {
return ""
}
return "vertex"
case "claude":
if authKind == "apikey" {
return ""
}
return "claude"
case "codex":
if authKind == "apikey" {
return ""
}
return "codex"
case "gemini-cli", "aistudio", "antigravity", "kimi":
return provider
default:
return ""
return provider
}
}
func normalizeOAuthModelAliasAuthKind(authKind string) string {
authKind = strings.ToLower(strings.TrimSpace(authKind))
switch authKind {
case "api_key", "api-key":
return "apikey"
default:
return authKind
}
}
@@ -172,6 +172,17 @@ func TestOAuthModelAliasChannel_Kimi(t *testing.T) {
}
}
func TestOAuthModelAliasChannel_PluginProvider(t *testing.T) {
t.Parallel()
if got := OAuthModelAliasChannel(" Qoder ", "oauth"); got != "qoder" {
t.Fatalf("OAuthModelAliasChannel() = %q, want %q", got, "qoder")
}
if got := OAuthModelAliasChannel("qoder", "api_key"); got != "" {
t.Fatalf("OAuthModelAliasChannel() = %q, want empty channel for API key", got)
}
}
func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
t.Parallel()
@@ -190,3 +201,41 @@ func TestApplyOAuthModelAlias_SuffixPreservation(t *testing.T) {
t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "gemini-2.5-pro-exp-03-25(8192)")
}
}
func TestApplyOAuthModelAlias_PluginProvider(t *testing.T) {
t.Parallel()
aliases := map[string][]internalconfig.OAuthModelAlias{
"qoder": {{Name: "qmodel_latest", Alias: "qlatest"}},
}
mgr := NewManager(nil, nil, nil)
mgr.SetConfig(&internalconfig.Config{})
mgr.SetOAuthModelAlias(aliases)
auth := &Auth{ID: "qoder-auth", Provider: "qoder", Attributes: map[string]string{"auth_kind": "oauth"}}
resolvedModel := mgr.applyOAuthModelAlias(auth, "qlatest")
if resolvedModel != "qmodel_latest" {
t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "qmodel_latest")
}
}
func TestApplyOAuthModelAlias_PluginProviderSkipsAPIKey(t *testing.T) {
t.Parallel()
aliases := map[string][]internalconfig.OAuthModelAlias{
"qoder": {{Name: "qmodel_latest", Alias: "qlatest"}},
}
mgr := NewManager(nil, nil, nil)
mgr.SetConfig(&internalconfig.Config{})
mgr.SetOAuthModelAlias(aliases)
auth := &Auth{ID: "qoder-auth", Provider: "qoder", Attributes: map[string]string{"auth_kind": "api_key"}}
resolvedModel := mgr.applyOAuthModelAlias(auth, "qlatest")
if resolvedModel != "qlatest" {
t.Errorf("applyOAuthModelAlias() model = %q, want %q", resolvedModel, "qlatest")
}
}
+53 -1
View File
@@ -4,12 +4,15 @@
package cliproxy
import (
"context"
"fmt"
"strings"
"time"
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
@@ -47,6 +50,12 @@ type Builder struct {
// coreManager handles core authentication and execution.
coreManager *coreauth.Manager
// pluginHost owns dynamic plugin lifecycle and adapters.
pluginHost *pluginhost.Host
// postAuthHook is called after auth record creation and before persistence.
postAuthHook coreauth.PostAuthHook
// serverOptions contains additional server configuration options.
serverOptions []api.ServerOption
}
@@ -139,6 +148,12 @@ func (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder {
return b
}
// WithPluginHost overrides the dynamic plugin host used by the service.
func (b *Builder) WithPluginHost(host *pluginhost.Host) *Builder {
b.pluginHost = host
return b
}
// WithServerOptions appends server configuration options used during construction.
func (b *Builder) WithServerOptions(opts ...api.ServerOption) *Builder {
b.serverOptions = append(b.serverOptions, opts...)
@@ -160,7 +175,7 @@ func (b *Builder) WithPostAuthHook(hook coreauth.PostAuthHook) *Builder {
if hook == nil {
return b
}
b.serverOptions = append(b.serverOptions, api.WithPostAuthHook(hook))
b.postAuthHook = hook
return b
}
@@ -199,6 +214,14 @@ func (b *Builder) Build() (*Service, error) {
}
configaccess.Register(&b.cfg.SDKConfig)
pluginHost := b.pluginHost
if pluginHost == nil {
pluginHost = pluginhost.New()
}
if b.cfg != nil {
pluginHost.ApplyConfig(context.Background(), b.cfg)
pluginHost.RegisterFrontendAuthProviders()
}
accessManager.SetProviders(sdkaccess.RegisteredProviders())
coreManager := b.coreManager
@@ -254,7 +277,36 @@ func (b *Builder) Build() (*Service, error) {
authManager: authManager,
accessManager: accessManager,
coreManager: coreManager,
pluginHost: pluginHost,
serverOptions: append([]api.ServerOption(nil), b.serverOptions...),
}
if b.postAuthHook != nil {
service.serverOptions = append(service.serverOptions, api.WithPostAuthHook(b.postAuthHook))
}
service.serverOptions = append(service.serverOptions, api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()), api.WithPluginHost(pluginHost))
return service, nil
}
func (s *Service) runtimeAuthSyncHook() coreauth.PostAuthHook {
return func(ctx context.Context, auth *coreauth.Auth) error {
if s == nil || auth == nil || auth.ID == "" {
return nil
}
action := watcher.AuthUpdateActionAdd
if s.coreManager != nil {
if _, ok := s.coreManager.GetByID(auth.ID); ok {
action = watcher.AuthUpdateActionModify
}
}
update := watcher.AuthUpdate{
Action: action,
ID: auth.ID,
Auth: auth,
}
if s.watcher != nil && s.watcher.DispatchPersistedAuthUpdate(update) {
return nil
}
s.handleAuthUpdate(coreauth.WithSkipPersist(ctx), update)
return nil
}
}
+671 -83
View File
@@ -15,18 +15,21 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
"github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
)
@@ -91,6 +94,9 @@ type Service struct {
// coreManager handles core authentication and execution.
coreManager *coreauth.Manager
// pluginHost owns dynamic plugin lifecycle and runtime capability adapters.
pluginHost *pluginhost.Host
// shutdownOnce ensures shutdown is called only once.
shutdownOnce sync.Once
@@ -102,6 +108,19 @@ type Service struct {
homeLogForwarder *logging.HomeAppLogForwarder
}
const modelRegistrationMaxWorkersPerCategory = 5
const (
modelRegistrationPhaseConfigAPIKey = iota
modelRegistrationPhaseOther
)
type modelRegistrationTask struct {
phase int
category string
run func()
}
// RegisterUsagePlugin registers a usage plugin on the global usage manager.
// This allows external code to monitor API usage and token consumption.
//
@@ -111,6 +130,278 @@ func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {
usage.RegisterPlugin(plugin)
}
func (s *Service) registerPluginAuthParser() {
var parser PluginAuthParser
if s != nil && s.pluginHost != nil {
parser = s.pluginHost
}
sdkAuth.RegisterPluginAuthParser(parser)
if s != nil && s.watcher != nil {
s.watcher.SetPluginAuthParser(parser)
}
}
func (s *Service) syncPluginRuntime(ctx context.Context) {
if !s.syncPluginRuntimeConfig(ctx) {
return
}
s.syncPluginModelRuntime(ctx)
}
func (s *Service) syncPluginRuntimeConfig(ctx context.Context) bool {
if s == nil {
sdkAuth.RegisterPluginAuthParser(nil)
return false
}
if ctx == nil {
ctx = context.Background()
}
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if s.pluginHost != nil {
s.pluginHost.ApplyConfig(ctx, cfg)
}
s.registerPluginAuthParser()
if s.pluginHost == nil {
return false
}
s.pluginHost.RegisterFrontendAuthProviders()
if s.accessManager != nil {
s.accessManager.SetProviders(sdkaccess.RegisteredProviders())
}
s.pluginHost.RegisterUsagePlugins()
sdktranslator.SetPluginHooks(s.pluginHost)
if s.server != nil {
s.server.RefreshPluginManagementRoutes()
}
return true
}
func (s *Service) syncPluginModelRuntime(ctx context.Context) {
if s == nil || s.pluginHost == nil || s.coreManager == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry())
s.rebindExecutors()
s.pluginHost.RegisterExecutors(s.coreManager, registry.GetGlobalRegistry())
s.refreshPluginModelRegistrations(ctx)
s.coreManager.RefreshSchedulerAll()
}
func (s *Service) refreshPluginModelRegistrations(ctx context.Context) {
if s == nil || s.pluginHost == nil || s.coreManager == nil {
return
}
s.registerModelsForAuthBatch(ctx, s.coreManager.List())
}
func (s *Service) registerModelsForAuthBatch(ctx context.Context, auths []*coreauth.Auth) {
if s == nil || s.coreManager == nil || len(auths) == 0 {
return
}
tasks := make([]modelRegistrationTask, 0, len(auths))
for _, auth := range auths {
if auth == nil {
continue
}
authForRegistration := auth.Clone()
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRegistration),
category: modelRegistrationCategory(authForRegistration),
run: func() {
s.completeModelRegistrationForAuth(ctx, authForRegistration)
},
})
}
s.runModelRegistrationTasks(ctx, tasks)
}
func (s *Service) runModelRegistrationTasks(ctx context.Context, tasks []modelRegistrationTask) {
if len(tasks) == 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
configAPIKeyTasks := make([]modelRegistrationTask, 0)
otherTasks := make([]modelRegistrationTask, 0)
for _, task := range tasks {
if task.phase == modelRegistrationPhaseConfigAPIKey {
configAPIKeyTasks = append(configAPIKeyTasks, task)
continue
}
otherTasks = append(otherTasks, task)
}
s.runModelRegistrationTaskPhase(ctx, configAPIKeyTasks)
s.runModelRegistrationTaskPhase(ctx, otherTasks)
}
func (s *Service) runModelRegistrationTaskPhase(ctx context.Context, tasks []modelRegistrationTask) {
if len(tasks) == 0 {
return
}
grouped := make(map[string][]modelRegistrationTask)
order := make([]string, 0)
for _, task := range tasks {
if task.run == nil {
continue
}
category := strings.ToLower(strings.TrimSpace(task.category))
if category == "" {
category = "unknown"
}
if _, exists := grouped[category]; !exists {
order = append(order, category)
}
grouped[category] = append(grouped[category], task)
}
var wg sync.WaitGroup
for _, category := range order {
group := grouped[category]
workers := len(group)
if workers > modelRegistrationMaxWorkersPerCategory {
workers = modelRegistrationMaxWorkersPerCategory
}
if workers <= 0 {
continue
}
taskCh := make(chan modelRegistrationTask)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-ctx.Done():
return
default:
}
task.run()
}
}()
}
go func(group []modelRegistrationTask) {
defer close(taskCh)
for _, task := range group {
select {
case <-ctx.Done():
return
case taskCh <- task:
}
}
}(group)
}
wg.Wait()
}
func modelRegistrationPhase(auth *coreauth.Auth) int {
if isConfigAPIKeyAuth(auth) {
return modelRegistrationPhaseConfigAPIKey
}
return modelRegistrationPhaseOther
}
func isConfigAPIKeyAuth(auth *coreauth.Auth) bool {
if auth == nil || auth.Attributes == nil {
return false
}
if strings.TrimSpace(auth.Attributes["api_key"]) == "" {
return false
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(auth.Attributes["source"])), "config:")
}
func modelRegistrationCategory(auth *coreauth.Auth) string {
if auth == nil {
return "unknown"
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if compatProviderKey, _, compatDetected := openAICompatInfoFromAuth(auth); compatDetected {
if compatProviderKey != "" {
provider = compatProviderKey
} else {
provider = "openai-compatibility"
}
}
if provider == "" {
provider = "unknown"
}
authKind := strings.ToLower(strings.TrimSpace(auth.Attributes["auth_kind"]))
if authKind == "" {
if kind, _ := auth.AccountInfo(); strings.EqualFold(kind, "api_key") {
authKind = "apikey"
}
}
if authKind == "" {
return provider
}
return provider + ":" + authKind
}
func (s *Service) registerModelRefreshCallback() {
// Register callback for startup and periodic model catalog refresh.
// When remote model definitions change, re-register models for affected providers.
// This intentionally rebuilds per-auth model availability from the latest catalog
// snapshot instead of preserving prior registry suppression state.
registry.SetModelRefreshCallback(func(changedProviders []string) {
if s == nil || s.coreManager == nil || len(changedProviders) == 0 {
return
}
providerSet := make(map[string]bool, len(changedProviders))
for _, p := range changedProviders {
providerSet[strings.ToLower(strings.TrimSpace(p))] = true
}
auths := s.coreManager.List()
refreshed := 0
var refreshedMu sync.Mutex
tasks := make([]modelRegistrationTask, 0, len(auths))
for _, item := range auths {
if item == nil || item.ID == "" {
continue
}
auth, ok := s.coreManager.GetByID(item.ID)
if !ok || auth == nil || auth.Disabled {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if !providerSet[provider] {
continue
}
authForRefresh := auth
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRefresh),
category: modelRegistrationCategory(authForRefresh),
run: func() {
if s.refreshModelRegistrationForAuth(authForRefresh) {
refreshedMu.Lock()
refreshed++
refreshedMu.Unlock()
}
},
})
}
s.runModelRegistrationTasks(context.Background(), tasks)
if refreshed > 0 {
log.Infof("re-registered models for %d auth(s) due to model catalog changes: %v", refreshed, changedProviders)
}
})
}
// newDefaultAuthManager creates a default authentication manager with all supported providers.
func newDefaultAuthManager() *sdkAuth.Manager {
return sdkAuth.NewManager(
@@ -147,16 +438,17 @@ func (s *Service) consumeAuthUpdates(ctx context.Context) {
if !ok {
return
}
s.handleAuthUpdate(ctx, update)
updates := []watcher.AuthUpdate{update}
labelDrain:
for {
select {
case nextUpdate := <-s.authUpdates:
s.handleAuthUpdate(ctx, nextUpdate)
updates = append(updates, nextUpdate)
default:
break labelDrain
}
}
s.handleAuthUpdates(ctx, updates)
}
}
}
@@ -183,33 +475,99 @@ func (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate)
}
func (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {
s.handleAuthUpdates(ctx, []watcher.AuthUpdate{update})
}
func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthUpdate) {
if s == nil {
return
}
updates = coalesceAuthUpdates(updates)
s.cfgMu.RLock()
cfg := s.cfg
s.cfgMu.RUnlock()
if cfg == nil || s.coreManager == nil {
return
}
switch update.Action {
case watcher.AuthUpdateActionAdd, watcher.AuthUpdateActionModify:
if update.Auth == nil || update.Auth.ID == "" {
return
tasks := make([]modelRegistrationTask, 0, len(updates))
needsPluginSync := false
for _, update := range updates {
switch update.Action {
case watcher.AuthUpdateActionAdd, watcher.AuthUpdateActionModify:
if update.Auth == nil || update.Auth.ID == "" {
continue
}
auth := s.prepareCoreAuthForModelRegistration(ctx, update.Auth)
if auth == nil {
continue
}
authForRegistration := auth
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhase(authForRegistration),
category: modelRegistrationCategory(authForRegistration),
run: func() {
s.completeModelRegistrationForAuth(ctx, authForRegistration)
},
})
needsPluginSync = true
case watcher.AuthUpdateActionDelete:
id := update.ID
if id == "" && update.Auth != nil {
id = update.Auth.ID
}
if id == "" {
continue
}
s.applyCoreAuthRemoval(ctx, id)
default:
log.Debugf("received unknown auth update action: %v", update.Action)
}
s.applyCoreAuthAddOrUpdate(ctx, update.Auth)
case watcher.AuthUpdateActionDelete:
id := update.ID
if id == "" && update.Auth != nil {
id = update.Auth.ID
}
if id == "" {
return
}
s.applyCoreAuthRemoval(ctx, id)
default:
log.Debugf("received unknown auth update action: %v", update.Action)
}
s.runModelRegistrationTasks(ctx, tasks)
if needsPluginSync {
s.syncPluginRuntime(ctx)
}
}
func coalesceAuthUpdates(updates []watcher.AuthUpdate) []watcher.AuthUpdate {
if len(updates) <= 1 {
return updates
}
order := make([]string, 0, len(updates))
byID := make(map[string]watcher.AuthUpdate, len(updates))
unkeyed := make([]watcher.AuthUpdate, 0)
for _, update := range updates {
id := authUpdateID(update)
if id == "" {
unkeyed = append(unkeyed, update)
continue
}
if _, exists := byID[id]; !exists {
order = append(order, id)
}
byID[id] = update
}
if len(byID) == 0 {
return unkeyed
}
out := make([]watcher.AuthUpdate, 0, len(byID)+len(unkeyed))
for _, id := range order {
out = append(out, byID[id])
}
out = append(out, unkeyed...)
return out
}
func authUpdateID(update watcher.AuthUpdate) string {
if strings.TrimSpace(update.ID) != "" {
return strings.TrimSpace(update.ID)
}
if update.Auth != nil {
return strings.TrimSpace(update.Auth.ID)
}
return ""
}
func (s *Service) ensureWebsocketGateway() {
@@ -284,9 +642,18 @@ func (s *Service) wsOnDisconnected(channelID string, reason error) {
}
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
auth = s.prepareCoreAuthForModelRegistration(ctx, auth)
if auth == nil {
return
}
s.completeModelRegistrationForAuth(ctx, auth)
s.syncPluginRuntime(ctx)
}
func (s *Service) prepareCoreAuthForModelRegistration(ctx context.Context, auth *coreauth.Auth) *coreauth.Auth {
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
return nil
}
auth = auth.Clone()
s.ensureExecutorsForAuth(auth)
@@ -314,15 +681,18 @@ func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.A
current, ok := s.coreManager.GetByID(auth.ID)
if !ok || current.Disabled {
GlobalModelRegistry().UnregisterClient(auth.ID)
return
return nil
}
auth = current
}
return auth
}
// Register models after auth is updated in coreManager.
// This operation may block on network calls, but the auth configuration
// is already effective at this point.
s.registerModelsForAuth(auth)
func (s *Service) completeModelRegistrationForAuth(ctx context.Context, auth *coreauth.Auth) {
if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
return
}
s.registerModelsForAuth(ctx, auth)
s.coreManager.ReconcileRegistryModelStates(ctx, auth.ID)
// Refresh the scheduler entry so that the auth's supportedModelSet is rebuilt
@@ -349,6 +719,7 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
if strings.EqualFold(provider, "codex") {
executor.CloseCodexWebsocketSessionsForAuthID(id, "auth_removed")
}
s.syncPluginRuntime(ctx)
}
func (s *Service) applyRetryConfig(cfg *config.Config) {
@@ -379,6 +750,57 @@ func openAICompatInfoFromAuth(a *coreauth.Auth) (providerKey string, compatName
return "", "", false
}
func (s *Service) hasNativeOpenAICompatExecutorConfig(a *coreauth.Auth, providerKey string) bool {
if a == nil {
return false
}
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if a.Attributes != nil {
if strings.TrimSpace(a.Attributes["base_url"]) != "" {
return true
}
if strings.TrimSpace(a.Attributes["compat_name"]) != "" {
return true
}
}
if strings.EqualFold(strings.TrimSpace(a.Provider), "openai-compatibility") {
return true
}
if s == nil || s.cfg == nil {
return false
}
candidates := make([]string, 0, 3)
if providerKey != "" {
candidates = append(candidates, providerKey)
}
if a.Attributes != nil {
if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" {
candidates = append(candidates, strings.ToLower(v))
}
}
if provider := strings.TrimSpace(a.Provider); provider != "" {
candidates = append(candidates, strings.ToLower(provider))
}
for i := range s.cfg.OpenAICompatibility {
compat := &s.cfg.OpenAICompatibility[i]
if compat.Disabled {
continue
}
name := strings.ToLower(strings.TrimSpace(compat.Name))
if name == "" {
continue
}
for _, candidate := range candidates {
if candidate != "" && candidate == name {
return true
}
}
}
return false
}
func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
s.ensureExecutorsForAuthWithMode(a, false)
}
@@ -441,6 +863,11 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace
if providerKey == "" {
providerKey = "openai-compatibility"
}
if s.pluginHost != nil &&
s.pluginHost.HasExecutorCandidateProvider(providerKey) &&
!s.hasNativeOpenAICompatExecutorConfig(a, providerKey) {
return
}
s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(providerKey, s.cfg))
}
}
@@ -449,11 +876,140 @@ func (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey st
if a == nil || a.ID == "" {
return
}
if len(models) == 0 {
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if providerKey == "" {
GlobalModelRegistry().UnregisterClient(a.ID)
return
}
GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
normalizedModels := make([]*ModelInfo, 0, len(models))
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
clone := *model
clone.ID = modelID
normalizedModels = append(normalizedModels, &clone)
}
if len(normalizedModels) == 0 {
GlobalModelRegistry().UnregisterClient(a.ID)
return
}
GlobalModelRegistry().RegisterClient(a.ID, providerKey, normalizedModels)
}
func (s *Service) pluginModelsForProvider(providerKey string) []*ModelInfo {
if s == nil || s.pluginHost == nil {
return nil
}
return s.pluginHost.ModelsForProvider(providerKey)
}
func (s *Service) appendPluginModels(providerKey string, models []*ModelInfo) []*ModelInfo {
pluginModels := s.pluginModelsForProvider(providerKey)
if len(pluginModels) == 0 {
return models
}
out := make([]*ModelInfo, 0, len(models)+len(pluginModels))
seen := make(map[string]struct{}, len(models)+len(pluginModels))
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID != "" {
seen[modelID] = struct{}{}
}
out = append(out, model)
}
for _, model := range pluginModels {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
if _, exists := seen[modelID]; exists {
continue
}
seen[modelID] = struct{}{}
out = append(out, model)
}
return out
}
func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreauth.Auth, provider, authKind string, excluded []string) bool {
if s == nil || s.pluginHost == nil || a == nil {
return false
}
result := s.pluginHost.ModelsForAuth(ctx, a)
if !result.Handled {
return false
}
if result.Err != nil {
return true
}
activeAuth := a
providerKey := strings.ToLower(strings.TrimSpace(result.Provider))
if providerKey == "" {
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
if result.Auth != nil && s.coreManager != nil {
result.Auth.ID = a.ID
if result.Auth.Provider == "" {
result.Auth.Provider = a.Provider
}
if result.Auth.FileName == "" {
result.Auth.FileName = a.FileName
}
if result.Auth.Attributes == nil {
result.Auth.Attributes = make(map[string]string)
}
for key, value := range a.Attributes {
if _, exists := result.Auth.Attributes[key]; !exists {
result.Auth.Attributes[key] = value
}
}
if updated, errUpdate := s.coreManager.Update(context.Background(), result.Auth); errUpdate == nil && updated != nil {
activeAuth = updated.Clone()
}
}
if activeAuth == nil {
activeAuth = a
}
if activeProvider := strings.ToLower(strings.TrimSpace(activeAuth.Provider)); activeProvider != "" {
providerKey = activeProvider
}
if providerKey == "" {
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
activeAuthKind := strings.ToLower(strings.TrimSpace(activeAuth.Attributes["auth_kind"]))
if activeAuthKind == "" {
if kind, _ := activeAuth.AccountInfo(); strings.EqualFold(kind, "api_key") {
activeAuthKind = "apikey"
}
}
activeExcluded := s.oauthExcludedModels(providerKey, activeAuthKind)
if a == activeAuth && len(activeExcluded) == 0 {
activeExcluded = excluded
}
if activeAuth.Attributes != nil {
if val, ok := activeAuth.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
activeExcluded = strings.Split(val, ",")
}
}
models := applyExcludedModels(result.Models, activeExcluded)
models = applyOAuthModelAlias(s.cfg, providerKey, activeAuthKind, models)
if len(models) > 0 {
s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
return true
}
GlobalModelRegistry().UnregisterClient(activeAuth.ID)
return true
}
// rebindExecutors refreshes provider executors so they observe the latest configuration.
@@ -562,6 +1118,48 @@ func (s *Service) applyConfigUpdate(newCfg *config.Config) {
s.registerHomeExecutors()
}
s.rebindExecutors()
ctx := context.Background()
s.registerConfigAPIKeyAuths(ctx, newCfg)
s.syncPluginRuntime(ctx)
}
func (s *Service) registerConfigAPIKeyAuths(ctx context.Context, cfg *config.Config) {
if s == nil || s.coreManager == nil || cfg == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
configSynth := synthesizer.NewConfigSynthesizer()
auths, errSynthesize := configSynth.Synthesize(&synthesizer.SynthesisContext{
Config: cfg,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
})
if errSynthesize != nil {
log.Warnf("failed to synthesize config API key auths: %v", errSynthesize)
return
}
tasks := make([]modelRegistrationTask, 0, len(auths))
for _, auth := range auths {
if !isConfigAPIKeyAuth(auth) {
continue
}
prepared := s.prepareCoreAuthForModelRegistration(ctx, auth)
if prepared == nil {
continue
}
authForRegistration := prepared
tasks = append(tasks, modelRegistrationTask{
phase: modelRegistrationPhaseConfigAPIKey,
category: modelRegistrationCategory(authForRegistration),
run: func() {
s.completeModelRegistrationForAuth(ctx, authForRegistration)
},
})
}
s.runModelRegistrationTasks(ctx, tasks)
}
func forceHomeRuntimeConfig(cfg *config.Config) {
@@ -786,6 +1384,7 @@ func (s *Service) Run(ctx context.Context) error {
s.applyRetryConfig(s.cfg)
s.registerPluginAuthParser()
if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
@@ -812,8 +1411,19 @@ func (s *Service) Run(ctx context.Context) error {
// legacy clients removed; no caches to refresh
s.ensureWebsocketGateway()
if homeEnabled {
s.registerHomeExecutors()
// Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
redisqueue.SetEnabled(true)
}
// handlers no longer depend on legacy clients; pass nil slice initially
s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
s.syncPluginRuntimeConfig(ctx)
if homeEnabled {
s.syncPluginModelRuntime(ctx)
}
if s.authManager == nil {
s.authManager = newDefaultAuthManager()
@@ -823,7 +1433,6 @@ func (s *Service) Run(ctx context.Context) error {
s.startHomeSubscriber(ctx)
}
s.ensureWebsocketGateway()
if s.server != nil && s.wsGateway != nil {
s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())
s.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) {
@@ -844,54 +1453,10 @@ func (s *Service) Run(ctx context.Context) error {
})
}
if homeEnabled {
s.registerHomeExecutors()
// Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
redisqueue.SetEnabled(true)
}
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}
// Register callback for startup and periodic model catalog refresh.
// When remote model definitions change, re-register models for affected providers.
// This intentionally rebuilds per-auth model availability from the latest catalog
// snapshot instead of preserving prior registry suppression state.
registry.SetModelRefreshCallback(func(changedProviders []string) {
if s == nil || s.coreManager == nil || len(changedProviders) == 0 {
return
}
providerSet := make(map[string]bool, len(changedProviders))
for _, p := range changedProviders {
providerSet[strings.ToLower(strings.TrimSpace(p))] = true
}
auths := s.coreManager.List()
refreshed := 0
for _, item := range auths {
if item == nil || item.ID == "" {
continue
}
auth, ok := s.coreManager.GetByID(item.ID)
if !ok || auth == nil || auth.Disabled {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if !providerSet[provider] {
continue
}
if s.refreshModelRegistrationForAuth(auth) {
refreshed++
}
}
if refreshed > 0 {
log.Infof("re-registered models for %d auth(s) due to model catalog changes: %v", refreshed, changedProviders)
}
})
s.serverErr = make(chan error, 1)
go func() {
if errStart := s.server.Start(); errStart != nil {
@@ -924,6 +1489,7 @@ func (s *Service) Run(ctx context.Context) error {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
watcherWrapper.SetConfig(s.cfg)
s.registerPluginAuthParser()
watcherCtx, watcherCancel := context.WithCancel(context.Background())
s.watcherCancel = watcherCancel
@@ -931,8 +1497,11 @@ func (s *Service) Run(ctx context.Context) error {
return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
log.Info("file watcher started for config and auth directory changes")
s.syncPluginModelRuntime(ctx)
}
s.registerModelRefreshCallback()
// Prefer core auth manager auto refresh if available.
if s.coreManager != nil && !homeEnabled {
interval := 15 * time.Minute
@@ -1053,10 +1622,13 @@ func (s *Service) ensureAuthDir() error {
}
// registerModelsForAuth (re)binds provider models in the global registry using the core auth ID as client identifier.
func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) {
if a == nil || a.ID == "" {
return
}
if ctx == nil {
ctx = context.Background()
}
if a.Disabled {
GlobalModelRegistry().UnregisterClient(a.ID)
return
@@ -1094,6 +1666,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
excluded = strings.Split(val, ",")
}
}
if s.tryRegisterPluginModelsForAuth(ctx, a, provider, authKind, excluded) {
return
}
var models []*ModelInfo
switch provider {
case "gemini":
@@ -1223,27 +1798,39 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
if providerKey == "" {
providerKey = "openai-compatibility"
}
ms = s.appendPluginModels(providerKey, ms)
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
// Ensure stale registrations are cleared when model list becomes empty.
GlobalModelRegistry().UnregisterClient(a.ID)
ms = s.appendPluginModels(providerKey, nil)
if len(ms) > 0 {
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else {
GlobalModelRegistry().UnregisterClient(a.ID)
}
}
return
}
}
if isCompatAuth {
// No matching provider found or models removed entirely; drop any prior registration.
GlobalModelRegistry().UnregisterClient(a.ID)
models = s.appendPluginModels(providerKey, nil)
if len(models) > 0 {
s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
} else {
// No matching provider found or models removed entirely; drop any prior registration.
GlobalModelRegistry().UnregisterClient(a.ID)
}
return
}
}
}
models = applyOAuthModelAlias(s.cfg, provider, authKind, models)
key := provider
if key == "" {
key = strings.ToLower(strings.TrimSpace(a.Provider))
}
models = s.appendPluginModels(key, models)
if len(models) > 0 {
key := provider
if key == "" {
key = strings.ToLower(strings.TrimSpace(a.Provider))
}
s.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
return
}
@@ -1263,11 +1850,12 @@ func (s *Service) refreshModelRegistrationForAuth(current *coreauth.Auth) bool {
return false
}
ctx := context.Background()
if !current.Disabled {
s.ensureExecutorsForAuth(current)
}
s.registerModelsForAuth(current)
s.coreManager.ReconcileRegistryModelStates(context.Background(), current.ID)
s.registerModelsForAuth(ctx, current)
s.coreManager.ReconcileRegistryModelStates(ctx, current.ID)
latest, ok := s.latestAuthForModelRegistration(current.ID)
if !ok || latest.Disabled {
@@ -1280,8 +1868,8 @@ func (s *Service) refreshModelRegistrationForAuth(current *coreauth.Auth) bool {
// stale model registrations behind. This may duplicate registration work when
// no auth fields changed, but keeps the refresh path simple and correct.
s.ensureExecutorsForAuth(latest)
s.registerModelsForAuth(latest)
s.coreManager.ReconcileRegistryModelStates(context.Background(), latest.ID)
s.registerModelsForAuth(ctx, latest)
s.coreManager.ReconcileRegistryModelStates(ctx, latest.ID)
s.coreManager.RefreshSchedulerEntry(current.ID)
return true
}
+3 -2
View File
@@ -1,6 +1,7 @@
package cliproxy
import (
"context"
"strings"
"testing"
@@ -33,7 +34,7 @@ func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T
registry.UnregisterClient(auth.ID)
})
service.registerModelsForAuth(auth)
service.registerModelsForAuth(context.Background(), auth)
models := registry.GetAvailableModelsByProvider("gemini-cli")
if len(models) == 0 {
@@ -97,7 +98,7 @@ func TestRegisterModelsForAuth_OpenAICompatibilityImageModelType(t *testing.T) {
modelRegistry.UnregisterClient(auth.ID)
})
service.registerModelsForAuth(auth)
service.registerModelsForAuth(context.Background(), auth)
models := modelRegistry.GetModelsForClient(auth.ID)
var imageModel *internalregistry.ModelInfo
@@ -90,3 +90,45 @@ func TestApplyOAuthModelAlias_ForkAddsMultipleAliases(t *testing.T) {
t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name)
}
}
func TestApplyOAuthModelAlias_PluginProvider(t *testing.T) {
cfg := &config.Config{
OAuthModelAlias: map[string][]config.OAuthModelAlias{
"qoder": {
{Name: "qmodel_latest", Alias: "qlatest"},
},
},
}
models := []*ModelInfo{
{ID: "qmodel_latest", Name: "models/qmodel_latest"},
}
out := applyOAuthModelAlias(cfg, "qoder", "oauth", models)
if len(out) != 1 {
t.Fatalf("expected 1 model, got %d", len(out))
}
if out[0].ID != "qlatest" {
t.Fatalf("expected plugin alias id %q, got %q", "qlatest", out[0].ID)
}
if out[0].Name != "models/qlatest" {
t.Fatalf("expected plugin alias name %q, got %q", "models/qlatest", out[0].Name)
}
}
func TestApplyOAuthModelAlias_PluginProviderSkipsAPIKey(t *testing.T) {
cfg := &config.Config{
OAuthModelAlias: map[string][]config.OAuthModelAlias{
"qoder": {
{Name: "qmodel_latest", Alias: "qlatest"},
},
},
}
models := []*ModelInfo{
{ID: "qmodel_latest", Name: "models/qmodel_latest"},
}
out := applyOAuthModelAlias(cfg, "qoder", "api_key", models)
if len(out) != 1 || out[0].ID != "qmodel_latest" {
t.Fatalf("expected API key plugin model to remain unchanged, got %#v", out)
}
}
@@ -0,0 +1,59 @@
package cliproxy
import (
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestHasNativeOpenAICompatExecutorConfig(t *testing.T) {
service := &Service{
cfg: &config.Config{
OpenAICompatibility: []config.OpenAICompatibility{
{Name: "native-provider", BaseURL: "https://native.example.com/v1"},
},
},
}
tests := []struct {
name string
auth *coreauth.Auth
providerKey string
want bool
}{
{
name: "config provider",
auth: &coreauth.Auth{Provider: "native-provider"},
providerKey: "native-provider",
want: true,
},
{
name: "inline base url",
auth: &coreauth.Auth{Provider: "plugin-provider", Attributes: map[string]string{"base_url": "https://compat.example.com/v1"}},
providerKey: "plugin-provider",
want: true,
},
{
name: "compat metadata",
auth: &coreauth.Auth{Provider: "openai-compatibility", Attributes: map[string]string{"compat_name": "compat"}},
providerKey: "compat",
want: true,
},
{
name: "plain plugin auth",
auth: &coreauth.Auth{Provider: "plugin-provider", Attributes: map[string]string{"api_key": "test"}},
providerKey: "plugin-provider",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := service.hasNativeOpenAICompatExecutorConfig(tt.auth, tt.providerKey)
if got != tt.want {
t.Fatalf("hasNativeOpenAICompatExecutorConfig() = %v, want %v", got, tt.want)
}
})
}
}
+24
View File
@@ -9,6 +9,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
// TokenClientProvider loads clients backed by stored authentication tokens.
@@ -80,6 +81,11 @@ type APIKeyClientResult struct {
// - error: An error if watcher creation fails
type WatcherFactory func(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error)
// PluginAuthParser parses auth JSON owned by plugin providers.
type PluginAuthParser interface {
ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error)
}
// WatcherWrapper exposes the subset of watcher methods required by the SDK.
type WatcherWrapper struct {
start func(ctx context.Context) error
@@ -89,6 +95,8 @@ type WatcherWrapper struct {
snapshotAuths func() []*coreauth.Auth
setUpdateQueue func(queue chan<- watcher.AuthUpdate)
dispatchRuntimeUpdate func(update watcher.AuthUpdate) bool
dispatchPersistedAuth func(update watcher.AuthUpdate) bool
setPluginAuthParser func(parser PluginAuthParser)
}
// Start proxies to the underlying watcher Start implementation.
@@ -115,6 +123,14 @@ func (w *WatcherWrapper) SetConfig(cfg *config.Config) {
w.setConfig(cfg)
}
// SetPluginAuthParser updates the plugin auth parser used by the watcher.
func (w *WatcherWrapper) SetPluginAuthParser(parser PluginAuthParser) {
if w == nil || w.setPluginAuthParser == nil {
return
}
w.setPluginAuthParser(parser)
}
// DispatchRuntimeAuthUpdate forwards runtime auth updates (e.g., websocket providers)
// into the watcher-managed auth update queue when available.
// Returns true if the update was enqueued successfully.
@@ -125,6 +141,14 @@ func (w *WatcherWrapper) DispatchRuntimeAuthUpdate(update watcher.AuthUpdate) bo
return w.dispatchRuntimeUpdate(update)
}
// DispatchPersistedAuthUpdate forwards already-persisted file auth updates.
func (w *WatcherWrapper) DispatchPersistedAuthUpdate(update watcher.AuthUpdate) bool {
if w == nil || w.dispatchPersistedAuth == nil {
return false
}
return w.dispatchPersistedAuth(update)
}
// SetClients updates the watcher file-backed clients registry.
// SetClients and SetAPIKeyClients removed; watcher manages its own caches
+28
View File
@@ -175,6 +175,7 @@ type Manager struct {
pluginsMu sync.RWMutex
plugins []Plugin
named map[string]int
}
// NewManager constructs a manager with a buffered queue.
@@ -225,6 +226,30 @@ func (m *Manager) Register(plugin Plugin) {
m.pluginsMu.Unlock()
}
// RegisterNamed registers or replaces a plugin by name.
func (m *Manager) RegisterNamed(name string, plugin Plugin) {
if m == nil || plugin == nil {
return
}
name = strings.TrimSpace(name)
if name == "" {
return
}
m.pluginsMu.Lock()
if m.named == nil {
m.named = make(map[string]int)
}
if index, exists := m.named[name]; exists && index >= 0 && index < len(m.plugins) {
m.plugins[index] = plugin
m.pluginsMu.Unlock()
return
}
m.named[name] = len(m.plugins)
m.plugins = append(m.plugins, plugin)
m.pluginsMu.Unlock()
}
// Publish enqueues a usage record for processing. If no plugin is registered
// the record will be discarded downstream.
func (m *Manager) Publish(ctx context.Context, record Record) {
@@ -293,6 +318,9 @@ func DefaultManager() *Manager { return defaultManager }
// RegisterPlugin registers a plugin on the default manager.
func RegisterPlugin(plugin Plugin) { DefaultManager().Register(plugin) }
// RegisterNamedPlugin registers or replaces a named plugin on the default manager.
func RegisterNamedPlugin(name string, plugin Plugin) { DefaultManager().RegisterNamed(name, plugin) }
// PublishRecord publishes a record using the default manager.
func PublishRecord(ctx context.Context, record Record) { DefaultManager().Publish(ctx, record) }
+6
View File
@@ -31,5 +31,11 @@ func defaultWatcherFactory(configPath, authDir string, reload func(*config.Confi
dispatchRuntimeUpdate: func(update watcher.AuthUpdate) bool {
return w.DispatchRuntimeAuthUpdate(update)
},
dispatchPersistedAuth: func(update watcher.AuthUpdate) bool {
return w.DispatchPersistedAuthUpdate(update)
},
setPluginAuthParser: func(parser PluginAuthParser) {
w.SetPluginAuthParser(parser)
},
}, nil
}
+876
View File
@@ -0,0 +1,876 @@
// Package pluginapi defines the stable ABI used by Go dynamic plugins.
package pluginapi
import (
"context"
"net/http"
"net/url"
"time"
)
// Plugin is the exported plugin entrypoint returned by dynamic plugin binaries.
type Plugin struct {
// Metadata identifies the plugin binary and its published source.
Metadata Metadata
// Capabilities declares the optional integration points implemented by the plugin.
Capabilities Capabilities
}
// Metadata describes a plugin for registry, logging, and diagnostics.
type Metadata struct {
// Name is the stable human-readable plugin name.
Name string
// Version is the plugin release version.
Version string
// Author identifies the plugin author or organization.
Author string
// GitHubRepository is the repository URL for plugin source and support.
GitHubRepository string
// Logo is a plugin-provided display asset reference for management clients.
Logo string
// ConfigFields describes plugin-owned configuration fields for management clients.
ConfigFields []ConfigField
}
// ConfigFieldType classifies plugin-owned configuration values for management clients.
type ConfigFieldType string
const (
// ConfigFieldTypeString describes a string configuration value.
ConfigFieldTypeString ConfigFieldType = "string"
// ConfigFieldTypeNumber describes a numeric configuration value.
ConfigFieldTypeNumber ConfigFieldType = "number"
// ConfigFieldTypeInteger describes an integer configuration value.
ConfigFieldTypeInteger ConfigFieldType = "integer"
// ConfigFieldTypeBoolean describes a boolean configuration value.
ConfigFieldTypeBoolean ConfigFieldType = "boolean"
// ConfigFieldTypeEnum describes a string value constrained to EnumValues.
ConfigFieldTypeEnum ConfigFieldType = "enum"
// ConfigFieldTypeArray describes an array configuration value.
ConfigFieldTypeArray ConfigFieldType = "array"
// ConfigFieldTypeObject describes an object configuration value.
ConfigFieldTypeObject ConfigFieldType = "object"
)
// ConfigField describes a plugin-owned configuration field for management clients.
type ConfigField struct {
// Name is the configuration key under plugins.configs.<pluginID>.
Name string
// Type classifies the field value for management clients.
Type ConfigFieldType
// EnumValues lists allowed values when Type is ConfigFieldTypeEnum.
EnumValues []string
// Description explains how the plugin uses the field.
Description string
}
// Capabilities groups the optional host integration interfaces exposed by a plugin.
type Capabilities struct {
// ModelRegistrar contributes development-time model metadata to the host registry.
ModelRegistrar ModelRegistrar
// ModelProvider contributes provider-native static and per-auth model metadata.
ModelProvider ModelProvider
// AuthProvider lets the host parse, login, poll, and refresh plugin provider auths.
AuthProvider AuthProvider
// FrontendAuthProvider authenticates frontend requests before proxy handling.
FrontendAuthProvider FrontendAuthProvider
// Executor sends requests to an upstream provider or local backend.
Executor ProviderExecutor
// ExecutorModelScope declares whether Executor serves static models, OAuth auth models, or both.
// Empty defaults to ExecutorModelScopeBoth for backward compatibility.
ExecutorModelScope ExecutorModelScope
// RequestTranslator converts canonical requests into provider-specific payloads.
RequestTranslator RequestTranslator
// RequestNormalizer converts provider-specific requests into canonical payloads.
RequestNormalizer RequestNormalizer
// ResponseTranslator converts canonical responses into provider-specific payloads.
ResponseTranslator ResponseTranslator
// ResponseBeforeTranslator normalizes upstream responses before native translation.
ResponseBeforeTranslator ResponseNormalizer
// ResponseAfterTranslator normalizes translated responses before delivery.
ResponseAfterTranslator ResponseNormalizer
// ThinkingApplier applies validated thinking configuration to provider payloads.
ThinkingApplier ThinkingApplier
// UsagePlugin receives completed usage records.
UsagePlugin UsagePlugin
// CommandLinePlugin declares and handles plugin-owned command-line flags.
CommandLinePlugin CommandLinePlugin
// ManagementAPI declares plugin-owned diagnostic Management API routes.
ManagementAPI ManagementAPI
}
// ExecutorModelScope declares which model-registration paths a plugin executor supports.
type ExecutorModelScope string
const (
// ExecutorModelScopeBoth means the executor supports static and OAuth auth-bound models.
ExecutorModelScopeBoth ExecutorModelScope = "both"
// ExecutorModelScopeStatic means the executor supports only non-OAuth static models.
ExecutorModelScopeStatic ExecutorModelScope = "static"
// ExecutorModelScopeOAuth means the executor supports only OAuth auth-bound models.
ExecutorModelScopeOAuth ExecutorModelScope = "oauth"
)
// ModelInfo describes a model contributed by a plugin.
type ModelInfo struct {
// ID is the stable model identifier used in API requests.
ID string
// Object is the API object type, usually "model".
Object string
// Created is the Unix timestamp when the model metadata was created.
Created int64
// OwnedBy identifies the model owner or provider.
OwnedBy string
// Type classifies the model capability family.
Type string
// DisplayName is the user-facing model name.
DisplayName string
// Name is the provider-native model name.
Name string
// Version identifies the model revision when available.
Version string
// Description is a short user-facing model summary.
Description string
// InputTokenLimit is the maximum accepted input token count.
InputTokenLimit int64
// OutputTokenLimit is the maximum generated output token count.
OutputTokenLimit int64
// SupportedGenerationMethods lists supported generation method names.
SupportedGenerationMethods []string
// ContextLength is the maximum combined context length.
ContextLength int64
// MaxCompletionTokens is the maximum completion token count.
MaxCompletionTokens int64
// SupportedParameters lists request parameters supported by the model.
SupportedParameters []string
// SupportedInputModalities lists accepted input modality names.
SupportedInputModalities []string
// SupportedOutputModalities lists produced output modality names.
SupportedOutputModalities []string
// Thinking describes optional reasoning controls for the model.
Thinking *ThinkingSupport
// UserDefined reports whether the model was provided by user configuration.
UserDefined bool
}
// ThinkingSupport describes supported reasoning budget controls.
type ThinkingSupport struct {
// Min is the minimum accepted reasoning budget.
Min int
// Max is the maximum accepted reasoning budget.
Max int
// ZeroAllowed reports whether disabling reasoning is supported.
ZeroAllowed bool
// DynamicAllowed reports whether automatic reasoning budget selection is supported.
DynamicAllowed bool
// Levels lists supported named reasoning levels.
Levels []string
}
// HostConfigSummary describes host configuration relevant to plugin providers.
type HostConfigSummary struct {
// AuthDir is the resolved directory containing provider auth material.
AuthDir string
// ProxyURL is the configured upstream proxy URL.
ProxyURL string
// ForceModelPrefix reports whether model aliases should keep provider prefixes.
ForceModelPrefix bool
// OAuthModelAlias maps providers to configured model aliases.
OAuthModelAlias map[string][]ModelAlias
// ExcludedModels maps providers to model names hidden by host configuration.
ExcludedModels map[string][]string
}
// ModelAlias describes one configured provider model alias.
type ModelAlias struct {
// Name is the provider model name.
Name string
// Alias is the host-facing model alias.
Alias string
}
// AuthData describes a plugin provider auth record exchanged with the host.
type AuthData struct {
// Provider is the provider key associated with the auth.
Provider string
// ID is the stable host auth identifier.
ID string
// FileName is the source or persisted auth file name.
FileName string
// Label is the user-facing auth label.
Label string
// Prefix is the configured model prefix for this auth.
Prefix string
// ProxyURL is the auth-specific proxy URL when configured.
ProxyURL string
// Disabled reports whether the auth should be skipped.
Disabled bool
// StorageJSON contains provider-owned persisted auth data.
StorageJSON []byte
// Metadata contains mutable host-managed auth metadata.
Metadata map[string]any
// Attributes contains immutable routing and provider attributes.
Attributes map[string]string
// NextRefreshAfter is the earliest time the host should refresh this auth.
NextRefreshAfter time.Time
}
// AuthParseRequest describes auth material offered to a plugin parser.
type AuthParseRequest struct {
// Provider is the provider key being parsed.
Provider string
// Path is the source path of the auth material when available.
Path string
// FileName is the auth file name.
FileName string
// RawJSON contains the raw auth file payload.
RawJSON []byte
// Host contains relevant host configuration.
Host HostConfigSummary
}
// AuthParseResponse returns the parser decision and parsed auth data.
type AuthParseResponse struct {
// Handled reports whether the plugin recognized the auth material.
Handled bool
// Auth is the parsed auth record when Handled is true.
Auth AuthData
}
// AuthProvider parses, logs in, polls, and refreshes plugin provider auths.
type AuthProvider interface {
Identifier() string
ParseAuth(context.Context, AuthParseRequest) (AuthParseResponse, error)
StartLogin(context.Context, AuthLoginStartRequest) (AuthLoginStartResponse, error)
PollLogin(context.Context, AuthLoginPollRequest) (AuthLoginPollResponse, error)
RefreshAuth(context.Context, AuthRefreshRequest) (AuthRefreshResponse, error)
}
// AuthLoginStartRequest asks a plugin to start a provider login flow.
type AuthLoginStartRequest struct {
// Provider is the provider key for the login flow.
Provider string
// BaseURL is the host callback or login base URL.
BaseURL string
// Host contains relevant host configuration.
Host HostConfigSummary
// HTTPClient executes upstream HTTP requests through host transport policy.
HTTPClient HostHTTPClient
// Metadata carries plugin-defined login context.
Metadata map[string]any
}
// AuthLoginStartResponse returns login flow state for polling.
type AuthLoginStartResponse struct {
// Provider is the provider key for the login flow.
Provider string
// URL is the user-facing login URL.
URL string
// State is the opaque plugin login state used for polling.
State string
// ExpiresAt is the time when this login flow expires.
ExpiresAt time.Time
// Metadata carries plugin-defined polling context.
Metadata map[string]any
}
// AuthLoginPollRequest asks a plugin to poll a provider login flow.
type AuthLoginPollRequest struct {
// Provider is the provider key for the login flow.
Provider string
// State is the opaque plugin login state returned by StartLogin.
State string
// Host contains relevant host configuration.
Host HostConfigSummary
// HTTPClient executes upstream HTTP requests through host transport policy.
HTTPClient HostHTTPClient
// Metadata carries plugin-defined polling context.
Metadata map[string]any
}
// AuthLoginStatus describes the current provider login state.
type AuthLoginStatus string
const (
// AuthLoginStatusPending means the login flow is still waiting.
AuthLoginStatusPending AuthLoginStatus = "pending"
// AuthLoginStatusSuccess means the login flow produced auth data.
AuthLoginStatusSuccess AuthLoginStatus = "success"
// AuthLoginStatusError means the login flow failed.
AuthLoginStatusError AuthLoginStatus = "error"
)
// AuthLoginPollResponse returns the login poll status and auth data.
type AuthLoginPollResponse struct {
// Status is the current login flow state.
Status AuthLoginStatus
// Message contains provider-facing login progress or error text.
Message string
// Auth is the completed auth record when Status is success.
Auth AuthData
}
// AuthRefreshRequest asks a plugin to refresh provider auth data.
type AuthRefreshRequest struct {
// AuthID identifies the auth record to refresh.
AuthID string
// AuthProvider identifies the credential provider.
AuthProvider string
// StorageJSON contains provider-owned persisted auth data.
StorageJSON []byte
// Metadata contains mutable host-managed auth metadata.
Metadata map[string]any
// Attributes contains immutable routing and provider attributes.
Attributes map[string]string
// Host contains relevant host configuration.
Host HostConfigSummary
// HTTPClient executes upstream HTTP requests through host transport policy.
HTTPClient HostHTTPClient
}
// AuthRefreshResponse returns refreshed provider auth data.
type AuthRefreshResponse struct {
// Auth is the refreshed auth record.
Auth AuthData
// NextRefreshAfter is the earliest time the host should refresh again.
NextRefreshAfter time.Time
}
// ModelRegistrar registers plugin-provided models with the host.
type ModelRegistrar interface {
RegisterModels(context.Context, ModelRegistrationRequest) (ModelRegistrationResponse, error)
}
// ModelRegistrationRequest carries host context for model registration.
type ModelRegistrationRequest struct {
// Plugin is the metadata of the plugin being registered.
Plugin Metadata
}
// ModelRegistrationResponse returns provider and model metadata to register.
type ModelRegistrationResponse struct {
// Provider is the provider key associated with the returned models.
Provider string
// Models is the complete set of plugin-provided models.
Models []ModelInfo
}
// ModelProvider contributes provider-native static and per-auth model metadata.
type ModelProvider interface {
StaticModels(context.Context, StaticModelRequest) (ModelResponse, error)
ModelsForAuth(context.Context, AuthModelRequest) (ModelResponse, error)
}
// StaticModelRequest carries host context for provider static models.
type StaticModelRequest struct {
// Plugin is the metadata of the plugin being registered.
Plugin Metadata
// Host contains relevant host configuration.
Host HostConfigSummary
}
// AuthModelRequest carries auth context for provider model discovery.
type AuthModelRequest struct {
// Plugin is the metadata of the plugin being registered.
Plugin Metadata
// AuthID identifies the auth record used for discovery.
AuthID string
// AuthProvider identifies the credential provider.
AuthProvider string
// StorageJSON contains provider-owned persisted auth data.
StorageJSON []byte
// Metadata contains mutable host-managed auth metadata.
Metadata map[string]any
// Attributes contains immutable routing and provider attributes.
Attributes map[string]string
// Host contains relevant host configuration.
Host HostConfigSummary
// HTTPClient executes upstream HTTP requests through host transport policy.
HTTPClient HostHTTPClient
}
// ModelResponse returns provider and model metadata discovered by a plugin.
type ModelResponse struct {
// Provider is the provider key associated with the returned models.
Provider string
// Models is the complete set of discovered provider models.
Models []ModelInfo
// AuthUpdate contains updated auth data from model discovery when needed.
AuthUpdate AuthData
}
// FrontendAuthProvider authenticates frontend requests before proxy routing.
type FrontendAuthProvider interface {
Identifier() string
Authenticate(context.Context, FrontendAuthRequest) (FrontendAuthResponse, error)
}
// FrontendAuthRequest describes an inbound frontend authentication request.
type FrontendAuthRequest struct {
// Method is the HTTP method.
Method string
// Path is the request path.
Path string
// Headers contains inbound request headers.
Headers http.Header
// Query contains inbound query parameters.
Query url.Values
// Body contains the raw request body.
Body []byte
}
// FrontendAuthResponse reports the authentication decision and identity metadata.
type FrontendAuthResponse struct {
// Authenticated reports whether the request was accepted.
Authenticated bool
// Principal is the authenticated subject identifier.
Principal string
// Metadata carries plugin-defined identity attributes for downstream use.
Metadata map[string]string
}
// ProviderExecutor handles model execution, streaming, HTTP bridging, and token counting.
type ProviderExecutor interface {
Identifier() string
Execute(context.Context, ExecutorRequest) (ExecutorResponse, error)
ExecuteStream(context.Context, ExecutorRequest) (ExecutorStreamResponse, error)
CountTokens(context.Context, ExecutorRequest) (ExecutorResponse, error)
HttpRequest(context.Context, ExecutorHTTPRequest) (ExecutorHTTPResponse, error)
}
// HostHTTPClient executes plugin HTTP requests through host transport policy.
// Plugin executors must use this client for upstream calls so request-log can
// capture the outbound request and raw upstream response when enabled.
type HostHTTPClient interface {
Do(context.Context, HTTPRequest) (HTTPResponse, error)
DoStream(context.Context, HTTPRequest) (HTTPStreamResponse, error)
}
// HTTPRequest describes an upstream HTTP request issued through the host.
type HTTPRequest struct {
// Method is the HTTP method.
Method string
// URL is the absolute upstream URL.
URL string
// Headers contains request headers.
Headers http.Header
// Body contains the raw request body.
Body []byte
}
// HTTPResponse describes a non-streaming host HTTP response.
type HTTPResponse struct {
// StatusCode is the upstream HTTP status code.
StatusCode int
// Headers contains upstream response headers.
Headers http.Header
// Body contains the raw response body.
Body []byte
}
// HTTPStreamResponse describes a streaming host HTTP response.
type HTTPStreamResponse struct {
// StatusCode is the upstream HTTP status code.
StatusCode int
// Headers contains upstream response headers.
Headers http.Header
// Chunks yields streaming payload chunks until the channel closes.
Chunks <-chan HTTPStreamChunk
}
// HTTPStreamChunk carries one host HTTP stream chunk or an error.
type HTTPStreamChunk struct {
// Payload contains the raw stream chunk bytes.
Payload []byte
// Err reports a stream error associated with this chunk.
Err error
}
// ExecutorHTTPRequest describes an executor-owned HTTP request.
type ExecutorHTTPRequest struct {
// AuthID identifies the selected credential.
AuthID string
// AuthProvider identifies the credential provider.
AuthProvider string
// Method is the HTTP method.
Method string
// URL is the absolute upstream URL.
URL string
// Headers contains request headers.
Headers http.Header
// Body contains the raw request body.
Body []byte
// StorageJSON contains provider-owned auth storage for this concrete auth.
StorageJSON []byte
// Metadata contains mutable host-managed auth metadata.
Metadata map[string]any
// 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
}
// ExecutorHTTPResponse describes an executor-owned HTTP response.
type ExecutorHTTPResponse struct {
// StatusCode is the upstream HTTP status code.
StatusCode int
// Headers contains upstream response headers.
Headers http.Header
// Body contains the raw response body.
Body []byte
}
// ExecutorRequest describes a model execution or token counting call.
type ExecutorRequest struct {
// AuthID identifies the selected credential.
AuthID string
// AuthProvider identifies the credential provider.
AuthProvider string
// Model is the requested model identifier.
Model string
// Format is the target request or response protocol format.
Format string
// Stream reports whether the request expects streaming output.
Stream bool
// Alt carries an alternate route or mode suffix when present.
Alt string
// Headers contains request headers passed to the executor.
Headers http.Header
// Query contains request query parameters passed to the executor.
Query url.Values
// OriginalRequest contains the raw client request body.
OriginalRequest []byte
// SourceFormat is the original client protocol format.
SourceFormat string
// Payload contains the translated provider payload.
Payload []byte
// Metadata is an extension bag for host and plugin coordination data.
Metadata map[string]any
// StorageJSON contains provider-owned auth storage for this concrete auth.
StorageJSON []byte
// AuthMetadata contains mutable host-managed auth metadata.
AuthMetadata map[string]any
// 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
}
// ExecutorResponse returns a non-streaming executor result.
type ExecutorResponse struct {
// Payload contains the raw response body.
Payload []byte
// Headers contains response headers to forward or inspect.
Headers http.Header
// Metadata is an extension bag for executor-specific response data.
Metadata map[string]any
}
// ExecutorStreamResponse returns a streaming executor result.
type ExecutorStreamResponse struct {
// Headers contains response headers available before stream chunks.
Headers http.Header
// Chunks yields streaming payload chunks until the channel closes.
Chunks <-chan ExecutorStreamChunk
}
// ExecutorStreamChunk carries one streaming payload chunk or an error.
type ExecutorStreamChunk struct {
// Payload contains the raw stream chunk bytes.
Payload []byte
// Err reports a stream error associated with this chunk.
Err error
}
// RequestTranslator converts canonical request payloads to another format.
type RequestTranslator interface {
TranslateRequest(context.Context, RequestTransformRequest) (PayloadResponse, error)
}
// RequestNormalizer converts request payloads into a canonical format.
type RequestNormalizer interface {
NormalizeRequest(context.Context, RequestTransformRequest) (PayloadResponse, error)
}
// ResponseTranslator converts canonical response payloads to another format.
type ResponseTranslator interface {
TranslateResponse(context.Context, ResponseTransformRequest) (PayloadResponse, error)
}
// ResponseNormalizer converts response payloads into a canonical format.
type ResponseNormalizer interface {
NormalizeResponse(context.Context, ResponseTransformRequest) (PayloadResponse, error)
}
// RequestTransformRequest describes a request payload transformation.
type RequestTransformRequest struct {
// FromFormat is the source protocol format.
FromFormat string
// ToFormat is the target protocol format.
ToFormat string
// Model is the requested model identifier.
Model string
// Stream reports whether the request expects streaming output.
Stream bool
// Body contains the payload to transform.
Body []byte
}
// ResponseTransformRequest describes a response payload transformation.
type ResponseTransformRequest struct {
// FromFormat is the source protocol format.
FromFormat string
// ToFormat is the target protocol format.
ToFormat string
// Model is the requested model identifier.
Model string
// Stream reports whether the response is streaming.
Stream bool
// OriginalRequest contains the raw client request body.
OriginalRequest []byte
// TranslatedRequest contains the provider request body.
TranslatedRequest []byte
// Body contains the response payload to transform.
Body []byte
}
// PayloadResponse returns a transformed raw payload.
type PayloadResponse struct {
// Body contains the transformed payload bytes.
Body []byte
}
// ThinkingConfig is the public canonical thinking configuration passed to plugins.
type ThinkingConfig struct {
// Mode is the canonical thinking mode: budget, level, none, or auto.
Mode string
// Budget is the normalized thinking token budget.
Budget int
// Level is the normalized named thinking effort level.
Level string
}
// ThinkingApplyRequest asks a plugin to apply canonical thinking config.
type ThinkingApplyRequest struct {
// Provider is the normalized provider key being applied.
Provider string
// Model describes the model associated with the request.
Model ModelInfo
// Config is the already parsed and normalized thinking config.
Config ThinkingConfig
// Body contains the provider payload to rewrite.
Body []byte
}
// ThinkingApplier applies provider-specific thinking configuration.
type ThinkingApplier interface {
// Identifier returns the provider key handled by this thinking applier.
Identifier() string
// ApplyThinking returns the payload with provider-specific thinking fields.
ApplyThinking(context.Context, ThinkingApplyRequest) (PayloadResponse, error)
}
// UsagePlugin receives usage records after request completion.
type UsagePlugin interface {
HandleUsage(context.Context, UsageRecord)
}
// CommandLinePlugin declares and handles plugin-owned command-line flags.
type CommandLinePlugin interface {
RegisterCommandLine(context.Context, CommandLineRegistrationRequest) (CommandLineRegistrationResponse, error)
ExecuteCommandLine(context.Context, CommandLineExecutionRequest) (CommandLineExecutionResponse, error)
}
// CommandLineRegistrationRequest carries host context for command-line registration.
type CommandLineRegistrationRequest struct {
// Plugin is the metadata of the plugin being registered.
Plugin Metadata
}
// CommandLineRegistrationResponse lists command-line flags owned by a plugin.
type CommandLineRegistrationResponse struct {
// Flags contains the concrete flags to expose in -help.
Flags []CommandLineFlag
}
// CommandLineFlag describes one plugin-owned command-line flag.
type CommandLineFlag struct {
// Name is the flag name without leading dashes.
Name string
// Usage is shown in -help output.
Usage string
// Type is one of bool, string, int, int64, float64, or duration.
Type string
// DefaultValue is parsed according to Type before flag registration.
DefaultValue string
}
// CommandLineFlagValue describes a parsed command-line flag value.
type CommandLineFlagValue struct {
// Name is the flag name without leading dashes.
Name string
// Type is one of bool, string, int, int64, float64, or duration.
Type string
// Value is the parsed value in string form.
Value string
// Set reports whether the user explicitly provided this flag.
Set bool
}
// CommandLineExecutionRequest describes a plugin command-line invocation.
type CommandLineExecutionRequest struct {
// Plugin is the metadata of the plugin being executed.
Plugin Metadata
// Program is os.Args[0].
Program string
// Args contains every command-line argument after Program, including all flags.
Args []string
// ConfigPath is the effective configuration path used by the host.
ConfigPath string
// Host contains relevant host configuration.
Host HostConfigSummary
// Flags contains all currently registered command-line flags visible to the host.
Flags map[string]CommandLineFlagValue
// TriggeredFlags contains the plugin-owned flags that triggered this execution.
TriggeredFlags map[string]CommandLineFlagValue
}
// CommandLineExecutionResponse returns command-line output from a plugin.
type CommandLineExecutionResponse struct {
// Stdout is written to process stdout after plugin execution.
Stdout []byte
// Stderr is written to process stderr after plugin execution.
Stderr []byte
// Auths contains auth records created by the command. The host persists them.
Auths []AuthData
// ExitCode is used as the process exit code when non-zero.
ExitCode int
}
// ManagementAPI declares plugin-owned Management API routes.
type ManagementAPI interface {
RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error)
}
// ManagementRegistrationRequest carries host context for Management API registration.
type ManagementRegistrationRequest struct {
// Plugin is the metadata of the plugin being registered.
Plugin Metadata
// BasePath is the only Management API prefix plugins may register under.
BasePath string
}
// ManagementRegistrationResponse lists plugin-owned Management API routes.
type ManagementRegistrationResponse struct {
// Routes contains the exact Management API routes to expose.
Routes []ManagementRoute
}
// ManagementRoute describes one plugin-owned Management API route.
type ManagementRoute struct {
// Method is the HTTP method, for example GET or POST.
Method string
// Path is an exact path under /v0/management/. Relative paths are resolved under that prefix.
Path string
// Menu is the optional management UI menu label for GET routes.
Menu string
// Description explains the management route for UI display.
Description string
// Handler processes matching Management API requests.
Handler ManagementHandler
}
// ManagementHandler handles one plugin-owned Management API route.
type ManagementHandler interface {
HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error)
}
// ManagementRequest describes an authenticated Management API request.
type ManagementRequest struct {
// Method is the HTTP method.
Method string
// Path is the request path.
Path string
// Headers contains request headers.
Headers http.Header
// Query contains request query parameters.
Query url.Values
// Body contains the raw request body.
Body []byte
}
// ManagementResponse describes a plugin Management API response.
type ManagementResponse struct {
// StatusCode is the HTTP status code. Zero defaults to 200.
StatusCode int
// Headers contains response headers.
Headers http.Header
// Body contains the raw response body.
Body []byte
}
// UsageRecord describes request usage and billing metadata.
type UsageRecord struct {
// Provider identifies the upstream provider.
Provider string
// ExecutorType identifies the executor implementation.
ExecutorType string
// Model is the model used for the request.
Model string
// Alias is the user-facing model alias when one was used.
Alias string
// APIKey is the client API key identifier when available.
APIKey string
// AuthID identifies the selected credential.
AuthID string
// AuthIndex identifies the credential index when applicable.
AuthIndex string
// AuthType identifies the credential type.
AuthType string
// Source identifies the request source or integration.
Source string
// ReasoningEffort records the requested reasoning effort.
ReasoningEffort string
// ServiceTier records the requested or reported service tier.
ServiceTier string
// RequestedAt is the time the request was received.
RequestedAt time.Time
// Latency is the total request latency.
Latency time.Duration
// TTFT is the time to first token for streaming requests.
TTFT time.Duration
// Failed reports whether the request failed.
Failed bool
// Failure contains failure details when Failed is true.
Failure UsageFailure
// Detail contains token usage counters.
Detail UsageDetail
// ResponseHeaders contains selected upstream response headers.
ResponseHeaders http.Header
}
// UsageFailure describes an upstream or executor failure.
type UsageFailure struct {
// StatusCode is the HTTP status code associated with the failure.
StatusCode int
// Body contains the failure response body or message.
Body string
}
// UsageDetail contains token accounting counters.
type UsageDetail struct {
// InputTokens is the prompt or input token count.
InputTokens int64
// OutputTokens is the completion or output token count.
OutputTokens int64
// ReasoningTokens is the reasoning token count.
ReasoningTokens int64
// CachedTokens is the total cached token count.
CachedTokens int64
// CacheReadTokens is the cache read token count.
CacheReadTokens int64
// CacheCreationTokens is the cache creation token count.
CacheCreationTokens int64
// TotalTokens is the total token count.
TotalTokens int64
}
+152
View File
@@ -0,0 +1,152 @@
package pluginapi
import (
"context"
"testing"
)
type compileTimePlugin struct{}
var _ ModelRegistrar = (*compileTimePlugin)(nil)
var _ ModelProvider = (*compileTimePlugin)(nil)
var _ AuthProvider = (*compileTimePlugin)(nil)
var _ FrontendAuthProvider = (*compileTimePlugin)(nil)
var _ ProviderExecutor = (*compileTimePlugin)(nil)
var _ HostHTTPClient = (*compileTimePlugin)(nil)
var _ RequestTranslator = (*compileTimePlugin)(nil)
var _ RequestNormalizer = (*compileTimePlugin)(nil)
var _ ResponseTranslator = (*compileTimePlugin)(nil)
var _ ResponseNormalizer = (*compileTimePlugin)(nil)
var _ ThinkingApplier = (*compileTimePlugin)(nil)
var _ UsagePlugin = (*compileTimePlugin)(nil)
var _ CommandLinePlugin = (*compileTimePlugin)(nil)
var _ ManagementAPI = (*compileTimePlugin)(nil)
var _ ManagementHandler = (*compileTimePlugin)(nil)
func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) {
meta := Metadata{
Name: "example",
Version: "1.0.0",
Author: "test",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://example.com/logo.svg",
ConfigFields: []ConfigField{{
Name: "mode",
Type: ConfigFieldTypeEnum,
EnumValues: []string{"safe", "fast"},
Description: "Execution mode.",
}},
}
if meta.Logo == "" || len(meta.ConfigFields) != 1 {
t.Fatalf("metadata missing logo or config fields: %#v", meta)
}
}
func TestManagementRouteMenuFieldsExposeManagementUIHints(t *testing.T) {
route := ManagementRoute{
Method: "GET",
Path: "/plugins/example/status",
Menu: "Example Status",
Description: "Shows example plugin status.",
Handler: compileTimePlugin{},
}
if route.Menu == "" || route.Description == "" {
t.Fatalf("management route missing menu fields: %#v", route)
}
}
func (compileTimePlugin) RegisterModels(context.Context, ModelRegistrationRequest) (ModelRegistrationResponse, error) {
return ModelRegistrationResponse{}, nil
}
func (compileTimePlugin) StaticModels(context.Context, StaticModelRequest) (ModelResponse, error) {
return ModelResponse{}, nil
}
func (compileTimePlugin) ModelsForAuth(context.Context, AuthModelRequest) (ModelResponse, error) {
return ModelResponse{}, nil
}
func (compileTimePlugin) Identifier() string { return "compile-time" }
func (compileTimePlugin) ParseAuth(context.Context, AuthParseRequest) (AuthParseResponse, error) {
return AuthParseResponse{}, nil
}
func (compileTimePlugin) StartLogin(context.Context, AuthLoginStartRequest) (AuthLoginStartResponse, error) {
return AuthLoginStartResponse{}, nil
}
func (compileTimePlugin) PollLogin(context.Context, AuthLoginPollRequest) (AuthLoginPollResponse, error) {
return AuthLoginPollResponse{}, nil
}
func (compileTimePlugin) RefreshAuth(context.Context, AuthRefreshRequest) (AuthRefreshResponse, error) {
return AuthRefreshResponse{}, nil
}
func (compileTimePlugin) Authenticate(context.Context, FrontendAuthRequest) (FrontendAuthResponse, error) {
return FrontendAuthResponse{}, nil
}
func (compileTimePlugin) Execute(context.Context, ExecutorRequest) (ExecutorResponse, error) {
return ExecutorResponse{}, nil
}
func (compileTimePlugin) ExecuteStream(context.Context, ExecutorRequest) (ExecutorStreamResponse, error) {
return ExecutorStreamResponse{}, nil
}
func (compileTimePlugin) CountTokens(context.Context, ExecutorRequest) (ExecutorResponse, error) {
return ExecutorResponse{}, nil
}
func (compileTimePlugin) HttpRequest(context.Context, ExecutorHTTPRequest) (ExecutorHTTPResponse, error) {
return ExecutorHTTPResponse{}, nil
}
func (compileTimePlugin) Do(context.Context, HTTPRequest) (HTTPResponse, error) {
return HTTPResponse{}, nil
}
func (compileTimePlugin) DoStream(context.Context, HTTPRequest) (HTTPStreamResponse, error) {
return HTTPStreamResponse{}, nil
}
func (compileTimePlugin) TranslateRequest(context.Context, RequestTransformRequest) (PayloadResponse, error) {
return PayloadResponse{}, nil
}
func (compileTimePlugin) NormalizeRequest(context.Context, RequestTransformRequest) (PayloadResponse, error) {
return PayloadResponse{}, nil
}
func (compileTimePlugin) TranslateResponse(context.Context, ResponseTransformRequest) (PayloadResponse, error) {
return PayloadResponse{}, nil
}
func (compileTimePlugin) NormalizeResponse(context.Context, ResponseTransformRequest) (PayloadResponse, error) {
return PayloadResponse{}, nil
}
func (compileTimePlugin) ApplyThinking(context.Context, ThinkingApplyRequest) (PayloadResponse, error) {
return PayloadResponse{}, nil
}
func (compileTimePlugin) HandleUsage(context.Context, UsageRecord) {}
func (compileTimePlugin) RegisterCommandLine(context.Context, CommandLineRegistrationRequest) (CommandLineRegistrationResponse, error) {
return CommandLineRegistrationResponse{}, nil
}
func (compileTimePlugin) ExecuteCommandLine(context.Context, CommandLineExecutionRequest) (CommandLineExecutionResponse, error) {
return CommandLineExecutionResponse{}, nil
}
func (compileTimePlugin) RegisterManagement(context.Context, ManagementRegistrationRequest) (ManagementRegistrationResponse, error) {
return ManagementRegistrationResponse{}, nil
}
func (compileTimePlugin) HandleManagement(context.Context, ManagementRequest) (ManagementResponse, error) {
return ManagementResponse{}, nil
}
+5
View File
@@ -7,6 +7,11 @@ func TranslateRequestByFormatName(from, to Format, model string, rawJSON []byte,
return TranslateRequest(from, to, model, rawJSON, stream)
}
// HasRequestTransformerByFormatName reports whether a request translator exists between two schemas.
func HasRequestTransformerByFormatName(from, to Format) bool {
return HasRequestTransformer(from, to)
}
// HasResponseTransformerByFormatName reports whether a response translator exists between two schemas.
func HasResponseTransformerByFormatName(from, to Format) bool {
return HasResponseTransformer(from, to)
+12
View File
@@ -0,0 +1,12 @@
package translator
import "context"
// PluginHooks defines optional translator extension hooks provided by plugins.
type PluginHooks interface {
NormalizeRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) []byte
TranslateRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) ([]byte, bool)
NormalizeResponseBefore(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte
TranslateResponse(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool)
NormalizeResponseAfter(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte
}
+100 -19
View File
@@ -14,6 +14,7 @@ type Registry struct {
mu sync.RWMutex
requests map[Format]map[Format]RequestTransform
responses map[Format]map[Format]ResponseTransform
hooks PluginHooks
}
// NewRegistry constructs an empty translator registry.
@@ -42,27 +43,62 @@ func (r *Registry) Register(from, to Format, request RequestTransform, response
r.responses[from][to] = response
}
// SetPluginHooks stores translator plugin hooks for this registry.
func (r *Registry) SetPluginHooks(hooks PluginHooks) {
r.mu.Lock()
defer r.mu.Unlock()
r.hooks = hooks
}
// TranslateRequest converts a payload between schemas, returning the original payload
// if no translator is registered. When falling back to the original payload, the
// "model" field is still updated to match the resolved model name so that
// client-side prefixes (e.g. "copilot/gpt-5-mini") are not leaked upstream.
func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {
r.mu.RLock()
var fn RequestTransform
if byTarget, ok := r.requests[from]; ok {
fn = byTarget[to]
}
hooks := r.hooks
r.mu.RUnlock()
body := rawJSON
if fn != nil {
body = fn(model, body, stream)
} else {
if model != "" && gjson.GetBytes(body, "model").String() != model {
if updated, err := sjson.SetBytes(body, "model", model); err != nil {
log.Warnf("translator: failed to normalize model in request fallback: %v", err)
} else {
body = updated
}
}
}
if hooks != nil {
body = hooks.NormalizeRequest(context.Background(), from, to, model, body, stream)
if fn == nil {
if translated, ok := hooks.TranslateRequest(context.Background(), from, to, model, body, stream); ok {
body = translated
}
}
}
return body
}
// HasRequestTransformer indicates whether a request translator exists.
func (r *Registry) HasRequestTransformer(from, to Format) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if byTarget, ok := r.requests[from]; ok {
if fn, isOk := byTarget[to]; isOk && fn != nil {
return fn(model, rawJSON, stream)
return true
}
}
if model != "" && gjson.GetBytes(rawJSON, "model").String() != model {
if updated, err := sjson.SetBytes(rawJSON, "model", model); err != nil {
log.Warnf("translator: failed to normalize model in request fallback: %v", err)
} else {
return updated
}
}
return rawJSON
return false
}
// HasResponseTransformer indicates whether a response translator exists.
@@ -81,27 +117,62 @@ func (r *Registry) HasResponseTransformer(from, to Format) bool {
// TranslateStream applies the registered streaming response translator.
func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
r.mu.RLock()
defer r.mu.RUnlock()
var fn ResponseTransform
if byTarget, ok := r.responses[to]; ok {
if fn, isOk := byTarget[from]; isOk && fn.Stream != nil {
return fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
fn = byTarget[from]
}
hooks := r.hooks
r.mu.RUnlock()
body := rawJSON
if hooks != nil {
body = hooks.NormalizeResponseBefore(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, true)
}
var outputs [][]byte
if fn.Stream != nil {
outputs = fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, body, param)
} else if hooks != nil {
if translated, ok := hooks.TranslateResponse(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, true); ok {
outputs = [][]byte{translated}
}
}
return [][]byte{rawJSON}
if outputs == nil {
outputs = [][]byte{body}
}
if hooks != nil {
for i, output := range outputs {
outputs[i] = hooks.NormalizeResponseAfter(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, output, true)
}
}
return outputs
}
// TranslateNonStream applies the registered non-stream response translator.
func (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
r.mu.RLock()
defer r.mu.RUnlock()
var fn ResponseTransform
if byTarget, ok := r.responses[to]; ok {
if fn, isOk := byTarget[from]; isOk && fn.NonStream != nil {
return fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param)
fn = byTarget[from]
}
hooks := r.hooks
r.mu.RUnlock()
body := rawJSON
if hooks != nil {
body = hooks.NormalizeResponseBefore(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false)
}
if fn.NonStream != nil {
body = fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, body, param)
} else if hooks != nil {
if translated, ok := hooks.TranslateResponse(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false); ok {
body = translated
}
}
return rawJSON
if hooks != nil {
body = hooks.NormalizeResponseAfter(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false)
}
return body
}
// TranslateTokenCount applies the registered token count response translator.
@@ -129,11 +200,21 @@ func Register(from, to Format, request RequestTransform, response ResponseTransf
defaultRegistry.Register(from, to, request, response)
}
// SetPluginHooks stores plugin hooks on the default registry.
func SetPluginHooks(hooks PluginHooks) {
defaultRegistry.SetPluginHooks(hooks)
}
// TranslateRequest is a helper on the default registry.
func TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte {
return defaultRegistry.TranslateRequest(from, to, model, rawJSON, stream)
}
// HasRequestTransformer inspects the default registry.
func HasRequestTransformer(from, to Format) bool {
return defaultRegistry.HasRequestTransformer(from, to)
}
// HasResponseTransformer inspects the default registry.
func HasResponseTransformer(from, to Format) bool {
return defaultRegistry.HasResponseTransformer(from, to)
+204
View File
@@ -1,11 +1,66 @@
package translator
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
type fakePluginHooks struct {
calls []string
requestTranslateBody []byte
requestTranslateOK bool
responseTranslateBody []byte
responseTranslateOK bool
normalizeRequest func([]byte) []byte
normalizeBefore func([]byte) []byte
normalizeAfter func([]byte) []byte
}
func (h *fakePluginHooks) NormalizeRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) []byte {
h.calls = append(h.calls, "normalize-request")
if h.normalizeRequest != nil {
return h.normalizeRequest(body)
}
return body
}
func (h *fakePluginHooks) TranslateRequest(ctx context.Context, from, to Format, model string, body []byte, stream bool) ([]byte, bool) {
h.calls = append(h.calls, "translate-request")
return h.requestTranslateBody, h.requestTranslateOK
}
func (h *fakePluginHooks) NormalizeResponseBefore(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
h.calls = append(h.calls, "normalize-response-before")
if h.normalizeBefore != nil {
return h.normalizeBefore(body)
}
return body
}
func (h *fakePluginHooks) TranslateResponse(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool) {
h.calls = append(h.calls, "translate-response")
return h.responseTranslateBody, h.responseTranslateOK
}
func (h *fakePluginHooks) NormalizeResponseAfter(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
h.calls = append(h.calls, "normalize-response-after")
if h.normalizeAfter != nil {
return h.normalizeAfter(body)
}
return body
}
func hasCall(calls []string, want string) bool {
for _, call := range calls {
if call == want {
return true
}
}
return false
}
func TestTranslateRequest_FallbackNormalizesModel(t *testing.T) {
r := NewRegistry()
@@ -90,3 +145,152 @@ func TestTranslateRequest_RegisteredTransformTakesPrecedence(t *testing.T) {
t.Errorf("expected registered transform to take precedence, got model = %q", gotModel)
}
}
func TestHasRequestTransformer(t *testing.T) {
r := NewRegistry()
from := Format("from")
to := Format("to")
if r.HasRequestTransformer(from, to) {
t.Fatal("request transformer exists before registration")
}
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
return rawJSON
}, ResponseTransform{})
if !r.HasRequestTransformer(from, to) {
t.Fatal("request transformer is missing after registration")
}
}
func TestTranslateRequest_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
from := Format("from")
to := Format("to")
missingNative := NewRegistry()
missingHooks := &fakePluginHooks{
requestTranslateBody: []byte(`{"model":"plugin-request"}`),
requestTranslateOK: true,
}
missingNative.SetPluginHooks(missingHooks)
gotMissing := missingNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
if gjson.GetBytes(gotMissing, "model").String() != "plugin-request" {
t.Fatalf("plugin request translator was not used, got %s", gotMissing)
}
if !hasCall(missingHooks.calls, "translate-request") {
t.Fatal("plugin request translator was not called when native transformer was missing")
}
withNative := NewRegistry()
nativeHooks := &fakePluginHooks{
requestTranslateBody: []byte(`{"model":"plugin-request"}`),
requestTranslateOK: true,
}
withNative.SetPluginHooks(nativeHooks)
withNative.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
return []byte(`{"model":"native-request"}`)
}, ResponseTransform{})
gotNative := withNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
if gjson.GetBytes(gotNative, "model").String() != "native-request" {
t.Fatalf("native request transformer was not preserved, got %s", gotNative)
}
if hasCall(nativeHooks.calls, "translate-request") {
t.Fatal("plugin request translator was called despite native transformer")
}
}
func TestTranslateNonStream_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
ctx := context.Background()
from := Format("client")
to := Format("upstream")
missingNative := NewRegistry()
missingHooks := &fakePluginHooks{
responseTranslateBody: []byte(`{"output":"plugin-response"}`),
responseTranslateOK: true,
}
missingNative.SetPluginHooks(missingHooks)
gotMissing := missingNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
if gjson.GetBytes(gotMissing, "output").String() != "plugin-response" {
t.Fatalf("plugin response translator was not used, got %s", gotMissing)
}
if !hasCall(missingHooks.calls, "translate-response") {
t.Fatal("plugin response translator was not called when native transformer was missing")
}
withNative := NewRegistry()
nativeHooks := &fakePluginHooks{
responseTranslateBody: []byte(`{"output":"plugin-response"}`),
responseTranslateOK: true,
}
withNative.SetPluginHooks(nativeHooks)
withNative.Register(to, from, nil, ResponseTransform{
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
return []byte(`{"output":"native-response"}`)
},
})
gotNative := withNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
if gjson.GetBytes(gotNative, "output").String() != "native-response" {
t.Fatalf("native response transformer was not preserved, got %s", gotNative)
}
if hasCall(nativeHooks.calls, "translate-response") {
t.Fatal("plugin response translator was called despite native transformer")
}
}
func TestPluginNormalizersChainAfterNative(t *testing.T) {
ctx := context.Background()
r := NewRegistry()
from := Format("client")
to := Format("upstream")
hooks := &fakePluginHooks{
normalizeRequest: func(body []byte) []byte {
if string(body) != `{"stage":"native-request"}` {
t.Fatalf("request normalizer saw %s", body)
}
return []byte(`{"stage":"normalized-request"}`)
},
normalizeBefore: func(body []byte) []byte {
if string(body) != `{"stage":"raw-response"}` {
t.Fatalf("response before normalizer saw %s", body)
}
return []byte(`{"stage":"before-response"}`)
},
normalizeAfter: func(body []byte) []byte {
if string(body) != `{"stage":"native-response"}` {
t.Fatalf("response after normalizer saw %s", body)
}
return []byte(`{"stage":"after-response"}`)
},
}
r.SetPluginHooks(hooks)
r.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
return []byte(`{"stage":"native-request"}`)
}, ResponseTransform{})
r.Register(to, from, nil, ResponseTransform{
NonStream: func(ctx context.Context, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
if string(rawJSON) != `{"stage":"before-response"}` {
t.Fatalf("native response transformer saw %s", rawJSON)
}
return []byte(`{"stage":"native-response"}`)
},
})
gotRequest := r.TranslateRequest(from, to, "model", []byte(`{"stage":"raw-request"}`), false)
if string(gotRequest) != `{"stage":"normalized-request"}` {
t.Fatalf("request normalizer did not run after native transformer, got %s", gotRequest)
}
gotResponse := r.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"stage":"raw-response"}`), nil)
if string(gotResponse) != `{"stage":"after-response"}` {
t.Fatalf("response normalizers did not wrap native transformer, got %s", gotResponse)
}
if hasCall(hooks.calls, "translate-request") || hasCall(hooks.calls, "translate-response") {
t.Fatalf("plugin translators should not run when native transformers exist, calls=%v", hooks.calls)
}
}