diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 416cac09..adaa3b86 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,6 +40,10 @@ jobs: - `CLIProxyAPI__linux_.tar.gz` is the default Linux build. It supports dynamic library plugins and is built against a GLIBC 2.17 baseline. - `CLIProxyAPI__linux__no-plugin.tar.gz` is the portable Linux build for musl-based or older systems such as OpenWrt. It does not support dynamic library plugins. + ## FreeBSD release assets + + - `CLIProxyAPI__freebsd_aarch64_no-plugin.tar.gz` is the FreeBSD arm64 build. It is built without CGO and does not support dynamic library plugins. + EOF @@ -489,21 +493,28 @@ jobs: gh release upload "$GITHUB_REF_NAME" "$tmp_dir/checksums.txt" --clobber build-freebsd: - name: build freebsd-${{ matrix.goarch }} + name: build ${{ matrix.target }} needs: prepare-release runs-on: ubuntu-latest env: - TARGET: freebsd-${{ matrix.goarch }} + TARGET: ${{ matrix.target }} GOARCH: ${{ matrix.goarch }} ASSET_ARCH: ${{ matrix.asset_arch }} + ASSET_SUFFIX: ${{ matrix.asset_suffix }} strategy: fail-fast: false matrix: include: - - goarch: amd64 + - target: freebsd-amd64 + goarch: amd64 asset_arch: amd64 - - goarch: arm64 + asset_suffix: '' + cgo_enabled: true + - target: freebsd-arm64-no-plugin + goarch: arm64 asset_arch: aarch64 + asset_suffix: _no-plugin + cgo_enabled: false steps: - uses: actions/checkout@v6 with: @@ -540,13 +551,19 @@ jobs: echo "release_version=$release_version" >> "$GITHUB_OUTPUT" echo "commit=$commit" >> "$GITHUB_OUTPUT" echo "build_date=$build_date" >> "$GITHUB_OUTPUT" - - name: Install FreeBSD cross-build dependencies + - name: Prepare FreeBSD output + shell: bash run: | set -euo pipefail rm -rf "dist/${TARGET}" + - name: Install FreeBSD cross-build dependencies + if: ${{ matrix.cgo_enabled }} + run: | + set -euo pipefail sudo apt-get update sudo apt-get install -y clang lld wget - - name: Build FreeBSD binary + - name: Build FreeBSD binary with CGO + if: ${{ matrix.cgo_enabled }} timeout-minutes: 45 uses: go-cross/cgo-actions@v1 with: @@ -560,13 +577,22 @@ jobs: -X main.Version=${{ steps.metadata.outputs.release_version }} -X main.Commit=${{ steps.metadata.outputs.commit }} -X main.BuildDate=${{ steps.metadata.outputs.build_date }} + - name: Build FreeBSD no-plugin binary + if: ${{ !matrix.cgo_enabled }} + shell: bash + run: | + set -euo pipefail + mkdir -p "dist/${TARGET}/bin" + CGO_ENABLED=0 GOOS=freebsd GOARCH="$GOARCH" go build -buildvcs=false \ + -ldflags="-s -w -X main.Version=${RELEASE_VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \ + -o "dist/${TARGET}/bin/cli-proxy-api" ./cmd/server/ - name: Package FreeBSD archive shell: bash run: | set -euo pipefail archive_dir="dist/${TARGET}/archive" - archive_name="CLIProxyAPI_${RELEASE_VERSION}_freebsd_${ASSET_ARCH}.tar.gz" + archive_name="CLIProxyAPI_${RELEASE_VERSION}_freebsd_${ASSET_ARCH}${ASSET_SUFFIX}.tar.gz" mkdir -p "$archive_dir" echo "Packaging ${archive_name}" @@ -575,7 +601,7 @@ jobs: tar -C "$archive_dir" -czf "dist/$archive_name" cli-proxy-api LICENSE README.md README_CN.md config.example.yaml - uses: actions/upload-artifact@v4 with: - name: freebsd-${{ matrix.goarch }} + name: ${{ matrix.target }} path: dist/CLIProxyAPI_* if-no-files-found: error - name: Upload release assets diff --git a/examples/plugin/jshandler/Makefile b/examples/plugin/jshandler/Makefile deleted file mode 100644 index f1db3a3c..00000000 --- a/examples/plugin/jshandler/Makefile +++ /dev/null @@ -1,23 +0,0 @@ -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) diff --git a/examples/plugin/jshandler/README.md b/examples/plugin/jshandler/README.md deleted file mode 100644 index e69264da..00000000 --- a/examples/plugin/jshandler/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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`, `on_after_auth_request`): Modify request payloads and headers before and after credential selection. -- **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 credential selection. At this point the target upstream protocol is not selected yet. - -**ctx structure:** -```javascript -{ - "id": "request-id", - "body": "...", // Request body string - "headers": {}, // Request headers - "url": "", - "model": "gpt-4", - "protocol": "openai", - "source_format": "openai", - "sourceFormat": "openai", - "to_format": "", - "toFormat": "" -} -``` - -### `on_after_auth_request(ctx)` - -Called after credential selection and before request translation, request normalization, and built-in payload configuration. - -**ctx structure:** -```javascript -{ - "id": "request-id", - "body": "...", // Request body string - "headers": {}, // Request headers - "url": "", - "model": "gpt-4", - "protocol": "openai", // Same as source_format - "source_format": "openai", - "sourceFormat": "openai", - "to_format": "codex", - "toFormat": "codex" -} -``` - -### `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 -``` diff --git a/examples/plugin/jshandler/abi.go b/examples/plugin/jshandler/abi.go deleted file mode 100644 index 39f506a3..00000000 --- a/examples/plugin/jshandler/abi.go +++ /dev/null @@ -1,402 +0,0 @@ -package main - -/* -#define _GNU_SOURCE -#include -#include -#include - -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 abiRequestInterceptRequest struct { - pluginapi.RequestInterceptRequest - HostCallbackID string `json:"host_callback_id,omitempty"` -} - -type abiResponseInterceptRequest struct { - pluginapi.ResponseInterceptRequest - HostCallbackID string `json:"host_callback_id,omitempty"` -} - -type abiStreamChunkInterceptRequest struct { - pluginapi.StreamChunkInterceptRequest - HostCallbackID string `json:"host_callback_id,omitempty"` -} - -type abiHostLogRequest struct { - HostCallbackID string `json:"host_callback_id,omitempty"` - Level string `json:"level,omitempty"` - Message string `json:"message,omitempty"` - Fields map[string]any `json:"fields,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 abiRequestInterceptRequest - if errDecode := json.Unmarshal(request, &req); errDecode != nil { - return nil, errDecode - } - resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, "on_before_request", req.HostCallbackID) - return abiOKEnvelopeWithError(resp, errCall) - case pluginabi.MethodRequestInterceptAfter: - var req abiRequestInterceptRequest - if errDecode := json.Unmarshal(request, &req); errDecode != nil { - return nil, errDecode - } - resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, "on_after_auth_request", req.HostCallbackID) - return abiOKEnvelopeWithError(resp, errCall) - case pluginabi.MethodResponseInterceptAfter: - var req abiResponseInterceptRequest - if errDecode := json.Unmarshal(request, &req); errDecode != nil { - return nil, errDecode - } - resp, errCall := p.interceptResponse(ctx, req.ResponseInterceptRequest, req.HostCallbackID) - return abiOKEnvelopeWithError(resp, errCall) - case pluginabi.MethodResponseInterceptStreamChunk: - var req abiStreamChunkInterceptRequest - if errDecode := json.Unmarshal(request, &req); errDecode != nil { - return nil, errDecode - } - resp, errCall := p.interceptStreamChunk(ctx, req.StreamChunkInterceptRequest, req.HostCallbackID) - 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)) -} - -func newHostJSConsoleLogger(hostCallbackID string) jsConsoleLogger { - return func(message string) error { - if errLog := writeHostJSConsoleLog(hostCallbackID, message); errLog != nil { - return defaultJSConsoleLogger(message) - } - return nil - } -} - -func writeHostJSConsoleLog(hostCallbackID string, message string) error { - raw, errMarshal := json.Marshal(abiHostLogRequest{ - HostCallbackID: hostCallbackID, - Level: "info", - Message: "JS console log: " + message, - Fields: map[string]any{ - "plugin_id": pluginName, - }, - }) - if errMarshal != nil { - return errMarshal - } - - rawResp, errCall := callHost(pluginabi.MethodHostLog, raw) - if errCall != nil { - return errCall - } - if len(rawResp) == 0 { - return nil - } - var resp abiEnvelope - if errDecode := json.Unmarshal(rawResp, &resp); errDecode != nil { - return fmt.Errorf("decode host log response: %w", errDecode) - } - if !resp.OK { - if resp.Error != nil { - return fmt.Errorf("host log failed: %s", resp.Error.Message) - } - return fmt.Errorf("host log failed") - } - return nil -} - -func callHost(method string, payload []byte) ([]byte, error) { - jsHandlerABIState.RLock() - defer jsHandlerABIState.RUnlock() - if jsHandlerABIState.host == nil { - return nil, fmt.Errorf("host callback is unavailable") - } - - cMethod := C.CString(method) - defer C.free(unsafe.Pointer(cMethod)) - - var cPayload unsafe.Pointer - if len(payload) > 0 { - cPayload = C.CBytes(payload) - if cPayload == nil { - return nil, fmt.Errorf("allocate host callback payload") - } - defer C.free(cPayload) - } - - var response C.cliproxy_buffer - rc := C.jshandler_call_host( - jsHandlerABIState.host, - cMethod, - (*C.uint8_t)(cPayload), - C.size_t(len(payload)), - &response, - ) - var out []byte - if response.ptr != nil && response.len > 0 { - out = C.GoBytes(response.ptr, C.int(response.len)) - } - if response.ptr != nil { - C.jshandler_free_host_buffer(jsHandlerABIState.host, response.ptr, response.len) - } - if rc != 0 { - return nil, fmt.Errorf("host callback %s returned %d: %s", method, int(rc), string(out)) - } - return out, nil -} diff --git a/examples/plugin/jshandler/abi_test.go b/examples/plugin/jshandler/abi_test.go deleted file mode 100644 index c46eb1f5..00000000 --- a/examples/plugin/jshandler/abi_test.go +++ /dev/null @@ -1,36 +0,0 @@ -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) - } -} diff --git a/examples/plugin/jshandler/config.go b/examples/plugin/jshandler/config.go deleted file mode 100644 index 9a6c24f2..00000000 --- a/examples/plugin/jshandler/config.go +++ /dev/null @@ -1,140 +0,0 @@ -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) -} diff --git a/examples/plugin/jshandler/config_test.go b/examples/plugin/jshandler/config_test.go deleted file mode 100644 index 8ff3abb1..00000000 --- a/examples/plugin/jshandler/config_test.go +++ /dev/null @@ -1,64 +0,0 @@ -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) - } -} diff --git a/examples/plugin/jshandler/engine.go b/examples/plugin/jshandler/engine.go deleted file mode 100644 index 5f076cd1..00000000 --- a/examples/plugin/jshandler/engine.go +++ /dev/null @@ -1,200 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/dop251/goja" - log "github.com/sirupsen/logrus" -) - -type jsEngine struct { - vm *goja.Runtime - consoleLogger jsConsoleLogger -} - -const maxJSScriptBytes = 8 * 1024 * 1024 - -type jsConsoleLogger func(message string) error - -func newJSEngine(loggers ...jsConsoleLogger) *jsEngine { - consoleLogger := defaultJSConsoleLogger - if len(loggers) > 0 && loggers[0] != nil { - consoleLogger = loggers[0] - } - engine := &jsEngine{ - vm: goja.New(), - consoleLogger: consoleLogger, - } - engine.initConsole() - return engine -} - -func defaultJSConsoleLogger(message string) error { - log.Info("JS console log: ", message) - return nil -} - -func (engine *jsEngine) initConsole() { - console := engine.vm.NewObject() - consoleLogWrapper := func(call goja.FunctionCall) goja.Value { - args := make([]string, len(call.Arguments)) - for i, arg := range call.Arguments { - args[i] = fmt.Sprint(arg.Export()) - } - message := strings.Join(args, " ") - if errLog := engine.consoleLogger(message); errLog != nil { - defaultJSConsoleLogger(message) - } - 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 -} diff --git a/examples/plugin/jshandler/engine_test.go b/examples/plugin/jshandler/engine_test.go deleted file mode 100644 index 45c5f8d3..00000000 --- a/examples/plugin/jshandler/engine_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "bytes" - "strings" - "testing" - "time" - - log "github.com/sirupsen/logrus" -) - -func TestConsoleLogWritesToLogger(t *testing.T) { - var out bytes.Buffer - logger := log.StandardLogger() - originalOut := logger.Out - originalFormatter := logger.Formatter - originalLevel := logger.Level - log.SetOutput(&out) - log.SetFormatter(&log.TextFormatter{ - DisableColors: true, - DisableTimestamp: true, - }) - log.SetLevel(log.InfoLevel) - defer func() { - log.SetOutput(originalOut) - log.SetFormatter(originalFormatter) - log.SetLevel(originalLevel) - }() - - engine := newJSEngine() - _, errRun := engine.vm.RunString(`console.log("alpha", 42, true);`) - if errRun != nil { - t.Fatalf("RunString() error = %v", errRun) - } - - got := out.String() - if !strings.Contains(got, "JS console log: alpha 42 true") { - t.Fatalf("console.log output = %q, want logger output with JS message", got) - } -} - -func TestConsoleLogUsesConfiguredLogger(t *testing.T) { - var messages []string - engine := newJSEngine(func(message string) error { - messages = append(messages, message) - return nil - }) - _, errRun := engine.vm.RunString(`console.log("alpha", 42, true);`) - if errRun != nil { - t.Fatalf("RunString() error = %v", errRun) - } - if len(messages) != 1 || messages[0] != "alpha 42 true" { - t.Fatalf("console log messages = %#v, want formatted message", messages) - } -} - -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) - } -} diff --git a/examples/plugin/jshandler/go.mod b/examples/plugin/jshandler/go.mod deleted file mode 100644 index 33f4c6bc..00000000 --- a/examples/plugin/jshandler/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -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 -) - -replace github.com/router-for-me/CLIProxyAPI/v7 => ../../.. diff --git a/examples/plugin/jshandler/go.sum b/examples/plugin/jshandler/go.sum deleted file mode 100644 index 7654f0cc..00000000 --- a/examples/plugin/jshandler/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -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= diff --git a/examples/plugin/jshandler/interceptor.go b/examples/plugin/jshandler/interceptor.go deleted file mode 100644 index 3a33a418..00000000 --- a/examples/plugin/jshandler/interceptor.go +++ /dev/null @@ -1,500 +0,0 @@ -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) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) { - return p.interceptRequest(ctx, req, "on_before_request", "") -} - -func (p *jsHandlerPlugin) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) { - return p.interceptRequest(ctx, req, "on_after_auth_request", "") -} - -func (p *jsHandlerPlugin) interceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest, hookName, hostCallbackID string) (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.applyJSRequestHook(scriptPath, hookName, []byte(body), req.Model, req.SourceFormat, req.ToFormat, headers, hostCallbackID) - 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) { - return p.interceptResponse(ctx, req, "") -} - -func (p *jsHandlerPlugin) interceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest, hostCallbackID string) (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, - hostCallbackID, - ) - 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) { - return p.interceptStreamChunk(ctx, req, "") -} - -func (p *jsHandlerPlugin) interceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest, hostCallbackID string) (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, - hostCallbackID, - ) - 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) applyJSRequestHook(scriptPath, hookName string, payloadBytes []byte, model, sourceFormat, toFormat string, headers http.Header, hostCallbackID string) ([]byte, []string, error) { - program, err := getJSProgram(scriptPath) - if err != nil { - return nil, nil, err - } - - engine := newJSEngine(newHostJSConsoleLogger(hostCallbackID)) - 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": sourceFormat, - "source_format": sourceFormat, - "to_format": toFormat, - "sourceFormat": sourceFormat, - "toFormat": toFormat, - } - - jsVal, errCall := engine.callFunction(hookName, p.cfg.Timeout, jsCtx) - if errCall != nil { - if errors.Is(errCall, ErrFunctionNotFound) { - return payloadBytes, nil, nil - } - return nil, nil, fmt.Errorf("%s failed for %s: %w", hookName, 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, - hostCallbackID string, -) (string, *processedHeaders, bool, error) { - program, err := getJSProgram(scriptPath) - if err != nil { - return bodyStr, nil, false, err - } - - engine := newJSEngine(newHostJSConsoleLogger(hostCallbackID)) - 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) -} diff --git a/examples/plugin/jshandler/interceptor_test.go b/examples/plugin/jshandler/interceptor_test.go deleted file mode 100644 index cc1810a1..00000000 --- a/examples/plugin/jshandler/interceptor_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package main - -import ( - "net/http" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestApplyJSBeforeRequestUsesReturnedCtxBody(t *testing.T) { - scriptPath := filepath.Join(t.TempDir(), "before.js") - script := ` -function on_before_request(ctx) { - var req = JSON.parse(ctx.body); - req.messages[0].content = req.messages[0].content.replace("sensitive_word", "safe_word"); - ctx.body = JSON.stringify(req); - ctx.headers["X-Plugin"] = "updated"; - return ctx; -} -` - if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil { - t.Fatalf("os.WriteFile() error = %v", errWrite) - } - - plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()} - headers := http.Header{"X-Plugin": []string{"original"}} - processed, _, errApply := plugin.applyJSRequestHook( - scriptPath, - "on_before_request", - []byte(`{"messages":[{"role":"user","content":"contains sensitive_word"}]}`), - "gpt-test", - "openai", - "", - headers, - "", - ) - if errApply != nil { - t.Fatalf("applyJSRequestHook() error = %v", errApply) - } - if body := string(processed); !strings.Contains(body, "safe_word") || strings.Contains(body, "sensitive_word") { - t.Fatalf("processed body = %q, want sensitive word rewritten", body) - } - if got := headers.Get("X-Plugin"); got != "updated" { - t.Fatalf("header X-Plugin = %q, want updated", got) - } -} - -func TestApplyJSAfterAuthRequestReceivesFormats(t *testing.T) { - scriptPath := filepath.Join(t.TempDir(), "after_auth.js") - script := ` -function on_after_auth_request(ctx) { - if (ctx.source_format !== "openai" || ctx.to_format !== "codex") { - throw new Error("unexpected formats: " + ctx.source_format + " -> " + ctx.to_format); - } - if (ctx.sourceFormat !== "openai" || ctx.toFormat !== "codex") { - throw new Error("unexpected camel formats: " + ctx.sourceFormat + " -> " + ctx.toFormat); - } - var req = JSON.parse(ctx.body); - req.after_auth = ctx.source_format + "_to_" + ctx.to_format; - ctx.headers["X-Protocol"] = req.after_auth; - ctx.body = JSON.stringify(req); - return ctx; -} -` - if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil { - t.Fatalf("os.WriteFile() error = %v", errWrite) - } - - plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()} - headers := http.Header{} - processed, _, errApply := plugin.applyJSRequestHook( - scriptPath, - "on_after_auth_request", - []byte(`{"model":"gpt-test"}`), - "gpt-test", - "openai", - "codex", - headers, - "", - ) - if errApply != nil { - t.Fatalf("applyJSRequestHook() error = %v", errApply) - } - if body := string(processed); !strings.Contains(body, `"after_auth":"openai_to_codex"`) { - t.Fatalf("processed body = %q, want after_auth marker", body) - } - if got := headers.Get("X-Protocol"); got != "openai_to_codex" { - t.Fatalf("header X-Protocol = %q, want openai_to_codex", got) - } -} - -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) - } -} diff --git a/examples/plugin/jshandler/main.go b/examples/plugin/jshandler/main.go deleted file mode 100644 index 06358a7a..00000000 --- a/examples/plugin/jshandler/main.go +++ /dev/null @@ -1,50 +0,0 @@ -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 -} diff --git a/examples/plugin/jshandler/scripts/copilot_handler.js b/examples/plugin/jshandler/scripts/copilot_handler.js deleted file mode 100644 index 81831630..00000000 --- a/examples/plugin/jshandler/scripts/copilot_handler.js +++ /dev/null @@ -1,124 +0,0 @@ -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 on_after_auth_request(ctx) { - console.log("[" + ctx.id + "] Selected request protocol: " + ctx.source_format + " -> " + ctx.to_format); - if (ctx.source_format === "openai" && ctx.to_format === "codex") { - ctx.headers["X-JS-Handler-Protocol"] = "openai-to-codex"; - } - 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; -}