mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
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:
@@ -12,6 +12,7 @@ logs/*
|
||||
conv/*
|
||||
temp/*
|
||||
refs/*
|
||||
plugins/*
|
||||
|
||||
# Storage backends
|
||||
pgstore/*
|
||||
|
||||
+74
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ func ParseConfigBytes(data []byte) (*Config, error) {
|
||||
cfg.MaxRetryCredentials = 0
|
||||
}
|
||||
|
||||
cfg.NormalizePluginsConfig()
|
||||
|
||||
// Apply the same sanitization pipeline.
|
||||
cfg.SanitizeGeminiKeys()
|
||||
cfg.SanitizeVertexCompatKeys()
|
||||
|
||||
@@ -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
@@ -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 ©Record
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ©Node
|
||||
}
|
||||
|
||||
func mustMarshalYAML(v any) []byte {
|
||||
raw, errMarshal := yaml.Marshal(v)
|
||||
if errMarshal != nil {
|
||||
return append([]byte(nil), defaultRuntimeConfigYAML...)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user