mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user