feat(jshandler): add new plugin providing JavaScript-based interceptors and capabilities

- Implemented `RequestInterceptor`, `ResponseInterceptor`, and `StreamChunkInterceptor` using embedded JavaScript.
- Added support for configuring script paths, built-in script resolution, and safe execution.
- Introduced ABI lifecycle management, plugin registration, and execution monitoring.
- Enhanced with extensive test coverage for both plugin behavior and configuration logic.
This commit is contained in:
Luis Pater
2026-06-09 08:28:00 +08:00
parent 5e41e079e5
commit 583053509d
14 changed files with 1672 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
PLUGIN_NAME ?= jshandler
BUILD_DIR ?= .
GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH)
EXT_linux = so
EXT_freebsd = so
EXT_darwin = dylib
EXT_windows = dll
PLUGIN_EXT = $(or $(EXT_$(GOOS)),so)
PLUGIN_OUTPUT ?= $(BUILD_DIR)/$(PLUGIN_NAME).$(PLUGIN_EXT)
PLUGIN_HEADER = $(basename $(PLUGIN_OUTPUT)).h
.PHONY: build clean
build:
CGO_ENABLED=1 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -buildmode=c-shared -o $(PLUGIN_OUTPUT) .
clean:
rm -f $(BUILD_DIR)/$(PLUGIN_NAME).so
rm -f $(BUILD_DIR)/$(PLUGIN_NAME).dylib
rm -f $(BUILD_DIR)/$(PLUGIN_NAME).dll
rm -f $(PLUGIN_HEADER)
+119
View File
@@ -0,0 +1,119 @@
# JS Handler Plugin
A CLIProxyAPI plugin that executes external JavaScript scripts to intercept and modify requests, responses, and streaming chunks using the Goja VM engine.
## Features
- **Request Interception** (`on_before_request`): Modify request payloads and headers before upstream delivery.
- **Response Interception** (`on_after_nonstream_response`): Modify non-streaming response bodies and headers.
- **Stream Chunk Interception** (`on_after_stream_response`): Modify individual streaming chunks with read-only `history_chunks` context.
- **Hot Reload**: Scripts are automatically reloaded when modified on disk.
- **Execution Timeout**: Configurable timeout prevents infinite loops.
- **Graceful Degradation**: Original data is preserved on JS execution errors.
## Configuration
```yaml
plugins:
enabled: true
dir: "plugins-dir"
configs:
jshandler:
enabled: true
script_paths:
- /path/to/custom_handler.js
- ./relative_handler.js
timeout: 1s
```
### Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | boolean | `true` | Enable or disable the plugin |
| `script_paths` | array | `[]` | JS script file paths (absolute or relative to plugin directory) |
| `timeout` | string | `1s` | Execution timeout per JS hook call |
## JS Script API
Scripts can export these global functions:
### `on_before_request(ctx)`
Called before the request is sent upstream.
**ctx structure:**
```javascript
{
"id": "request-id",
"body": "...", // Request body string
"headers": {}, // Request headers
"url": "",
"model": "gpt-4",
"protocol": "openai"
}
```
### `on_after_nonstream_response(ctx)`
Called after a non-streaming response is received from upstream.
**ctx structure (non-streaming):**
```javascript
{
"id": "request-id",
"body": "...", // Full response body
"req": { "body": "...", "headers": {}, "url": "" },
"protocol": "openai",
"headers": {},
"chunk": null,
"history_chunks": null
}
```
### `on_after_stream_response(ctx)`
Called after each streaming response chunk is received from upstream.
**ctx structure:**
```javascript
{
"id": "request-id",
"body": null,
"req": { "body": "...", "headers": {}, "url": "" },
"protocol": "openai",
"headers": {},
"chunk": "...", // Current writable chunk
"history_chunks": ["..."] // Read-only frozen array
}
```
### Return Value
Return the modified `ctx` object, or a plain string to replace the body/chunk.
## Built-in Scripts
The `scripts/` directory contains built-in scripts loaded automatically:
- `copilot_handler.js`: Fixes tool-call `finish_reason` for GitHub Copilot compatibility.
## Building
```bash
make build
```
The Makefile chooses the plugin extension from the target platform:
| GOOS | Output |
|------|--------|
| `linux` / `freebsd` | `jshandler.so` |
| `darwin` | `jshandler.dylib` |
| `windows` | `jshandler.dll` |
You can override the target and output directory:
```bash
make build GOOS=darwin GOARCH=arm64 BUILD_DIR=/path/to/plugins/darwin/arm64
```
+291
View File
@@ -0,0 +1,291 @@
package main
/*
#define _GNU_SOURCE
#include <dlfcn.h>
#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 JSHandlerPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
extern void JSHandlerPluginFree(void*, size_t);
extern void JSHandlerPluginShutdown(void);
static const char* jshandler_shared_object_path() {
Dl_info info;
if (dladdr((void*)&JSHandlerPluginCall, &info) == 0 || info.dli_fname == NULL) {
return NULL;
}
return info.dli_fname;
}
static int jshandler_call_host(cliproxy_host_api* api, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
return api->call(api->host_ctx, method, request, request_len, response);
}
static void jshandler_free_host_buffer(cliproxy_host_api* api, void* ptr, size_t len) {
api->free_buffer(ptr, len);
}
*/
import "C"
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sync"
"unsafe"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
var jsHandlerABIState = struct {
sync.RWMutex
host *C.cliproxy_host_api
plugin *jsHandlerPlugin
shuttingDown bool
inFlight sync.WaitGroup
}{}
const maxCGoBytesLen = C.size_t(1<<31 - 1)
type abiEnvelope struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *abiError `json:"error,omitempty"`
}
type abiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type abiLifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
PluginDir string `json:"plugin_dir,omitempty"`
}
type abiRegistration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities abiCapabilities `json:"capabilities"`
}
type abiCapabilities struct {
RequestInterceptor bool `json:"request_interceptor"`
ResponseInterceptor bool `json:"response_interceptor"`
StreamChunkInterceptor bool `json:"response_stream_interceptor"`
}
type abiIdentifierResponse struct {
Identifier string `json:"identifier"`
}
func main() {}
func inferPluginDir() string {
sharedObjectPath := C.jshandler_shared_object_path()
if sharedObjectPath == nil {
return ""
}
return filepath.Dir(C.GoString(sharedObjectPath))
}
//export cliproxy_plugin_init
func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
if host == nil || plugin == nil {
return 1
}
jsHandlerABIState.Lock()
jsHandlerABIState.host = host
jsHandlerABIState.shuttingDown = false
jsHandlerABIState.Unlock()
plugin.abi_version = C.uint32_t(pluginabi.ABIVersion)
plugin.call = C.cliproxy_plugin_call_fn(C.JSHandlerPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.JSHandlerPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.JSHandlerPluginShutdown)
return 0
}
//export JSHandlerPluginCall
func JSHandlerPluginCall(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 {
writeABIResponse(response, abiErrorEnvelope("invalid_method", "method is required"))
return 0
}
var requestBytes []byte
if request != nil && requestLen > 0 {
if requestLen > maxCGoBytesLen {
writeABIResponse(response, abiErrorEnvelope("request_too_large", "request payload is too large"))
return 0
}
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
raw, errHandle := handleJSHandlerABIMethod(context.Background(), C.GoString(method), requestBytes)
if errHandle != nil {
writeABIResponse(response, abiErrorEnvelope("plugin_error", errHandle.Error()))
return 0
}
writeABIResponse(response, raw)
return 0
}
//export JSHandlerPluginFree
func JSHandlerPluginFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
}
//export JSHandlerPluginShutdown
func JSHandlerPluginShutdown() {
jsHandlerABIState.Lock()
jsHandlerABIState.shuttingDown = true
jsHandlerABIState.plugin = nil
jsHandlerABIState.host = nil
jsHandlerABIState.Unlock()
jsHandlerABIState.inFlight.Wait()
}
func handleJSHandlerABIMethod(ctx context.Context, method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
return handleJSHandlerRegister(request)
}
p, done, errPlugin := beginJSHandlerPluginCall()
if errPlugin != nil {
return nil, errPlugin
}
defer done()
switch method {
case pluginabi.MethodRequestInterceptBefore:
var req pluginapi.RequestInterceptRequest
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
return nil, errDecode
}
resp, errCall := p.InterceptRequest(ctx, req)
return abiOKEnvelopeWithError(resp, errCall)
case pluginabi.MethodResponseInterceptAfter:
var req pluginapi.ResponseInterceptRequest
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
return nil, errDecode
}
resp, errCall := p.InterceptResponse(ctx, req)
return abiOKEnvelopeWithError(resp, errCall)
case pluginabi.MethodResponseInterceptStreamChunk:
var req pluginapi.StreamChunkInterceptRequest
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
return nil, errDecode
}
resp, errCall := p.InterceptStreamChunk(ctx, req)
return abiOKEnvelopeWithError(resp, errCall)
default:
return abiErrorEnvelope("unknown_method", "unknown method: "+method), nil
}
}
func handleJSHandlerRegister(request []byte) ([]byte, error) {
var req abiLifecycleRequest
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
return nil, errDecode
}
plugin, errBuild := buildPlugin(req.ConfigYAML, req.PluginDir)
if errBuild != nil {
return nil, errBuild
}
p, ok := plugin.Capabilities.RequestInterceptor.(*jsHandlerPlugin)
if !ok || p == nil {
return nil, fmt.Errorf("jshandler plugin registration returned invalid interceptor")
}
jsHandlerABIState.Lock()
jsHandlerABIState.plugin = p
jsHandlerABIState.shuttingDown = false
jsHandlerABIState.Unlock()
return abiOKEnvelope(abiRegistration{
SchemaVersion: pluginabi.SchemaVersion,
Metadata: plugin.Metadata,
Capabilities: abiCapabilities{
RequestInterceptor: plugin.Capabilities.RequestInterceptor != nil,
ResponseInterceptor: plugin.Capabilities.ResponseInterceptor != nil,
StreamChunkInterceptor: plugin.Capabilities.StreamChunkInterceptor != nil,
},
})
}
func beginJSHandlerPluginCall() (*jsHandlerPlugin, func(), error) {
jsHandlerABIState.Lock()
defer jsHandlerABIState.Unlock()
if jsHandlerABIState.shuttingDown {
return nil, nil, fmt.Errorf("jshandler plugin is shutting down")
}
if jsHandlerABIState.plugin == nil {
return nil, nil, fmt.Errorf("jshandler plugin is not registered")
}
jsHandlerABIState.inFlight.Add(1)
return jsHandlerABIState.plugin, jsHandlerABIState.inFlight.Done, nil
}
func abiOKEnvelopeWithError(v any, err error) ([]byte, error) {
if err != nil {
return nil, err
}
return abiOKEnvelope(v)
}
func abiOKEnvelope(v any) ([]byte, error) {
raw, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return json.Marshal(abiEnvelope{OK: true, Result: raw})
}
func abiErrorEnvelope(code, message string) []byte {
raw, _ := json.Marshal(abiEnvelope{OK: false, Error: &abiError{Code: code, Message: message}})
return raw
}
func writeABIResponse(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))
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"encoding/json"
"testing"
)
func TestABIRegistrationUsesHostStreamCapabilityField(t *testing.T) {
raw, errMarshal := abiOKEnvelope(abiRegistration{
Capabilities: abiCapabilities{
RequestInterceptor: true,
ResponseInterceptor: true,
StreamChunkInterceptor: true,
},
})
if errMarshal != nil {
t.Fatalf("abiOKEnvelope() error = %v", errMarshal)
}
var envelope abiEnvelope
if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
t.Fatalf("json.Unmarshal(envelope) error = %v", errUnmarshal)
}
var result struct {
Capabilities map[string]bool `json:"capabilities"`
}
if errUnmarshal := json.Unmarshal(envelope.Result, &result); errUnmarshal != nil {
t.Fatalf("json.Unmarshal(result) error = %v", errUnmarshal)
}
if !result.Capabilities["response_stream_interceptor"] {
t.Fatalf("response_stream_interceptor capability was not advertised: %v", result.Capabilities)
}
if _, exists := result.Capabilities["stream_chunk_interceptor"]; exists {
t.Fatalf("legacy stream_chunk_interceptor field should not be advertised: %v", result.Capabilities)
}
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const jsHandlerProvider = "jshandler"
const pluginName = "jshandler"
type jsHandlerConfig struct {
Enabled bool `yaml:"enabled"`
ScriptPaths []string `yaml:"script_paths"`
TimeoutRaw string `yaml:"timeout"`
Timeout time.Duration `yaml:"-"`
}
func defaultJSHandlerConfig() jsHandlerConfig {
return jsHandlerConfig{
Enabled: true,
Timeout: 1 * time.Second,
}
}
func parseJSHandlerConfig(raw []byte) (jsHandlerConfig, error) {
cfg := defaultJSHandlerConfig()
if len(strings.TrimSpace(string(raw))) > 0 {
if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil {
return cfg, fmt.Errorf("invalid jshandler config: %w", errUnmarshal)
}
}
if strings.TrimSpace(cfg.TimeoutRaw) != "" {
parsed, errParse := time.ParseDuration(strings.TrimSpace(cfg.TimeoutRaw))
if errParse != nil || parsed <= 0 {
return cfg, fmt.Errorf("invalid jshandler timeout %q", cfg.TimeoutRaw)
}
cfg.Timeout = parsed
}
if cfg.Timeout <= 0 {
cfg.Timeout = 1 * time.Second
}
return cfg, nil
}
func (cfg *jsHandlerConfig) resolvedScriptPaths(pluginDir string) ([]string, error) {
var paths []string
for _, p := range cfg.ScriptPaths {
p = strings.TrimSpace(p)
if p == "" {
continue
}
originalPath := p
relativePath := !filepath.IsAbs(p)
if !filepath.IsAbs(p) {
if pluginDir == "" {
return nil, fmt.Errorf("relative script path %q requires plugin_dir", originalPath)
}
p = filepath.Join(pluginDir, p)
if !isPathWithinDir(p, pluginDir) {
return nil, fmt.Errorf("relative script path %q escapes plugin_dir", originalPath)
}
}
cleanPath, errClean := filepath.Abs(filepath.Clean(p))
if errClean != nil {
return nil, errClean
}
if relativePath {
resolvedPath, errEval := filepath.EvalSymlinks(cleanPath)
if errEval != nil {
return nil, errEval
}
if !isResolvedPathWithinDir(resolvedPath, pluginDir) {
return nil, fmt.Errorf("relative script path %q escapes plugin_dir through symlink", originalPath)
}
cleanPath = resolvedPath
}
paths = append(paths, cleanPath)
}
return paths, nil
}
func builtinScriptPaths(pluginDir string) []string {
if pluginDir == "" {
return nil
}
scriptsDir := filepath.Join(pluginDir, "scripts")
cleanScriptsDir, errClean := filepath.Abs(filepath.Clean(scriptsDir))
if errClean != nil {
return nil
}
entries, errRead := os.ReadDir(scriptsDir)
if errRead != nil {
return nil
}
var paths []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(strings.ToLower(name), ".js") {
candidate := filepath.Join(cleanScriptsDir, name)
resolved, errEval := filepath.EvalSymlinks(candidate)
if errEval != nil || !isResolvedPathWithinDir(resolved, cleanScriptsDir) {
continue
}
paths = append(paths, resolved)
}
}
return paths
}
func isPathWithinDir(path, dir string) bool {
cleanPath, errPath := filepath.Abs(filepath.Clean(path))
if errPath != nil {
return false
}
cleanDir, errDir := filepath.Abs(filepath.Clean(dir))
if errDir != nil {
return false
}
rel, errRel := filepath.Rel(cleanDir, cleanPath)
if errRel != nil {
return false
}
return rel == "." || (rel != "" && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..")
}
func isResolvedPathWithinDir(path, dir string) bool {
resolvedDir, errEval := filepath.EvalSymlinks(dir)
if errEval != nil {
return false
}
return isPathWithinDir(path, resolvedDir)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolvedScriptPathsRejectsRelativeSymlinkEscapingPluginDir(t *testing.T) {
pluginDir := t.TempDir()
outsideDir := t.TempDir()
outsideScript := filepath.Join(outsideDir, "handler.js")
if errWrite := os.WriteFile(outsideScript, []byte("function on_before_request(ctx) { return ctx; }\n"), 0600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
linkPath := filepath.Join(pluginDir, "handler.js")
if errSymlink := os.Symlink(outsideScript, linkPath); errSymlink != nil {
t.Skipf("os.Symlink() is not available: %v", errSymlink)
}
cfg := jsHandlerConfig{ScriptPaths: []string{"handler.js"}}
_, errResolve := cfg.resolvedScriptPaths(pluginDir)
if errResolve == nil {
t.Fatal("resolvedScriptPaths() expected error for escaping symlink")
}
if !strings.Contains(errResolve.Error(), "escapes plugin_dir") {
t.Fatalf("resolvedScriptPaths() error = %v, want escapes plugin_dir", errResolve)
}
}
func TestResolvedScriptPathsAllowsRelativeSymlinkInsidePluginDir(t *testing.T) {
pluginDir := t.TempDir()
scriptsDir := filepath.Join(pluginDir, "scripts")
if errMkdir := os.Mkdir(scriptsDir, 0700); errMkdir != nil {
t.Fatalf("os.Mkdir() error = %v", errMkdir)
}
realScript := filepath.Join(scriptsDir, "handler.js")
if errWrite := os.WriteFile(realScript, []byte("function on_before_request(ctx) { return ctx; }\n"), 0600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
linkPath := filepath.Join(pluginDir, "handler.js")
if errSymlink := os.Symlink(realScript, linkPath); errSymlink != nil {
t.Skipf("os.Symlink() is not available: %v", errSymlink)
}
cfg := jsHandlerConfig{ScriptPaths: []string{"handler.js"}}
paths, errResolve := cfg.resolvedScriptPaths(pluginDir)
if errResolve != nil {
t.Fatalf("resolvedScriptPaths() error = %v", errResolve)
}
if len(paths) != 1 {
t.Fatalf("resolvedScriptPaths() returned %d paths, want 1", len(paths))
}
resolvedRealScript, errEval := filepath.EvalSymlinks(realScript)
if errEval != nil {
t.Fatalf("filepath.EvalSymlinks() error = %v", errEval)
}
if paths[0] != resolvedRealScript {
t.Fatalf("resolvedScriptPaths()[0] = %q, want %q", paths[0], resolvedRealScript)
}
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/dop251/goja"
log "github.com/sirupsen/logrus"
)
type jsEngine struct {
vm *goja.Runtime
}
const maxJSScriptBytes = 8 * 1024 * 1024
func newJSEngine() *jsEngine {
engine := &jsEngine{
vm: goja.New(),
}
engine.initConsole()
return engine
}
func (engine *jsEngine) initConsole() {
console := engine.vm.NewObject()
consoleLogWrapper := func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
for i, arg := range call.Arguments {
args[i] = arg.Export()
}
log.Info("JS console log: ", fmt.Sprint(args...))
return goja.Undefined()
}
_ = console.Set("log", consoleLogWrapper)
_ = engine.vm.Set("console", console)
}
func (engine *jsEngine) runProgram(program *goja.Program, timeout time.Duration) error {
if program == nil {
return errors.New("program is nil")
}
timer, done := engine.startInterruptTimer(timeout)
defer engine.stopInterruptTimer(timer, done)
_, err := engine.vm.RunProgram(program)
if err != nil {
return fmt.Errorf("failed to run JS program: %w", err)
}
return nil
}
var ErrFunctionNotFound = errors.New("function not found")
var errJSTimeout = errors.New("javascript execution timeout")
func (engine *jsEngine) startInterruptTimer(timeout time.Duration) (*time.Timer, <-chan struct{}) {
done := make(chan struct{})
timer := time.AfterFunc(timeout, func() {
defer close(done)
engine.vm.Interrupt(errJSTimeout)
})
return timer, done
}
func (engine *jsEngine) stopInterruptTimer(timer *time.Timer, done <-chan struct{}) {
if timer == nil {
return
}
if timer.Stop() {
return
}
<-done
engine.vm.ClearInterrupt()
}
func (engine *jsEngine) frozenStringArray(values []string) (goja.Value, error) {
items := make([]interface{}, len(values))
for i, value := range values {
items[i] = value
}
array := engine.vm.NewArray(items...)
objectValue := engine.vm.Get("Object")
if objectValue == nil || goja.IsUndefined(objectValue) {
return nil, errors.New("Object constructor is unavailable")
}
freezeValue := objectValue.ToObject(engine.vm).Get("freeze")
freezeFunc, ok := goja.AssertFunction(freezeValue)
if !ok {
return nil, errors.New("Object.freeze is unavailable")
}
if _, errFreeze := freezeFunc(goja.Undefined(), array); errFreeze != nil {
return nil, errFreeze
}
return array, nil
}
func (engine *jsEngine) callFunction(name string, timeout time.Duration, args ...interface{}) (goja.Value, error) {
jsVal := engine.vm.Get(name)
if jsVal == nil || goja.IsUndefined(jsVal) {
return nil, fmt.Errorf("%w: function '%s' does not exist", ErrFunctionNotFound, name)
}
jsFunc, ok := goja.AssertFunction(jsVal)
if !ok {
return nil, fmt.Errorf("function '%s' is invalid", name)
}
jsArgs := make([]goja.Value, len(args))
for i, arg := range args {
jsArgs[i] = engine.vm.ToValue(arg)
}
timer, done := engine.startInterruptTimer(timeout)
defer engine.stopInterruptTimer(timer, done)
result, err := jsFunc(goja.Undefined(), jsArgs...)
if err != nil {
return nil, err
}
return result, nil
}
type jsCachedProgram struct {
program *goja.Program
modTime time.Time
}
var (
jsProgramsMU sync.RWMutex
jsProgramsCache = make(map[string]jsCachedProgram)
)
func getJSProgram(path string) (*goja.Program, error) {
cleanPath, errClean := filepath.Abs(filepath.Clean(path))
if errClean != nil {
return nil, errClean
}
resolvedPath, errEval := filepath.EvalSymlinks(cleanPath)
if errEval != nil {
return nil, errEval
}
info, err := os.Stat(resolvedPath)
if err != nil {
return nil, err
}
if info.Size() > maxJSScriptBytes {
return nil, fmt.Errorf("JS script %s is too large: %d bytes", resolvedPath, info.Size())
}
modTime := info.ModTime()
jsProgramsMU.RLock()
cached, exists := jsProgramsCache[resolvedPath]
jsProgramsMU.RUnlock()
if exists && cached.modTime.Equal(modTime) {
return cached.program, nil
}
data, errRead := os.ReadFile(resolvedPath)
if errRead != nil {
return nil, errRead
}
compiled, errCompile := goja.Compile(resolvedPath, string(data), false)
if errCompile != nil {
return nil, fmt.Errorf("failed to compile JS script %s: %w", resolvedPath, errCompile)
}
jsProgramsMU.Lock()
defer jsProgramsMU.Unlock()
if cached, exists = jsProgramsCache[resolvedPath]; exists && cached.modTime.Equal(modTime) {
return cached.program, nil
}
jsProgramsCache[resolvedPath] = jsCachedProgram{
program: compiled,
modTime: modTime,
}
return compiled, nil
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"testing"
"time"
)
func TestStopInterruptTimerClearsExpiredInterrupt(t *testing.T) {
engine := newJSEngine()
timer, done := engine.startInterruptTimer(time.Nanosecond)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("interrupt timer did not fire")
}
engine.stopInterruptTimer(timer, done)
value, errRun := engine.vm.RunString("1 + 1")
if errRun != nil {
t.Fatalf("RunString() error after clearing interrupt = %v", errRun)
}
if got := value.ToInteger(); got != 2 {
t.Fatalf("RunString() = %d, want 2", got)
}
}
+18
View File
@@ -0,0 +1,18 @@
module github.com/router-for-me/CLIProxyAPIPlugins/jshandler
go 1.26.0
require (
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/router-for-me/CLIProxyAPI/v7 v7.1.55
github.com/sirupsen/logrus v1.9.4
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/router-for-me/CLIProxyAPI/v7 v7.1.55 h1:gaZc8W025JV/CpTBpFH16af8yenC/IYuK/nBa+Age4k=
github.com/router-for-me/CLIProxyAPI/v7 v7.1.55/go.mod h1:5LQLwZuB03QHP2jsRo4Kl7pJBgsu9w0O/v1F6Ze+d4U=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+477
View File
@@ -0,0 +1,477 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/dop251/goja"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type jsHandlerPlugin struct {
cfg jsHandlerConfig
configYAML []byte
pluginDir string
}
type processedHeaders struct {
headers http.Header
clearHeaders []string
}
var _ pluginapi.RequestInterceptor = (*jsHandlerPlugin)(nil)
var _ pluginapi.ResponseInterceptor = (*jsHandlerPlugin)(nil)
var _ pluginapi.StreamChunkInterceptor = (*jsHandlerPlugin)(nil)
func (p *jsHandlerPlugin) Identifier() string {
return jsHandlerProvider
}
func (p *jsHandlerPlugin) allScriptPaths() []string {
paths := builtinScriptPaths(p.pluginDir)
configuredPaths, errPaths := p.cfg.resolvedScriptPaths(p.pluginDir)
if errPaths != nil {
log.Warnf("failed to resolve JS handler script paths: %v", errPaths)
return paths
}
paths = append(paths, configuredPaths...)
return paths
}
func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
resp := pluginapi.RequestInterceptResponse{}
scriptPaths := p.allScriptPaths()
if len(scriptPaths) == 0 {
return resp, nil
}
body := string(req.Body)
headers := cloneHeader(req.Headers)
var clearHeaders []string
for _, scriptPath := range scriptPaths {
scriptPath = strings.TrimSpace(scriptPath)
if scriptPath == "" {
continue
}
processed, cleared, errJS := p.applyJSBeforeRequest(scriptPath, []byte(body), req.Model, req.SourceFormat, headers)
if errJS != nil {
log.Warnf("failed to execute JS request interceptor [%s]: %v", scriptPath, errJS)
continue
}
body = string(processed)
clearHeaders = append(clearHeaders, cleared...)
}
if len(body) > 0 {
resp.Body = []byte(body)
}
resp.Headers = headers
resp.ClearHeaders = dedupeStrings(clearHeaders)
return resp, nil
}
func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {
resp := pluginapi.ResponseInterceptResponse{}
scriptPaths := p.allScriptPaths()
if len(scriptPaths) == 0 {
return resp, nil
}
bodyStr := string(req.Body)
reqHeadersMap := headerToAnyMap(req.RequestHeaders)
respHeaders := cloneHeader(req.ResponseHeaders)
var clearHeaders []string
for _, scriptPath := range scriptPaths {
scriptPath = strings.TrimSpace(scriptPath)
if scriptPath == "" {
continue
}
processedBody, processedHeaders, bodyModified, errJS := p.applyJSAfterResponse(
scriptPath, req.Model, req.SourceFormat,
reqHeadersMap, req.RequestBody,
bodyStr, nil, respHeaders, false, nil,
)
if errJS != nil {
log.Warnf("failed to execute JS response interceptor [%s]: %v", scriptPath, errJS)
continue
}
if bodyModified {
bodyStr = processedBody
}
if processedHeaders != nil {
respHeaders = processedHeaders.headers
clearHeaders = append(clearHeaders, processedHeaders.clearHeaders...)
}
}
if len(bodyStr) > 0 {
resp.Body = []byte(bodyStr)
}
resp.Headers = respHeaders
resp.ClearHeaders = dedupeStrings(clearHeaders)
return resp, nil
}
func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {
resp := pluginapi.StreamChunkInterceptResponse{}
scriptPaths := p.allScriptPaths()
if len(scriptPaths) == 0 {
return resp, nil
}
reqHeadersMap := headerToAnyMap(req.RequestHeaders)
respHeaders := cloneHeader(req.ResponseHeaders)
var clearHeaders []string
historyStrings := make([]string, 0, len(req.HistoryChunks))
for _, hc := range req.HistoryChunks {
historyStrings = append(historyStrings, string(hc))
}
isHeaderInit := req.ChunkIndex == pluginapi.StreamChunkHeaderInitIndex
chunkStr := ""
if !isHeaderInit && len(req.Body) > 0 {
chunkStr = string(req.Body)
}
var chunkPtr *string
chunkModified := false
if !isHeaderInit {
chunkPtr = &chunkStr
}
for _, scriptPath := range scriptPaths {
scriptPath = strings.TrimSpace(scriptPath)
if scriptPath == "" {
continue
}
processedBody, processedHeaders, chunkChanged, errJS := p.applyJSAfterResponse(
scriptPath, req.Model, req.SourceFormat,
reqHeadersMap, req.RequestBody,
"", chunkPtr, respHeaders, !isHeaderInit, historyStrings,
)
if errJS != nil {
log.Warnf("failed to execute JS stream chunk interceptor [%s]: %v", scriptPath, errJS)
continue
}
if processedHeaders != nil {
respHeaders = processedHeaders.headers
clearHeaders = append(clearHeaders, processedHeaders.clearHeaders...)
}
if chunkPtr != nil && chunkChanged {
*chunkPtr = processedBody
chunkModified = true
}
}
resp.Headers = respHeaders
resp.ClearHeaders = dedupeStrings(clearHeaders)
if chunkPtr != nil && *chunkPtr != "" {
resp.Body = []byte(*chunkPtr)
} else if isHeaderInit {
// header-only init, no body to return
} else if chunkModified || len(req.Body) == 0 {
resp.DropChunk = true
}
return resp, nil
}
func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes []byte, model, protocol string, headers http.Header) ([]byte, []string, error) {
program, err := getJSProgram(scriptPath)
if err != nil {
return nil, nil, err
}
engine := newJSEngine()
if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {
return nil, nil, errRun
}
headersMap := headerToAnyMap(headers)
jsCtx := map[string]any{
"id": generateRequestID(),
"body": string(payloadBytes),
"headers": headersMap,
"url": "",
"model": model,
"protocol": protocol,
}
jsVal, errCall := engine.callFunction("on_before_request", p.cfg.Timeout, jsCtx)
if errCall != nil {
if errors.Is(errCall, ErrFunctionNotFound) {
return payloadBytes, nil, nil
}
return nil, nil, fmt.Errorf("on_before_request failed for %s: %w", scriptPath, errCall)
}
if jsVal == nil || goja.IsUndefined(jsVal) || goja.IsNull(jsVal) {
return payloadBytes, nil, nil
}
exported := jsVal.Export()
if exported == nil {
return payloadBytes, nil, nil
}
var clearHeaders []string
if objMap, ok := exported.(map[string]any); ok {
if headersVal, exists := objMap["headers"]; exists {
clearHeaders = append(clearHeaders, updateHeaderFromAny(headers, headersVal)...)
}
if bodyVal, exists := objMap["body"]; exists {
if bodyStr, okStr := bodyVal.(string); okStr {
return []byte(bodyStr), clearHeaders, nil
}
}
}
if bodyStr, ok := exported.(string); ok {
return []byte(bodyStr), clearHeaders, nil
}
return payloadBytes, clearHeaders, nil
}
func (p *jsHandlerPlugin) applyJSAfterResponse(
scriptPath, model, protocol string,
reqHeadersMap map[string]any, reqBody []byte,
bodyStr string, chunkStr *string,
respHeaders http.Header, isStream bool, historyChunks []string,
) (string, *processedHeaders, bool, error) {
program, err := getJSProgram(scriptPath)
if err != nil {
return bodyStr, nil, false, err
}
engine := newJSEngine()
if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {
return bodyStr, nil, false, errRun
}
var bodyVal any = bodyStr
if isStream {
bodyVal = nil
}
reqCtx := engine.vm.NewObject()
if errSet := reqCtx.Set("body", string(reqBody)); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := reqCtx.Set("headers", reqHeadersMap); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := reqCtx.Set("url", ""); errSet != nil {
return bodyStr, nil, false, errSet
}
jsCtx := engine.vm.NewObject()
if errSet := jsCtx.Set("id", generateRequestID()); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := jsCtx.Set("body", bodyVal); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := jsCtx.Set("req", reqCtx); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := jsCtx.Set("protocol", protocol); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := jsCtx.Set("headers", headerToAnyMap(respHeaders)); errSet != nil {
return bodyStr, nil, false, errSet
}
if isStream {
if chunkStr != nil {
if errSet := jsCtx.Set("chunk", *chunkStr); errSet != nil {
return bodyStr, nil, false, errSet
}
} else {
if errSet := jsCtx.Set("chunk", ""); errSet != nil {
return bodyStr, nil, false, errSet
}
}
historyChunksValue, errHistory := engine.frozenStringArray(historyChunks)
if errHistory != nil {
return bodyStr, nil, false, fmt.Errorf("failed to freeze history_chunks: %w", errHistory)
}
if errDefine := jsCtx.DefineDataProperty("history_chunks", historyChunksValue, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE); errDefine != nil {
return bodyStr, nil, false, fmt.Errorf("failed to define history_chunks: %w", errDefine)
}
} else {
if errSet := jsCtx.Set("chunk", nil); errSet != nil {
return bodyStr, nil, false, errSet
}
if errSet := jsCtx.Set("history_chunks", nil); errSet != nil {
return bodyStr, nil, false, errSet
}
}
hookName := "on_after_nonstream_response"
if isStream {
hookName = "on_after_stream_response"
}
jsVal, errCall := engine.callFunction(hookName, p.cfg.Timeout, jsCtx)
if errCall != nil {
if errors.Is(errCall, ErrFunctionNotFound) {
return bodyStr, nil, false, nil
}
return bodyStr, nil, false, fmt.Errorf("%s failed for %s: %w", hookName, scriptPath, errCall)
}
if jsVal == nil || goja.IsUndefined(jsVal) || goja.IsNull(jsVal) {
return bodyStr, nil, false, nil
}
exported := jsVal.Export()
if exported == nil {
return bodyStr, nil, false, nil
}
var headersResult *processedHeaders
if objMap, ok := exported.(map[string]any); ok {
if headersVal, exists := objMap["headers"]; exists {
cleared := updateHeaderFromAny(respHeaders, headersVal)
headersResult = &processedHeaders{headers: respHeaders, clearHeaders: cleared}
}
if !isStream {
if bodyVal, exists := objMap["body"]; exists {
if bStr, okStr := bodyVal.(string); okStr {
return bStr, headersResult, true, nil
}
}
} else {
if chunkVal, exists := objMap["chunk"]; exists {
if cStr, okStr := chunkVal.(string); okStr {
return cStr, headersResult, true, nil
}
}
}
}
if strVal, ok := exported.(string); ok {
return strVal, headersResult, true, nil
}
return bodyStr, headersResult, false, nil
}
func headerToAnyMap(h http.Header) map[string]any {
m := make(map[string]any)
if h == nil {
return m
}
for k, v := range h {
switch len(v) {
case 0:
continue
case 1:
m[k] = v[0]
default:
m[k] = append([]string(nil), v...)
}
}
return m
}
func updateHeaderFromAny(h http.Header, val interface{}) []string {
var clearHeaders []string
if h == nil || val == nil {
return clearHeaders
}
rv := reflect.ValueOf(val)
if rv.Kind() != reflect.Map {
return clearHeaders
}
for _, key := range rv.MapKeys() {
kStr := key.String()
vVal := rv.MapIndex(key).Interface()
if vVal == nil {
h.Del(kStr)
clearHeaders = append(clearHeaders, kStr)
} else if valStr, ok := vVal.(string); ok {
h.Set(kStr, valStr)
} else {
values, okValues := stringSliceFromAny(vVal)
if !okValues {
h.Set(kStr, fmt.Sprintf("%v", vVal))
continue
}
if len(values) == 0 {
h.Del(kStr)
clearHeaders = append(clearHeaders, kStr)
} else {
h[http.CanonicalHeaderKey(kStr)] = values
}
}
}
return clearHeaders
}
func cloneHeader(h http.Header) http.Header {
cloned := make(http.Header, len(h))
for key, values := range h {
cloned[key] = append([]string(nil), values...)
}
return cloned
}
func stringSliceFromAny(val any) ([]string, bool) {
switch typed := val.(type) {
case []string:
return append([]string(nil), typed...), true
case []any:
values := make([]string, 0, len(typed))
for _, item := range typed {
itemStr, okItem := item.(string)
if !okItem {
return nil, false
}
values = append(values, itemStr)
}
return values, true
}
rv := reflect.ValueOf(val)
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil, false
}
values := make([]string, 0, rv.Len())
for i := 0; i < rv.Len(); i++ {
item, okItem := rv.Index(i).Interface().(string)
if !okItem {
return nil, false
}
values = append(values, item)
}
return values, true
}
func dedupeStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
deduped := make([]string, 0, len(values))
for _, value := range values {
canonical := http.CanonicalHeaderKey(value)
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
deduped = append(deduped, canonical)
}
return deduped
}
func generateRequestID() string {
return fmt.Sprintf("%s-%x", time.Now().Format("20060102150405"), time.Now().UnixNano()&0xffffffff)
}
@@ -0,0 +1,100 @@
package main
import (
"net/http"
"os"
"path/filepath"
"testing"
)
func TestApplyJSAfterResponseUsesFrozenNativeHistoryChunks(t *testing.T) {
scriptPath := filepath.Join(t.TempDir(), "stream.js")
script := `
function on_after_stream_response(ctx) {
if (!Object.isFrozen(ctx.history_chunks)) {
throw new Error("history_chunks is not frozen");
}
var original = ctx.history_chunks[0];
try {
ctx.history_chunks[0] = "changed";
} catch (e) {
}
if (ctx.history_chunks[0] !== original) {
throw new Error("history_chunks item was changed");
}
try {
ctx.history_chunks = ["changed"];
} catch (e) {
}
if (ctx.history_chunks[0] !== original) {
throw new Error("history_chunks property was replaced");
}
return { chunk: ctx.chunk + "|ok" };
}
`
if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()}
chunk := `data: {"choices":[{"delta":{},"finish_reason":null}]}`
processedBody, _, changed, errApply := plugin.applyJSAfterResponse(
scriptPath,
"gpt-test",
"openai",
nil,
nil,
"",
&chunk,
http.Header{},
true,
[]string{`data: {"choices":[{"delta":{"tool_calls":[{"index":0}]}}]}`},
)
if errApply != nil {
t.Fatalf("applyJSAfterResponse() error = %v", errApply)
}
if !changed {
t.Fatal("applyJSAfterResponse() changed = false, want true")
}
if processedBody != chunk+"|ok" {
t.Fatalf("applyJSAfterResponse() body = %q, want %q", processedBody, chunk+"|ok")
}
}
func TestApplyJSAfterResponseDispatchesNonStreamHook(t *testing.T) {
scriptPath := filepath.Join(t.TempDir(), "nonstream.js")
script := `
function on_after_stream_response(ctx) {
throw new Error("stream hook should not run");
}
function on_after_nonstream_response(ctx) {
return { body: ctx.body + "|nonstream" };
}
`
if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil {
t.Fatalf("os.WriteFile() error = %v", errWrite)
}
plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()}
processedBody, _, changed, errApply := plugin.applyJSAfterResponse(
scriptPath,
"gpt-test",
"openai",
nil,
nil,
`{"ok":true}`,
nil,
http.Header{},
false,
nil,
)
if errApply != nil {
t.Fatalf("applyJSAfterResponse() error = %v", errApply)
}
if !changed {
t.Fatal("applyJSAfterResponse() changed = false, want true")
}
if processedBody != `{"ok":true}|nonstream` {
t.Fatalf("applyJSAfterResponse() body = %q", processedBody)
}
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func buildPlugin(configYAML []byte, pluginDir string) (pluginapi.Plugin, error) {
cfg, errParse := parseJSHandlerConfig(configYAML)
if errParse != nil {
return pluginapi.Plugin{}, errParse
}
if pluginDir == "" {
pluginDir = inferPluginDir()
}
p := &jsHandlerPlugin{
cfg: cfg,
configYAML: append([]byte(nil), configYAML...),
pluginDir: pluginDir,
}
return pluginapi.Plugin{
Metadata: pluginapi.Metadata{
Name: pluginName,
Version: "0.1.0",
Author: "router-for-me",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
ConfigFields: []pluginapi.ConfigField{
{
Name: "enabled",
Type: pluginapi.ConfigFieldTypeBoolean,
Description: "Enable or disable the JS handler plugin.",
},
{
Name: "script_paths",
Type: pluginapi.ConfigFieldTypeArray,
Description: "List of JS script file paths to load (absolute or relative to plugin directory).",
},
{
Name: "timeout",
Type: pluginapi.ConfigFieldTypeString,
Description: "Execution timeout per JS hook call as a Go duration, such as 1s.",
},
},
},
Capabilities: pluginapi.Capabilities{
RequestInterceptor: p,
ResponseInterceptor: p,
StreamChunkInterceptor: p,
},
}, nil
}
@@ -0,0 +1,116 @@
function on_before_request(ctx) {
try {
var req = JSON.parse(ctx.body);
console.log("[" + ctx.id + "] message: " + ctx.body);
if (req.messages) {
for (var i = 0; i < req.messages.length; i++) {
if (typeof req.messages[i].content === "string") {
req.messages[i].content = req.messages[i].content.replace("sensitive_word", "safe_word");
}
}
}
ctx.body = JSON.stringify(req);
console.log("[" + ctx.id + "] message: " + ctx.body);
} catch (e) {
console.log("[" + ctx.id + "] Failed to parse request JSON, skipping payload modification: " + e.message);
}
return ctx;
}
function parse_stream_chunk(chunk) {
var leading = "";
var payload = chunk.trim();
var trailing = "";
var dataIndex = chunk.indexOf("data:");
if (dataIndex >= 0) {
leading = chunk.substring(0, dataIndex) + "data:";
var afterData = chunk.substring(dataIndex + 5);
var newlineIndex = afterData.indexOf("\n");
if (newlineIndex >= 0) {
payload = afterData.substring(0, newlineIndex).trim();
trailing = afterData.substring(newlineIndex);
} else {
payload = afterData.trim();
}
}
if (payload === "" || payload === "[DONE]") {
return null;
}
return {
obj: JSON.parse(payload),
leading: leading,
trailing: trailing
};
}
function stringify_stream_chunk(parsed) {
if (parsed.leading !== "") {
return parsed.leading + " " + JSON.stringify(parsed.obj) + parsed.trailing;
}
return JSON.stringify(parsed.obj);
}
function on_after_stream_response(ctx) {
console.log("[" + ctx.id + "] Received response with status: " + ctx.status);
if (ctx.chunk === undefined || ctx.chunk === null || ctx.chunk === "") {
return ctx;
}
try {
var parsed = parse_stream_chunk(ctx.chunk);
if (parsed === null) {
return ctx;
}
var obj = parsed.obj;
if (obj.choices && obj.choices.length > 0) {
var choice = obj.choices[0];
var has_tool_calls = choice.delta && choice.delta.tool_calls && choice.delta.tool_calls.length > 0;
if (has_tool_calls) {
if (choice.finish_reason !== null) {
console.log("[" + ctx.id + "] Tool call chunk has finish_reason = [" + choice.finish_reason + "], forcing reset to null, tool index: " + choice.delta.tool_calls[0].index);
choice.finish_reason = null;
ctx.chunk = stringify_stream_chunk(parsed);
}
} else {
var history_had_tool_calls = false;
if (ctx.history_chunks && ctx.history_chunks.length > 0) {
for (var i = 0; i < ctx.history_chunks.length; i++) {
try {
var h_parsed = parse_stream_chunk(ctx.history_chunks[i]);
if (h_parsed === null) {
continue;
}
var hist_obj = h_parsed.obj;
if (hist_obj.choices && hist_obj.choices.length > 0) {
var h_choice = hist_obj.choices[0];
if (h_choice.delta && h_choice.delta.tool_calls && h_choice.delta.tool_calls.length > 0) {
history_had_tool_calls = true;
break;
}
}
} catch (err) {
}
}
}
if (history_had_tool_calls && choice.finish_reason !== null && choice.finish_reason !== "tool_calls") {
console.log("[" + ctx.id + "] Detected history contains tool calls, modifying finish_reason from [" + choice.finish_reason + "] to [tool_calls]");
choice.finish_reason = "tool_calls";
ctx.chunk = stringify_stream_chunk(parsed);
}
}
}
} catch (e) {
console.log("[" + ctx.id + "] Failed to parse streaming response JSON chunk: " + e.message + " | chunk content: " + ctx.chunk);
}
return ctx;
}
function on_after_nonstream_response(ctx) {
console.log("[" + ctx.id + "] Received non-streaming response. Response content: " + ctx.body);
return ctx;
}