Files
CLIProxyAPI/examples/plugin/simple/go/main.go
Luis Pater 44ea9abced feat(pluginhost): introduce browser-navigable plugin resources in Management API
- Added `resources` field in `management.register` for defining browser-accessible resources.
- Updated examples and documentation to reflect resource-based paths under `/v0/resource/plugins/<pluginID>/...`.
- Replaced legacy `GET` menu routes with resource-based implementations for consistent plugin behavior.
- Enhanced request handling for resource paths, including proper response headers and streamlined test coverage.
2026-06-09 22:46:27 +08:00

349 lines
13 KiB
Go

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);
*/
import "C"
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
var usageCount atomic.Int64
type envelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *envelopeError `json:"error,omitempty"`
}
type envelopeError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type lifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
}
type registration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities registrationCapability `json:"capabilities"`
}
type registrationCapability struct {
ModelRegistrar bool `json:"model_registrar"`
ModelProvider bool `json:"model_provider"`
AuthProvider bool `json:"auth_provider"`
FrontendAuthProvider bool `json:"frontend_auth_provider"`
Executor bool `json:"executor"`
ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"`
ExecutorInputFormats []string `json:"executor_input_formats,omitempty"`
ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"`
RequestTranslator bool `json:"request_translator"`
RequestNormalizer bool `json:"request_normalizer"`
ResponseTranslator bool `json:"response_translator"`
ResponseBeforeTranslator bool `json:"response_before_translator"`
ResponseAfterTranslator bool `json:"response_after_translator"`
ThinkingApplier bool `json:"thinking_applier"`
UsagePlugin bool `json:"usage_plugin"`
CommandLinePlugin bool `json:"command_line_plugin"`
ManagementAPI bool `json:"management_api"`
}
type identifierResponse struct {
Identifier string `json:"identifier"`
}
type streamResponse struct {
Headers http.Header `json:"headers,omitempty"`
Chunks []pluginapi.ExecutorStreamChunk `json:"chunks,omitempty"`
}
type managementRegistrationResponse struct {
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
}
func main() {}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if plugin == nil {
return 1
}
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
return 0
}
//export cliproxyPluginCall
func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
if response != nil {
response.ptr = nil
response.len = 0
}
if method == nil {
writeResponse(response, errorEnvelope("invalid_method", "method is required"))
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleMethod(C.GoString(method), requestBytes)
if errHandle != nil {
writeResponse(response, errorEnvelope("plugin_error", errHandle.Error()))
return 1
}
writeResponse(response, raw)
return 0
}
//export cliproxyPluginFree
func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {}
func handleMethod(method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
return okEnvelope(exampleRegistration())
case pluginabi.MethodModelRegister:
return okEnvelope(pluginapi.ModelRegistrationResponse{Provider: "plugin-example", Models: exampleModels()})
case pluginabi.MethodModelStatic, pluginabi.MethodModelForAuth:
return okEnvelope(pluginapi.ModelResponse{Provider: "plugin-example", Models: exampleModels()})
case pluginabi.MethodAuthIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodAuthParse:
return okEnvelope(pluginapi.AuthParseResponse{Handled: true, Auth: exampleAuthData(request)})
case pluginabi.MethodAuthLoginStart:
return okEnvelope(pluginapi.AuthLoginStartResponse{
Provider: "plugin-example",
URL: "https://example.invalid/plugin-login",
State: "example-state",
ExpiresAt: time.Now().Add(5 * time.Minute).UTC(),
})
case pluginabi.MethodAuthLoginPoll:
return okEnvelope(pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusError, Message: "example plugin has no interactive login"})
case pluginabi.MethodAuthRefresh:
return okEnvelope(pluginapi.AuthRefreshResponse{Auth: exampleAuthData(request)})
case pluginabi.MethodFrontendAuthIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodFrontendAuthAuthenticate:
return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: true, Principal: "plugin-example"})
case pluginabi.MethodExecutorIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodExecutorExecute:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"id":"plugin-example","object":"chat.completion"}`)})
case pluginabi.MethodExecutorExecuteStream:
return okEnvelope(streamResponse{Chunks: []pluginapi.ExecutorStreamChunk{{Payload: []byte("plugin-example")}}})
case pluginabi.MethodExecutorCountTokens:
return okEnvelope(pluginapi.ExecutorResponse{Payload: []byte(`{"total_tokens":0}`)})
case pluginabi.MethodExecutorHTTPRequest:
return okEnvelope(pluginapi.ExecutorHTTPResponse{StatusCode: http.StatusOK, Body: []byte(`{"plugin":"example"}`)})
case pluginabi.MethodRequestTranslate, pluginabi.MethodRequestNormalize:
return payloadEcho(request)
case pluginabi.MethodResponseTranslate, pluginabi.MethodResponseNormalizeBefore, pluginabi.MethodResponseNormalizeAfter:
return responsePayloadEcho(request)
case pluginabi.MethodThinkingIdentifier:
return okEnvelope(identifierResponse{Identifier: "plugin-example"})
case pluginabi.MethodThinkingApply:
return applyThinking(request)
case pluginabi.MethodUsageHandle:
usageCount.Add(1)
return okEnvelope(map[string]any{})
case pluginabi.MethodCommandLineRegister:
return okEnvelope(pluginapi.CommandLineRegistrationResponse{Flags: []pluginapi.CommandLineFlag{{
Name: "plugin-example-command",
Usage: "Run the example C ABI plugin command",
Type: "bool",
}}})
case pluginabi.MethodCommandLineExecute:
return okEnvelope(pluginapi.CommandLineExecutionResponse{Stdout: []byte("plugin example command\n")})
case pluginabi.MethodManagementRegister:
// CPA exposes menu resources under /v0/resource/plugins/<plugin-id>/.
return okEnvelope(managementRegistrationResponse{Resources: []pluginapi.ResourceRoute{{
Path: "/status",
Menu: "Example Plugin",
Description: "Shows example plugin status as a browser-navigable resource.",
}}})
case pluginabi.MethodManagementHandle:
return okEnvelope(pluginapi.ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: []byte(`<!doctype html><title>Example Plugin</title><main>Example Plugin</main>`),
})
default:
return errorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func exampleRegistration() registration {
return registration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: pluginapi.Metadata{
Name: "example",
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png",
ConfigFields: []pluginapi.ConfigField{
{Name: "config1", Type: pluginapi.ConfigFieldTypeBoolean, Description: "Enables the example boolean option."},
{Name: "config2", Type: pluginapi.ConfigFieldTypeString, Description: "Stores the example string option."},
{Name: "config3", Type: pluginapi.ConfigFieldTypeInteger, Description: "Stores the example integer option."},
{Name: "mode", Type: pluginapi.ConfigFieldTypeEnum, EnumValues: []string{"safe", "fast"}, Description: "Selects the example execution mode."},
},
},
Capabilities: registrationCapability{
ModelRegistrar: true,
ModelProvider: true,
AuthProvider: true,
FrontendAuthProvider: true,
Executor: true,
ExecutorModelScope: pluginapi.ExecutorModelScopeBoth,
ExecutorInputFormats: []string{"chat-completions"},
ExecutorOutputFormats: []string{"chat-completions"},
RequestTranslator: true,
RequestNormalizer: true,
ResponseTranslator: true,
ResponseBeforeTranslator: true,
ResponseAfterTranslator: true,
ThinkingApplier: true,
UsagePlugin: true,
CommandLinePlugin: true,
ManagementAPI: true,
},
}
}
func exampleModels() []pluginapi.ModelInfo {
return []pluginapi.ModelInfo{{
ID: "plugin-example-model",
Object: "model",
OwnedBy: "plugin-example",
DisplayName: "Plugin Example Model",
SupportedGenerationMethods: []string{"chat"},
ContextLength: 8192,
MaxCompletionTokens: 1024,
UserDefined: true,
}}
}
func exampleAuthData(raw []byte) pluginapi.AuthData {
return pluginapi.AuthData{
Provider: "plugin-example",
ID: "plugin-example",
FileName: "plugin-example.json",
Label: "Plugin Example",
StorageJSON: append([]byte(nil), raw...),
Metadata: map[string]any{"type": "plugin-example"},
}
}
func payloadEcho(raw []byte) ([]byte, error) {
var req pluginapi.RequestTransformRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: req.Body})
}
func responsePayloadEcho(raw []byte) ([]byte, error) {
var req pluginapi.ResponseTransformRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: req.Body})
}
func applyThinking(raw []byte) ([]byte, error) {
var req pluginapi.ThinkingApplyRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return nil, errUnmarshal
}
body := map[string]any{}
_ = json.Unmarshal(req.Body, &body)
body["plugin_example_thinking"] = map[string]any{
"mode": req.Config.Mode,
"budget": req.Config.Budget,
"level": req.Config.Level,
}
out, errMarshal := json.Marshal(body)
if errMarshal != nil {
return nil, errMarshal
}
return okEnvelope(pluginapi.PayloadResponse{Body: out})
}
func okEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(envelope{OK: true, Result: raw})
}
func errorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}})
return raw
}
func writeResponse(response *C.cliproxy_buffer, raw []byte) {
if response == nil || len(raw) == 0 {
return
}
ptr := C.CBytes(raw)
if ptr == nil {
return
}
response.ptr = ptr
response.len = C.size_t(len(raw))
}