feat(pluginhost): implement host authentication callbacks and add tests

- Introduced `auth_callbacks` for handling host authentication list, get, runtime, and save operations.
- Added extensive unit tests to validate functionality, including disk fallback and runtime-specific cases.
- Created example implementation in Go to demonstrate host callback integrations.
This commit is contained in:
Luis Pater
2026-06-14 23:51:40 +08:00
parent c59eee60b7
commit 6f923a28f7
14 changed files with 1706 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback
EXAMPLES := simple model auth frontend-auth executor protocol-format request-translator request-normalizer response-translator response-normalizer thinking usage cli management-api host-callback host-callback-auth-files host-model-callback
LANGUAGES := go c rust
BIN_DIR := $(CURDIR)/bin
BUILD_DIR := $(BIN_DIR)/build

View File

@@ -4,7 +4,8 @@ This directory contains standard dynamic library plugin examples for the CLIProx
## Layout
- `simple/`: full provider-native skeleton that declares every supported capability.
- `simple/`- : Go-only plugin resource that calls host auth file callbacks (, , , ).
- : full provider-native skeleton that declares every supported capability.
- `model/`: model capability only.
- `auth/`: auth provider capability only.
- `frontend-auth/`: frontend auth provider capability only.
@@ -22,6 +23,7 @@ This directory contains standard dynamic library plugin examples for the CLIProx
- `cli/`: command-line capability only.
- `management-api/`: Management API and resource capability only.
- `host-callback/`: minimal plugin resource that demonstrates host callbacks.
- `host-callback-auth-files/`: Go-only plugin resource that calls host auth file callbacks.
- `host-model-callback/`: Go-only plugin resource that calls the host model execution callbacks.
Most standard capability examples contain `go/`, `c/`, and `rust/` subdirectories. Specialized examples may provide only the implementation language they need.
@@ -39,6 +41,22 @@ plugins:
fast: false
```
## Host Auth Files Callback
`host-callback-auth-files` declares the Management API capability and exposes a browser resource named `Host Auth Files`. The resource demonstrates `host.auth.list`, `host.auth.get` (physical JSON file), `host.auth.get_runtime`, and `host.auth.save`.
```yaml
plugins:
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
See `host-callback-auth-files/README.md` for URL examples.
## Host Model Callback
`host-model-callback` declares the Management API capability and exposes a browser resource named `Host Model Callback`. The resource calls `host.model.execute` for non-streaming requests and `host.model.execute_stream` plus `host.model.stream_read` for streaming requests. It demonstrates explicit stream close with `host.model.stream_close` and an `implicit_close=true` option for RPC-scope host cleanup.

View File

@@ -1,4 +1,5 @@
# 标准动态库插件示例
- :仅 Go 实现的插件资源,演示 host 凭证文件回调(、、、)。
- # 标准动态库插件示例
本目录包含 CLIProxyAPI C ABI 的标准动态库插件示例。
@@ -22,6 +23,7 @@
- `cli/`:只演示命令行扩展能力。
- `management-api/`:只演示 Management API 和资源扩展能力。
- `host-callback/`:使用最小插件资源演示宿主回调。
- `host-callback-auth-files/`:仅 Go 实现的插件资源,演示 host 凭证文件回调。
- `host-model-callback/`:仅 Go 实现的插件资源,演示调用宿主模型执行回调。
多数标准能力示例都包含 `go/``c/``rust/` 三个子目录。专用示例可能只提供所需的实现语言。
@@ -39,6 +41,22 @@ plugins:
fast: false
```
## Host Auth Files 回调
`host-callback-auth-files` 声明 Management API 能力,并暴露名为 `Host Auth Files` 的浏览器资源,演示 `host.auth.list``host.auth.get`(物理 JSON 文件)、`host.auth.get_runtime``host.auth.save`
```yaml
plugins:
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
详见 `host-callback-auth-files/README.md`
## Host Model Callback
`host-model-callback` 声明 Management API 能力,并暴露名为 `Host Model Callback` 的浏览器资源。该资源在非流式请求中调用 `host.model.execute`,在流式请求中调用 `host.model.execute_stream``host.model.stream_read`。它演示了通过 `host.model.stream_close` 显式关闭流,也提供 `implicit_close=true` 用于演示 RPC 作用域结束时的宿主隐式清理。

View File

