From d625caddd9a66cff97e0cc281d827dbc4b46a7fe Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 6 Jun 2026 18:35:17 +0800 Subject: [PATCH] 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. --- .gitignore | 1 + cmd/server/main.go | 76 +- config.example.yaml | 23 + examples/plugin/README.md | 416 ++++ examples/plugin/README_CN.md | 416 ++++ examples/plugin/main.go | 420 ++++ .../api/handlers/management/auth_files.go | 139 +- internal/api/handlers/management/handler.go | 18 + .../api/handlers/management/oauth_sessions.go | 76 +- internal/api/handlers/management/plugins.go | 459 ++++ .../api/handlers/management/plugins_test.go | 244 ++ internal/api/server.go | 119 +- internal/api/server_test.go | 26 + internal/cmd/run.go | 17 + internal/config/config.go | 114 +- internal/config/parse.go | 2 + internal/config/plugin_config_test.go | 160 ++ internal/pluginhost/adapters.go | 1644 +++++++++++++ internal/pluginhost/adapters_test.go | 2182 +++++++++++++++++ internal/pluginhost/auth_provider.go | 495 ++++ internal/pluginhost/auth_provider_test.go | 317 +++ internal/pluginhost/command_line.go | 420 ++++ internal/pluginhost/command_line_test.go | 212 ++ internal/pluginhost/config.go | 156 ++ internal/pluginhost/config_test.go | 35 + internal/pluginhost/host.go | 263 ++ internal/pluginhost/host_test.go | 250 ++ internal/pluginhost/http_bridge.go | 172 ++ internal/pluginhost/loader_plugin.go | 35 + internal/pluginhost/loader_unsupported.go | 23 + internal/pluginhost/management.go | 193 ++ internal/pluginhost/management_test.go | 156 ++ internal/pluginhost/platform.go | 126 + internal/pluginhost/platform_test.go | 158 ++ internal/pluginhost/snapshot.go | 99 + internal/pluginhost/test_helpers_test.go | 133 + internal/registry/model_registry.go | 2 +- internal/thinking/apply.go | 85 +- internal/thinking/validate.go | 2 +- internal/watcher/clients.go | 92 +- internal/watcher/dispatcher.go | 75 +- internal/watcher/events.go | 9 +- internal/watcher/synthesizer/context.go | 10 + internal/watcher/synthesizer/file.go | 27 +- internal/watcher/watcher.go | 20 +- internal/watcher/watcher_test.go | 4 +- sdk/auth/filestore.go | 60 +- sdk/cliproxy/auth/conductor.go | 16 + sdk/cliproxy/auth/oauth_model_alias.go | 29 +- sdk/cliproxy/auth/oauth_model_alias_test.go | 49 + sdk/cliproxy/builder.go | 54 +- sdk/cliproxy/service.go | 754 +++++- sdk/cliproxy/service_excluded_models_test.go | 5 +- .../service_oauth_model_alias_test.go | 42 + sdk/cliproxy/service_plugin_executor_test.go | 59 + sdk/cliproxy/types.go | 24 + sdk/cliproxy/usage/manager.go | 28 + sdk/cliproxy/watcher.go | 6 + sdk/pluginapi/types.go | 876 +++++++ sdk/pluginapi/types_test.go | 152 ++ sdk/translator/helpers.go | 5 + sdk/translator/plugin_hooks.go | 12 + sdk/translator/registry.go | 119 +- sdk/translator/registry_test.go | 204 ++ 64 files changed, 12424 insertions(+), 191 deletions(-) create mode 100644 examples/plugin/README.md create mode 100644 examples/plugin/README_CN.md create mode 100644 examples/plugin/main.go create mode 100644 internal/api/handlers/management/plugins.go create mode 100644 internal/api/handlers/management/plugins_test.go create mode 100644 internal/config/plugin_config_test.go create mode 100644 internal/pluginhost/adapters.go create mode 100644 internal/pluginhost/adapters_test.go create mode 100644 internal/pluginhost/auth_provider.go create mode 100644 internal/pluginhost/auth_provider_test.go create mode 100644 internal/pluginhost/command_line.go create mode 100644 internal/pluginhost/command_line_test.go create mode 100644 internal/pluginhost/config.go create mode 100644 internal/pluginhost/config_test.go create mode 100644 internal/pluginhost/host.go create mode 100644 internal/pluginhost/host_test.go create mode 100644 internal/pluginhost/http_bridge.go create mode 100644 internal/pluginhost/loader_plugin.go create mode 100644 internal/pluginhost/loader_unsupported.go create mode 100644 internal/pluginhost/management.go create mode 100644 internal/pluginhost/management_test.go create mode 100644 internal/pluginhost/platform.go create mode 100644 internal/pluginhost/platform_test.go create mode 100644 internal/pluginhost/snapshot.go create mode 100644 internal/pluginhost/test_helpers_test.go create mode 100644 sdk/cliproxy/service_plugin_executor_test.go create mode 100644 sdk/pluginapi/types.go create mode 100644 sdk/pluginapi/types_test.go create mode 100644 sdk/translator/plugin_hooks.go diff --git a/.gitignore b/.gitignore index 0ef122297..9f8bad4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ logs/* conv/* temp/* refs/* +plugins/* # Storage backends pgstore/* diff --git a/cmd/server/main.go b/cmd/server/main.go index 4181faeca..ff7fb9e43 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 +} diff --git a/config.example.yaml b/config.example.yaml index 4b30dd887..0070e9d3c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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..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: diff --git a/examples/plugin/README.md b/examples/plugin/README.md new file mode 100644 index 000000000..e9c86fc31 --- /dev/null +++ b/examples/plugin/README.md @@ -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//-/*.so +plugins///*.so +plugins/*.so +``` + +For `amd64`, `` is selected from CPU capabilities as `v4`, `v3`, `v2`, or `v1`. CPU-specific builds therefore belong under paths such as `plugins/linux/amd64-v3/`. + +Replacing an already opened `.so` file requires a process restart. Go plugins cannot be unloaded from the current process. + +## Configure the host + +Dynamic plugins are disabled by default. Enable them in `config.yaml`: + +```yaml +plugins: + enabled: true + dir: "plugins" + configs: + example: + enabled: true + priority: 1 + config1: true + config2: "string" + config3: 3 +``` + +Configuration rules: + +- `plugins.enabled=false` skips all plugin loading and execution. +- `plugins.dir` defaults to `plugins` when omitted or empty. +- `plugins.configs.` is the per-plugin YAML subtree passed to `Register` or `Reconfigure`. +- `enabled` defaults to `true` for a configured plugin instance. +- `priority` defaults to `0`. +- The host injects normalized `enabled` and `priority` into the YAML bytes passed to the plugin when they are missing. +- Higher `priority` plugins run before lower `priority` plugins. Equal priorities are ordered by plugin ID. + +Hot reload updates the runtime plugin snapshot. Already opened plugin binaries stay in memory, but disabled plugins are removed from the active capability set. If a loaded plugin remains enabled, the host calls `Reconfigure(configYAML)` instead of `Register(configYAML)`. + +## 插件 metadata、Logo 和配置字段 + +插件通过 `pluginapi.Metadata` 向宿主管理接口提供展示信息: + +```go +type Metadata struct { + Name string + Version string + Author string + GitHubRepository string + Logo string + ConfigFields []ConfigField +} +``` + +`Logo` 是给管理端展示的字符串。宿主只透传该值,不校验它是 URL、data URI、文件路径或其他格式。 + +`ConfigFields` 描述 `plugins.configs.` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下: + +```go +type ConfigField struct { + Name string + Type ConfigFieldType + EnumValues []string + Description string +} +``` + +支持的 `ConfigFieldType` 值包括 `string`、`number`、`integer`、`boolean`、`enum`、`array` 和 `object`。当类型是 `enum` 时,`EnumValues` 应列出所有可选值。 + +## Add auth material + +Executor-backed plugin models need a matching auth record so the scheduler can select the provider. The auth `type` must match the provider returned by `ModelProvider`, `AuthProvider.Identifier`, and `ProviderExecutor.Identifier`. + +For this sample: + +```json +{ + "type": "plugin-example", + "api_key": "plugin-or-upstream-secret" +} +``` + +Place the file under the configured auth directory, for example: + +```text +auths/plugin-example.json +``` + +Do not configure `base_url`, `compat_name`, or an `openai-compatibility` entry for the same provider unless you intentionally want the native OpenAI-compatible executor to own that provider. Native executors always win over plugin executors. + +Auth provider behavior in this sample: + +- `ParseAuth` accepts JSON offered by the host auth loader and returns `pluginapi.AuthData`. +- `StartLogin` and `PollLogin` are present but return non-interactive errors in this sample. +- `RefreshAuth` returns the current auth data unchanged. +- A real plugin can return `AuthData` from command-line execution or login polling; the host persists it through the normal auth store. + +## Model registration and executor scope + +The current provider-native model path is `ModelProvider`: + +- `StaticModels` returns provider models that are available without inspecting a specific auth record. +- `ModelsForAuth` returns models discovered for one selected auth record and can return an `AuthUpdate` when discovery refreshes persisted provider state. + +The host applies normal model processing after plugin discovery: aliases, excluded models, prefixes, registry reconciliation, and scheduler rules. + +`ExecutorModelScope` controls which model-registration paths are allowed when `Capabilities.Executor` is present: + +| Scope | Meaning | +| --- | --- | +| `pluginapi.ExecutorModelScopeBoth` | The executor supports both static models and auth-bound OAuth-style models. This is the default when the scope is empty or invalid. | +| `pluginapi.ExecutorModelScopeStatic` | The executor supports only non-OAuth static models. `ModelsForAuth` is skipped for executor-backed registration. | +| `pluginapi.ExecutorModelScopeOAuth` | The executor supports only auth-bound models. Static executor model clients are not registered. | + +Use the narrowest scope that matches the provider. This avoids exposing models through the wrong registration path. + +## Execution flow + +A plugin executor runs only when: + +- global plugins are enabled, +- the specific plugin is enabled, +- the plugin has not been panic-fused, +- the selected auth provider matches the executor provider, +- no native executor owns the same provider or selected model, +- and no higher-priority plugin has already claimed the same provider/model. + +`ProviderExecutor` receives a `pluginapi.ExecutorRequest` with: + +- `Model`: the host-resolved model identifier after alias handling, +- `Format`: the target provider format, +- `SourceFormat`: the original client format, +- `OriginalRequest`: the raw client payload, +- `Payload`: the translated provider payload, +- `StorageJSON`, `AuthMetadata`, and `AuthAttributes`: selected auth state, +- `HTTPClient`: the host HTTP bridge. + +Executor upstream HTTP calls must use `req.HTTPClient.Do` or `req.HTTPClient.DoStream`. Do not build a separate proxy-aware client inside the plugin. The host bridge preserves host transport policy and lets `request-log` capture the outbound upstream request and the raw upstream response before plugin-side translation. + +The sample methods are intentionally deterministic: + +- `Execute` returns one OpenAI-shaped JSON response. +- `ExecuteStream` emits one stream chunk and closes the channel. +- `CountTokens` returns zero token counts. +- `HttpRequest` forwards raw HTTP through the host bridge. + +For real providers, use `req.Model` for provider routing and model rewriting decisions. Do not assume every protocol payload has a trustworthy top-level `model` field. + +## Translators, normalizers, and thinking + +Native logic is authoritative. Plugin transforms fill gaps instead of replacing built-in provider support. + +Request and response behavior: + +- Request normalizers run from higher priority to lower priority and are chained. +- Response normalizers before and after translation follow the same priority ordering. +- Request translators and response translators run only when no native translator exists for the format pair. +- Only the highest-priority plugin translator is selected for a missing translation path. + +Thinking behavior: + +- The host parses, normalizes, and validates thinking config centrally. +- `ThinkingApplier` receives canonical `pluginapi.ThinkingConfig`. +- A plugin thinking applier only applies provider keys that are not owned by native thinking providers. +- When a plugin is disabled, removed from the active snapshot, or panic-fused, its thinking applier is removed. + +The sample writes these provider-specific fields into the payload: + +```json +{ + "plugin_example_thinking": { + "mode": "budget", + "budget": 1024, + "level": "" + } +} +``` + +## Command-line flags + +The sample declares two plugin-owned flags: + +```bash +./cli-proxy-api -config config.yaml -plugin-example-command +./cli-proxy-api -config config.yaml -plugin-example-command -plugin-example-message "custom message" +``` + +Plugin command-line flags are registered before normal flag parsing so they appear in `-help`. + +Rules: + +- Supported flag types are `bool`, `string`, `int`, `int64`, `float64`, and `duration`. +- Flag names cannot start with `-`, contain whitespace, contain `=`, or be `help` / `h`. +- Native flags cannot be replaced. +- Higher-priority plugin flags cannot be replaced by lower-priority plugins. +- When any plugin-owned flag is provided, the host passes every argument, every visible parsed flag, and the triggered plugin-owned flags to `ExecuteCommandLine`. +- If final config disables global plugins or this plugin, the flag can still be parsed but plugin execution is skipped. +- If `ExecuteCommandLine` returns `Auths`, the host persists them through the configured auth store and appends saved paths to stdout. + +## Management API routes + +宿主提供原生插件管理接口: + +```text +GET /v0/management/plugins +PATCH /v0/management/plugins/{pluginID}/enabled +PUT /v0/management/plugins/{pluginID}/config +PATCH /v0/management/plugins/{pluginID}/config +``` + +`GET /v0/management/plugins` 会按宿主当前扫描规则列出插件目录中的 `.so` 文件,也会列出只存在于 `plugins.configs` 中的配置项。已成功注册的插件会返回 `logo`、`config_fields` 和 `supports_oauth`。 + +如果插件注册的 Management API 路由是 `GET` 方法,并且 `ManagementRoute.Menu` 不为空,`GET /v0/management/plugins` 会在该插件条目的 `menus` 数组中返回 `path`、`menu` 和 `description`。`Menu` 用作管理端菜单名称,`Description` 用作菜单说明。 + +`PATCH /v0/management/plugins/{pluginID}/enabled` 只更新 `plugins.configs..enabled`,不会隐式修改全局 `plugins.enabled`。因此当 `plugins.enabled=false` 时,单插件可以显示为启用,但实际运行时仍不会加载插件能力。 + +`PUT /v0/management/plugins/{pluginID}/config` 会替换整个插件配置子树。`PATCH /v0/management/plugins/{pluginID}/config` 会做浅层合并;请求中的 `null` 会删除对应字段。 + +The sample routes are: + +```text +GET /v0/management/plugins/example/status +GET /v0/management/plugins/example/capabilities +``` + +Management API route rules: + +- Routes are exact method/path matches under `/v0/management/`. +- A plugin may return relative paths such as `/plugins/example/status`; the host resolves them under `/v0/management`. +- Paths cannot contain whitespace, `:`, or `*`. +- Native Management API routes cannot be replaced. +- Higher-priority plugin routes cannot be replaced by lower-priority plugins. +- Routes require the normal Management API authentication. +- Routes are unavailable when Home mode or Management API availability disables local Management routes. +- The route table is rebuilt on config reload. + +## Frontend authentication + +The sample `FrontendAuthProvider` accepts a request only when this header is present: + +```text +X-Plugin-Example: allow +``` + +The registered frontend provider key is namespaced by the host as: + +```text +plugin:: +``` + +For this sample, the provider identifier is `plugin-example`, so downstream auth metadata is kept separate from native frontend auth providers. + +## Usage plugin + +`UsagePlugin.HandleUsage` receives completed usage records after request execution. The sample increments an in-memory counter that is visible through the diagnostic Management API status route. + +Usage records include provider, executor type, model, alias, selected auth, source, requested reasoning effort, service tier, latency, TTFT, failure details, token counters, and selected response headers. + +Keep this hook lightweight. Usage dispatch is part of the request accounting path, and the host will recover from panics by fusing the plugin. + +## Priority, native precedence, and panic fuse + +The plugin system is additive: + +- Native providers, executors, translators, thinking appliers, flags, and Management routes have priority over plugins. +- Plugins fill provider gaps and add plugin-owned surfaces. +- Higher-priority plugins are considered before lower-priority plugins. +- Plugin executors do not override native executors. +- Plugin Management routes and command-line flags do not override native routes or flags. + +Every lifecycle and capability call is protected by panic recovery. If a plugin panics during `Register`, `Reconfigure`, or any capability method, the host marks that plugin fused for the current process lifetime. A fused plugin is no longer called, even if config reload enables it again. Restart the service to clear the fused state. + +Go plugins are trusted in-process code, not a sandbox. Panic recovery cannot prevent a plugin from calling `os.Exit`, mutating shared process state, starting background work, or leaking secrets. Treat plugin binaries as code with the same trust level as the service binary. + +## Extending this sample + +When turning this sample into a real provider plugin: + +1. Keep `package main` and the exported `Register` / `Reconfigure` functions. +2. Rename metadata, provider keys, model IDs, command-line flags, and Management paths consistently. +3. Build the `.so` filename to match the desired plugin ID. +4. Choose the narrowest `ExecutorModelScope`. +5. Use `HostHTTPClient` for all upstream provider calls. +6. Return `AuthData` instead of writing directly to auth storage when the host is already managing login or command-line persistence. +7. Keep provider-specific payload rewriting inside the plugin boundary. +8. Avoid logging secrets, tokens, raw auth JSON, or signed request headers. +9. Keep background goroutines tied to context or explicit lifecycle state, because Go plugins cannot be unloaded. +10. Add plugin-local tests and build the plugin with the same toolchain as the service. + +## Verification + +Compile the sample plugin: + +```bash +go build -buildmode=plugin -o /tmp/cliproxy-example-plugin.so ./examples/plugin && rm -f /tmp/cliproxy-example-plugin.so +``` + +Check Markdown whitespace after editing docs: + +```bash +git diff --check -- examples/plugin/README.md examples/plugin/README_CN.md +``` + +If you changed Go code as part of a plugin implementation, also run the repository-required server compile: + +```bash +go build -o test-output ./cmd/server && rm test-output +``` + +## Troubleshooting + +`plugin.Open` fails with a type or version error: + +Build the plugin with the same Go version, module path, build tags, and dependency versions as the service binary. + +The plugin is not loaded: + +Confirm `plugins.enabled=true`, the `.so` file is under the selected plugin directory, the plugin ID is valid, and the per-plugin config is not disabled. + +The plugin loads but no capability is active: + +Confirm `Register` or `Reconfigure` returns valid metadata and at least one non-nil capability. + +The executor is not used: + +Confirm a matching auth record exists, the auth `type` matches the provider key, the executor scope allows the desired model path, and no native executor owns the provider or model. + +The command-line flag appears but does nothing: + +Confirm the final loaded config still enables global plugins and this plugin. CLI flags are registered before final config dispatch, but execution is checked against the final active plugin snapshot. + +The Management route returns 404: + +Confirm local Management API routes are available, the route path is exact, the plugin is enabled, and no native or higher-priority route claimed the same method/path. diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md new file mode 100644 index 000000000..aaaabbe19 --- /dev/null +++ b/examples/plugin/README_CN.md @@ -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//-/*.so +plugins///*.so +plugins/*.so +``` + +对于 `amd64`,`` 会根据 CPU 能力选择为 `v4`、`v3`、`v2` 或 `v1`。因此,CPU 专属构建可以放在类似 `plugins/linux/amd64-v3/` 的路径下。 + +替换已经打开的 `.so` 文件需要重启进程。Go 插件无法从当前进程中卸载。 + +## 配置宿主 + +动态插件默认关闭。请在 `config.yaml` 中启用: + +```yaml +plugins: + enabled: true + dir: "plugins" + configs: + example: + enabled: true + priority: 1 + config1: true + config2: "string" + config3: 3 +``` + +配置规则: + +- `plugins.enabled=false` 会跳过所有插件加载和执行。 +- `plugins.dir` 为空或未配置时默认使用 `plugins`。 +- `plugins.configs.` 是传给 `Register` 或 `Reconfigure` 的插件专属 YAML 子树。 +- 已配置插件实例的 `enabled` 默认值为 `true`。 +- `priority` 默认值为 `0`。 +- 如果插件配置中缺少 `enabled` 或 `priority`,宿主会把规整后的值注入到传给插件的 YAML 字节中。 +- `priority` 越高,插件越先执行。相同优先级按插件 ID 排序。 + +热重载会更新运行时插件快照。已经打开的插件二进制仍然留在内存中,但被禁用的插件会从当前活动能力集合中移除。如果已加载插件仍处于启用状态,宿主会调用 `Reconfigure(configYAML)`,而不是再次调用 `Register(configYAML)`。 + +## 插件 metadata、Logo 和配置字段 + +插件通过 `pluginapi.Metadata` 向宿主管理接口提供展示信息: + +```go +type Metadata struct { + Name string + Version string + Author string + GitHubRepository string + Logo string + ConfigFields []ConfigField +} +``` + +`Logo` 是给管理端展示的字符串。宿主只透传该值,不校验它是 URL、data URI、文件路径或其他格式。 + +`ConfigFields` 描述 `plugins.configs.` 下的插件自定义配置字段。它只用于管理端展示和生成配置表单,宿主不会用它校验插件配置。字段结构如下: + +```go +type ConfigField struct { + Name string + Type ConfigFieldType + EnumValues []string + Description string +} +``` + +支持的 `ConfigFieldType` 值包括 `string`、`number`、`integer`、`boolean`、`enum`、`array` 和 `object`。当类型是 `enum` 时,`EnumValues` 应列出所有可选值。 + +## 添加 auth 材料 + +带执行器的插件模型需要匹配的 auth 记录,这样调度器才能选择对应 provider。auth 的 `type` 必须匹配 `ModelProvider`、`AuthProvider.Identifier` 和 `ProviderExecutor.Identifier` 返回的 provider。 + +这个示例对应: + +```json +{ + "type": "plugin-example", + "api_key": "plugin-or-upstream-secret" +} +``` + +把文件放入已配置的 auth 目录,例如: + +```text +auths/plugin-example.json +``` + +除非你有意让原生 OpenAI-compatible 执行器拥有这个 provider,否则不要为同一个 provider 配置 `base_url`、`compat_name` 或 `openai-compatibility`。原生执行器始终优先于插件执行器。 + +这个示例中的 auth provider 行为: + +- `ParseAuth` 接收宿主 auth loader 提供的 JSON,并返回 `pluginapi.AuthData`。 +- `StartLogin` 和 `PollLogin` 存在,但在示例中返回非交互式错误。 +- `RefreshAuth` 原样返回当前 auth 数据。 +- 真实插件可以从命令行执行或登录轮询中返回 `AuthData`;宿主会通过正常 auth store 持久化这些数据。 + +## 模型注册和执行器范围 + +当前 provider-native 模型路径是 `ModelProvider`: + +- `StaticModels` 返回不依赖具体 auth 记录即可使用的 provider 模型。 +- `ModelsForAuth` 返回为某个选中 auth 记录发现的模型;如果发现过程刷新了 provider 状态,也可以返回 `AuthUpdate`。 + +插件发现模型后,宿主会继续应用正常模型处理流程:别名、排除模型、前缀、registry reconcile 和调度规则。 + +当 `Capabilities.Executor` 存在时,`ExecutorModelScope` 控制允许的模型注册路径: + +| Scope | 含义 | +| --- | --- | +| `pluginapi.ExecutorModelScopeBoth` | 执行器同时支持静态模型和 auth 绑定的 OAuth 风格模型。scope 为空或非法时默认使用这个值。 | +| `pluginapi.ExecutorModelScopeStatic` | 执行器只支持非 OAuth 的静态模型。执行器模型注册会跳过 `ModelsForAuth`。 | +| `pluginapi.ExecutorModelScopeOAuth` | 执行器只支持 auth 绑定模型。不会注册静态 executor model client。 | + +请使用与 provider 匹配的最窄 scope,避免通过错误的注册路径暴露模型。 + +## 执行流程 + +插件执行器只会在以下条件全部满足时运行: + +- 全局插件已启用; +- 当前插件已启用; +- 当前插件没有被 panic fuse; +- 选中的 auth provider 匹配执行器 provider; +- 没有原生执行器拥有同一个 provider 或选中的模型; +- 没有更高优先级插件已经声明同一个 provider/model。 + +`ProviderExecutor` 会收到 `pluginapi.ExecutorRequest`,其中包括: + +- `Model`:经过宿主别名处理后的模型 ID; +- `Format`:目标 provider 格式; +- `SourceFormat`:客户端原始格式; +- `OriginalRequest`:客户端原始 payload; +- `Payload`:已经翻译到 provider 侧的 payload; +- `StorageJSON`、`AuthMetadata` 和 `AuthAttributes`:选中 auth 的状态; +- `HTTPClient`:宿主 HTTP bridge。 + +执行器访问上游 HTTP 时必须使用 `req.HTTPClient.Do` 或 `req.HTTPClient.DoStream`。不要在插件内部自行构造 proxy-aware client。宿主 bridge 会保持宿主传输策略,并且让 `request-log` 在插件转换响应前记录发往上游的请求和上游返回的原始响应。 + +示例方法刻意保持确定性: + +- `Execute` 返回一个 OpenAI 形态的 JSON 响应。 +- `ExecuteStream` 输出一个 stream chunk 后关闭 channel。 +- `CountTokens` 返回 0 token 统计。 +- `HttpRequest` 通过宿主 bridge 转发原始 HTTP。 + +真实 provider 中应使用 `req.Model` 做 provider 路由和模型改写判断。不要假设每种协议 payload 都有可信的顶层 `model` 字段。 + +## Translator、Normalizer 和 Thinking + +原生逻辑是权威实现。插件转换用于补齐空白,而不是替换内置 provider 支持。 + +请求和响应行为: + +- 请求 normalizer 按优先级从高到低链式执行。 +- 翻译前和翻译后的响应 normalizer 也遵循同样的优先级顺序。 +- 只有当某个格式转换不存在原生 translator 时,请求 translator 和响应 translator 才会运行。 +- 对于缺失的翻译路径,只会选择优先级最高的一个插件 translator。 + +Thinking 行为: + +- 宿主集中解析、规整并验证 thinking 配置。 +- `ThinkingApplier` 接收 canonical `pluginapi.ThinkingConfig`。 +- 插件 thinking applier 只会处理没有原生 thinking provider 拥有的 provider key。 +- 插件被禁用、从活动快照中移除或被 panic fuse 后,它的 thinking applier 会被移除。 + +示例会向 payload 写入这些 provider 专属字段: + +```json +{ + "plugin_example_thinking": { + "mode": "budget", + "budget": 1024, + "level": "" + } +} +``` + +## 命令行参数 + +示例声明了两个插件自有参数: + +```bash +./cli-proxy-api -config config.yaml -plugin-example-command +./cli-proxy-api -config config.yaml -plugin-example-command -plugin-example-message "custom message" +``` + +插件命令行参数会在正常 flag 解析前注册,因此会显示在 `-help` 中。 + +规则: + +- 支持的 flag 类型为 `bool`、`string`、`int`、`int64`、`float64` 和 `duration`。 +- flag 名称不能以 `-` 开头,不能包含空白字符,不能包含 `=`,也不能是 `help` / `h`。 +- 原生 flag 不能被替换。 +- 更高优先级插件的 flag 不能被低优先级插件替换。 +- 当提供了任意插件自有 flag 时,宿主会把所有参数、所有可见的已解析 flag,以及触发执行的插件自有 flag 传给 `ExecuteCommandLine`。 +- 如果最终配置禁用了全局插件或当前插件,flag 仍可能被解析,但插件执行会被跳过。 +- 如果 `ExecuteCommandLine` 返回 `Auths`,宿主会通过已配置的 auth store 持久化它们,并把保存路径追加到 stdout。 + +## Management API 路由 + +宿主提供原生插件管理接口: + +```text +GET /v0/management/plugins +PATCH /v0/management/plugins/{pluginID}/enabled +PUT /v0/management/plugins/{pluginID}/config +PATCH /v0/management/plugins/{pluginID}/config +``` + +`GET /v0/management/plugins` 会按宿主当前扫描规则列出插件目录中的 `.so` 文件,也会列出只存在于 `plugins.configs` 中的配置项。已成功注册的插件会返回 `logo`、`config_fields` 和 `supports_oauth`。 + +如果插件注册的 Management API 路由是 `GET` 方法,并且 `ManagementRoute.Menu` 不为空,`GET /v0/management/plugins` 会在该插件条目的 `menus` 数组中返回 `path`、`menu` 和 `description`。`Menu` 用作管理端菜单名称,`Description` 用作菜单说明。 + +`PATCH /v0/management/plugins/{pluginID}/enabled` 只更新 `plugins.configs..enabled`,不会隐式修改全局 `plugins.enabled`。因此当 `plugins.enabled=false` 时,单插件可以显示为启用,但实际运行时仍不会加载插件能力。 + +`PUT /v0/management/plugins/{pluginID}/config` 会替换整个插件配置子树。`PATCH /v0/management/plugins/{pluginID}/config` 会做浅层合并;请求中的 `null` 会删除对应字段。 + +示例路由: + +```text +GET /v0/management/plugins/example/status +GET /v0/management/plugins/example/capabilities +``` + +Management API 路由规则: + +- 路由是 `/v0/management/` 下按 method/path 精确匹配的路由。 +- 插件可以返回类似 `/plugins/example/status` 的相对路径;宿主会把它解析到 `/v0/management` 下。 +- 路径不能包含空白字符、`:` 或 `*`。 +- 原生 Management API 路由不能被替换。 +- 更高优先级插件的路由不能被低优先级插件替换。 +- 路由仍需要正常的 Management API 鉴权。 +- 当 Home 模式或 Management API 可用性禁用本地 Management 路由时,这些路由不可用。 +- 路由表会在配置热重载时重建。 + +## 前端鉴权 + +示例 `FrontendAuthProvider` 只接受带有以下 header 的请求: + +```text +X-Plugin-Example: allow +``` + +注册后的前端 provider key 会被宿主命名空间化: + +```text +plugin:: +``` + +这个示例的 provider identifier 是 `plugin-example`,因此下游 auth metadata 会与原生前端鉴权 provider 隔离。 + +## Usage 插件 + +`UsagePlugin.HandleUsage` 会在请求执行完成后收到 usage record。示例会递增内存计数器,并通过诊断 Management API status 路由展示。 + +Usage record 包含 provider、executor type、model、alias、选中 auth、source、请求的 reasoning effort、service tier、latency、TTFT、失败详情、token 计数和选定响应头。 + +这个 hook 应保持轻量。Usage 派发属于请求计费/统计路径,宿主会从 panic 中恢复并 fuse 插件。 + +## 优先级、原生优先和 panic fuse + +插件系统是增量扩展机制: + +- 原生 provider、executor、translator、thinking applier、flag 和 Management route 都优先于插件。 +- 插件用于补齐 provider 空白并增加插件自有能力面。 +- 高优先级插件先于低优先级插件被考虑。 +- 插件执行器不会覆盖原生执行器。 +- 插件 Management 路由和命令行 flag 不会覆盖原生路由或 flag。 + +每个生命周期调用和能力调用都带有 panic recovery。如果插件在 `Register`、`Reconfigure` 或任意能力方法中 panic,宿主会在当前进程生命周期内把该插件标记为 fused。fused 插件不会再被调用,即使后续配置热重载重新启用它也一样。重启服务后才会清除 fused 状态。 + +Go 插件是可信的进程内代码,不是沙箱。panic recovery 无法阻止插件调用 `os.Exit`、修改共享进程状态、启动后台任务或泄露 secret。请把插件二进制视为与服务二进制同等信任级别的代码。 + +## 扩展示例 + +把这个示例改造成真实 provider 插件时: + +1. 保留 `package main` 和导出的 `Register` / `Reconfigure` 函数。 +2. 统一修改 metadata、provider key、model ID、命令行 flag 和 Management path。 +3. 让 `.so` 文件名匹配期望的插件 ID。 +4. 选择最窄的 `ExecutorModelScope`。 +5. 所有上游 provider 调用都使用 `HostHTTPClient`。 +6. 当宿主已经负责登录或命令行持久化时,返回 `AuthData`,不要直接写 auth storage。 +7. 把 provider 专属 payload 改写保持在插件边界内。 +8. 不要记录 secret、token、原始 auth JSON 或签名请求头。 +9. 后台 goroutine 需要绑定 context 或显式生命周期状态,因为 Go 插件无法卸载。 +10. 添加插件本地测试,并使用与服务相同的工具链构建插件。 + +## 验证 + +编译示例插件: + +```bash +go build -buildmode=plugin -o /tmp/cliproxy-example-plugin.so ./examples/plugin && rm -f /tmp/cliproxy-example-plugin.so +``` + +编辑文档后检查 Markdown 空白问题: + +```bash +git diff --check -- examples/plugin/README.md examples/plugin/README_CN.md +``` + +如果插件实现过程中修改了 Go 代码,还需要执行仓库要求的服务端编译: + +```bash +go build -o test-output ./cmd/server && rm test-output +``` + +## 排障 + +`plugin.Open` 因类型或版本错误失败: + +请使用与服务二进制一致的 Go 版本、module path、build tags 和依赖版本构建插件。 + +插件没有被加载: + +确认 `plugins.enabled=true`,`.so` 文件位于被选中的插件目录下,插件 ID 合法,并且单插件配置没有禁用它。 + +插件加载了,但没有能力生效: + +确认 `Register` 或 `Reconfigure` 返回有效 metadata,并且至少有一个非 nil capability。 + +执行器没有被使用: + +确认存在匹配的 auth 记录,auth 的 `type` 匹配 provider key,执行器 scope 允许目标模型路径,并且没有原生执行器拥有该 provider 或模型。 + +命令行 flag 出现了但没有执行: + +确认最终加载的配置仍启用了全局插件和当前插件。CLI flag 会在最终配置分发之前注册,但执行时会检查最终活动插件快照。 + +Management 路由返回 404: + +确认本地 Management API 路由可用,路由路径完全匹配,插件处于启用状态,并且没有原生或更高优先级路由声明了同一个 method/path。 diff --git a/examples/plugin/main.go b/examples/plugin/main.go new file mode 100644 index 000000000..1ac082308 --- /dev/null +++ b/examples/plugin/main.go @@ -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 +} diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index b26bea753..41036a506 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -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"}) } diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 0f884ef05..01e96f053 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -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. diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index d861b788e..6c51ff453 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -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) } diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go new file mode 100644 index 000000000..3b9ebc7cd --- /dev/null +++ b/internal/api/handlers/management/plugins.go @@ -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..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. 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. 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 +} diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go new file mode 100644 index 000000000..4cb44d696 --- /dev/null +++ b/internal/api/handlers/management/plugins_test.go @@ -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) +} diff --git a/internal/api/server.go b/internal/api/server.go index e81ca6707..a148dd875 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 155f2fa40..c01dff2b1 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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") diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 38f189b4a..fd2a2fca9 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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{}) diff --git a/internal/config/config.go b/internal/config/config.go index d0a599730..38283e14e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" } diff --git a/internal/config/parse.go b/internal/config/parse.go index 283740e5f..393b629ce 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -73,6 +73,8 @@ func ParseConfigBytes(data []byte) (*Config, error) { cfg.MaxRetryCredentials = 0 } + cfg.NormalizePluginsConfig() + // Apply the same sanitization pipeline. cfg.SanitizeGeminiKeys() cfg.SanitizeVertexCompatKeys() diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go new file mode 100644 index 000000000..5ed2b89c2 --- /dev/null +++ b/internal/config/plugin_config_test.go @@ -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) + } + } +} diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go new file mode 100644 index 000000000..4d8c73c07 --- /dev/null +++ b/internal/pluginhost/adapters.go @@ -0,0 +1,1644 @@ +package pluginhost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "runtime/debug" + "sort" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + log "github.com/sirupsen/logrus" +) + +type registryModelInfo = registry.ModelInfo + +type modelRegistry interface { + RegisterClient(clientID, clientProvider string, models []*registry.ModelInfo) + UnregisterClient(clientID string) +} + +type modelProviderRegistry interface { + modelRegistry + GetModelProviders(modelID string) []string +} + +type pluginModelRegistration struct { + pluginID string + provider string + priority int + models []*registry.ModelInfo + hasExecutor bool +} + +func normalizedExecutorModelScope(caps pluginapi.Capabilities) pluginapi.ExecutorModelScope { + if caps.Executor == nil { + return pluginapi.ExecutorModelScopeBoth + } + switch caps.ExecutorModelScope { + case pluginapi.ExecutorModelScopeStatic, pluginapi.ExecutorModelScopeOAuth, pluginapi.ExecutorModelScopeBoth: + return caps.ExecutorModelScope + default: + return pluginapi.ExecutorModelScopeBoth + } +} + +func executorScopeAllowsStaticModels(caps pluginapi.Capabilities) bool { + if caps.Executor == nil { + return true + } + scope := normalizedExecutorModelScope(caps) + return scope == pluginapi.ExecutorModelScopeStatic || scope == pluginapi.ExecutorModelScopeBoth +} + +func executorScopeAllowsOAuthModels(caps pluginapi.Capabilities) bool { + if caps.Executor == nil { + return true + } + scope := normalizedExecutorModelScope(caps) + return scope == pluginapi.ExecutorModelScopeOAuth || scope == pluginapi.ExecutorModelScopeBoth +} + +type AuthModelResult struct { + Provider string + Models []*registry.ModelInfo + Auth *coreauth.Auth + Handled bool + Err error +} + +func pluginModelInfoToRegistryModelInfo(model pluginapi.ModelInfo) *registry.ModelInfo { + return ®istry.ModelInfo{ + ID: model.ID, + Object: model.Object, + Created: model.Created, + OwnedBy: model.OwnedBy, + Type: model.Type, + DisplayName: model.DisplayName, + Name: model.Name, + Version: model.Version, + Description: model.Description, + InputTokenLimit: int(model.InputTokenLimit), + OutputTokenLimit: int(model.OutputTokenLimit), + SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods), + ContextLength: int(model.ContextLength), + MaxCompletionTokens: int(model.MaxCompletionTokens), + SupportedParameters: cloneStringSlice(model.SupportedParameters), + SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities), + SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities), + Thinking: pluginThinkingSupportToRegistryThinkingSupport(model.Thinking), + UserDefined: model.UserDefined, + } +} + +func pluginThinkingSupportToRegistryThinkingSupport(thinking *pluginapi.ThinkingSupport) *registry.ThinkingSupport { + if thinking == nil { + return nil + } + return ®istry.ThinkingSupport{ + Min: thinking.Min, + Max: thinking.Max, + ZeroAllowed: thinking.ZeroAllowed, + DynamicAllowed: thinking.DynamicAllowed, + Levels: cloneStringSlice(thinking.Levels), + } +} + +func registryModelInfoToPluginModelInfo(model *registry.ModelInfo) pluginapi.ModelInfo { + if model == nil { + return pluginapi.ModelInfo{} + } + return pluginapi.ModelInfo{ + ID: model.ID, + Object: model.Object, + Created: model.Created, + OwnedBy: model.OwnedBy, + Type: model.Type, + DisplayName: model.DisplayName, + Name: model.Name, + Version: model.Version, + Description: model.Description, + InputTokenLimit: int64(model.InputTokenLimit), + OutputTokenLimit: int64(model.OutputTokenLimit), + SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods), + ContextLength: int64(model.ContextLength), + MaxCompletionTokens: int64(model.MaxCompletionTokens), + SupportedParameters: cloneStringSlice(model.SupportedParameters), + SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities), + SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities), + Thinking: registryThinkingSupportToPluginThinkingSupport(model.Thinking), + UserDefined: model.UserDefined, + } +} + +func registryThinkingSupportToPluginThinkingSupport(thinking *registry.ThinkingSupport) *pluginapi.ThinkingSupport { + if thinking == nil { + return nil + } + return &pluginapi.ThinkingSupport{ + Min: thinking.Min, + Max: thinking.Max, + ZeroAllowed: thinking.ZeroAllowed, + DynamicAllowed: thinking.DynamicAllowed, + Levels: cloneStringSlice(thinking.Levels), + } +} + +func cloneStringSlice(in []string) []string { + if len(in) == 0 { + return nil + } + return append([]string(nil), in...) +} + +func cloneRegistryModels(in []*registry.ModelInfo) []*registry.ModelInfo { + if len(in) == 0 { + return nil + } + out := make([]*registry.ModelInfo, 0, len(in)) + for _, model := range in { + if model == nil { + continue + } + copyModel := *model + copyModel.SupportedGenerationMethods = cloneStringSlice(model.SupportedGenerationMethods) + copyModel.SupportedParameters = cloneStringSlice(model.SupportedParameters) + copyModel.SupportedInputModalities = cloneStringSlice(model.SupportedInputModalities) + copyModel.SupportedOutputModalities = cloneStringSlice(model.SupportedOutputModalities) + if model.Thinking != nil { + thinking := *model.Thinking + thinking.Levels = cloneStringSlice(model.Thinking.Levels) + copyModel.Thinking = &thinking + } + out = append(out, ©Model) + } + return out +} + +func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) { + if h == nil || modelRegistry == nil { + return + } + + snap := h.Snapshot() + registrations := make([]modelClientRegistration, 0) + nextClients := make(map[string]struct{}) + nextProviders := make(map[string]string) + nextModelRegistrations := make(map[string]pluginModelRegistration) + for _, record := range snap.records { + modelProvider := record.plugin.Capabilities.ModelProvider + registrar := record.plugin.Capabilities.ModelRegistrar + if modelProvider == nil && registrar == nil { + continue + } + if !executorScopeAllowsStaticModels(record.plugin.Capabilities) { + continue + } + var resp pluginapi.ModelRegistrationResponse + var errRegisterModels error + if modelProvider != nil { + modelResp, errStaticModels := h.callModelProviderStaticModels(ctx, record, modelProvider) + errRegisterModels = errStaticModels + resp = pluginapi.ModelRegistrationResponse{ + Provider: modelResp.Provider, + Models: modelResp.Models, + } + } else { + resp, errRegisterModels = h.callModelRegistrar(ctx, record, registrar) + } + if errRegisterModels != nil { + log.Warnf("pluginhost: model registrar %s failed: %v", record.id, errRegisterModels) + continue + } + + provider := strings.ToLower(strings.TrimSpace(resp.Provider)) + if provider == "" || len(resp.Models) == 0 { + continue + } + + models := make([]*registry.ModelInfo, 0, len(resp.Models)) + for _, item := range resp.Models { + model := pluginModelInfoToRegistryModelInfo(item) + if model == nil || strings.TrimSpace(model.ID) == "" { + continue + } + model.ID = strings.TrimSpace(model.ID) + models = append(models, model) + } + if len(models) == 0 { + continue + } + + nextModelRegistrations[record.id] = pluginModelRegistration{ + pluginID: record.id, + provider: provider, + priority: record.priority, + models: cloneRegistryModels(models), + hasExecutor: record.plugin.Capabilities.Executor != nil, + } + nextProviders[record.id] = provider + if record.plugin.Capabilities.Executor == nil { + clientID := "plugin:" + record.id + ":" + provider + registrations = append(registrations, modelClientRegistration{ + clientID: clientID, + provider: provider, + models: models, + }) + nextClients[clientID] = struct{}{} + } + } + h.commitModelClients(snap, modelRegistry, registrations, nextClients, nextProviders, nextModelRegistrations) +} + +func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult { + if h == nil || auth == nil { + return AuthModelResult{} + } + providerKey := normalizeProviderID(auth.Provider) + if providerKey == "" { + return AuthModelResult{} + } + for _, record := range h.Snapshot().records { + modelProvider := record.plugin.Capabilities.ModelProvider + if modelProvider == nil || h.isPluginFused(record.id) { + continue + } + if !executorScopeAllowsOAuthModels(record.plugin.Capabilities) { + continue + } + authProvider := record.plugin.Capabilities.AuthProvider + if authProvider != nil { + identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider) + if !okIdentifier || normalizeProviderID(identifier) != providerKey { + continue + } + } else { + recordProvider := normalizeProviderID(h.modelProvider(record.id)) + if recordProvider == "" { + executor := record.plugin.Capabilities.Executor + if executor != nil { + candidate, okCandidate := h.executorProvider(record, executor) + if okCandidate { + recordProvider = candidate + } + } + } + if recordProvider != providerKey { + continue + } + } + resp, errModels := h.callModelsForAuth(ctx, record, modelProvider, auth) + if errModels != nil { + log.Warnf("pluginhost: models for auth %s failed: %v", auth.ID, errModels) + return AuthModelResult{Handled: true, Err: errModels} + } + respProvider := normalizeProviderID(resp.Provider) + if respProvider != "" && respProvider != providerKey { + continue + } + if respProvider == "" { + respProvider = providerKey + } + models := make([]*registry.ModelInfo, 0, len(resp.Models)) + for _, item := range resp.Models { + model := pluginModelInfoToRegistryModelInfo(item) + if model != nil { + model.ID = strings.TrimSpace(model.ID) + } + if model != nil && model.ID != "" { + models = append(models, model) + } + } + path := "" + if auth.Attributes != nil { + path = auth.Attributes["path"] + } + var updated *coreauth.Auth + if authDataHasValue(resp.AuthUpdate) { + updated = h.AuthDataToCoreAuth(authDataWithDefaults(resp.AuthUpdate, auth), path, auth.FileName) + } + return AuthModelResult{Provider: respProvider, Models: models, Auth: updated, Handled: true} + } + return AuthModelResult{} +} + +func authDataHasValue(data pluginapi.AuthData) bool { + return strings.TrimSpace(data.Provider) != "" || + strings.TrimSpace(data.ID) != "" || + strings.TrimSpace(data.FileName) != "" || + strings.TrimSpace(data.Label) != "" || + strings.TrimSpace(data.Prefix) != "" || + strings.TrimSpace(data.ProxyURL) != "" || + data.Disabled || + len(data.StorageJSON) > 0 || + len(data.Metadata) > 0 || + len(data.Attributes) > 0 || + !data.NextRefreshAfter.IsZero() +} + +func authDataWithDefaults(data pluginapi.AuthData, auth *coreauth.Auth) pluginapi.AuthData { + if auth == nil { + return data + } + if strings.TrimSpace(data.Provider) == "" { + data.Provider = auth.Provider + } + if strings.TrimSpace(data.ID) == "" { + data.ID = auth.ID + } + if strings.TrimSpace(data.FileName) == "" { + data.FileName = auth.FileName + } + if strings.TrimSpace(data.Label) == "" { + data.Label = auth.Label + } + if strings.TrimSpace(data.Prefix) == "" { + data.Prefix = auth.Prefix + } + if strings.TrimSpace(data.ProxyURL) == "" { + data.ProxyURL = auth.ProxyURL + } + if len(data.Metadata) == 0 { + data.Metadata = cloneAnyMap(auth.Metadata) + } else { + metadata := cloneAnyMap(data.Metadata) + for key, value := range auth.Metadata { + if _, exists := metadata[key]; !exists { + metadata[key] = value + } + } + data.Metadata = metadata + } + if len(data.Attributes) == 0 { + data.Attributes = cloneStringMap(auth.Attributes) + } else { + attributes := cloneStringMap(data.Attributes) + for key, value := range auth.Attributes { + if _, exists := attributes[key]; !exists { + attributes[key] = value + } + } + data.Attributes = attributes + } + if len(data.StorageJSON) == 0 { + data.StorageJSON = storageJSONFromAuth(auth) + } + if data.NextRefreshAfter.IsZero() { + data.NextRefreshAfter = auth.NextRefreshAfter + } + return data +} + +type modelClientRegistration struct { + clientID string + provider string + models []*registry.ModelInfo +} + +func (h *Host) callModelRegistrar(ctx context.Context, record capabilityRecord, registrar pluginapi.ModelRegistrar) (resp pluginapi.ModelRegistrationResponse, err error) { + if h == nil || registrar == nil || h.isPluginFused(record.id) { + return pluginapi.ModelRegistrationResponse{}, nil + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "ModelRegistrar.RegisterModels", recovered) + resp = pluginapi.ModelRegistrationResponse{} + err = fmt.Errorf("model registrar panic: %v", recovered) + } + }() + return registrar.RegisterModels(ctx, pluginapi.ModelRegistrationRequest{Plugin: record.meta}) +} + +func (h *Host) callModelProviderStaticModels(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider) (resp pluginapi.ModelResponse, err error) { + if h == nil || provider == nil || h.isPluginFused(record.id) { + return pluginapi.ModelResponse{}, nil + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "ModelProvider.StaticModels", recovered) + resp = pluginapi.ModelResponse{} + err = fmt.Errorf("model provider panic: %v", recovered) + } + }() + return provider.StaticModels(ctx, pluginapi.StaticModelRequest{ + Plugin: record.meta, + Host: h.hostConfigSummary(), + }) +} + +func (h *Host) callModelsForAuth(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider, auth *coreauth.Auth) (resp pluginapi.ModelResponse, err error) { + if h == nil || provider == nil || auth == nil || h.isPluginFused(record.id) { + return pluginapi.ModelResponse{}, nil + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "ModelProvider.ModelsForAuth", recovered) + resp = pluginapi.ModelResponse{} + err = fmt.Errorf("model provider per-auth models panic: %v", recovered) + } + }() + return provider.ModelsForAuth(ctx, pluginapi.AuthModelRequest{ + Plugin: record.meta, + AuthID: auth.ID, + AuthProvider: auth.Provider, + StorageJSON: storageJSONFromAuth(auth), + Metadata: cloneAnyMap(auth.Metadata), + Attributes: cloneStringMap(auth.Attributes), + Host: h.hostConfigSummary(), + HTTPClient: h.newHTTPClient(auth), + }) +} + +func (h *Host) commitModelClients(snap *Snapshot, modelRegistry modelRegistry, registrations []modelClientRegistration, nextClients map[string]struct{}, nextProviders map[string]string, nextModelRegistrations map[string]pluginModelRegistration) { + if h == nil || modelRegistry == nil { + return + } + + staleClients := make([]string, 0) + h.mu.Lock() + if h.Snapshot() != snap { + h.mu.Unlock() + return + } + for clientID := range h.modelClientIDs { + if _, okClient := nextClients[clientID]; !okClient { + staleClients = append(staleClients, clientID) + } + } + h.modelClientIDs = nextClients + h.modelProviders = nextProviders + h.modelRegistrations = nextModelRegistrations + h.mu.Unlock() + + for _, registration := range registrations { + modelRegistry.RegisterClient(registration.clientID, registration.provider, registration.models) + } + for _, clientID := range staleClients { + modelRegistry.UnregisterClient(clientID) + } +} + +type executorManager interface { + Executor(provider string) (coreauth.ProviderExecutor, bool) + RegisterExecutor(coreauth.ProviderExecutor) + UnregisterExecutor(provider string) +} + +type executorRegistration struct { + provider string + adapter *executorAdapter +} + +func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelProviderRegistry) { + if h == nil || manager == nil { + return + } + + snap := h.Snapshot() + registrations := h.snapshotModelRegistrations() + selectedModels := make(map[string][]*registry.ModelInfo) + providerModels := make(map[string][]*registry.ModelInfo) + claimedModels := make(map[string]struct{}) + claimedProviders := make(map[string]string) + for _, registration := range registrations { + if !registration.hasExecutor { + appendModelsForProvider(providerModels, registration.provider, registration.models) + } + } + for _, record := range snap.records { + executor := record.plugin.Capabilities.Executor + if executor == nil || h.isPluginFused(record.id) { + continue + } + provider, okProvider := h.executorProvider(record, executor) + if !okProvider { + continue + } + registration := h.modelRegistration(record.id) + if h.providerHasNativeExecutor(manager, provider) { + appendModelsForProvider(providerModels, provider, registration.models) + continue + } + if len(registration.models) == 0 { + continue + } + if owner := claimedProviders[provider]; owner != "" && owner != record.id { + continue + } + for _, model := range registration.models { + modelID := strings.TrimSpace(model.ID) + if modelID == "" { + continue + } + if _, claimed := claimedModels[modelID]; claimed { + continue + } + if h.modelHasNativeExecutor(manager, modelRegistry, modelID) { + continue + } + claimedModels[modelID] = struct{}{} + claimedProviders[provider] = record.id + selectedModels[record.id] = append(selectedModels[record.id], model) + } + } + + seenProviders := make(map[string]struct{}) + nextProviders := make(map[string]struct{}) + nextModelClients := make(map[string]struct{}) + executorRegistrations := make([]executorRegistration, 0) + modelClientRegistrations := make([]modelClientRegistration, 0) + for _, record := range snap.records { + executor := record.plugin.Capabilities.Executor + if executor == nil || h.isPluginFused(record.id) { + continue + } + + provider, okProvider := h.executorProvider(record, executor) + if !okProvider { + continue + } + registration := h.modelRegistration(record.id) + if len(registration.models) > 0 && len(selectedModels[record.id]) == 0 { + continue + } + if _, seenProvider := seenProviders[provider]; seenProvider { + continue + } + seenProviders[provider] = struct{}{} + if h.providerHasNativeExecutor(manager, provider) { + continue + } + + nextProviders[provider] = struct{}{} + executorRegistrations = append(executorRegistrations, newExecutorAdapterRegistration(h, record, provider, executor)) + appendModelsForProvider(providerModels, provider, selectedModels[record.id]) + if len(selectedModels[record.id]) > 0 { + clientID := pluginExecutorModelClientID(record.id, provider) + modelClientRegistrations = append(modelClientRegistrations, modelClientRegistration{ + clientID: clientID, + provider: provider, + models: selectedModels[record.id], + }) + nextModelClients[clientID] = struct{}{} + } + } + h.commitExecutorState(snap, manager, modelRegistry, providerModels, executorRegistrations, nextProviders, modelClientRegistrations, nextModelClients) +} + +func pluginExecutorModelClientID(pluginID, provider string) string { + return "plugin:" + pluginID + ":" + provider + ":executor" +} + +func (h *Host) commitExecutorState(snap *Snapshot, manager executorManager, modelRegistry modelRegistry, providerModels map[string][]*registry.ModelInfo, registrations []executorRegistration, nextProviders map[string]struct{}, modelClientRegistrations []modelClientRegistration, nextModelClients map[string]struct{}) { + if h == nil || manager == nil { + return + } + + h.mu.Lock() + if h.Snapshot() != snap { + h.mu.Unlock() + return + } + + h.providerModels = make(map[string][]*registryModelInfo, len(providerModels)) + for provider, models := range providerModels { + h.providerModels[provider] = cloneRegistryModels(models) + } + + staleProviders := make([]string, 0) + for provider := range h.executorProviders { + if _, okProvider := nextProviders[provider]; !okProvider { + staleProviders = append(staleProviders, provider) + } + } + h.executorProviders = nextProviders + if nextModelClients == nil { + nextModelClients = make(map[string]struct{}) + } + staleModelClients := make([]string, 0) + for clientID := range h.executorModelClientIDs { + if _, okClient := nextModelClients[clientID]; !okClient { + staleModelClients = append(staleModelClients, clientID) + } + } + h.executorModelClientIDs = nextModelClients + + for _, registration := range registrations { + if registration.adapter == nil || registration.provider == "" { + continue + } + manager.RegisterExecutor(registration.adapter) + } + for _, provider := range staleProviders { + existing, okExecutor := manager.Executor(provider) + if !okExecutor || !h.ownsExecutor(existing) { + continue + } + manager.UnregisterExecutor(provider) + } + h.mu.Unlock() + + if modelRegistry == nil { + return + } + for _, registration := range modelClientRegistrations { + modelRegistry.RegisterClient(registration.clientID, registration.provider, registration.models) + } + for _, clientID := range staleModelClients { + modelRegistry.UnregisterClient(clientID) + } +} + +func newExecutorAdapterRegistration(h *Host, record capabilityRecord, provider string, executor pluginapi.ProviderExecutor) executorRegistration { + return executorRegistration{ + provider: provider, + adapter: &executorAdapter{ + host: h, + pluginID: record.id, + provider: provider, + executor: executor, + }, + } +} + +func (h *Host) snapshotModelRegistrations() []pluginModelRegistration { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + registrations := make([]pluginModelRegistration, 0, len(h.modelRegistrations)) + for _, registration := range h.modelRegistrations { + registration.models = cloneRegistryModels(registration.models) + registrations = append(registrations, registration) + } + sort.SliceStable(registrations, func(i, j int) bool { + if registrations[i].priority == registrations[j].priority { + return registrations[i].pluginID < registrations[j].pluginID + } + return registrations[i].priority > registrations[j].priority + }) + return registrations +} + +func (h *Host) modelRegistration(pluginID string) pluginModelRegistration { + if h == nil { + return pluginModelRegistration{} + } + h.mu.Lock() + defer h.mu.Unlock() + registration := h.modelRegistrations[pluginID] + registration.models = cloneRegistryModels(registration.models) + return registration +} + +func (h *Host) executorProvider(record capabilityRecord, executor pluginapi.ProviderExecutor) (string, bool) { + provider := h.modelProvider(record.id) + if provider == "" { + identifier, okIdentifier := h.callExecutorIdentifier(record.id, executor) + if !okIdentifier { + return "", false + } + provider = identifier + } + provider = strings.ToLower(strings.TrimSpace(provider)) + return provider, provider != "" +} + +func (h *Host) callExecutorIdentifier(pluginID string, executor pluginapi.ProviderExecutor) (provider string, ok bool) { + if h == nil || executor == nil || h.isPluginFused(pluginID) { + return "", false + } + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(pluginID, "Executor.Identifier", recovered) + provider = "" + ok = false + } + }() + return executor.Identifier(), true +} + +func (h *Host) providerHasNativeExecutor(manager executorManager, provider string) bool { + if h == nil || manager == nil { + return false + } + existing, okExecutor := manager.Executor(provider) + return okExecutor && existing != nil && !h.ownsExecutor(existing) +} + +func (h *Host) modelHasNativeExecutor(manager executorManager, modelRegistry modelProviderRegistry, modelID string) bool { + if h == nil || manager == nil || modelRegistry == nil { + return false + } + for _, provider := range modelRegistry.GetModelProviders(modelID) { + if h.providerHasNativeExecutor(manager, provider) { + return true + } + } + return false +} + +func appendModelsForProvider(out map[string][]*registry.ModelInfo, provider string, models []*registry.ModelInfo) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || len(models) == 0 { + return + } + seen := make(map[string]struct{}, len(out[provider])+len(models)) + for _, model := range out[provider] { + if model != nil && strings.TrimSpace(model.ID) != "" { + seen[strings.TrimSpace(model.ID)] = struct{}{} + } + } + for _, model := range models { + if model == nil { + continue + } + modelID := strings.TrimSpace(model.ID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + out[provider] = append(out[provider], cloneRegistryModels([]*registry.ModelInfo{model})...) + } +} + +func (h *Host) ModelsForProvider(provider string) []*registry.ModelInfo { + if h == nil { + return nil + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + return cloneRegistryModels(h.providerModels[provider]) +} + +func (h *Host) HasExecutorCandidateProvider(provider string) bool { + if h == nil { + return false + } + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return false + } + for _, record := range h.Snapshot().records { + executor := record.plugin.Capabilities.Executor + if executor == nil || h.isPluginFused(record.id) { + continue + } + candidate, okCandidate := h.executorProvider(record, executor) + if okCandidate && candidate == provider { + return true + } + } + return false +} + +func (h *Host) ownsExecutor(executor coreauth.ProviderExecutor) bool { + adapter, okAdapter := executor.(*executorAdapter) + return okAdapter && adapter != nil && adapter.host == h +} + +func (h *Host) modelProvider(pluginID string) string { + if h == nil { + return "" + } + h.mu.Lock() + defer h.mu.Unlock() + return h.modelProviders[pluginID] +} + +func (h *Host) RegisterFrontendAuthProviders() { + if h == nil { + return + } + + nextKeys := make(map[string]struct{}) + for _, record := range h.Snapshot().records { + provider := record.plugin.Capabilities.FrontendAuthProvider + if provider == nil || h.isPluginFused(record.id) { + continue + } + adapter := &accessAdapter{ + host: h, + pluginID: record.id, + provider: provider, + } + key := strings.TrimSpace(adapter.Identifier()) + if key == "" { + continue + } + sdkaccess.RegisterProvider(key, adapter) + nextKeys[key] = struct{}{} + } + + h.pruneStaleAccessProviders(nextKeys) +} + +func (h *Host) pruneStaleAccessProviders(nextKeys map[string]struct{}) { + if h == nil { + return + } + + staleKeys := make([]string, 0) + h.mu.Lock() + for key := range h.accessProviderKeys { + if _, okKey := nextKeys[key]; !okKey { + staleKeys = append(staleKeys, key) + } + } + h.accessProviderKeys = nextKeys + h.mu.Unlock() + + for _, key := range staleKeys { + sdkaccess.UnregisterProvider(key) + } +} + +func (h *Host) RegisterUsagePlugins() { + if h == nil { + return + } + + for _, record := range h.Snapshot().records { + plugin := record.plugin.Capabilities.UsagePlugin + if plugin == nil || h.isPluginFused(record.id) { + continue + } + coreusage.RegisterNamedPlugin("plugin:"+record.id, &usageAdapter{ + host: h, + pluginID: record.id, + plugin: plugin, + }) + } +} + +func (h *Host) refreshThinkingProviders(records []capabilityRecord) { + thinking.ClearPluginProviders() + if h == nil { + return + } + for _, record := range records { + applier := record.plugin.Capabilities.ThinkingApplier + if applier == nil || h.isPluginFused(record.id) { + continue + } + provider, okProvider := h.callThinkingIdentifier(record, applier) + if !okProvider { + continue + } + thinking.RegisterPluginProvider(record.id, provider, record.priority, &thinkingAdapter{ + host: h, + pluginID: record.id, + provider: provider, + applier: applier, + }) + } +} + +func (h *Host) callThinkingIdentifier(record capabilityRecord, applier pluginapi.ThinkingApplier) (provider string, ok bool) { + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "ThinkingApplier.Identifier", recovered) + provider = "" + ok = false + } + }() + provider = strings.ToLower(strings.TrimSpace(applier.Identifier())) + if provider == "" { + return "", false + } + return provider, true +} + +func (h *Host) currentUsagePlugin(pluginID string) pluginapi.UsagePlugin { + if h == nil || strings.TrimSpace(pluginID) == "" { + return nil + } + for _, record := range h.Snapshot().records { + if record.id != pluginID { + continue + } + if h.isPluginFused(record.id) { + return nil + } + return record.plugin.Capabilities.UsagePlugin + } + return nil +} + +func (h *Host) fusePlugin(id, method string, recovered any) { + if h == nil { + return + } + h.mu.Lock() + h.fused[id] = fmt.Sprintf("%s panic: %v", method, recovered) + h.mu.Unlock() + thinking.UnregisterPluginProviders(id) + log.WithField("plugin_id", id).WithField("method", method).Errorf("pluginhost: plugin panic recovered: %v\n%s", recovered, debug.Stack()) +} + +func (h *Host) isPluginFused(id string) bool { + if h == nil { + return false + } + h.mu.Lock() + _, fused := h.fused[id] + h.mu.Unlock() + return fused +} + +type accessAdapter struct { + host *Host + pluginID string + provider pluginapi.FrontendAuthProvider +} + +func (a *accessAdapter) Identifier() (identifier string) { + if a == nil || a.provider == nil { + return "" + } + defer func() { + if recovered := recover(); recovered != nil { + if a.host != nil { + a.host.fusePlugin(a.pluginID, "FrontendAuthProvider.Identifier", recovered) + } + identifier = "" + } + }() + pluginID := strings.TrimSpace(a.pluginID) + providerID := strings.TrimSpace(a.provider.Identifier()) + if pluginID == "" || providerID == "" { + return "" + } + return "plugin:" + pluginID + ":" + providerID +} + +func (a *accessAdapter) Authenticate(ctx context.Context, r *http.Request) (result *sdkaccess.Result, authErr *sdkaccess.AuthError) { + if a == nil || a.provider == nil || a.host.isPluginFused(a.pluginID) { + return nil, sdkaccess.NewNotHandledError() + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "FrontendAuthProvider.Authenticate", recovered) + result = nil + authErr = sdkaccess.NewNotHandledError() + } + }() + + body, errReadAll := readAndRestoreRequestBody(r) + if errReadAll != nil { + return nil, sdkaccess.NewInternalAuthError("failed to read plugin auth request body", errReadAll) + } + resp, errAuthenticate := a.provider.Authenticate(ctx, pluginapi.FrontendAuthRequest{ + Method: r.Method, + Path: r.URL.Path, + Headers: cloneHeader(r.Header), + Query: cloneValues(r.URL.Query()), + Body: bytes.Clone(body), + }) + if errAuthenticate != nil || !resp.Authenticated { + return nil, sdkaccess.NewNotHandledError() + } + providerID := a.Identifier() + if providerID == "" { + return nil, sdkaccess.NewNotHandledError() + } + return &sdkaccess.Result{ + Provider: providerID, + Principal: resp.Principal, + Metadata: cloneStringMap(resp.Metadata), + }, nil +} + +type executorAdapter struct { + host *Host + pluginID string + provider string + executor pluginapi.ProviderExecutor +} + +func (a *executorAdapter) Identifier() string { + if a == nil { + return "" + } + return a.provider +} + +func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "Executor.Execute", recovered) + resp = coreexecutor.Response{} + err = fmt.Errorf("plugin executor %s panic: %v", a.Identifier(), recovered) + } + }() + + pluginResp, errExecute := a.executor.Execute(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + if errExecute != nil { + return coreexecutor.Response{}, errExecute + } + return coreexecutor.Response{ + Payload: bytes.Clone(pluginResp.Payload), + Metadata: cloneAnyMap(pluginResp.Metadata), + Headers: cloneHeader(pluginResp.Headers), + }, nil +} + +func (a *executorAdapter) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (result *coreexecutor.StreamResult, err error) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "Executor.ExecuteStream", recovered) + result = nil + err = fmt.Errorf("plugin executor %s stream panic: %v", a.Identifier(), recovered) + } + }() + + pluginResp, errExecuteStream := a.executor.ExecuteStream(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + if errExecuteStream != nil { + return nil, errExecuteStream + } + return &coreexecutor.StreamResult{ + Headers: cloneHeader(pluginResp.Headers), + Chunks: mapExecutorStreamChunks(ctx, pluginResp.Chunks), + }, nil +} + +func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, err error) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) + } + record := a.host.authProviderRecord(authProvider(auth)) + if record == nil || record.plugin.Capabilities.AuthProvider == nil { + return auth.Clone(), nil + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered) + refreshed = nil + err = fmt.Errorf("plugin executor %s refresh panic: %v", a.Identifier(), recovered) + } + }() + + pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{ + AuthID: authID(auth), + AuthProvider: authProvider(auth), + StorageJSON: storageJSONFromAuth(auth), + Metadata: cloneAnyMap(authMetadata(auth)), + Attributes: authAttributes(auth), + Host: a.host.hostConfigSummary(), + HTTPClient: a.host.newHTTPClient(auth), + }) + if errRefresh != nil { + return nil, errRefresh + } + data := pluginResp.Auth + if strings.TrimSpace(data.Provider) == "" { + data.Provider = authProvider(auth) + } + if strings.TrimSpace(data.ID) == "" { + data.ID = authID(auth) + } + if strings.TrimSpace(data.FileName) == "" && auth != nil { + data.FileName = auth.FileName + } + if strings.TrimSpace(data.Label) == "" && auth != nil { + data.Label = auth.Label + } + if strings.TrimSpace(data.Prefix) == "" && auth != nil { + data.Prefix = auth.Prefix + } + if strings.TrimSpace(data.ProxyURL) == "" && auth != nil { + data.ProxyURL = auth.ProxyURL + } + if len(data.Metadata) == 0 && auth != nil { + data.Metadata = cloneAnyMap(auth.Metadata) + } + if len(data.Attributes) == 0 && auth != nil { + data.Attributes = cloneStringMap(auth.Attributes) + } + if len(data.StorageJSON) == 0 { + data.StorageJSON = storageJSONFromAuth(auth) + } + if pluginResp.NextRefreshAfter.IsZero() && auth != nil { + data.NextRefreshAfter = auth.NextRefreshAfter + } + if !pluginResp.NextRefreshAfter.IsZero() { + data.NextRefreshAfter = pluginResp.NextRefreshAfter + } + next := a.host.AuthDataToCoreAuth(data, "", data.FileName) + if next == nil { + return nil, fmt.Errorf("plugin executor %s refresh returned invalid auth data", a.Identifier()) + } + if auth != nil { + next.CreatedAt = auth.CreatedAt + next.UpdatedAt = auth.UpdatedAt + } + return next, nil +} + +func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "Executor.CountTokens", recovered) + resp = coreexecutor.Response{} + err = fmt.Errorf("plugin executor %s count tokens panic: %v", a.Identifier(), recovered) + } + }() + + pluginResp, errCountTokens := a.executor.CountTokens(ctx, buildExecutorRequest(a.host, a.provider, auth, req, opts)) + if errCountTokens != nil { + return coreexecutor.Response{}, errCountTokens + } + return coreexecutor.Response{ + Payload: bytes.Clone(pluginResp.Payload), + Metadata: cloneAnyMap(pluginResp.Metadata), + Headers: cloneHeader(pluginResp.Headers), + }, nil +} + +func (a *executorAdapter) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (resp *http.Response, err error) { + if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) { + return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier()) + } + if req == nil { + return nil, fmt.Errorf("plugin executor %s received nil HTTP request", a.Identifier()) + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "Executor.HttpRequest", recovered) + resp = nil + err = fmt.Errorf("plugin executor %s http request panic: %v", a.Identifier(), recovered) + } + }() + body, errReadAll := readAndRestoreRequestBody(req) + if errReadAll != nil { + return nil, fmt.Errorf("read plugin http request body: %w", errReadAll) + } + pluginResp, errHTTPRequest := a.executor.HttpRequest(ctx, pluginapi.ExecutorHTTPRequest{ + AuthID: authID(auth), + AuthProvider: authProvider(auth), + Method: req.Method, + URL: req.URL.String(), + Headers: cloneHeader(req.Header), + Body: bytes.Clone(body), + StorageJSON: storageJSONFromAuth(auth), + Metadata: cloneAnyMap(authMetadata(auth)), + Attributes: authAttributes(auth), + HTTPClient: a.host.newHTTPClient(auth, a.provider), + }) + if errHTTPRequest != nil { + return nil, errHTTPRequest + } + status := pluginResp.StatusCode + if status == 0 { + status = http.StatusOK + } + resp = &http.Response{ + StatusCode: status, + Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Header: cloneHeader(pluginResp.Headers), + Body: io.NopCloser(bytes.NewReader(bytes.Clone(pluginResp.Body))), + Request: req, + } + return resp, nil +} + +type usageAdapter struct { + host *Host + pluginID string + plugin pluginapi.UsagePlugin +} + +type thinkingAdapter struct { + host *Host + pluginID string + provider string + applier pluginapi.ThinkingApplier +} + +func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) { + if a == nil { + return + } + plugin := a.host.currentUsagePlugin(a.pluginID) + if plugin == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "UsagePlugin.HandleUsage", recovered) + } + }() + plugin.HandleUsage(ctx, pluginapi.UsageRecord{ + Provider: record.Provider, + ExecutorType: record.ExecutorType, + Model: record.Model, + Alias: record.Alias, + APIKey: record.APIKey, + AuthID: record.AuthID, + AuthIndex: record.AuthIndex, + AuthType: record.AuthType, + Source: record.Source, + ReasoningEffort: record.ReasoningEffort, + ServiceTier: record.ServiceTier, + RequestedAt: record.RequestedAt, + Latency: record.Latency, + TTFT: record.TTFT, + Failed: record.Failed, + Failure: pluginapi.UsageFailure{ + StatusCode: record.Fail.StatusCode, + Body: record.Fail.Body, + }, + Detail: pluginapi.UsageDetail{ + InputTokens: record.Detail.InputTokens, + OutputTokens: record.Detail.OutputTokens, + ReasoningTokens: record.Detail.ReasoningTokens, + CachedTokens: record.Detail.CachedTokens, + CacheReadTokens: record.Detail.CacheReadTokens, + CacheCreationTokens: record.Detail.CacheCreationTokens, + TotalTokens: record.Detail.TotalTokens, + }, + ResponseHeaders: cloneHeader(record.ResponseHeaders), + }) +} + +func (a *thinkingAdapter) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (out []byte, err error) { + if a == nil || a.applier == nil || a.host == nil || a.host.isPluginFused(a.pluginID) { + return bytes.Clone(body), nil + } + defer func() { + if recovered := recover(); recovered != nil { + a.host.fusePlugin(a.pluginID, "ThinkingApplier.ApplyThinking", recovered) + out = bytes.Clone(body) + err = nil + } + }() + resp, errApply := a.applier.ApplyThinking(context.Background(), pluginapi.ThinkingApplyRequest{ + Provider: a.provider, + Model: registryModelInfoToPluginModelInfo(modelInfo), + Config: pluginapi.ThinkingConfig{ + Mode: config.Mode.String(), + Budget: config.Budget, + Level: string(config.Level), + }, + Body: bytes.Clone(body), + }) + if errApply != nil || len(resp.Body) == 0 { + return bytes.Clone(body), nil + } + return bytes.Clone(resp.Body), nil +} + +func (h *Host) NormalizeRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) []byte { + current := bytes.Clone(body) + for _, record := range h.Snapshot().records { + if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestNormalizer == nil { + continue + } + if normalized, ok := h.callRequestNormalizer(ctx, record, from, to, model, current, stream); ok { + current = normalized + } + } + return current +} + +func (h *Host) TranslateRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) ([]byte, bool) { + for _, record := range h.Snapshot().records { + if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestTranslator == nil { + continue + } + if translated, ok := h.callRequestTranslator(ctx, record, from, to, model, body, stream); ok { + return translated, true + } + } + return bytes.Clone(body), false +} + +func (h *Host) NormalizeResponseBefore(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte { + current := bytes.Clone(body) + for _, record := range h.Snapshot().records { + normalizer := record.plugin.Capabilities.ResponseBeforeTranslator + if h.isPluginFused(record.id) || normalizer == nil { + continue + } + if normalized, ok := h.callResponseNormalizer(ctx, record.id, "ResponseBeforeTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { + current = normalized + } + } + return current +} + +func (h *Host) TranslateResponse(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool) { + for _, record := range h.Snapshot().records { + translator := record.plugin.Capabilities.ResponseTranslator + if h.isPluginFused(record.id) || translator == nil { + continue + } + if translated, ok := h.callResponseTranslator(ctx, record.id, translator, from, to, model, originalRequestRawJSON, requestRawJSON, body, stream); ok { + return translated, true + } + } + return bytes.Clone(body), false +} + +func (h *Host) NormalizeResponseAfter(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte { + current := bytes.Clone(body) + for _, record := range h.Snapshot().records { + normalizer := record.plugin.Capabilities.ResponseAfterTranslator + if h.isPluginFused(record.id) || normalizer == nil { + continue + } + if normalized, ok := h.callResponseNormalizer(ctx, record.id, "ResponseAfterTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok { + current = normalized + } + } + return current +} + +func (h *Host) callRequestNormalizer(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) { + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "RequestNormalizer.NormalizeRequest", recovered) + out = nil + ok = false + } + }() + resp, errNormalizeRequest := record.plugin.Capabilities.RequestNormalizer.NormalizeRequest(ctx, pluginapi.RequestTransformRequest{ + FromFormat: from.String(), + ToFormat: to.String(), + Model: model, + Stream: stream, + Body: bytes.Clone(body), + }) + if errNormalizeRequest != nil || len(resp.Body) == 0 { + return nil, false + } + return bytes.Clone(resp.Body), true +} + +func (h *Host) callRequestTranslator(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) { + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(record.id, "RequestTranslator.TranslateRequest", recovered) + out = nil + ok = false + } + }() + resp, errTranslateRequest := record.plugin.Capabilities.RequestTranslator.TranslateRequest(ctx, pluginapi.RequestTransformRequest{ + FromFormat: from.String(), + ToFormat: to.String(), + Model: model, + Stream: stream, + Body: bytes.Clone(body), + }) + if errTranslateRequest != nil || len(resp.Body) == 0 { + return nil, false + } + return bytes.Clone(resp.Body), true +} + +func (h *Host) callResponseNormalizer(ctx context.Context, pluginID, method string, normalizer pluginapi.ResponseNormalizer, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(pluginID, method, recovered) + out = nil + ok = false + } + }() + resp, errNormalizeResponse := normalizer.NormalizeResponse(ctx, pluginapi.ResponseTransformRequest{ + FromFormat: from.String(), + ToFormat: to.String(), + Model: model, + Stream: stream, + OriginalRequest: bytes.Clone(originalRequestRawJSON), + TranslatedRequest: bytes.Clone(requestRawJSON), + Body: bytes.Clone(body), + }) + if errNormalizeResponse != nil || len(resp.Body) == 0 { + return nil, false + } + return bytes.Clone(resp.Body), true +} + +func (h *Host) callResponseTranslator(ctx context.Context, pluginID string, translator pluginapi.ResponseTranslator, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) { + defer func() { + if recovered := recover(); recovered != nil { + h.fusePlugin(pluginID, "ResponseTranslator.TranslateResponse", recovered) + out = nil + ok = false + } + }() + resp, errTranslateResponse := translator.TranslateResponse(ctx, pluginapi.ResponseTransformRequest{ + FromFormat: from.String(), + ToFormat: to.String(), + Model: model, + Stream: stream, + OriginalRequest: bytes.Clone(originalRequestRawJSON), + TranslatedRequest: bytes.Clone(requestRawJSON), + Body: bytes.Clone(body), + }) + if errTranslateResponse != nil || len(resp.Body) == 0 { + return nil, false + } + return bytes.Clone(resp.Body), true +} + +func buildExecutorRequest(host *Host, provider string, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) pluginapi.ExecutorRequest { + return pluginapi.ExecutorRequest{ + AuthID: authID(auth), + AuthProvider: authProvider(auth), + Model: req.Model, + Format: req.Format.String(), + Stream: opts.Stream, + Alt: opts.Alt, + Headers: cloneHeader(opts.Headers), + Query: cloneValues(opts.Query), + OriginalRequest: bytes.Clone(opts.OriginalRequest), + SourceFormat: opts.SourceFormat.String(), + Payload: bytes.Clone(req.Payload), + Metadata: mergeExecutorMetadata(req.Metadata, opts.Metadata), + StorageJSON: storageJSONFromAuth(auth), + AuthMetadata: cloneAnyMap(authMetadata(auth)), + AuthAttributes: authAttributes(auth), + HTTPClient: host.newHTTPClient(auth, provider), + } +} + +func storageJSONFromAuth(auth *coreauth.Auth) []byte { + if auth == nil { + return nil + } + if rawProvider, okRaw := auth.Storage.(interface{ RawJSON() []byte }); okRaw { + return bytes.Clone(rawProvider.RawJSON()) + } + if len(auth.Metadata) == 0 { + return nil + } + data, errMarshal := json.Marshal(auth.Metadata) + if errMarshal != nil { + return nil + } + return data +} + +func authAttributes(auth *coreauth.Auth) map[string]string { + if auth == nil { + return nil + } + return cloneStringMap(auth.Attributes) +} + +func mergeExecutorMetadata(reqMetadata, optsMetadata map[string]any) map[string]any { + if len(reqMetadata) == 0 && len(optsMetadata) == 0 { + return nil + } + merged := make(map[string]any, len(reqMetadata)+len(optsMetadata)) + for key, value := range reqMetadata { + merged[key] = value + } + for key, value := range optsMetadata { + merged[key] = value + } + return merged +} + +func mapExecutorStreamChunks(ctx context.Context, in <-chan pluginapi.ExecutorStreamChunk) <-chan coreexecutor.StreamChunk { + if ctx == nil { + ctx = context.Background() + } + out := make(chan coreexecutor.StreamChunk) + if in == nil { + close(out) + return out + } + go func() { + defer close(out) + for { + var mapped coreexecutor.StreamChunk + select { + case <-ctx.Done(): + return + case chunk, ok := <-in: + if !ok { + return + } + mapped = coreexecutor.StreamChunk{ + Payload: bytes.Clone(chunk.Payload), + Err: chunk.Err, + } + } + select { + case <-ctx.Done(): + return + case out <- mapped: + } + } + }() + return out +} + +func readAndRestoreRequestBody(r *http.Request) ([]byte, error) { + if r == nil || r.Body == nil { + return nil, nil + } + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + r.Body = io.NopCloser(bytes.NewReader(body)) + return nil, errReadAll + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return body, nil +} + +func authID(auth *coreauth.Auth) string { + if auth == nil { + return "" + } + return auth.ID +} + +func authProvider(auth *coreauth.Auth) string { + if auth == nil { + return "" + } + return auth.Provider +} + +func authMetadata(auth *coreauth.Auth) map[string]any { + if auth == nil { + return nil + } + return auth.Metadata +} + +func cloneHeader(in http.Header) http.Header { + if len(in) == 0 { + return nil + } + out := make(http.Header, len(in)) + for key, values := range in { + out[key] = append([]string(nil), values...) + } + return out +} + +func cloneValues(in url.Values) url.Values { + if len(in) == 0 { + return nil + } + out := make(url.Values, len(in)) + for key, values := range in { + out[key] = append([]string(nil), values...) + } + return out +} + +func cloneAnyMap(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 +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go new file mode 100644 index 000000000..df73ddd1d --- /dev/null +++ b/internal/pluginhost/adapters_test.go @@ -0,0 +1,2182 @@ +package pluginhost + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +func TestPluginModelInfoToRegistryModelInfoClonesThinkingAndSlices(t *testing.T) { + model := pluginapi.ModelInfo{ + ID: "model-1", + Object: "model", + Created: 123, + OwnedBy: "owner", + Type: "plugin", + DisplayName: "Model One", + Name: "provider-model", + Version: "v1", + Description: "desc", + InputTokenLimit: 100, + OutputTokenLimit: 200, + SupportedGenerationMethods: []string{"generate"}, + ContextLength: 300, + MaxCompletionTokens: 400, + SupportedParameters: []string{"temperature"}, + SupportedInputModalities: []string{"text"}, + SupportedOutputModalities: []string{"image"}, + Thinking: &pluginapi.ThinkingSupport{ + Min: 1, + Max: 2, + ZeroAllowed: true, + DynamicAllowed: true, + Levels: []string{"low", "high"}, + }, + UserDefined: true, + } + + got := pluginModelInfoToRegistryModelInfo(model) + if got.ID != model.ID || got.Object != model.Object || got.Created != model.Created || got.OwnedBy != model.OwnedBy || got.Type != model.Type || + got.DisplayName != model.DisplayName || got.Name != model.Name || got.Version != model.Version || got.Description != model.Description || + got.InputTokenLimit != int(model.InputTokenLimit) || got.OutputTokenLimit != int(model.OutputTokenLimit) || + got.ContextLength != int(model.ContextLength) || got.MaxCompletionTokens != int(model.MaxCompletionTokens) || !got.UserDefined { + t.Fatalf("converted model = %#v, want fields copied from %#v", got, model) + } + if got.Thinking == nil { + t.Fatal("Thinking = nil, want converted thinking support") + } + if got.Thinking.Min != 1 || got.Thinking.Max != 2 || !got.Thinking.ZeroAllowed || !got.Thinking.DynamicAllowed || fmt.Sprint(got.Thinking.Levels) != "[low high]" { + t.Fatalf("Thinking = %#v, want copied thinking support", got.Thinking) + } + + model.SupportedGenerationMethods[0] = "mutated" + model.SupportedParameters[0] = "mutated" + model.SupportedInputModalities[0] = "mutated" + model.SupportedOutputModalities[0] = "mutated" + model.Thinking.Levels[0] = "mutated" + if got.SupportedGenerationMethods[0] != "generate" || got.SupportedParameters[0] != "temperature" || + got.SupportedInputModalities[0] != "text" || got.SupportedOutputModalities[0] != "image" || + got.Thinking.Levels[0] != "low" { + t.Fatalf("converted model kept aliases to plugin slices: %#v", got) + } +} + +func TestRegisterModelsRegistersProviderModelsAndClientID(t *testing.T) { + modelRegistry := newFakeModelRegistry() + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + meta: pluginapi.Metadata{Name: "Alpha", Version: "1.0.0"}, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + if req.Plugin.Name != "Alpha" || req.Plugin.Version != "1.0.0" { + t.Fatalf("RegisterModels request plugin = %#v, want Alpha metadata", req.Plugin) + } + return pluginapi.ModelRegistrationResponse{ + Provider: " MixedProvider ", + Models: []pluginapi.ModelInfo{{ + ID: " model-1 ", + Object: "model", + Created: 123, + OwnedBy: "owner", + Type: "chat", + DisplayName: "Model One", + Name: "native-model-1", + Version: "v1", + Description: "description", + InputTokenLimit: 100, + OutputTokenLimit: 200, + SupportedGenerationMethods: []string{"generate"}, + ContextLength: 300, + MaxCompletionTokens: 400, + SupportedParameters: []string{"temperature"}, + SupportedInputModalities: []string{"text"}, + SupportedOutputModalities: []string{"text"}, + Thinking: &pluginapi.ThinkingSupport{ + Min: 1, + Max: 2, + ZeroAllowed: true, + DynamicAllowed: true, + Levels: []string{"low"}, + }, + UserDefined: true, + }}, + }, nil + }), + }}, + }) + + host.RegisterModels(context.Background(), modelRegistry) + + reg := modelRegistry.clients["plugin:alpha:mixedprovider"] + if reg == nil { + t.Fatal("plugin:alpha:mixedprovider was not registered") + } + if reg.provider != "mixedprovider" { + t.Fatalf("registered provider = %q, want mixedprovider", reg.provider) + } + if len(reg.models) != 1 { + t.Fatalf("registered model count = %d, want 1", len(reg.models)) + } + model := reg.models[0] + if model.ID != "model-1" || model.Object != "model" || model.Created != 123 || model.OwnedBy != "owner" || model.Type != "chat" || + model.DisplayName != "Model One" || model.Name != "native-model-1" || model.Version != "v1" || model.Description != "description" || + model.InputTokenLimit != 100 || model.OutputTokenLimit != 200 || model.ContextLength != 300 || model.MaxCompletionTokens != 400 || + model.SupportedGenerationMethods[0] != "generate" || model.SupportedParameters[0] != "temperature" || + model.SupportedInputModalities[0] != "text" || model.SupportedOutputModalities[0] != "text" || !model.UserDefined { + t.Fatalf("registered model = %#v, want converted fields", model) + } + if model.Thinking == nil || model.Thinking.Min != 1 || model.Thinking.Max != 2 || !model.Thinking.ZeroAllowed || + !model.Thinking.DynamicAllowed || model.Thinking.Levels[0] != "low" { + t.Fatalf("registered thinking = %#v, want converted thinking", model.Thinking) + } +} + +func TestRegisterModelsUsesModelProviderStaticModels(t *testing.T) { + modelRegistry := newFakeModelRegistry() + called := false + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + meta: pluginapi.Metadata{Name: "Alpha", Version: "1.0.0"}, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelProvider: modelProviderFunc{ + staticModels: func(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + called = true + if req.Plugin.Name != "Alpha" || req.Plugin.Version != "1.0.0" { + t.Fatalf("StaticModels request plugin = %#v, want Alpha metadata", req.Plugin) + } + if req.Host.AuthDir != "/tmp/plugin-auth" || req.Host.ProxyURL != "http://proxy.local" || !req.Host.ForceModelPrefix { + t.Fatalf("StaticModels host = %#v, want configured summary", req.Host) + } + if len(req.Host.OAuthModelAlias["plugin-provider"]) != 1 || req.Host.OAuthModelAlias["plugin-provider"][0].Alias != "alias-model" { + t.Fatalf("StaticModels OAuthModelAlias = %#v, want configured alias", req.Host.OAuthModelAlias) + } + if len(req.Host.ExcludedModels["plugin-provider"]) != 1 || req.Host.ExcludedModels["plugin-provider"][0] != "hidden-model" { + t.Fatalf("StaticModels ExcludedModels = %#v, want configured exclusion", req.Host.ExcludedModels) + } + return pluginapi.ModelResponse{ + Provider: " Plugin-Provider ", + Models: []pluginapi.ModelInfo{{ + ID: " model-static ", + Object: "model", + DisplayName: "Static Model", + }}, + }, nil + }, + }, + ModelRegistrar: staticModelRegistrar("legacy-provider", "legacy-model"), + }}, + }) + host.runtimeConfig = &config.Config{ + SDKConfig: config.SDKConfig{ + ProxyURL: "http://proxy.local", + ForceModelPrefix: true, + }, + AuthDir: "/tmp/plugin-auth", + OAuthModelAlias: map[string][]config.OAuthModelAlias{ + "plugin-provider": []config.OAuthModelAlias{{Name: "upstream-model", Alias: "alias-model"}}, + }, + OAuthExcludedModels: map[string][]string{ + "plugin-provider": []string{"hidden-model"}, + }, + } + + host.RegisterModels(context.Background(), modelRegistry) + + if !called { + t.Fatal("ModelProvider.StaticModels was not called") + } + reg := modelRegistry.clients["plugin:alpha:plugin-provider"] + if reg == nil { + t.Fatal("plugin:alpha:plugin-provider was not registered") + } + if reg.provider != "plugin-provider" { + t.Fatalf("registered provider = %q, want plugin-provider", reg.provider) + } + if len(reg.models) != 1 || reg.models[0].ID != "model-static" || reg.models[0].DisplayName != "Static Model" { + t.Fatalf("registered models = %#v, want static model", reg.models) + } + if _, okLegacy := modelRegistry.clients["plugin:alpha:legacy-provider"]; okLegacy { + t.Fatal("legacy ModelRegistrar path was used despite ModelProvider.StaticModels") + } +} + +func TestRegisterModelsSkipsErrorEmptyAndInvalidModels(t *testing.T) { + modelRegistry := newFakeModelRegistry() + host := newHostWithRecords( + capabilityRecord{ + id: "error", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return pluginapi.ModelRegistrationResponse{}, errors.New("register failed") + }), + }}, + }, + capabilityRecord{ + id: "empty-provider", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return pluginapi.ModelRegistrationResponse{Provider: " ", Models: []pluginapi.ModelInfo{{ID: "model"}}}, nil + }), + }}, + }, + capabilityRecord{ + id: "empty-models", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return pluginapi.ModelRegistrationResponse{Provider: "provider"}, nil + }), + }}, + }, + capabilityRecord{ + id: "invalid-models", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return pluginapi.ModelRegistrationResponse{Provider: "provider", Models: []pluginapi.ModelInfo{{ID: " "}}}, nil + }), + }}, + }, + ) + + host.RegisterModels(context.Background(), modelRegistry) + + if len(modelRegistry.clients) != 0 { + t.Fatalf("registered clients = %#v, want none", modelRegistry.clients) + } +} + +func TestRegisterModelsPrunesStaleClientAfterSnapshotChange(t *testing.T) { + modelRegistry := newFakeModelRegistry() + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("provider-a", "model-a"), + }}, + }) + host.RegisterModels(context.Background(), modelRegistry) + + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "bravo", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("provider-b", "model-b"), + }}, + }}}) + host.RegisterModels(context.Background(), modelRegistry) + + if _, okClient := modelRegistry.clients["plugin:alpha:provider-a"]; okClient { + t.Fatal("stale alpha client is still registered") + } + if modelRegistry.unregisters[0] != "plugin:alpha:provider-a" { + t.Fatalf("unregistered clients = %#v, want alpha client first", modelRegistry.unregisters) + } + if _, okClient := modelRegistry.clients["plugin:bravo:provider-b"]; !okClient { + t.Fatal("bravo client was not registered") + } +} + +func TestRegisterModelsDropsResultsWhenSnapshotChangesDuringRegistration(t *testing.T) { + modelRegistry := newFakeModelRegistry() + host := New() + oldSnap := &Snapshot{enabled: true, records: []capabilityRecord{{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "bravo", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("provider-b", "model-b"), + }}, + }}}) + return pluginapi.ModelRegistrationResponse{ + Provider: "provider-a", + Models: []pluginapi.ModelInfo{{ + ID: "model-a", + }}, + }, nil + }), + }}, + }}} + host.snapshot.Store(oldSnap) + host.modelProviders["alpha"] = "existing-provider" + + host.RegisterModels(context.Background(), modelRegistry) + + if len(modelRegistry.clients) != 0 { + t.Fatalf("registered clients = %#v, want none after stale snapshot", modelRegistry.clients) + } + if len(modelRegistry.unregisters) != 0 { + t.Fatalf("unregistered clients = %#v, want none after stale snapshot", modelRegistry.unregisters) + } + if host.modelProvider("alpha") != "existing-provider" { + t.Fatalf("model provider = %q, want existing-provider", host.modelProvider("alpha")) + } +} + +func TestRegisterModelsPanicFusesPluginAndSkipsLaterCalls(t *testing.T) { + calls := 0 + modelRegistry := newFakeModelRegistry() + host := newHostWithRecords(capabilityRecord{ + id: "panic-plugin", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + calls++ + panic("register models panic") + }), + }}, + }) + + host.RegisterModels(context.Background(), modelRegistry) + host.RegisterModels(context.Background(), modelRegistry) + + if calls != 1 { + t.Fatalf("RegisterModels calls = %d, want 1", calls) + } + if !host.isPluginFused("panic-plugin") { + t.Fatal("panic-plugin was not fused") + } + if len(modelRegistry.clients) != 0 { + t.Fatalf("registered clients = %#v, want none", modelRegistry.clients) + } +} + +func TestRegisterExecutorsDoesNotOverwriteExistingExecutor(t *testing.T) { + manager := newFakeExecutorManager() + existing := &fakeProviderExecutor{provider: "provider"} + manager.RegisterExecutor(existing) + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{identifier: "provider"}, + }}, + }) + + host.RegisterExecutors(manager, nil) + + if manager.registerCalls != 1 { + t.Fatalf("RegisterExecutor calls = %d, want only existing registration", manager.registerCalls) + } + got, _ := manager.Executor("provider") + if got != existing { + t.Fatalf("registered executor = %#v, want existing executor", got) + } +} + +func TestRegisterExecutorsSameProviderKeepsFirstSnapshotCandidate(t *testing.T) { + manager := newFakeExecutorManager() + first := &fakeExecutor{identifier: "provider"} + second := &fakeExecutor{identifier: "provider"} + host := newHostWithRecords( + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: second, + }}, + }, + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: first, + }}, + }, + ) + + host.RegisterExecutors(manager, nil) + + if manager.registerCalls != 1 { + t.Fatalf("RegisterExecutor calls = %d, want 1", manager.registerCalls) + } + adapter, okAdapter := manager.executors["provider"].(*executorAdapter) + if !okAdapter { + t.Fatalf("registered executor = %#v, want executorAdapter", manager.executors["provider"]) + } + if adapter.pluginID != "high" || adapter.executor != first { + t.Fatalf("registered adapter = %#v, want high priority executor", adapter) + } +} + +func TestRegisterExecutorsIdentifierPanicFusesPlugin(t *testing.T) { + manager := newFakeExecutorManager() + host := newHostWithRecords(capabilityRecord{ + id: "panic-identifier", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: &fakeExecutor{panicIdentifier: true}, + }}, + }) + + host.RegisterExecutors(manager, nil) + + if !host.isPluginFused("panic-identifier") { + t.Fatal("panic-identifier was not fused") + } + if manager.registerCalls != 0 { + t.Fatalf("RegisterExecutor calls = %d, want 0", manager.registerCalls) + } +} + +func TestRegisterExecutorsSelectsHighestPriorityPluginExecutorPerModel(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + host := newHostWithRecords( + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("low-provider", "shared-model"), + Executor: &fakeExecutor{identifier: "low-provider"}, + }}, + }, + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("high-provider", "shared-model"), + Executor: &fakeExecutor{identifier: "high-provider"}, + }}, + }, + ) + host.RegisterModels(context.Background(), modelRegistry) + + host.RegisterExecutors(manager, modelRegistry) + + if _, okLow := manager.executors["low-provider"]; okLow { + t.Fatal("low priority executor was registered for shared-model") + } + if _, okHigh := manager.executors["high-provider"]; !okHigh { + t.Fatal("high priority executor was not registered for shared-model") + } + if got := host.ModelsForProvider("low-provider"); len(got) != 0 { + t.Fatalf("low provider models = %#v, want none", got) + } + got := host.ModelsForProvider("high-provider") + if len(got) != 1 || got[0].ID != "shared-model" { + t.Fatalf("high provider models = %#v, want shared-model", got) + } +} + +func TestRegisterExecutorsKeepsPluginModelsForNativeProviderWithoutOverwritingExecutor(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + native := &fakeProviderExecutor{provider: "native-provider"} + manager.RegisterExecutor(native) + host := newHostWithRecords(capabilityRecord{ + id: "native-extension", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("native-provider", "native-extension-model"), + Executor: &fakeExecutor{identifier: "native-provider"}, + }}, + }) + host.RegisterModels(context.Background(), modelRegistry) + + host.RegisterExecutors(manager, modelRegistry) + + if manager.registerCalls != 1 { + t.Fatalf("RegisterExecutor calls = %d, want only native registration", manager.registerCalls) + } + gotExecutor, _ := manager.Executor("native-provider") + if gotExecutor != native { + t.Fatalf("native provider executor = %#v, want native executor", gotExecutor) + } + gotModels := host.ModelsForProvider("native-provider") + if len(gotModels) != 1 || gotModels[0].ID != "native-extension-model" { + t.Fatalf("native provider plugin models = %#v, want native-extension-model", gotModels) + } +} + +func TestRegisterExecutorsSkipsPluginModelWhenModelAlreadyHasNativeExecutor(t *testing.T) { + modelRegistry := newFakeModelRegistry() + modelRegistry.RegisterClient("native-auth", "native-provider", []*registry.ModelInfo{{ID: "shared-model"}}) + manager := newFakeExecutorManager() + manager.RegisterExecutor(&fakeProviderExecutor{provider: "native-provider"}) + host := newHostWithRecords(capabilityRecord{ + id: "plugin-executor", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("plugin-provider", "shared-model"), + Executor: &fakeExecutor{identifier: "plugin-provider"}, + }}, + }) + host.RegisterModels(context.Background(), modelRegistry) + + host.RegisterExecutors(manager, modelRegistry) + + if _, okPlugin := manager.executors["plugin-provider"]; okPlugin { + t.Fatal("plugin executor was registered for a model that already has a native executor") + } + if got := host.ModelsForProvider("plugin-provider"); len(got) != 0 { + t.Fatalf("plugin provider models = %#v, want none", got) + } +} + +func TestRegisterExecutorsUsesRegisteredModelProviderBeforeFallback(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + exec := &fakeExecutor{identifier: "fallback-provider"} + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("registered-provider", "model"), + Executor: exec, + }}, + }) + host.RegisterModels(context.Background(), modelRegistry) + + host.RegisterExecutors(manager, modelRegistry) + + adapter, okAdapter := manager.executors["registered-provider"].(*executorAdapter) + if !okAdapter { + t.Fatalf("registered executor = %#v, want executorAdapter", manager.executors["registered-provider"]) + } + if adapter.provider != "registered-provider" || adapter.executor != exec { + t.Fatalf("adapter = %#v, want registered provider executor", adapter) + } + if _, okFallback := manager.executors["fallback-provider"]; okFallback { + t.Fatal("fallback provider was registered despite model provider cache") + } +} + +func TestRegisterExecutorsExposesExecutorModelsForUserAuthBinding(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + exec := &fakeExecutor{identifier: "plugin-provider"} + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("plugin-provider", "plugin-model"), + Executor: exec, + }}, + }) + host.RegisterModels(context.Background(), modelRegistry) + + if len(modelRegistry.clients) != 0 { + t.Fatalf("registered model clients = %#v, want none until a matching auth binds provider models", modelRegistry.clients) + } + + host.RegisterExecutors(manager, modelRegistry) + + if _, okExecutor := manager.executors["plugin-provider"]; !okExecutor { + t.Fatal("plugin provider executor was not registered") + } + models := host.ModelsForProvider("plugin-provider") + if len(models) != 1 || models[0].ID != "plugin-model" { + t.Fatalf("provider models = %#v, want plugin-model for user auth binding", models) + } + clientID := pluginExecutorModelClientID("alpha", "plugin-provider") + reg := modelRegistry.clients[clientID] + if reg == nil { + t.Fatalf("executor model client %s was not registered", clientID) + } + if reg.provider != "plugin-provider" || len(reg.models) != 1 || reg.models[0].ID != "plugin-model" { + t.Fatalf("executor model registry client = %#v, want plugin-provider/plugin-model", reg) + } + if providers := modelRegistry.GetModelProviders("plugin-model"); len(providers) != 1 || providers[0] != "plugin-provider" { + t.Fatalf("providers for plugin-model = %#v, want plugin-provider", providers) + } +} + +func TestRegisterExecutorsOAuthScopeSkipsStaticModelClientButRegistersExecutor(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + staticCalled := false + host := newHostWithRecords(capabilityRecord{ + id: "qoder", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{identifier: "qoder"}, + ModelProvider: modelProviderFunc{ + staticModels: func(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + staticCalled = true + return pluginapi.ModelResponse{ + Provider: "qoder", + Models: []pluginapi.ModelInfo{{ID: "static-model"}}, + }, nil + }, + modelsForAuth: func(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + return pluginapi.ModelResponse{ + Provider: "qoder", + Models: []pluginapi.ModelInfo{{ID: "oauth-model"}}, + }, nil + }, + }, + Executor: &fakeExecutor{identifier: "qoder"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth, + }}, + }) + + host.RegisterModels(context.Background(), modelRegistry) + host.RegisterExecutors(manager, modelRegistry) + + if staticCalled { + t.Fatal("StaticModels was called for an OAuth-only executor") + } + if _, okExecutor := manager.executors["qoder"]; !okExecutor { + t.Fatal("OAuth-only executor was not registered") + } + if _, okClient := modelRegistry.clients[pluginExecutorModelClientID("qoder", "qoder")]; okClient { + t.Fatal("OAuth-only executor registered a static model client") + } + if got := host.ModelsForProvider("qoder"); len(got) != 0 { + t.Fatalf("OAuth-only provider models = %#v, want none", got) + } + + result := host.ModelsForAuth(context.Background(), &coreauth.Auth{ + ID: "qoder-auth", + Provider: "qoder", + }) + if !result.Handled || result.Provider != "qoder" || len(result.Models) != 1 || result.Models[0].ID != "oauth-model" { + t.Fatalf("OAuth model result = %#v, want oauth-model", result) + } +} + +func TestModelsForAuthOAuthScopeFallsBackToExecutorIdentifier(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelProvider: modelProviderFunc{ + modelsForAuth: func(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + return pluginapi.ModelResponse{ + Provider: "plugin-provider", + Models: []pluginapi.ModelInfo{{ID: "oauth-model"}}, + }, nil + }, + }, + Executor: &fakeExecutor{identifier: "plugin-provider"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth, + }}, + }) + + result := host.ModelsForAuth(context.Background(), &coreauth.Auth{ + ID: "plugin-auth", + Provider: "plugin-provider", + }) + + if !result.Handled || result.Provider != "plugin-provider" || len(result.Models) != 1 || result.Models[0].ID != "oauth-model" { + t.Fatalf("OAuth model result = %#v, want executor-identifier match", result) + } +} + +func TestRegisterExecutorsStaticScopeSkipsModelsForAuth(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + modelsForAuthCalled := false + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{identifier: "plugin-provider"}, + ModelProvider: modelProviderFunc{ + staticModels: func(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + return pluginapi.ModelResponse{ + Provider: "plugin-provider", + Models: []pluginapi.ModelInfo{{ID: "static-model"}}, + }, nil + }, + modelsForAuth: func(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + modelsForAuthCalled = true + return pluginapi.ModelResponse{ + Provider: "plugin-provider", + Models: []pluginapi.ModelInfo{{ID: "oauth-model"}}, + }, nil + }, + }, + Executor: &fakeExecutor{identifier: "plugin-provider"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeStatic, + }}, + }) + + host.RegisterModels(context.Background(), modelRegistry) + host.RegisterExecutors(manager, modelRegistry) + + clientID := pluginExecutorModelClientID("alpha", "plugin-provider") + reg := modelRegistry.clients[clientID] + if reg == nil || reg.provider != "plugin-provider" || len(reg.models) != 1 || reg.models[0].ID != "static-model" { + t.Fatalf("static executor model client = %#v, want static-model", reg) + } + result := host.ModelsForAuth(context.Background(), &coreauth.Auth{ + ID: "plugin-auth", + Provider: "plugin-provider", + }) + if result.Handled { + t.Fatalf("static-only executor handled per-auth models: %#v", result) + } + if modelsForAuthCalled { + t.Fatal("ModelsForAuth was called for a static-only executor") + } +} + +func TestRegisterExecutorsBothScopeKeepsStaticAndOAuthModels(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{identifier: "plugin-provider"}, + ModelProvider: modelProviderFunc{ + staticModels: func(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + return pluginapi.ModelResponse{ + Provider: "plugin-provider", + Models: []pluginapi.ModelInfo{{ID: "static-model"}}, + }, nil + }, + modelsForAuth: func(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + return pluginapi.ModelResponse{ + Provider: "plugin-provider", + Models: []pluginapi.ModelInfo{{ID: "oauth-model"}}, + }, nil + }, + }, + Executor: &fakeExecutor{identifier: "plugin-provider"}, + ExecutorModelScope: pluginapi.ExecutorModelScopeBoth, + }}, + }) + + host.RegisterModels(context.Background(), modelRegistry) + host.RegisterExecutors(manager, modelRegistry) + + clientID := pluginExecutorModelClientID("alpha", "plugin-provider") + reg := modelRegistry.clients[clientID] + if reg == nil || reg.provider != "plugin-provider" || len(reg.models) != 1 || reg.models[0].ID != "static-model" { + t.Fatalf("both-scope static model client = %#v, want static-model", reg) + } + result := host.ModelsForAuth(context.Background(), &coreauth.Auth{ + ID: "plugin-auth", + Provider: "plugin-provider", + }) + if !result.Handled || result.Provider != "plugin-provider" || len(result.Models) != 1 || result.Models[0].ID != "oauth-model" { + t.Fatalf("both-scope OAuth model result = %#v, want oauth-model", result) + } +} + +func TestRegisterExecutorsDropsResultsWhenSnapshotChangesBeforeCommit(t *testing.T) { + manager := newFakeExecutorManager() + host := New() + staleExecutor := &executorAdapter{ + host: host, + pluginID: "stale", + provider: "stale-provider", + } + manager.executors["stale-provider"] = staleExecutor + host.executorProviders["stale-provider"] = struct{}{} + + changedSnapshot := false + exec := &fakeExecutor{ + identifierFunc: func() string { + if !changedSnapshot { + changedSnapshot = true + host.snapshot.Store(&Snapshot{enabled: true}) + } + return "provider-a" + }, + } + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: exec, + }}, + }}}) + + host.RegisterExecutors(manager, nil) + + if manager.registerCalls != 0 { + t.Fatalf("RegisterExecutor calls = %d, want none for stale snapshot", manager.registerCalls) + } + if _, okProvider := manager.executors["provider-a"]; okProvider { + t.Fatal("provider-a executor was registered from a stale snapshot") + } + if manager.executors["stale-provider"] != staleExecutor { + t.Fatalf("stale-provider executor = %#v, want existing executor preserved", manager.executors["stale-provider"]) + } + if _, okProvider := host.executorProviders["stale-provider"]; !okProvider { + t.Fatal("stale-provider ownership was pruned by a stale snapshot") + } +} + +func TestRegisterExecutorsFallbackUsesExecutorIdentifier(t *testing.T) { + manager := newFakeExecutorManager() + exec := &fakeExecutor{identifier: " FallbackProvider "} + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: exec, + }}, + }) + + host.RegisterExecutors(manager, nil) + + adapter, okAdapter := manager.executors["fallbackprovider"].(*executorAdapter) + if !okAdapter { + t.Fatalf("registered executor = %#v, want fallback executorAdapter", manager.executors["fallbackprovider"]) + } + if adapter.provider != "fallbackprovider" || adapter.executor != exec { + t.Fatalf("adapter = %#v, want fallback provider executor", adapter) + } +} + +func TestRegisterExecutorsPrunesStaleProviderAfterMigration(t *testing.T) { + modelRegistry := newFakeModelRegistry() + manager := newFakeExecutorManager() + exec := &fakeExecutor{identifier: "fallback-provider"} + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ModelRegistrar: staticModelRegistrar("provider-a", "plugin-model"), + Executor: exec, + }}, + }) + host.modelProviders["alpha"] = "provider-a" + host.modelRegistrations["alpha"] = pluginModelRegistration{ + pluginID: "alpha", + provider: "provider-a", + models: []*registry.ModelInfo{{ID: "plugin-model"}}, + hasExecutor: true, + } + host.RegisterExecutors(manager, modelRegistry) + + host.modelProviders["alpha"] = "provider-b" + host.modelRegistrations["alpha"] = pluginModelRegistration{ + pluginID: "alpha", + provider: "provider-b", + models: []*registry.ModelInfo{{ID: "plugin-model"}}, + hasExecutor: true, + } + host.RegisterExecutors(manager, modelRegistry) + + if _, okProvider := manager.executors["provider-a"]; okProvider { + t.Fatal("provider-a executor is still registered") + } + if manager.unregisters[0] != "provider-a" { + t.Fatalf("unregistered providers = %#v, want provider-a", manager.unregisters) + } + adapter, okAdapter := manager.executors["provider-b"].(*executorAdapter) + if !okAdapter { + t.Fatalf("provider-b executor = %#v, want executorAdapter", manager.executors["provider-b"]) + } + if adapter.executor != exec { + t.Fatalf("provider-b adapter executor = %#v, want migrated executor", adapter.executor) + } + if _, okClient := modelRegistry.clients[pluginExecutorModelClientID("alpha", "provider-a")]; okClient { + t.Fatal("provider-a executor model client is still registered") + } + if _, okClient := modelRegistry.clients[pluginExecutorModelClientID("alpha", "provider-b")]; !okClient { + t.Fatal("provider-b executor model client was not registered") + } +} + +func TestRegisterExecutorsDoesNotUnregisterStaleProviderOwnedExternally(t *testing.T) { + manager := newFakeExecutorManager() + exec := &fakeExecutor{identifier: "fallback-provider"} + host := newHostWithRecords(capabilityRecord{ + id: "alpha", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + Executor: exec, + }}, + }) + host.modelProviders["alpha"] = "provider-a" + host.RegisterExecutors(manager, nil) + + external := &fakeProviderExecutor{provider: "provider-a"} + manager.executors["provider-a"] = external + host.modelProviders["alpha"] = "provider-b" + host.RegisterExecutors(manager, nil) + + if len(manager.unregisters) != 0 { + t.Fatalf("unregistered providers = %#v, want none for external owner", manager.unregisters) + } + if manager.executors["provider-a"] != external { + t.Fatalf("provider-a executor = %#v, want external executor", manager.executors["provider-a"]) + } + if _, okProvider := manager.executors["provider-b"]; !okProvider { + t.Fatal("provider-b executor was not registered") + } +} + +func TestNormalizeRequestChainsByPriority(t *testing.T) { + host := newHostWithRecords( + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|high")...)}, nil + }), + }}, + }, + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|low")...)}, nil + }), + }}, + }, + ) + + got := host.NormalizeRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("start"), false) + if string(got) != "start|high|low" { + t.Fatalf("NormalizeRequest() = %q, want %q", got, "start|high|low") + } +} + +func TestTranslateRequestStopsAtFirstSuccessfulCandidate(t *testing.T) { + calls := make([]string, 0, 2) + host := newHostWithRecords( + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + calls = append(calls, "high") + return pluginapi.PayloadResponse{Body: []byte("translated-high")}, nil + }), + }}, + }, + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + calls = append(calls, "low") + return pluginapi.PayloadResponse{Body: []byte("translated-low")}, nil + }), + }}, + }, + ) + + got, ok := host.TranslateRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("input"), false) + if !ok { + t.Fatal("TranslateRequest() ok = false, want true") + } + if string(got) != "translated-high" { + t.Fatalf("TranslateRequest() = %q, want %q", got, "translated-high") + } + if fmt.Sprint(calls) != "[high]" { + t.Fatalf("calls = %v, want [high]", calls) + } +} + +func TestAdaptersKeepPayloadOrTryNextOnErrorAndEmptyBody(t *testing.T) { + host := newHostWithRecords( + capabilityRecord{ + id: "normalizer-error", + priority: 30, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, fmt.Errorf("normalize failed") + }), + }}, + }, + capabilityRecord{ + id: "normalizer-empty", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, nil + }), + }}, + }, + capabilityRecord{ + id: "normalizer-success", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: []byte("kept-then-success")}, nil + }), + }}, + }, + ) + + normalized := host.NormalizeRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("original"), false) + if string(normalized) != "kept-then-success" { + t.Fatalf("NormalizeRequest() = %q, want %q", normalized, "kept-then-success") + } + + translatorHost := newHostWithRecords( + capabilityRecord{ + id: "translator-error", + priority: 30, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, fmt.Errorf("translate failed") + }), + }}, + }, + capabilityRecord{ + id: "translator-empty", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, nil + }), + }}, + }, + capabilityRecord{ + id: "translator-success", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: []byte("translated")}, nil + }), + }}, + }, + ) + + translated, ok := translatorHost.TranslateRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("original"), false) + if !ok { + t.Fatal("TranslateRequest() ok = false, want true") + } + if string(translated) != "translated" { + t.Fatalf("TranslateRequest() = %q, want %q", translated, "translated") + } +} + +func TestTranslatorPanicFusesPlugin(t *testing.T) { + host := newHostWithRecords( + capabilityRecord{ + id: "panic-plugin", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + panic("normalize panic") + }), + }}, + }, + capabilityRecord{ + id: "next-plugin", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestNormalizer: requestNormalizerFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|next")...)}, nil + }), + }}, + }, + ) + + got := host.NormalizeRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("original"), false) + if string(got) != "original|next" { + t.Fatalf("NormalizeRequest() = %q, want %q", got, "original|next") + } + if !host.isPluginFused("panic-plugin") { + t.Fatal("panic-plugin was not fused") + } +} + +func TestTranslatorPanicFusesEveryHookPath(t *testing.T) { + cases := []struct { + name string + pluginID string + call func(*Host) ([]byte, bool) + }{ + { + name: "request translator", + pluginID: "request-translator-panic", + call: func(host *Host) ([]byte, bool) { + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "request-translator-panic", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + RequestTranslator: requestTranslatorFunc(func(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + panic("request translator panic") + }), + }}, + }}}) + return host.TranslateRequest(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("body"), false) + }, + }, + { + name: "response before normalizer", + pluginID: "response-before-panic", + call: func(host *Host) ([]byte, bool) { + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "response-before-panic", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + panic("response before panic") + }), + }}, + }}}) + return host.NormalizeResponseBefore(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("body"), false), false + }, + }, + { + name: "response translator", + pluginID: "response-translator-panic", + call: func(host *Host) ([]byte, bool) { + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "response-translator-panic", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + panic("response translator panic") + }), + }}, + }}}) + return host.TranslateResponse(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("body"), false) + }, + }, + { + name: "response after normalizer", + pluginID: "response-after-panic", + call: func(host *Host) ([]byte, bool) { + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "response-after-panic", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + panic("response after panic") + }), + }}, + }}}) + return host.NormalizeResponseAfter(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("body"), false), false + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + host := New() + got, _ := tt.call(host) + if string(got) != "body" { + t.Fatalf("hook result = %q, want original body", got) + } + if !host.isPluginFused(tt.pluginID) { + t.Fatalf("%s was not fused", tt.pluginID) + } + }) + } +} + +func TestResponseNormalizersChainByPriority(t *testing.T) { + host := newHostWithRecords( + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|before-high")...)}, nil + }), + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|after-high")...)}, nil + }), + }}, + }, + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|before-low")...)}, nil + }), + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: append(req.Body, []byte("|after-low")...)}, nil + }), + }}, + }, + ) + + before := host.NormalizeResponseBefore(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("original-request"), []byte("translated-request"), []byte("body"), true) + if string(before) != "body|before-high|before-low" { + t.Fatalf("NormalizeResponseBefore() = %q, want %q", before, "body|before-high|before-low") + } + after := host.NormalizeResponseAfter(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", []byte("original-request"), []byte("translated-request"), []byte("body"), true) + if string(after) != "body|after-high|after-low" { + t.Fatalf("NormalizeResponseAfter() = %q, want %q", after, "body|after-high|after-low") + } +} + +func TestTranslateResponseStopsAtFirstSuccessfulCandidate(t *testing.T) { + calls := make([]string, 0, 2) + host := newHostWithRecords( + capabilityRecord{ + id: "high", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + calls = append(calls, "high") + return pluginapi.PayloadResponse{Body: []byte("response-high")}, nil + }), + }}, + }, + capabilityRecord{ + id: "low", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + calls = append(calls, "low") + return pluginapi.PayloadResponse{Body: []byte("response-low")}, nil + }), + }}, + }, + ) + + got, ok := host.TranslateResponse(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("input"), false) + if !ok { + t.Fatal("TranslateResponse() ok = false, want true") + } + if string(got) != "response-high" { + t.Fatalf("TranslateResponse() = %q, want %q", got, "response-high") + } + if fmt.Sprint(calls) != "[high]" { + t.Fatalf("calls = %v, want [high]", calls) + } +} + +func TestResponseHooksKeepPayloadOrTryNextOnErrorAndEmptyBody(t *testing.T) { + normalizerHost := newHostWithRecords( + capabilityRecord{ + id: "before-error", + priority: 30, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, fmt.Errorf("before failed") + }), + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, fmt.Errorf("after failed") + }), + }}, + }, + capabilityRecord{ + id: "before-empty", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, nil + }), + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, nil + }), + }}, + }, + capabilityRecord{ + id: "before-success", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseBeforeTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: []byte("before-success")}, nil + }), + ResponseAfterTranslator: responseNormalizerFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: []byte("after-success")}, nil + }), + }}, + }, + ) + + before := normalizerHost.NormalizeResponseBefore(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("original"), false) + if string(before) != "before-success" { + t.Fatalf("NormalizeResponseBefore() = %q, want %q", before, "before-success") + } + after := normalizerHost.NormalizeResponseAfter(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("original"), false) + if string(after) != "after-success" { + t.Fatalf("NormalizeResponseAfter() = %q, want %q", after, "after-success") + } + + translatorHost := newHostWithRecords( + capabilityRecord{ + id: "translator-error", + priority: 30, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, fmt.Errorf("translate failed") + }), + }}, + }, + capabilityRecord{ + id: "translator-empty", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{}, nil + }), + }}, + }, + capabilityRecord{ + id: "translator-success", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + ResponseTranslator: responseTranslatorFunc(func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return pluginapi.PayloadResponse{Body: []byte("response-translated")}, nil + }), + }}, + }, + ) + + translated, ok := translatorHost.TranslateResponse(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "model", nil, nil, []byte("original"), false) + if !ok { + t.Fatal("TranslateResponse() ok = false, want true") + } + if string(translated) != "response-translated" { + t.Fatalf("TranslateResponse() = %q, want %q", translated, "response-translated") + } +} + +func TestUsageAdapterPanicFusesPlugin(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "usage-panic", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + UsagePlugin: usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) { + panic("usage panic") + }), + }}, + }) + adapter := &usageAdapter{ + host: host, + pluginID: "usage-panic", + } + + adapter.HandleUsage(context.Background(), coreusage.Record{Provider: "plugin-provider"}) + if !host.isPluginFused("usage-panic") { + t.Fatal("usage-panic was not fused") + } +} + +func TestUsageManagerRegisterNamedReplacesWithoutDuplicateDispatch(t *testing.T) { + manager := coreusage.NewManager(0) + defer manager.Stop() + + calls := make(chan string, 2) + manager.RegisterNamed("plugin:alpha", coreUsagePluginFunc(func(ctx context.Context, record coreusage.Record) { + calls <- "first" + })) + manager.RegisterNamed("plugin:alpha", coreUsagePluginFunc(func(ctx context.Context, record coreusage.Record) { + calls <- "second" + })) + + manager.Publish(context.Background(), coreusage.Record{Provider: "provider"}) + + select { + case got := <-calls: + if got != "second" { + t.Fatalf("first dispatch = %q, want second", got) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for usage dispatch") + } + select { + case got := <-calls: + t.Fatalf("unexpected duplicate dispatch from %q", got) + case <-time.After(50 * time.Millisecond): + } +} + +func TestRegisterFrontendAuthProvidersPrunesStaleKeys(t *testing.T) { + const key = "plugin:auth-active:custom-auth" + sdkaccess.UnregisterProvider(key) + defer sdkaccess.UnregisterProvider(key) + + host := newHostWithRecords(capabilityRecord{ + id: "auth-active", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{ + identifier: "custom-auth", + authenticate: func(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + return pluginapi.FrontendAuthResponse{Authenticated: true}, nil + }, + }, + }}, + }) + + host.RegisterFrontendAuthProviders() + if !registeredProviderIdentifier(key) { + t.Fatalf("registered providers did not include %q", key) + } + + host.snapshot.Store(&Snapshot{enabled: true}) + host.RegisterFrontendAuthProviders() + if registeredProviderIdentifier(key) { + t.Fatalf("registered providers still included stale key %q", key) + } +} + +func TestRegisterFrontendAuthProvidersIdentifierPanicFusesPlugin(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "auth-identifier-panic", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: panicFrontendAuthProvider{}, + }}, + }) + + host.RegisterFrontendAuthProviders() + + if !host.isPluginFused("auth-identifier-panic") { + t.Fatal("auth-identifier-panic was not fused") + } +} + +func TestUsageAdapterUsesCurrentSnapshotCapability(t *testing.T) { + oldCalls := 0 + newCalls := 0 + oldPlugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) { + oldCalls++ + }) + newPlugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) { + newCalls++ + }) + host := newHostWithRecords(capabilityRecord{ + id: "usage-active", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + UsagePlugin: oldPlugin, + }}, + }) + adapter := &usageAdapter{ + host: host, + pluginID: "usage-active", + plugin: oldPlugin, + } + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{{ + id: "usage-active", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + UsagePlugin: newPlugin, + }}, + }}}) + + adapter.HandleUsage(context.Background(), coreusage.Record{Provider: "provider"}) + + if oldCalls != 0 { + t.Fatalf("old usage plugin calls = %d, want 0", oldCalls) + } + if newCalls != 1 { + t.Fatalf("new usage plugin calls = %d, want 1", newCalls) + } +} + +func TestRegisterUsagePluginsStaleAdapterSkipsRemovedCapability(t *testing.T) { + calls := 0 + plugin := usagePluginFunc(func(ctx context.Context, record pluginapi.UsageRecord) { + calls++ + }) + host := newHostWithRecords(capabilityRecord{ + id: "usage-active", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + UsagePlugin: plugin, + }}, + }) + + host.RegisterUsagePlugins() + adapter := &usageAdapter{ + host: host, + pluginID: "usage-active", + plugin: plugin, + } + host.snapshot.Store(&Snapshot{enabled: true}) + adapter.HandleUsage(context.Background(), coreusage.Record{Provider: "provider"}) + + if calls != 0 { + t.Fatalf("usage plugin calls = %d, want 0 after capability removal", calls) + } +} + +func TestAccessAdapterUnauthenticatedReturnsNotHandled(t *testing.T) { + host := New() + adapter := &accessAdapter{ + host: host, + pluginID: "auth-plugin", + provider: frontendAuthProviderFunc{ + identifier: "custom-auth", + authenticate: func(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + return pluginapi.FrontendAuthResponse{Authenticated: false}, nil + }, + }, + } + req, errNewRequest := http.NewRequest(http.MethodGet, "http://example.test/v1/models", nil) + if errNewRequest != nil { + t.Fatalf("NewRequest() error = %v", errNewRequest) + } + + result, authErr := adapter.Authenticate(context.Background(), req) + if result != nil { + t.Fatalf("Authenticate() result = %#v, want nil", result) + } + if !sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNotHandled) { + t.Fatalf("Authenticate() error = %v, want not handled", authErr) + } +} + +func TestAccessAdapterPanicFusesAndReturnsNotHandled(t *testing.T) { + host := New() + adapter := &accessAdapter{ + host: host, + pluginID: "auth-panic", + provider: frontendAuthProviderFunc{ + identifier: "custom-auth", + authenticate: func(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + panic("auth panic") + }, + }, + } + req, errNewRequest := http.NewRequest(http.MethodGet, "http://example.test/v1/models", nil) + if errNewRequest != nil { + t.Fatalf("NewRequest() error = %v", errNewRequest) + } + + result, authErr := adapter.Authenticate(context.Background(), req) + if result != nil { + t.Fatalf("Authenticate() result = %#v, want nil", result) + } + if !sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNotHandled) { + t.Fatalf("Authenticate() error = %v, want not handled", authErr) + } + if !host.isPluginFused("auth-panic") { + t.Fatal("auth-panic was not fused") + } +} + +func TestAccessAdapterBodyReadFailureReturnsInternalError(t *testing.T) { + host := New() + called := false + adapter := &accessAdapter{ + host: host, + pluginID: "auth-plugin", + provider: frontendAuthProviderFunc{ + identifier: "custom-auth", + authenticate: func(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + called = true + return pluginapi.FrontendAuthResponse{Authenticated: true}, nil + }, + }, + } + req, errNewRequest := http.NewRequest(http.MethodPost, "http://example.test/v1/chat", nil) + if errNewRequest != nil { + t.Fatalf("NewRequest() error = %v", errNewRequest) + } + req.Body = failingReadCloser{} + + result, authErr := adapter.Authenticate(context.Background(), req) + if result != nil { + t.Fatalf("Authenticate() result = %#v, want nil", result) + } + if !sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInternal) { + t.Fatalf("Authenticate() error = %v, want internal auth error", authErr) + } + if called { + t.Fatal("plugin provider was called after body read failure") + } +} + +func TestAccessAdapterErrorReturnsNotHandledAndRestoresBody(t *testing.T) { + host := New() + adapter := &accessAdapter{ + host: host, + pluginID: "auth-plugin", + provider: frontendAuthProviderFunc{ + identifier: "custom-auth", + authenticate: func(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + if string(req.Body) != "request-body" { + t.Fatalf("plugin request body = %q, want %q", req.Body, "request-body") + } + return pluginapi.FrontendAuthResponse{}, fmt.Errorf("not mine") + }, + }, + } + req, errNewRequest := http.NewRequest(http.MethodPost, "http://example.test/v1/chat?x=1", bytes.NewBufferString("request-body")) + if errNewRequest != nil { + t.Fatalf("NewRequest() error = %v", errNewRequest) + } + + result, authErr := adapter.Authenticate(context.Background(), req) + if result != nil { + t.Fatalf("Authenticate() result = %#v, want nil", result) + } + if !sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNotHandled) { + t.Fatalf("Authenticate() error = %v, want not handled", authErr) + } + restored, errReadAll := io.ReadAll(req.Body) + if errReadAll != nil { + t.Fatalf("ReadAll(restored body) error = %v", errReadAll) + } + if string(restored) != "request-body" { + t.Fatalf("restored body = %q, want %q", restored, "request-body") + } +} + +func TestExecutorAdapterMethods(t *testing.T) { + streamChunks := make(chan pluginapi.ExecutorStreamChunk, 2) + streamErr := errors.New("stream failed") + streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("stream-1")} + streamChunks <- pluginapi.ExecutorStreamChunk{Err: streamErr} + close(streamChunks) + + pluginHTTPBody := []byte("http-response") + pluginHTTPHeaders := http.Header{"X-Http": []string{"1"}} + authProvider := fakeAuthProvider{ + identifier: "plugin-provider", + refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { + if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" || req.Metadata["old"] != "value" { + t.Fatalf("refresh request = %#v, want auth metadata", req) + } + if req.HTTPClient == nil { + t.Fatal("refresh request HTTPClient = nil, want host HTTP bridge") + } + return pluginapi.AuthRefreshResponse{ + Auth: pluginapi.AuthData{ + Metadata: map[string]any{"token": "new"}, + }, + }, nil + }, + } + host := newHostWithRecords(capabilityRecord{ + id: "auth-plugin", + plugin: pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + AuthProvider: authProvider, + }, + }, + }) + + exec := &fakeExecutor{ + identifier: "ignored-by-adapter", + execute: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + assertExecutorRequest(t, req) + return pluginapi.ExecutorResponse{ + Payload: []byte("execute-response"), + Headers: http.Header{"X-Execute": []string{"1"}}, + Metadata: map[string]any{ + "phase": "execute", + }, + }, nil + }, + executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) { + assertExecutorRequest(t, req) + return pluginapi.ExecutorStreamResponse{ + Headers: http.Header{"X-Stream": []string{"1"}}, + Chunks: streamChunks, + }, nil + }, + countTokens: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + assertExecutorRequest(t, req) + return pluginapi.ExecutorResponse{Payload: []byte(`{"total_tokens":3}`)}, nil + }, + httpRequest: func(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) { + if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" || req.Method != http.MethodPatch || + req.URL != "http://example.test/v1/raw?x=1" || req.Headers.Get("X-Raw") != "yes" || string(req.Body) != "raw-body" { + t.Fatalf("http request = %#v, want mapped raw HTTP request", req) + } + if req.HTTPClient == nil { + t.Fatal("http request HTTPClient = nil, want host HTTP bridge") + } + return pluginapi.ExecutorHTTPResponse{ + StatusCode: http.StatusAccepted, + Headers: pluginHTTPHeaders, + Body: pluginHTTPBody, + }, nil + }, + } + adapter := &executorAdapter{ + host: host, + pluginID: "executor-plugin", + provider: "plugin-provider", + executor: exec, + } + auth := &coreauth.Auth{ + ID: "auth-1", + Provider: "plugin-provider", + Metadata: map[string]any{"old": "value"}, + } + req := coreexecutor.Request{ + Model: "model-1", + Format: sdktranslator.FormatOpenAI, + Payload: []byte("payload"), + Metadata: map[string]any{ + "req": "metadata", + }, + } + opts := coreexecutor.Options{ + Stream: true, + Alt: "alt", + Headers: http.Header{"X-Request": []string{"yes"}}, + OriginalRequest: []byte("original"), + SourceFormat: sdktranslator.FormatClaude, + Metadata: map[string]any{ + "opt": "metadata", + }, + } + + if adapter.Identifier() != "plugin-provider" { + t.Fatalf("Identifier() = %q, want %q", adapter.Identifier(), "plugin-provider") + } + resp, errExecute := adapter.Execute(context.Background(), auth, req, opts) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if string(resp.Payload) != "execute-response" || resp.Headers.Get("X-Execute") != "1" || resp.Metadata["phase"] != "execute" { + t.Fatalf("Execute() = %#v, want mapped response", resp) + } + + stream, errExecuteStream := adapter.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatalf("ExecuteStream() error = %v", errExecuteStream) + } + if stream.Headers.Get("X-Stream") != "1" { + t.Fatalf("ExecuteStream() headers = %#v, want X-Stream", stream.Headers) + } + first := <-stream.Chunks + if string(first.Payload) != "stream-1" || first.Err != nil { + t.Fatalf("first stream chunk = %#v, want payload chunk", first) + } + second := <-stream.Chunks + if second.Err != streamErr { + t.Fatalf("second stream chunk err = %v, want %v", second.Err, streamErr) + } + if _, ok := <-stream.Chunks; ok { + t.Fatal("stream chunks channel still open, want closed") + } + + refreshed, errRefresh := adapter.Refresh(context.Background(), auth) + if errRefresh != nil { + t.Fatalf("Refresh() error = %v", errRefresh) + } + if refreshed == auth { + t.Fatal("Refresh() returned original auth pointer, want clone") + } + if refreshed.Metadata["token"] != "new" { + t.Fatalf("Refresh() metadata = %#v, want token=new", refreshed.Metadata) + } + + count, errCountTokens := adapter.CountTokens(context.Background(), auth, req, opts) + if errCountTokens != nil { + t.Fatalf("CountTokens() error = %v", errCountTokens) + } + if string(count.Payload) != `{"total_tokens":3}` { + t.Fatalf("CountTokens() payload = %q, want token payload", count.Payload) + } + + rawReq, errNewRawRequest := http.NewRequest(http.MethodPatch, "http://example.test/v1/raw?x=1", bytes.NewBufferString("raw-body")) + if errNewRawRequest != nil { + t.Fatalf("NewRequest(raw) error = %v", errNewRawRequest) + } + rawReq.Header.Set("X-Raw", "yes") + httpResp, errHTTPRequest := adapter.HttpRequest(context.Background(), auth, rawReq) + if errHTTPRequest != nil { + t.Fatalf("HttpRequest() error = %v", errHTTPRequest) + } + if httpResp.StatusCode != http.StatusAccepted || httpResp.Status != "202 Accepted" || httpResp.Header.Get("X-Http") != "1" { + t.Fatalf("HttpRequest() response = %#v, want mapped status/header", httpResp) + } + pluginHTTPBody[0] = 'X' + pluginHTTPHeaders.Set("X-Http", "mutated") + body, errReadBody := io.ReadAll(httpResp.Body) + if errReadBody != nil { + t.Fatalf("ReadAll(HttpRequest body) error = %v", errReadBody) + } + if string(body) != "http-response" || httpResp.Header.Get("X-Http") != "1" { + t.Fatalf("HttpRequest() response aliases plugin data: body=%q header=%q", body, httpResp.Header.Get("X-Http")) + } + restoredRawBody, errReadRawBody := io.ReadAll(rawReq.Body) + if errReadRawBody != nil { + t.Fatalf("ReadAll(restored raw request body) error = %v", errReadRawBody) + } + if string(restoredRawBody) != "raw-body" { + t.Fatalf("restored raw request body = %q, want raw-body", restoredRawBody) + } + + nilResp, errNilRequest := adapter.HttpRequest(context.Background(), auth, nil) + if nilResp != nil { + t.Fatalf("HttpRequest(nil) response = %#v, want nil", nilResp) + } + if errNilRequest == nil || !strings.Contains(errNilRequest.Error(), "nil HTTP request") { + t.Fatalf("HttpRequest(nil) error = %v, want nil request error", errNilRequest) + } +} + +func TestExecutorAdapterPanicFusesAndReturnsError(t *testing.T) { + host := New() + calls := 0 + adapter := &executorAdapter{ + host: host, + pluginID: "executor-panic", + provider: "plugin-provider", + executor: &fakeExecutor{ + execute: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + calls++ + panic("execute panic") + }, + countTokens: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + calls++ + return pluginapi.ExecutorResponse{Payload: []byte("should-not-run")}, nil + }, + }, + } + + resp, errExecute := adapter.Execute(context.Background(), &coreauth.Auth{}, coreexecutor.Request{}, coreexecutor.Options{}) + if errExecute == nil { + t.Fatal("Execute() error = nil, want panic converted to error") + } + if len(resp.Payload) != 0 { + t.Fatalf("Execute() response = %#v, want zero response", resp) + } + if !host.isPluginFused("executor-panic") { + t.Fatal("executor-panic was not fused") + } + if calls != 1 { + t.Fatalf("plugin calls after first Execute() = %d, want 1", calls) + } + + count, errCountTokens := adapter.CountTokens(context.Background(), &coreauth.Auth{}, coreexecutor.Request{}, coreexecutor.Options{}) + if errCountTokens == nil { + t.Fatal("CountTokens() error after fuse = nil, want unavailable error") + } + if len(count.Payload) != 0 { + t.Fatalf("CountTokens() response after fuse = %#v, want zero response", count) + } + if calls != 1 { + t.Fatalf("plugin calls after fused CountTokens() = %d, want 1", calls) + } +} + +func TestMapExecutorStreamChunksExitsWhenContextCanceledWithoutDownstreamConsumer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + in := make(chan pluginapi.ExecutorStreamChunk) + out := mapExecutorStreamChunks(ctx, in) + sent := make(chan struct{}) + + go func() { + in <- pluginapi.ExecutorStreamChunk{Payload: []byte("chunk")} + close(sent) + }() + + select { + case <-sent: + case <-time.After(100 * time.Millisecond): + t.Fatal("input chunk was not accepted by bridge") + } + cancel() + time.Sleep(10 * time.Millisecond) + + select { + case chunk, ok := <-out: + if ok { + t.Fatalf("output channel produced chunk after cancel: %#v", chunk) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("output channel was not closed after context cancellation") + } +} + +func newHostWithRecords(records ...capabilityRecord) *Host { + host := New() + sortRecords(records) + host.snapshot.Store(&Snapshot{enabled: true, records: records}) + return host +} + +type requestNormalizerFunc func(context.Context, pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) + +func (f requestNormalizerFunc) NormalizeRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return f(ctx, req) +} + +type requestTranslatorFunc func(context.Context, pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) + +func (f requestTranslatorFunc) TranslateRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) { + return f(ctx, req) +} + +type responseNormalizerFunc func(context.Context, pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) + +func (f responseNormalizerFunc) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return f(ctx, req) +} + +type responseTranslatorFunc func(context.Context, pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) + +func (f responseTranslatorFunc) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) { + return f(ctx, req) +} + +type usagePluginFunc func(context.Context, pluginapi.UsageRecord) + +func (f usagePluginFunc) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) { + f(ctx, record) +} + +type coreUsagePluginFunc func(context.Context, coreusage.Record) + +func (f coreUsagePluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) { + f(ctx, record) +} + +type frontendAuthProviderFunc struct { + identifier string + authenticate func(context.Context, pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) +} + +func (f frontendAuthProviderFunc) Identifier() string { + return f.identifier +} + +func (f frontendAuthProviderFunc) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + return f.authenticate(ctx, req) +} + +type panicFrontendAuthProvider struct{} + +func (panicFrontendAuthProvider) Identifier() string { + panic("identifier panic") +} + +func (panicFrontendAuthProvider) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) { + return pluginapi.FrontendAuthResponse{}, nil +} + +type fakeAuthProvider struct { + identifier string + parseAuth func(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) + startLogin func(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) + pollLogin func(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) + refreshAuth func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) +} + +func (p fakeAuthProvider) Identifier() string { + return p.identifier +} + +func (p fakeAuthProvider) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { + if p.parseAuth == nil { + return pluginapi.AuthParseResponse{}, nil + } + return p.parseAuth(ctx, req) +} + +func (p fakeAuthProvider) StartLogin(ctx context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) { + if p.startLogin == nil { + return pluginapi.AuthLoginStartResponse{}, nil + } + return p.startLogin(ctx, req) +} + +func (p fakeAuthProvider) PollLogin(ctx context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) { + if p.pollLogin == nil { + return pluginapi.AuthLoginPollResponse{}, nil + } + return p.pollLogin(ctx, req) +} + +func (p fakeAuthProvider) RefreshAuth(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { + if p.refreshAuth == nil { + return pluginapi.AuthRefreshResponse{}, nil + } + return p.refreshAuth(ctx, req) +} + +type modelRegistrarFunc func(context.Context, pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) + +func (f modelRegistrarFunc) RegisterModels(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return f(ctx, req) +} + +type modelProviderFunc struct { + staticModels func(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) + modelsForAuth func(context.Context, pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) +} + +func (f modelProviderFunc) StaticModels(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) { + if f.staticModels == nil { + return pluginapi.ModelResponse{}, nil + } + return f.staticModels(ctx, req) +} + +func (f modelProviderFunc) ModelsForAuth(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) { + if f.modelsForAuth == nil { + return pluginapi.ModelResponse{}, nil + } + return f.modelsForAuth(ctx, req) +} + +func staticModelRegistrar(provider, modelID string) pluginapi.ModelRegistrar { + return modelRegistrarFunc(func(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) { + return pluginapi.ModelRegistrationResponse{ + Provider: provider, + Models: []pluginapi.ModelInfo{{ + ID: modelID, + }}, + }, nil + }) +} + +func registeredProviderIdentifier(identifier string) bool { + for _, provider := range sdkaccess.RegisteredProviders() { + if provider != nil && provider.Identifier() == identifier { + return true + } + } + return false +} + +type fakeModelRegistry struct { + clients map[string]*fakeModelClient + unregisters []string +} + +type fakeModelClient struct { + provider string + models []*registry.ModelInfo +} + +func newFakeModelRegistry() *fakeModelRegistry { + return &fakeModelRegistry{ + clients: make(map[string]*fakeModelClient), + } +} + +func (r *fakeModelRegistry) RegisterClient(clientID, clientProvider string, models []*registry.ModelInfo) { + r.clients[clientID] = &fakeModelClient{ + provider: clientProvider, + models: models, + } +} + +func (r *fakeModelRegistry) UnregisterClient(clientID string) { + delete(r.clients, clientID) + r.unregisters = append(r.unregisters, clientID) +} + +func (r *fakeModelRegistry) GetModelProviders(modelID string) []string { + counts := make(map[string]int) + for _, client := range r.clients { + if client == nil || client.provider == "" { + continue + } + for _, model := range client.models { + if model != nil && model.ID == modelID { + counts[client.provider]++ + } + } + } + providers := make([]string, 0, len(counts)) + for provider := range counts { + providers = append(providers, provider) + } + sort.Strings(providers) + return providers +} + +type fakeExecutorManager struct { + executors map[string]coreauth.ProviderExecutor + registerCalls int + unregisters []string +} + +func newFakeExecutorManager() *fakeExecutorManager { + return &fakeExecutorManager{ + executors: make(map[string]coreauth.ProviderExecutor), + } +} + +func (m *fakeExecutorManager) Executor(provider string) (coreauth.ProviderExecutor, bool) { + executor, okExecutor := m.executors[provider] + return executor, okExecutor +} + +func (m *fakeExecutorManager) RegisterExecutor(executor coreauth.ProviderExecutor) { + m.registerCalls++ + m.executors[executor.Identifier()] = executor +} + +func (m *fakeExecutorManager) UnregisterExecutor(provider string) { + delete(m.executors, provider) + m.unregisters = append(m.unregisters, provider) +} + +type fakeProviderExecutor struct { + provider string +} + +func (e *fakeProviderExecutor) Identifier() string { + return e.provider +} + +func (e *fakeProviderExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, nil +} + +func (e *fakeProviderExecutor) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + return nil, nil +} + +func (e *fakeProviderExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + return auth, nil +} + +func (e *fakeProviderExecutor) CountTokens(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, nil +} + +func (e *fakeProviderExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) { + return nil, nil +} + +type fakeExecutor struct { + identifier string + identifierFunc func() string + panicIdentifier bool + execute func(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) + executeStream func(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) + countTokens func(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) + httpRequest func(context.Context, pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) +} + +func (e *fakeExecutor) Identifier() string { + if e.panicIdentifier { + panic("identifier panic") + } + if e.identifierFunc != nil { + return e.identifierFunc() + } + return e.identifier +} + +func (e *fakeExecutor) Execute(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + return e.execute(ctx, req) +} + +func (e *fakeExecutor) ExecuteStream(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) { + return e.executeStream(ctx, req) +} + +func (e *fakeExecutor) CountTokens(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) { + return e.countTokens(ctx, req) +} + +func (e *fakeExecutor) HttpRequest(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) { + if e.httpRequest == nil { + return pluginapi.ExecutorHTTPResponse{}, nil + } + return e.httpRequest(ctx, req) +} + +func assertExecutorRequest(t *testing.T, req pluginapi.ExecutorRequest) { + t.Helper() + if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" || req.Model != "model-1" || req.Format != sdktranslator.FormatOpenAI.String() || + !req.Stream || req.Alt != "alt" || req.Headers.Get("X-Request") != "yes" || string(req.OriginalRequest) != "original" || + req.SourceFormat != sdktranslator.FormatClaude.String() || string(req.Payload) != "payload" || + req.Metadata["req"] != "metadata" || req.Metadata["opt"] != "metadata" { + t.Fatalf("executor request = %#v, want mapped request", req) + } +} + +type failingReadCloser struct{} + +func (failingReadCloser) Read(p []byte) (int, error) { + copy(p, []byte("partial")) + return len("partial"), errors.New("read failed") +} + +func (failingReadCloser) Close() error { + return nil +} diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go new file mode 100644 index 000000000..6439f690f --- /dev/null +++ b/internal/pluginhost/auth_provider.go @@ -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 "" +} diff --git a/internal/pluginhost/auth_provider_test.go b/internal/pluginhost/auth_provider_test.go new file mode 100644 index 000000000..717d340b6 --- /dev/null +++ b/internal/pluginhost/auth_provider_test.go @@ -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") + } +} diff --git a/internal/pluginhost/command_line.go b/internal/pluginhost/command_line.go new file mode 100644 index 000000000..91fb57225 --- /dev/null +++ b/internal/pluginhost/command_line.go @@ -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) + } +} diff --git a/internal/pluginhost/command_line_test.go b/internal/pluginhost/command_line_test.go new file mode 100644 index 000000000..93f05024b --- /dev/null +++ b/internal/pluginhost/command_line_test.go @@ -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 +} diff --git a/internal/pluginhost/config.go b/internal/pluginhost/config.go new file mode 100644 index 000000000..9fe1a05e1 --- /dev/null +++ b/internal/pluginhost/config.go @@ -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 +} diff --git a/internal/pluginhost/config_test.go b/internal/pluginhost/config_test.go new file mode 100644 index 000000000..ddd96df23 --- /dev/null +++ b/internal/pluginhost/config_test.go @@ -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) + } + } +} diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go new file mode 100644 index 000000000..7e39ae221 --- /dev/null +++ b/internal/pluginhost/host.go @@ -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 "" + } + return reflect.TypeOf(v).String() +} diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go new file mode 100644 index 000000000..19fe7c23a --- /dev/null +++ b/internal/pluginhost/host_test.go @@ -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) + } + } +} diff --git a/internal/pluginhost/http_bridge.go b/internal/pluginhost/http_bridge.go new file mode 100644 index 000000000..edd279b13 --- /dev/null +++ b/internal/pluginhost/http_bridge.go @@ -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 +} diff --git a/internal/pluginhost/loader_plugin.go b/internal/pluginhost/loader_plugin.go new file mode 100644 index 000000000..421307cd8 --- /dev/null +++ b/internal/pluginhost/loader_plugin.go @@ -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{} +} diff --git a/internal/pluginhost/loader_unsupported.go b/internal/pluginhost/loader_unsupported.go new file mode 100644 index 000000000..d1d6c3433 --- /dev/null +++ b/internal/pluginhost/loader_unsupported.go @@ -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{} +} diff --git a/internal/pluginhost/management.go b/internal/pluginhost/management.go new file mode 100644 index 000000000..a0d764da6 --- /dev/null +++ b/internal/pluginhost/management.go @@ -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) +} diff --git a/internal/pluginhost/management_test.go b/internal/pluginhost/management_test.go new file mode 100644 index 000000000..2103e68fb --- /dev/null +++ b/internal/pluginhost/management_test.go @@ -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) +} diff --git a/internal/pluginhost/platform.go b/internal/pluginhost/platform.go new file mode 100644 index 000000000..25c6e0c25 --- /dev/null +++ b/internal/pluginhost/platform.go @@ -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" +} diff --git a/internal/pluginhost/platform_test.go b/internal/pluginhost/platform_test.go new file mode 100644 index 000000000..da4657efd --- /dev/null +++ b/internal/pluginhost/platform_test.go @@ -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) + } +} diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go new file mode 100644 index 000000000..053f774e7 --- /dev/null +++ b/internal/pluginhost/snapshot.go @@ -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 + }) +} diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go new file mode 100644 index 000000000..2990c158f --- /dev/null +++ b/internal/pluginhost/test_helpers_test.go @@ -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 +} diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index a3a64640d..afa3918b5 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -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 } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 3936cc9dd..52f8d990d 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -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 diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 909a2eeaa..46038a698 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -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 diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go index be6738ce9..8f1aca7a6 100644 --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -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])) diff --git a/internal/watcher/dispatcher.go b/internal/watcher/dispatcher.go index d0182e2c2..d1602bc1d 100644 --- a/internal/watcher/dispatcher.go +++ b/internal/watcher/dispatcher.go @@ -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 diff --git a/internal/watcher/events.go b/internal/watcher/events.go index d3a4ee8f7..806403f21 100644 --- a/internal/watcher/events.go +++ b/internal/watcher/events.go @@ -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) } } diff --git a/internal/watcher/synthesizer/context.go b/internal/watcher/synthesizer/context.go index f92b41dda..4572f8bb8 100644 --- a/internal/watcher/synthesizer/context.go +++ b/internal/watcher/synthesizer/context.go @@ -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 } diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 47990bc15..171267057 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -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" } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index c18cd84d0..af984a5e2 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -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) } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index d93c22335..98740df2e 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -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 }() diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 5675caac2..584481ad3 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -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) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index bd0573088..d16c62745 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -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. // diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 7e6740d6b..1de65afd2 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -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 } } diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go index 521e158e5..8e9f19420 100644 --- a/sdk/cliproxy/auth/oauth_model_alias_test.go +++ b/sdk/cliproxy/auth/oauth_model_alias_test.go @@ -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") + } +} diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index c7e187ee6..32cad4be1 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -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 + } +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index ff30ad372..159eb7a65 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -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 } diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go index fe67265f0..baaa60f6b 100644 --- a/sdk/cliproxy/service_excluded_models_test.go +++ b/sdk/cliproxy/service_excluded_models_test.go @@ -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 diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go index 7405f7cac..17990dbc9 100644 --- a/sdk/cliproxy/service_oauth_model_alias_test.go +++ b/sdk/cliproxy/service_oauth_model_alias_test.go @@ -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) + } +} diff --git a/sdk/cliproxy/service_plugin_executor_test.go b/sdk/cliproxy/service_plugin_executor_test.go new file mode 100644 index 000000000..c751cbe25 --- /dev/null +++ b/sdk/cliproxy/service_plugin_executor_test.go @@ -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) + } + }) + } +} diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go index c30b712bd..3d6ae352d 100644 --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -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 diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index b68d6f417..b7798dc29 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -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) } diff --git a/sdk/cliproxy/watcher.go b/sdk/cliproxy/watcher.go index e4a9081b4..865b2f950 100644 --- a/sdk/cliproxy/watcher.go +++ b/sdk/cliproxy/watcher.go @@ -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 } diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go new file mode 100644 index 000000000..9eb59ab39 --- /dev/null +++ b/sdk/pluginapi/types.go @@ -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.. + 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 +} diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go new file mode 100644 index 000000000..8b4e6c757 --- /dev/null +++ b/sdk/pluginapi/types_test.go @@ -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 +} diff --git a/sdk/translator/helpers.go b/sdk/translator/helpers.go index 0266b6a87..db38d745b 100644 --- a/sdk/translator/helpers.go +++ b/sdk/translator/helpers.go @@ -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) diff --git a/sdk/translator/plugin_hooks.go b/sdk/translator/plugin_hooks.go new file mode 100644 index 000000000..f10620947 --- /dev/null +++ b/sdk/translator/plugin_hooks.go @@ -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 +} diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 2df6b3356..ac07107b8 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -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) diff --git a/sdk/translator/registry_test.go b/sdk/translator/registry_test.go index 1cd4fb122..0b01053b4 100644 --- a/sdk/translator/registry_test.go +++ b/sdk/translator/registry_test.go @@ -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) + } +}