@@ -0,0 +1,89 @@
# Host Callback Auth Files Plugin
This Go-only plugin demonstrates how a plugin-owned browser resource can call the host auth file callbacks:
- `host.auth.list`
- `host.auth.get`
- `host.auth.get_runtime`
- `host.auth.save`
## Purpose and Scope
The plugin registers a Management API resource named `Host Auth Files` at `/status`. CPA exposes it under:
```text
/v0/resource/plugins/host-callback-auth-files/status
```
The resource reads URL query parameters, calls the host auth callbacks, and renders the result in HTML. It does not implement executor, translator, auth provider, or scheduler capabilities.
## Build
From this directory:
```bash
cd go
go build -buildmode=c-shared -o host-callback-auth-files.dylib .
rm -f host-callback-auth-files.dylib host-callback-auth-files.h
```
Use the platform extension expected by your target system:
- `.dylib` on macOS
- `.so` on Linux
- `.dll` on Windows
## Configuration
Build the dynamic library and place it under the configured plugin directory with a basename that matches the plugin ID. For example, `plugins/host-callback-auth-files.dylib` maps to `plugins.configs.host-callback-auth-files`.
```yaml
plugins:
enabled: true
dir: "plugins"
configs:
host-callback-auth-files:
enabled: true
priority: 1
```
This plugin does not define plugin-specific configuration fields.
## Resource URL Examples
List all auth files:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=list
```
Read physical JSON by auth index:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=get&auth_index=<AUTH_INDEX>
```
Read runtime info by auth index:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=runtime&auth_index=<AUTH_INDEX>
```
Save physical JSON:
```text
http://localhost:8080/v0/resource/plugins/host-callback-auth-files/status?op=save&name=example-auth.json&json=%7B%22type%22%3A%22gemini%22%2C%22email%22%3A%22demo%40example.com%22%2C%22api_key%22%3A%22demo-key%22%7D
```
## Parameters
- `op`: one of `list`, `get`, `runtime`, `save`. Default is `list`.
- `auth_index`: required for `get` and `runtime`.
- `name`: required for `save`. Must end with `.json`.
- `json`: required for `save`. Must be valid JSON.
## Notes
- `host.auth.get` returns the physical auth file JSON.
- `host.auth.get_runtime` returns runtime credential metadata.
- `host.auth.save` writes the JSON to the auth directory and upserts the runtime auth record.

View File

@@ -0,0 +1,7 @@
module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/host-callback-auth-files/go
go 1.26.0
require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..

View File

@@ -0,0 +1,531 @@
package main
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_host_free_fn)(void*, size_t);
typedef struct {
uint32_t abi_version;
void* host_ctx;
cliproxy_host_call_fn call;
cliproxy_host_free_fn free_buffer;
} cliproxy_host_api;
typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
typedef void (*cliproxy_plugin_shutdown_fn)(void);
typedef struct {
uint32_t abi_version;
cliproxy_plugin_call_fn call;
cliproxy_plugin_free_fn free_buffer;
cliproxy_plugin_shutdown_fn shutdown;
} cliproxy_plugin_api;
extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyPluginFree(void*, size_t);
extern void cliproxyPluginShutdown(void);
static const cliproxy_host_api* stored_host;
static void store_host_api(const cliproxy_host_api* host) {
stored_host = host;
}
static int call_host_api(const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
if (stored_host == NULL || stored_host->call == NULL) {
return 1;
}
return stored_host->call(stored_host->host_ctx, method, request, request_len, response);
}
static void free_host_buffer(void* ptr, size_t len) {
if (stored_host != NULL && stored_host->free_buffer != NULL && ptr != NULL) {
stored_host->free_buffer(ptr, len);
}
}
*/
import "C"
import (
"bytes"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
const (
pluginName = "host-callback-auth-files"
resourcePath = "/status"
resourceContentType = "text/html; charset=utf-8"
)
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapabilities `json:"capabilities"`
}
type registrationCapabilities struct {
ManagementAPI bool `json:"management_api"`
}
type managementRegistration struct {
Resources []managementResource `json:"resources,omitempty"`
}
type managementResource struct {
Path string `json:"Path"`
Menu string `json:"Menu"`
Description string `json:"Description"`
}
type managementRequest struct {
Method string
Path string
Headers http.Header
Query url.Values
Body []byte
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type managementResponse struct {
StatusCode int `json:"StatusCode"`
Headers http.Header `json:"Headers"`
Body []byte `json:"Body"`
}
type authListResponse struct {
Files []pluginapi.HostAuthFileEntry `json:"files"`
}
type authOpOptions struct {
Op string
AuthIndex string
Name string
JSON json.RawMessage
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
C.store_host_api(host)
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
_ = len
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
return okEnvelope(pluginRegistration())
case pluginabi.MethodManagementRegister:
return okEnvelope(managementRegistration{
Resources: []managementResource{{
Path: resourcePath,
Menu: "Host Auth Files",
Description: "Lists auth files and demonstrates host.auth list/get/runtime/save callbacks.",
}},
})
case pluginabi.MethodManagementHandle:
return handleManagement(request)
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func pluginRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: pluginName,
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png",
ConfigFields: []pluginapi.ConfigField{},
},
Capabilities: registrationCapabilities{
ManagementAPI: true,
},
}
}
func handleManagement(raw []byte) ([]byte, error) {
var req managementRequest
if len(raw) > 0 {
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode management request: %w", errUnmarshal)
}
}
opts, errOptions := optionsFromManagementRequest(req)
if errOptions != nil {
page := renderPage(opts, nil, errOptions.Error())
return okEnvelope(htmlResponse(http.StatusBadRequest, page))
}
result, errRun := runAuthOp(opts)
if errRun != nil {
page := renderPage(opts, nil, errRun.Error())
return okEnvelope(htmlResponse(http.StatusOK, page))
}
page := renderPage(opts, result, "")
return okEnvelope(htmlResponse(http.StatusOK, page))
}
func optionsFromManagementRequest(req managementRequest) (authOpOptions, error) {
opts := authOpOptions{Op: "list"}
if len(req.Body) > 0 {
var bodyOpts authOpOptions
if errUnmarshal := json.Unmarshal(req.Body, &bodyOpts); errUnmarshal != nil {
return opts, fmt.Errorf("decode JSON request body: %w", errUnmarshal)
}
applyAuthOpOptions(&opts, bodyOpts)
}
if errApply := applyQueryAuthOptions(&opts, req.Query); errApply != nil {
return opts, errApply
}
return opts, nil
}
func applyAuthOpOptions(dst *authOpOptions, src authOpOptions) {
if strings.TrimSpace(src.Op) != "" {
dst.Op = strings.ToLower(strings.TrimSpace(src.Op))
}
if strings.TrimSpace(src.AuthIndex) != "" {
dst.AuthIndex = strings.TrimSpace(src.AuthIndex)
}
if strings.TrimSpace(src.Name) != "" {
dst.Name = strings.TrimSpace(src.Name)
}
if len(src.JSON) > 0 && string(src.JSON) != "null" {
dst.JSON = append(json.RawMessage(nil), src.JSON...)
}
}
func applyQueryAuthOptions(opts *authOpOptions, query url.Values) error {
if query == nil {
return nil
}
if raw := strings.TrimSpace(query.Get("op")); raw != "" {
opts.Op = strings.ToLower(raw)
}
if raw := strings.TrimSpace(query.Get("auth_index")); raw != "" {
opts.AuthIndex = raw
}
if raw := strings.TrimSpace(query.Get("name")); raw != "" {
opts.Name = raw
}
if raw := strings.TrimSpace(query.Get("json")); raw != "" {
if !json.Valid([]byte(raw)) {
return fmt.Errorf("query json must be valid JSON")
}
opts.JSON = json.RawMessage(raw)
}
return nil
}
func runAuthOp(opts authOpOptions) (any, error) {
switch opts.Op {
case "list", "":
return callHostAuthList()
case "get":
if opts.AuthIndex == "" {
return nil, fmt.Errorf("auth_index is required for op=get")
}
return callHostAuthGet(opts.AuthIndex)
case "runtime", "get_runtime":
if opts.AuthIndex == "" {
return nil, fmt.Errorf("auth_index is required for op=runtime")
}
return callHostAuthGetRuntime(opts.AuthIndex)
case "save":
if opts.Name == "" {
return nil, fmt.Errorf("name is required for op=save")
}
if len(opts.JSON) == 0 {
return nil, fmt.Errorf("json is required for op=save")
}
return callHostAuthSave(opts.Name, opts.JSON)
default:
return nil, fmt.Errorf("unknown op %q: use list, get, runtime, or save", opts.Op)
}
}
func callHostAuthList() (authListResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthList, map[string]any{})
if errCall != nil {
return authListResponse{}, errCall
}
var resp authListResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return authListResponse{}, fmt.Errorf("decode host.auth.list result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthGet(authIndex string) (pluginapi.HostAuthGetResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthGet, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
if errCall != nil {
return pluginapi.HostAuthGetResponse{}, errCall
}
var resp pluginapi.HostAuthGetResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthGetResponse{}, fmt.Errorf("decode host.auth.get result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthGetRuntime(authIndex string) (pluginapi.HostAuthGetRuntimeResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthGetRuntime, pluginapi.HostAuthGetRequest{AuthIndex: authIndex})
if errCall != nil {
return pluginapi.HostAuthGetRuntimeResponse{}, errCall
}
var resp pluginapi.HostAuthGetRuntimeResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthGetRuntimeResponse{}, fmt.Errorf("decode host.auth.get_runtime result: %w", errUnmarshal)
}
return resp, nil
}
func callHostAuthSave(name string, rawJSON json.RawMessage) (pluginapi.HostAuthSaveResponse, error) {
result, errCall := callHost(pluginabi.MethodHostAuthSave, pluginapi.HostAuthSaveRequest{
Name: name,
JSON: rawJSON,
})
if errCall != nil {
return pluginapi.HostAuthSaveResponse{}, errCall
}
var resp pluginapi.HostAuthSaveResponse
if errUnmarshal := json.Unmarshal(result, &resp); errUnmarshal != nil {
return pluginapi.HostAuthSaveResponse{}, fmt.Errorf("decode host.auth.save result: %w", errUnmarshal)
}
return resp, nil
}
func callHost(method string, payload any) (json.RawMessage, error) {
rawPayload, errMarshal := json.Marshal(payload)
if errMarshal != nil {
return nil, fmt.Errorf("marshal host callback payload %s: %w", method, errMarshal)
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var response C.cliproxy_buffer
var requestPtr *C.uint8_t
if len(rawPayload) > 0 {
cPayload := C.CBytes(rawPayload)
if cPayload == nil {
return nil, fmt.Errorf("allocate host callback payload %s", method)
}
defer C.free(cPayload)
requestPtr = (*C.uint8_t)(cPayload)
}
callCode := C.call_host_api(cMethod, requestPtr, C.size_t(len(rawPayload)), &response)
var rawResponse []byte
if response.ptr != nil && response.len > 0 {
rawResponse = C.GoBytes(response.ptr, C.int(response.len))
}
if response.ptr != nil {
C.free_host_buffer(response.ptr, response.len)
}
if len(rawResponse) == 0 {
return nil, fmt.Errorf("host callback %s returned no response, code=%d", method, int(callCode))
}
var env envelope
if errUnmarshal := json.Unmarshal(rawResponse, &env); errUnmarshal != nil {
return nil, fmt.Errorf("decode host callback envelope %s: %w", method, errUnmarshal)
}
if !env.OK {
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return nil, fmt.Errorf("host callback %s failed", method)
}
if callCode != 0 {
return nil, fmt.Errorf("host callback %s returned code=%d", method, int(callCode))
}
return append(json.RawMessage(nil), env.Result...), nil
}
func htmlResponse(statusCode int, body []byte) managementResponse {
return managementResponse{
StatusCode: statusCode,
Headers: http.Header{
"content-type": []string{resourceContentType},
},
Body: body,
}
}
func renderPage(opts authOpOptions, result any, errText string) []byte {
var out bytes.Buffer
out.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>Host Auth Files</title>")
out.WriteString("<style>body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:2rem;line-height:1.45;color:#1f2933}code,pre{background:#f3f4f6;border-radius:6px}code{padding:.1rem .3rem}pre{padding:1rem;overflow:auto;white-space:pre-wrap}dl{display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}dt{font-weight:600}dd{margin:0}.error{color:#b42318}</style>")
out.WriteString("</head><body><main>")
out.WriteString("<h1>Host Auth Files</h1>")
out.WriteString("<dl>")
writeDefinition(&out, "op", opts.Op)
if opts.AuthIndex != "" {
writeDefinition(&out, "auth_index", opts.AuthIndex)
}
if opts.Name != "" {
writeDefinition(&out, "name", opts.Name)
}
out.WriteString("</dl>")
if errText != "" {
out.WriteString("<h2>Error</h2><pre class=\"error\">")
out.WriteString(html.EscapeString(errText))
out.WriteString("</pre>")
}
if result != nil {
out.WriteString("<h2>Result</h2><pre>")
out.WriteString(html.EscapeString(prettyJSON(result)))
out.WriteString("</pre>")
}
out.WriteString("<h2>Usage</h2><ul>")
out.WriteString("<li><code>?op=list</code></li>")
out.WriteString("<li><code>?op=get&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=runtime&amp;auth_index=&lt;AUTH_INDEX&gt;</code></li>")
out.WriteString("<li><code>?op=save&amp;name=example.json&amp;json=...</code></li>")
out.WriteString("</ul>")
out.WriteString("</main></body></html>")
return out.Bytes()
}
func writeDefinition(out *bytes.Buffer, key string, value string) {
out.WriteString("<dt>")
out.WriteString(html.EscapeString(key))
out.WriteString("</dt><dd><code>")
out.WriteString(html.EscapeString(value))
out.WriteString("</code></dd>")
}
func prettyBody(raw []byte) string {
var buf bytes.Buffer
if errIndent := json.Indent(&buf, raw, "", " "); errIndent == nil {
return buf.String()
}
return string(raw)
}
func prettyJSON(v any) string {
raw, errMarshal := json.MarshalIndent(v, "", " ")
if errMarshal != nil {
return fmt.Sprintf("%v", v)
}
return string(raw)
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}
func cloneHeader(headers http.Header) http.Header {
if headers == nil {
return nil
}
cloned := make(http.Header, len(headers))
for key, values := range headers {
cloned[key] = append([]string(nil), values...)
}
return cloned
}
func cloneValues(values url.Values) url.Values {
if values == nil {
return nil
}
cloned := make(url.Values, len(values))
for key, items := range values {
cloned[key] = append([]string(nil), items...)
}
return cloned
}

View File

@@ -318,6 +318,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
s.handlers.SetPluginHost(optionState.pluginHost)
if optionState.pluginHost != nil {
optionState.pluginHost.SetModelExecutor(s.handlers)
optionState.pluginHost.SetAuthManager(authManager)
}
// Save initial YAML snapshot
s.oldConfigYaml, _ = yaml.Marshal(cfg)
@@ -1650,6 +1651,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
s.handlers.SetPluginHost(s.pluginHost)
if s.pluginHost != nil {
s.pluginHost.SetModelExecutor(s.handlers)
s.pluginHost.SetAuthManager(s.handlers.AuthManager)
}
if s.mgmt != nil {

View File

@@ -0,0 +1,651 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcHostAuthGetRequest struct {
AuthIndex string `json:"auth_index"`
}
type rpcHostAuthListResponse struct {
Files []pluginapi.HostAuthFileEntry `json:"files"`
}
type rpcHostAuthGetResponse struct {
AuthIndex string `json:"auth_index"`
Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"`
JSON json.RawMessage `json:"json"`
}
func (h *Host) SetAuthManager(manager *coreauth.Manager) {
if h == nil {
return
}
h.mu.Lock()
h.authManager = manager
h.mu.Unlock()
}
func (h *Host) currentAuthManager() *coreauth.Manager {
if h == nil {
return nil
}
h.mu.Lock()
manager := h.authManager
h.mu.Unlock()
return manager
}
func (h *Host) callHostAuthList(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
if len(bytesTrimSpace(request)) > 0 {
var req map[string]any
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth list request: %w", errUnmarshal)
}
}
entries, errList := h.listAuthFiles()
if errList != nil {
return nil, errList
}
return marshalRPCResult(rpcHostAuthListResponse{Files: entries})
}
func (h *Host) callHostAuthGet(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
var req rpcHostAuthGetRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth get request: %w", errUnmarshal)
}
authIndex := strings.TrimSpace(req.AuthIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
auth, rawJSON, errGet := h.authPhysicalJSONByIndex(authIndex)
if errGet != nil {
return nil, errGet
}
name := strings.TrimSpace(auth.FileName)
if name == "" {
name = strings.TrimSpace(auth.ID)
}
path := strings.TrimSpace(authAttribute(auth, "path"))
return marshalRPCResult(rpcHostAuthGetResponse{
AuthIndex: authIndex,
Name: name,
Path: path,
JSON: json.RawMessage(rawJSON),
})
}
func (h *Host) callHostAuthGetRuntime(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
var req rpcHostAuthGetRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth get runtime request: %w", errUnmarshal)
}
authIndex := strings.TrimSpace(req.AuthIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
auth, errGet := h.authByIndex(authIndex)
if errGet != nil {
return nil, errGet
}
entry := h.buildHostAuthFileEntry(auth)
if entry == nil {
return nil, fmt.Errorf("auth runtime info not found for auth_index %s", authIndex)
}
return marshalRPCResult(pluginapi.HostAuthGetRuntimeResponse{Auth: *entry})
}
func (h *Host) callHostAuthSave(ctx context.Context, request []byte) ([]byte, error) {
var req pluginapi.HostAuthSaveRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth save request: %w", errUnmarshal)
}
name, rawJSON, errValidate := validateHostAuthSaveRequest(req)
if errValidate != nil {
return nil, errValidate
}
path, errSave := h.saveAuthFile(ctx, name, rawJSON)
if errSave != nil {
return nil, errSave
}
return marshalRPCResult(pluginapi.HostAuthSaveResponse{
Name: name,
Path: path,
})
}
func (h *Host) listAuthFiles() ([]pluginapi.HostAuthFileEntry, error) {
manager := h.currentAuthManager()
if manager != nil {
auths := manager.List()
entries := make([]pluginapi.HostAuthFileEntry, 0, len(auths))
for _, auth := range auths {
if entry := h.buildHostAuthFileEntry(auth); entry != nil {
entries = append(entries, *entry)
}
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
return entries, nil
}
return h.listAuthFilesFromDisk()
}
func (h *Host) listAuthFilesFromDisk() ([]pluginapi.HostAuthFileEntry, error) {
authDir := h.resolvedAuthDir()
if authDir == "" {
return nil, fmt.Errorf("auth directory is unavailable")
}
entries, errReadDir := os.ReadDir(authDir)
if errReadDir != nil {
return nil, fmt.Errorf("failed to read auth dir: %w", errReadDir)
}
files := make([]pluginapi.HostAuthFileEntry, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
full := filepath.Join(authDir, name)
fileEntry := pluginapi.HostAuthFileEntry{
Name: name,
Source: "file",
Path: full,
}
if info, errInfo := entry.Info(); errInfo == nil {
fileEntry.Size = info.Size()
fileEntry.ModTime = info.ModTime()
}
if data, errRead := os.ReadFile(full); errRead == nil {
var metadata map[string]any
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal == nil {
if provider, ok := metadata["type"].(string); ok {
fileEntry.Type = strings.TrimSpace(provider)
fileEntry.Provider = fileEntry.Type
}
if email, ok := metadata["email"].(string); ok {
fileEntry.Email = strings.TrimSpace(email)
}
if projectID, ok := metadata["project_id"].(string); ok {
fileEntry.ProjectID = strings.TrimSpace(projectID)
}
if rawPriority, ok := metadata["priority"]; ok {
if priority, okPriority := parsePriorityValue(rawPriority); okPriority {
fileEntry.Priority = priority
}
}
if note, ok := metadata["note"].(string); ok {
fileEntry.Note = strings.TrimSpace(note)
}
if websockets, okWebsockets := parseWebsocketsValue(metadata["websockets"]); okWebsockets {
fileEntry.Websockets = websockets
}
}
}
files = append(files, fileEntry)
}
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
return files, nil
}
func (h *Host) authByIndex(authIndex string) (*coreauth.Auth, error) {
authIndex = strings.TrimSpace(authIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
manager := h.currentAuthManager()
if manager == nil {
return nil, fmt.Errorf("core auth manager unavailable")
}
for _, auth := range manager.List() {
if auth == nil {
continue
}
auth.EnsureIndex()
if auth.Index == authIndex {
return auth, nil
}
}
return nil, fmt.Errorf("auth not found for auth_index %s", authIndex)
}
func (h *Host) authPhysicalJSONByIndex(authIndex string) (*coreauth.Auth, []byte, error) {
auth, errGet := h.authByIndex(authIndex)
if errGet != nil {
return nil, nil, errGet
}
path := strings.TrimSpace(authAttribute(auth, "path"))
if path == "" {
return nil, nil, fmt.Errorf("auth file path not found for auth_index %s", authIndex)
}
data, errRead := os.ReadFile(path)
if errRead != nil {
if os.IsNotExist(errRead) {
return nil, nil, fmt.Errorf("auth file not found for auth_index %s", authIndex)
}
return nil, nil, fmt.Errorf("failed to read auth file: %w", errRead)
}
if len(bytesTrimSpace(data)) == 0 {
return nil, nil, fmt.Errorf("auth file is empty for auth_index %s", authIndex)
}
var metadata map[string]any
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
return nil, nil, fmt.Errorf("invalid auth file for auth_index %s: %w", authIndex, errUnmarshal)
}
return auth, data, nil
}
func validateHostAuthSaveRequest(req pluginapi.HostAuthSaveRequest) (string, []byte, error) {
name := strings.TrimSpace(req.Name)
if isUnsafeAuthFileName(name) {
return "", nil, fmt.Errorf("invalid auth file name")
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
return "", nil, fmt.Errorf("auth file name must end with .json")
}
rawJSON := bytesTrimSpace(req.JSON)
if len(rawJSON) == 0 {
return "", nil, fmt.Errorf("json is required")
}
var metadata map[string]any
if errUnmarshal := json.Unmarshal(rawJSON, &metadata); errUnmarshal != nil {
return "", nil, fmt.Errorf("invalid auth json: %w", errUnmarshal)
}
return filepath.Base(name), rawJSON, nil
}
func (h *Host) saveAuthFile(ctx context.Context, name string, data []byte) (string, error) {
authDir := h.resolvedAuthDir()
if authDir == "" {
return "", fmt.Errorf("auth directory is unavailable")
}
dst := filepath.Join(authDir, filepath.Base(name))
if !filepath.IsAbs(dst) {
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
dst = abs
}
}
auth, errBuild := h.buildAuthFromFileData(dst, data)
if errBuild != nil {
return "", errBuild
}
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
return "", fmt.Errorf("failed to write auth file: %w", errWrite)
}
if errUpsert := h.upsertAuthRecord(ctx, auth); errUpsert != nil {
return "", errUpsert
}
return dst, nil
}
func (h *Host) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("auth path is empty")
}
if data == nil {
var errRead error
data, errRead = os.ReadFile(path)
if errRead != nil {
return nil, fmt.Errorf("failed to read auth file: %w", errRead)
}
}
metadata := make(map[string]any)
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
return nil, fmt.Errorf("invalid auth file: %w", errUnmarshal)
}
provider, _ := metadata["type"].(string)
if strings.TrimSpace(provider) == "" {
provider = "unknown"
}
label := provider
if email, ok := metadata["email"].(string); ok && strings.TrimSpace(email) != "" {
label = strings.TrimSpace(email)
}
authID := h.authIDForPath(path)
if authID == "" {
authID = path
}
auth := &coreauth.Auth{
ID: authID,
Provider: provider,
FileName: filepath.Base(path),
Label: label,
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: metadata,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if manager := h.currentAuthManager(); manager != nil {
if existing, ok := manager.GetByID(authID); ok {
auth.CreatedAt = existing.CreatedAt
auth.LastRefreshedAt = existing.LastRefreshedAt
auth.NextRetryAfter = existing.NextRetryAfter
auth.Runtime = existing.Runtime
}
}
coreauth.ApplyCustomHeadersFromMetadata(auth)
return auth, nil
}
func (h *Host) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error {
manager := h.currentAuthManager()
if manager == nil || auth == nil {
return nil
}
if existing, ok := manager.GetByID(auth.ID); ok {
auth.CreatedAt = existing.CreatedAt
_, errUpdate := manager.Update(ctx, auth)
return errUpdate
}
_, errRegister := manager.Register(ctx, auth)
return errRegister
}
func isUnsafeAuthFileName(name string) bool {
if strings.TrimSpace(name) == "" {
return true
}
if strings.ContainsAny(name, "/\\") {
return true
}
if filepath.VolumeName(name) != "" {
return true
}
return false
}
func (h *Host) buildHostAuthFileEntry(auth *coreauth.Auth) *pluginapi.HostAuthFileEntry {
if auth == nil {
return nil
}
auth.EnsureIndex()
runtimeOnly := isRuntimeOnlyAuth(auth)
if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
return nil
}
path := strings.TrimSpace(authAttribute(auth, "path"))
if path == "" && !runtimeOnly {
return nil
}
name := strings.TrimSpace(auth.FileName)
if name == "" {
name = auth.ID
}
entry := &pluginapi.HostAuthFileEntry{
ID: auth.ID,
AuthIndex: auth.Index,
Name: name,
Type: strings.TrimSpace(auth.Provider),
Provider: strings.TrimSpace(auth.Provider),
Label: auth.Label,
Status: string(auth.Status),
StatusMessage: auth.StatusMessage,
Disabled: auth.Disabled,
Unavailable: auth.Unavailable,
RuntimeOnly: runtimeOnly,
Source: "memory",
Success: auth.Success,
Failed: auth.Failed,
RecentRequests: hostRecentRequests(auth),
}
if email := authEmail(auth); email != "" {
entry.Email = email
}
if projectID := authProjectID(auth); projectID != "" {
entry.ProjectID = projectID
}
if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
entry.AccountType = accountType
entry.Account = account
}
if !auth.CreatedAt.IsZero() {
entry.CreatedAt = auth.CreatedAt
}
if !auth.UpdatedAt.IsZero() {
entry.ModTime = auth.UpdatedAt
entry.UpdatedAt = auth.UpdatedAt
}
if !auth.LastRefreshedAt.IsZero() {
entry.LastRefresh = auth.LastRefreshedAt
}
if !auth.NextRetryAfter.IsZero() {
entry.NextRetryAfter = auth.NextRetryAfter
}
if path != "" {
entry.Path = path
entry.Source = "file"
if info, err := os.Stat(path); err == nil {
entry.Size = info.Size()
entry.ModTime = info.ModTime()
} else if os.IsNotExist(err) {
if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
return nil
}
entry.Source = "memory"
}
}
if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
entry.Priority = parsed
}
} else if auth.Metadata != nil {
if rawPriority, ok := auth.Metadata["priority"]; ok {
if priority, okPriority := parsePriorityValue(rawPriority); okPriority {
entry.Priority = priority
}
}
}
if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" {
entry.Note = note
} else if auth.Metadata != nil {
if rawNote, ok := auth.Metadata["note"].(string); ok {
entry.Note = strings.TrimSpace(rawNote)
}
}
if websockets, ok := authWebsocketsValue(auth); ok {
entry.Websockets = websockets
}
return entry
}
func (h *Host) resolvedAuthDir() string {
if h == nil {
return ""
}
h.mu.Lock()
authDir := ""
if h.runtimeConfig != nil {
authDir = strings.TrimSpace(h.runtimeConfig.AuthDir)
}
h.mu.Unlock()
if authDir == "" {
return ""
}
authDir = filepath.Clean(authDir)
if !filepath.IsAbs(authDir) {
if abs, errAbs := filepath.Abs(authDir); errAbs == nil {
authDir = abs
}
}
return authDir
}
func (h *Host) authIDForPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
if abs, errAbs := filepath.Abs(path); errAbs == nil {
path = abs
}
}
id := path
if authDir := h.resolvedAuthDir(); authDir != "" {
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
id = rel
}
}
if runtime.GOOS == "windows" {
id = strings.ToLower(id)
}
return id
}
func authEmail(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
if auth.Metadata != nil {
if v, ok := auth.Metadata["email"].(string); ok {
return strings.TrimSpace(v)
}
}
if auth.Attributes != nil {
if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
return v
}
if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
return v
}
}
return ""
}
func authProjectID(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
if auth.Metadata != nil {
if v, ok := auth.Metadata["project_id"].(string); ok {
if projectID := strings.TrimSpace(v); projectID != "" {
return projectID
}
}
}
if auth.Attributes != nil {
if projectID := strings.TrimSpace(auth.Attributes["project_id"]); projectID != "" {
return projectID
}
if projectID := strings.TrimSpace(auth.Attributes["gemini_virtual_project"]); projectID != "" {
return projectID
}
}
return ""
}
func authAttribute(auth *coreauth.Auth, key string) string {
if auth == nil || len(auth.Attributes) == 0 {
return ""
}
return auth.Attributes[key]
}
func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
if auth == nil || len(auth.Attributes) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
}
func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) {
if auth == nil {
return false, false
}
if auth.Attributes != nil {
if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" {
parsed, errParse := strconv.ParseBool(raw)
if errParse == nil {
return parsed, true
}
}
}
if auth.Metadata == nil {
return false, false
}
return parseWebsocketsValue(auth.Metadata["websockets"])
}
func parsePriorityValue(raw any) (int, bool) {
switch v := raw.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return parsed, true
}
}
return 0, false
}
func parseWebsocketsValue(raw any) (bool, bool) {
switch v := raw.(type) {
case bool:
return v, true
case string:
parsed, errParse := strconv.ParseBool(strings.TrimSpace(v))
if errParse == nil {
return parsed, true
}
}
return false, false
}
func bytesTrimSpace(raw []byte) []byte {
return []byte(strings.TrimSpace(string(raw)))
}
func hostRecentRequests(auth *coreauth.Auth) []pluginapi.HostRecentRequestEntry {
if auth == nil {
return nil
}
snapshot := auth.RecentRequestsSnapshot(time.Now())
if len(snapshot) == 0 {
return nil
}
out := make([]pluginapi.HostRecentRequestEntry, 0, len(snapshot))
for _, entry := range snapshot {
out = append(out, pluginapi.HostRecentRequestEntry{
Time: entry.Time,
Success: entry.Success,
Failed: entry.Failed,
})
}
return out
}

View File

@@ -0,0 +1,249 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"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/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type memoryAuthStorage struct {
payload []byte
}
func (s *memoryAuthStorage) RawJSON() []byte {
if s == nil {
return nil
}
return append([]byte(nil), s.payload...)
}
func (s *memoryAuthStorage) SaveTokenToFile(authFilePath string) error {
if s == nil || len(s.payload) == 0 {
return fmt.Errorf("memory auth storage payload is empty")
}
return os.WriteFile(authFilePath, s.payload, 0o600)
}
func TestHostAuthListCallbackUsesAuthManager(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "gemini-a.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini","email":"a@example.com","api_key":"k1"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
auth := &coreauth.Auth{
ID: "gemini-a.json",
Provider: "gemini",
FileName: "gemini-a.json",
Label: "a@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: map[string]any{
"type": "gemini",
"email": "a@example.com",
"api_key": "k1",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"a@example.com","api_key":"k1"}`)},
}
auth.EnsureIndex()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if len(resp.Files) != 1 {
t.Fatalf("files = %#v, want one entry", resp.Files)
}
entry := resp.Files[0]
if entry.AuthIndex != auth.Index || entry.Name != "gemini-a.json" || entry.Email != "a@example.com" {
t.Fatalf("entry = %#v, want auth index and file metadata", entry)
}
}
func TestHostAuthGetCallbackReturnsPhysicalJSONByAuthIndex(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "gemini-b.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini","email":"b@example.com","api_key":"k2"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
auth := &coreauth.Auth{
ID: "gemini-b.json",
Provider: "gemini",
FileName: "gemini-b.json",
Label: "b@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: map[string]any{
"type": "gemini",
"email": "b@example.com",
"api_key": "k2",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"b@example.com","api_key":"changed"}`)},
}
auth.EnsureIndex()
host := New()
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGet, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthGetResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.AuthIndex != auth.Index || resp.Name != "gemini-b.json" {
t.Fatalf("response = %#v, want auth index and name", resp)
}
var decoded map[string]any
if errUnmarshal := json.Unmarshal(resp.JSON, &decoded); errUnmarshal != nil {
t.Fatalf("unmarshal auth json: %v", errUnmarshal)
}
if decoded["email"] != "b@example.com" || decoded["api_key"] != "k2" {
t.Fatalf("decoded json = %#v, want credential payload", decoded)
}
}
func TestHostAuthListCallbackFallsBackToDisk(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "claude-a.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"claude","email":"c@example.com"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if len(resp.Files) != 1 {
t.Fatalf("files = %#v, want one disk entry", resp.Files)
}
entry := resp.Files[0]
if entry.Name != "claude-a.json" || entry.Type != "claude" || entry.Email != "c@example.com" {
t.Fatalf("entry = %#v, want disk metadata", entry)
}
if entry.ModTime.IsZero() {
t.Fatalf("entry modtime is zero: %#v", entry)
}
_ = time.Now()
}
func TestHostAuthGetRuntimeCallbackReturnsRuntimeInfo(t *testing.T) {
auth := &coreauth.Auth{
ID: "gemini-runtime.json",
Provider: "gemini",
FileName: "gemini-runtime.json",
Label: "runtime@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"runtime_only": "true",
},
Metadata: map[string]any{
"type": "gemini",
"email": "runtime@example.com",
"api_key": "runtime-key",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"gemini","email":"runtime@example.com","api_key":"runtime-key"}`)},
}
auth.EnsureIndex()
host := New()
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGetRuntime, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthGetRuntimeResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Auth.AuthIndex != auth.Index || resp.Auth.RuntimeOnly != true || resp.Auth.Email != "runtime@example.com" {
t.Fatalf("response = %#v, want runtime auth entry", resp.Auth)
}
}
func TestHostAuthSaveCallbackWritesPhysicalFile(t *testing.T) {
authDir := t.TempDir()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
req, errMarshal := json.Marshal(pluginapi.HostAuthSaveRequest{
Name: "saved.json",
JSON: json.RawMessage(`{"type":"gemini","email":"saved@example.com","api_key":"saved-key"}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthSave, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthSaveResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Name != "saved.json" {
t.Fatalf("response = %#v, want saved file name", resp)
}
data, errRead := os.ReadFile(resp.Path)
if errRead != nil {
t.Fatalf("read saved file: %v", errRead)
}
if string(data) != `{"type":"gemini","email":"saved@example.com","api_key":"saved-key"}` {
t.Fatalf("saved file = %q, want credential json", string(data))
}
auths := host.currentAuthManager().List()
if len(auths) != 1 || auths[0].FileName != "saved.json" {
t.Fatalf("auths = %#v, want one registered auth", auths)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
@@ -40,6 +41,7 @@ type Host struct {
loaded map[string]*loadedPlugin
fused map[string]string
runtimeConfig *config.Config
authManager *coreauth.Manager
modelExecutor modelExecutor
modelClientIDs map[string]struct{}
executorModelClientIDs map[string]struct{}

View File

@@ -119,6 +119,14 @@ func (h *Host) callFromPlugin(ctx context.Context, method string, request []byte
return h.callHostStreamClose(request)
case pluginabi.MethodHostLog:
return h.callHostLog(ctx, request)
case pluginabi.MethodHostAuthList:
return h.callHostAuthList(ctx, request)
case pluginabi.MethodHostAuthGet:
return h.callHostAuthGet(ctx, request)
case pluginabi.MethodHostAuthGetRuntime:
return h.callHostAuthGetRuntime(ctx, request)
case pluginabi.MethodHostAuthSave:
return h.callHostAuthSave(ctx, request)
default:
return nil, fmt.Errorf("unsupported host callback %s", method)
}

View File

@@ -67,6 +67,10 @@ const (
MethodHostStreamEmit = "host.stream.emit"
MethodHostStreamClose = "host.stream.close"
MethodHostLog = "host.log"
MethodHostAuthList = "host.auth.list"
MethodHostAuthGet = "host.auth.get"
MethodHostAuthGetRuntime = "host.auth.get_runtime"
MethodHostAuthSave = "host.auth.save"
)
type Envelope struct {

View File

@@ -60,6 +60,18 @@ func TestMethodNamesAreStable(t *testing.T) {
if MethodHostModelStreamClose != "host.model.stream_close" {
t.Fatalf("MethodHostModelStreamClose = %q", MethodHostModelStreamClose)
}
if MethodHostAuthList != "host.auth.list" {
t.Fatalf("MethodHostAuthList = %q", MethodHostAuthList)
}
if MethodHostAuthGet != "host.auth.get" {
t.Fatalf("MethodHostAuthGet = %q", MethodHostAuthGet)
}
if MethodHostAuthGetRuntime != "host.auth.get_runtime" {
t.Fatalf("MethodHostAuthGetRuntime = %q", MethodHostAuthGetRuntime)
}
if MethodHostAuthSave != "host.auth.save" {
t.Fatalf("MethodHostAuthSave = %q", MethodHostAuthSave)
}
if MethodExecutorExecuteStream != "executor.execute_stream" {
t.Fatalf("MethodExecutorExecuteStream = %q", MethodExecutorExecuteStream)
}

View File

@@ -3,6 +3,7 @@ package pluginapi
import (
"context"
"encoding/json"
"net/http"
"net/url"
"time"
@@ -586,6 +587,117 @@ type HostModelStreamCloseRequest struct {
StreamID string `json:"stream_id"`
}
type HostRecentRequestEntry struct {
// Time is the recent request bucket label.
Time string `json:"time"`
// Success is the success count in the bucket.
Success int64 `json:"success"`
// Failed is the failure count in the bucket.
Failed int64 `json:"failed"`
}
// HostAuthFileEntry describes one credential exposed through host auth callbacks.
type HostAuthFileEntry struct {
// ID identifies the credential record.
ID string `json:"id,omitempty"`
// AuthIndex is the stable runtime credential index.
AuthIndex string `json:"auth_index,omitempty"`
// Name is the credential file name or runtime identifier.
Name string `json:"name"`
// Type is the credential provider type.
Type string `json:"type,omitempty"`
// Provider is the credential provider key.
Provider string `json:"provider,omitempty"`
// Label is the human-readable credential label.
Label string `json:"label,omitempty"`
// Status is the current credential status.
Status string `json:"status,omitempty"`
// StatusMessage carries the latest status detail.
StatusMessage string `json:"status_message,omitempty"`
// Disabled reports whether the credential is disabled.
Disabled bool `json:"disabled,omitempty"`
// Unavailable reports whether the credential is currently unavailable.
Unavailable bool `json:"unavailable,omitempty"`
// RuntimeOnly reports whether the credential has no backing auth file.
RuntimeOnly bool `json:"runtime_only,omitempty"`
// Source reports whether the credential came from file or memory.
Source string `json:"source,omitempty"`
// Path is the backing auth file path when available.
Path string `json:"path,omitempty"`
// Size is the backing auth file size when available.
Size int64 `json:"size,omitempty"`
// ModTime is the last modification time when available.
ModTime time.Time `json:"modtime,omitempty"`
// UpdatedAt is the last credential update time.
UpdatedAt time.Time `json:"updated_at,omitempty"`
// CreatedAt is the credential creation time.
CreatedAt time.Time `json:"created_at,omitempty"`
// LastRefresh is the last refresh timestamp.
LastRefresh time.Time `json:"last_refresh,omitempty"`
// NextRetryAfter is the next retry timestamp.
NextRetryAfter time.Time `json:"next_retry_after,omitempty"`
// Email is the credential email when available.
Email string `json:"email,omitempty"`
// ProjectID is the credential project identifier when available.
ProjectID string `json:"project_id,omitempty"`
// AccountType is the credential account type when available.
AccountType string `json:"account_type,omitempty"`
// Account is the credential account identifier when available.
Account string `json:"account,omitempty"`
// Priority is the credential routing priority when available.
Priority int `json:"priority,omitempty"`
// Note is the credential note when available.
Note string `json:"note,omitempty"`
// Websockets reports whether websocket mode is enabled when available.
Websockets bool `json:"websockets,omitempty"`
// Success is the recent success count.
Success int64 `json:"success,omitempty"`
// Failed is the recent failure count.
Failed int64 `json:"failed,omitempty"`
// RecentRequests is the recent request snapshot.
RecentRequests []HostRecentRequestEntry `json:"recent_requests,omitempty"`
}
// HostAuthGetRequest asks the host for credential JSON by auth index.
type HostAuthGetRequest struct {
// AuthIndex identifies the credential index.
AuthIndex string `json:"auth_index"`
}
// HostAuthGetResponse returns credential JSON resolved by auth index.
type HostAuthGetResponse struct {
// AuthIndex identifies the credential index.
AuthIndex string `json:"auth_index"`
// Name is the credential file name or runtime identifier.
Name string `json:"name,omitempty"`
// Path is the backing auth file path when available.
Path string `json:"path,omitempty"`
// JSON contains the credential JSON payload.
JSON json.RawMessage `json:"json"`
}
// HostAuthGetRuntimeResponse returns runtime credential information by auth index.
type HostAuthGetRuntimeResponse struct {
// Auth is the runtime credential entry.
Auth HostAuthFileEntry `json:"auth"`
}
// HostAuthSaveRequest asks the host to persist credential JSON to a physical auth file.
type HostAuthSaveRequest struct {
// Name is the target auth file name. It must end with .json.
Name string `json:"name"`
// JSON contains the credential JSON payload to save.
JSON json.RawMessage `json:"json"`
}
// HostAuthSaveResponse reports the saved physical auth file.
type HostAuthSaveResponse struct {
// Name is the saved auth file name.
Name string `json:"name"`
// Path is the saved auth file path.
Path string `json:"path"`
}
// HTTPRequest describes an upstream HTTP request issued through the host.
type HTTPRequest struct {
// Method is the HTTP method.