History

feat(pluginhost, jshandler): integrate HostCallbackID with interceptors and JS engine logging

- Added `HostCallbackID` to request, response, and stream chunk interceptors for enhanced context tracking. - Updated JavaScript engine to support custom console logging with `HostCallbackID` forwarding. - Introduced tests verifying proper integration of `HostCallbackID` in all interceptor flows and engine logging. - Enhanced logging and error handling for consistent callback-related logic implementation.

Luis Pater committed 5 months agov7.1.58

Commit 2aeb41c1 parent 1d1ee859 files · +359 −19Browse files

Unified · Split

9 files
  1. examples/plugin/jshandler/abi.go +110 −6
  2. examples/plugin/jshandler/engine.go +23 −6
  3. examples/plugin/jshandler/engine_test.go +49 −0
  4. examples/plugin/jshandler/interceptor.go +19 −4
  5. examples/plugin/jshandler/interceptor_test.go +3 −0
  6. internal/pluginhost/host_callbacks_test.go +44 −0
  7. internal/pluginhost/host_test.go +69 −0
  8. internal/pluginhost/rpc_client.go +27 −3
  9. internal/pluginhost/rpc_schema.go +15 −0
examples/plugin/jshandler/abi.go+110−6
@@ -92,6 +92,28 @@ type abiLifecycleRequest struct { · 91 unmodified lines
92 PluginDir string `json:"plugin_dir,omitempty"`92 PluginDir string `json:"plugin_dir,omitempty"`
93}93}
94 94
95type abiRequestInterceptRequest struct {
96 pluginapi.RequestInterceptRequest
97 HostCallbackID string `json:"host_callback_id,omitempty"`
98}
99
100type abiResponseInterceptRequest struct {
101 pluginapi.ResponseInterceptRequest
102 HostCallbackID string `json:"host_callback_id,omitempty"`
103}
104
105type abiStreamChunkInterceptRequest struct {
106 pluginapi.StreamChunkInterceptRequest
107 HostCallbackID string `json:"host_callback_id,omitempty"`
108}
109
110type abiHostLogRequest struct {
111 HostCallbackID string `json:"host_callback_id,omitempty"`
112 Level string `json:"level,omitempty"`
113 Message string `json:"message,omitempty"`
114 Fields map[string]any `json:"fields,omitempty"`
115}
116
95type abiRegistration struct {117type abiRegistration struct {
96 SchemaVersion uint32 `json:"schema_version"`118 SchemaVersion uint32 `json:"schema_version"`
97 Metadata pluginapi.Metadata `json:"metadata"`119 Metadata pluginapi.Metadata `json:"metadata"`
@@ -192,25 +214,25 @@ func handleJSHandlerABIMethod(ctx context.Context, method string, request []byte · 94 unmodified lines
192 defer done()214 defer done()
193 switch method {215 switch method {
194 case pluginabi.MethodRequestInterceptBefore:216 case pluginabi.MethodRequestInterceptBefore:
195 var req pluginapi.RequestInterceptRequest217 var req abiRequestInterceptRequest
196 if errDecode := json.Unmarshal(request, &req); errDecode != nil {218 if errDecode := json.Unmarshal(request, &req); errDecode != nil {
197 return nil, errDecode219 return nil, errDecode
198 }220 }
199 resp, errCall := p.InterceptRequest(ctx, req)221 resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, req.HostCallbackID)
200 return abiOKEnvelopeWithError(resp, errCall)222 return abiOKEnvelopeWithError(resp, errCall)
201 case pluginabi.MethodResponseInterceptAfter:223 case pluginabi.MethodResponseInterceptAfter:
202 var req pluginapi.ResponseInterceptRequest224 var req abiResponseInterceptRequest
203 if errDecode := json.Unmarshal(request, &req); errDecode != nil {225 if errDecode := json.Unmarshal(request, &req); errDecode != nil {
204 return nil, errDecode226 return nil, errDecode
205 }227 }
206 resp, errCall := p.InterceptResponse(ctx, req)228 resp, errCall := p.interceptResponse(ctx, req.ResponseInterceptRequest, req.HostCallbackID)
207 return abiOKEnvelopeWithError(resp, errCall)229 return abiOKEnvelopeWithError(resp, errCall)
208 case pluginabi.MethodResponseInterceptStreamChunk:230 case pluginabi.MethodResponseInterceptStreamChunk:
209 var req pluginapi.StreamChunkInterceptRequest231 var req abiStreamChunkInterceptRequest
210 if errDecode := json.Unmarshal(request, &req); errDecode != nil {232 if errDecode := json.Unmarshal(request, &req); errDecode != nil {
211 return nil, errDecode233 return nil, errDecode
212 }234 }
213 resp, errCall := p.InterceptStreamChunk(ctx, req)235 resp, errCall := p.interceptStreamChunk(ctx, req.StreamChunkInterceptRequest, req.HostCallbackID)
214 return abiOKEnvelopeWithError(resp, errCall)236 return abiOKEnvelopeWithError(resp, errCall)
215 default:237 default:
216 return abiErrorEnvelope("unknown_method", "unknown method: "+method), nil238 return abiErrorEnvelope("unknown_method", "unknown method: "+method), nil
@@ -289,3 +311,85 @@ func writeABIResponse(response *C.cliproxy_buffer, raw []byte) { · 72 unmodified lines
289 response.ptr = ptr311 response.ptr = ptr
290 response.len = C.size_t(len(raw))312 response.len = C.size_t(len(raw))
291}313}
314
315func newHostJSConsoleLogger(hostCallbackID string) jsConsoleLogger {
316 return func(message string) error {
317 if errLog := writeHostJSConsoleLog(hostCallbackID, message); errLog != nil {
318 return defaultJSConsoleLogger(message)
319 }
320 return nil
321 }
322}
323
324func writeHostJSConsoleLog(hostCallbackID string, message string) error {
325 raw, errMarshal := json.Marshal(abiHostLogRequest{
326 HostCallbackID: hostCallbackID,
327 Level: "info",
328 Message: "JS console log: " + message,
329 Fields: map[string]any{
330 "plugin_id": pluginName,
331 },
332 })
333 if errMarshal != nil {
334 return errMarshal
335 }
336
337 rawResp, errCall := callHost(pluginabi.MethodHostLog, raw)
338 if errCall != nil {
339 return errCall
340 }
341 if len(rawResp) == 0 {
342 return nil
343 }
344 var resp abiEnvelope
345 if errDecode := json.Unmarshal(rawResp, &resp); errDecode != nil {
346 return fmt.Errorf("decode host log response: %w", errDecode)
347 }
348 if !resp.OK {
349 if resp.Error != nil {
350 return fmt.Errorf("host log failed: %s", resp.Error.Message)
351 }
352 return fmt.Errorf("host log failed")
353 }
354 return nil
355}
356
357func callHost(method string, payload []byte) ([]byte, error) {
358 jsHandlerABIState.RLock()
359 defer jsHandlerABIState.RUnlock()
360 if jsHandlerABIState.host == nil {
361 return nil, fmt.Errorf("host callback is unavailable")
362 }
363
364 cMethod := C.CString(method)
365 defer C.free(unsafe.Pointer(cMethod))
366
367 var cPayload unsafe.Pointer
368 if len(payload) > 0 {
369 cPayload = C.CBytes(payload)
370 if cPayload == nil {
371 return nil, fmt.Errorf("allocate host callback payload")
372 }
373 defer C.free(cPayload)
374 }
375
376 var response C.cliproxy_buffer
377 rc := C.jshandler_call_host(
378 jsHandlerABIState.host,
379 cMethod,
380 (*C.uint8_t)(cPayload),
381 C.size_t(len(payload)),
382 &response,
383 )
384 var out []byte
385 if response.ptr != nil && response.len > 0 {
386 out = C.GoBytes(response.ptr, C.int(response.len))
387 }
388 if response.ptr != nil {
389 C.jshandler_free_host_buffer(jsHandlerABIState.host, response.ptr, response.len)
390 }
391 if rc != 0 {
392 return nil, fmt.Errorf("host callback %s returned %d: %s", method, int(rc), string(out))
393 }
394 return out, nil
395}
examples/plugin/jshandler/engine.go+23−6
@@ -5,6 +5,7 @@ import ( · 4 unmodified lines
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"
8 "sync"9 "sync"
9 "time"10 "time"
10 11
@@ -13,27 +14,43 @@ import ( · 2 unmodified lines
13)14)
14 15
15type jsEngine struct {16type jsEngine struct {
16 vm *goja.Runtime17 vm *goja.Runtime
18 consoleLogger jsConsoleLogger
17}19}
18 20
19const maxJSScriptBytes = 8 * 1024 * 102421const maxJSScriptBytes = 8 * 1024 * 1024
20 22
21func newJSEngine() *jsEngine {23type jsConsoleLogger func(message string) error
24
25func newJSEngine(loggers ...jsConsoleLogger) *jsEngine {
26 consoleLogger := defaultJSConsoleLogger
27 if len(loggers) > 0 && loggers[0] != nil {
28 consoleLogger = loggers[0]
29 }
22 engine := &jsEngine{30 engine := &jsEngine{
23 vm: goja.New(),31 vm: goja.New(),
32 consoleLogger: consoleLogger,
24 }33 }
25 engine.initConsole()34 engine.initConsole()
26 return engine35 return engine
27}36}
28 37
38func defaultJSConsoleLogger(message string) error {
39 log.Info("JS console log: ", message)
40 return nil
41}
42
29func (engine *jsEngine) initConsole() {43func (engine *jsEngine) initConsole() {
30 console := engine.vm.NewObject()44 console := engine.vm.NewObject()
31 consoleLogWrapper := func(call goja.FunctionCall) goja.Value {45 consoleLogWrapper := func(call goja.FunctionCall) goja.Value {
32 args := make([]interface{}, len(call.Arguments))46 args := make([]string, len(call.Arguments))
33 for i, arg := range call.Arguments {47 for i, arg := range call.Arguments {
34 args[i] = arg.Export()48 args[i] = fmt.Sprint(arg.Export())
49 }
50 message := strings.Join(args, " ")
51 if errLog := engine.consoleLogger(message); errLog != nil {
52 defaultJSConsoleLogger(message)
35 }53 }
36 log.Info("JS console log: ", fmt.Sprint(args...))
37 return goja.Undefined()54 return goja.Undefined()
38 }55 }
39 _ = console.Set("log", consoleLogWrapper)56 _ = console.Set("log", consoleLogWrapper)
examples/plugin/jshandler/engine_test.go+49−0
@@ -1,10 +1,59 @@
1package main1package main
2 2
3import (3import (
4 "bytes"
5 "strings"
4 "testing"6 "testing"
5 "time"7 "time"
8
9 log "github.com/sirupsen/logrus"
6)10)
7 11
12func TestConsoleLogWritesToLogger(t *testing.T) {
13 var out bytes.Buffer
14 logger := log.StandardLogger()
15 originalOut := logger.Out
16 originalFormatter := logger.Formatter
17 originalLevel := logger.Level
18 log.SetOutput(&out)
19 log.SetFormatter(&log.TextFormatter{
20 DisableColors: true,
21 DisableTimestamp: true,
22 })
23 log.SetLevel(log.InfoLevel)
24 defer func() {
25 log.SetOutput(originalOut)
26 log.SetFormatter(originalFormatter)
27 log.SetLevel(originalLevel)
28 }()
29
30 engine := newJSEngine()
31 _, errRun := engine.vm.RunString(`console.log("alpha", 42, true);`)
32 if errRun != nil {
33 t.Fatalf("RunString() error = %v", errRun)
34 }
35
36 got := out.String()
37 if !strings.Contains(got, "JS console log: alpha 42 true") {
38 t.Fatalf("console.log output = %q, want logger output with JS message", got)
39 }
40}
41
42func TestConsoleLogUsesConfiguredLogger(t *testing.T) {
43 var messages []string
44 engine := newJSEngine(func(message string) error {
45 messages = append(messages, message)
46 return nil
47 })
48 _, errRun := engine.vm.RunString(`console.log("alpha", 42, true);`)
49 if errRun != nil {
50 t.Fatalf("RunString() error = %v", errRun)
51 }
52 if len(messages) != 1 || messages[0] != "alpha 42 true" {
53 t.Fatalf("console log messages = %#v, want formatted message", messages)
54 }
55}
56
8func TestStopInterruptTimerClearsExpiredInterrupt(t *testing.T) {57func TestStopInterruptTimerClearsExpiredInterrupt(t *testing.T) {
9 engine := newJSEngine()58 engine := newJSEngine()
10 timer, done := engine.startInterruptTimer(time.Nanosecond)59 timer, done := engine.startInterruptTimer(time.Nanosecond)
examples/plugin/jshandler/interceptor.go+19−4
@@ -45,6 +45,10 @@ func (p *jsHandlerPlugin) allScriptPaths() []string { · 44 unmodified lines
45}45}
46 46
47func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {47func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
48 return p.interceptRequest(ctx, req, "")
49}
50
51func (p *jsHandlerPlugin) interceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest, hostCallbackID string) (pluginapi.RequestInterceptResponse, error) {
48 resp := pluginapi.RequestInterceptResponse{}52 resp := pluginapi.RequestInterceptResponse{}
49 scriptPaths := p.allScriptPaths()53 scriptPaths := p.allScriptPaths()
50 if len(scriptPaths) == 0 {54 if len(scriptPaths) == 0 {
@@ -60,7 +64,7 @@ func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.Re · 9 unmodified lines
60 if scriptPath == "" {64 if scriptPath == "" {
61 continue65 continue
62 }66 }
63 processed, cleared, errJS := p.applyJSBeforeRequest(scriptPath, []byte(body), req.Model, req.SourceFormat, headers)67 processed, cleared, errJS := p.applyJSBeforeRequest(scriptPath, []byte(body), req.Model, req.SourceFormat, headers, hostCallbackID)
64 if errJS != nil {68 if errJS != nil {
65 log.Warnf("failed to execute JS request interceptor [%s]: %v", scriptPath, errJS)69 log.Warnf("failed to execute JS request interceptor [%s]: %v", scriptPath, errJS)
66 continue70 continue
@@ -78,6 +82,10 @@ func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.Re · 11 unmodified lines
78}82}
79 83
80func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {84func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {
85 return p.interceptResponse(ctx, req, "")
86}
87
88func (p *jsHandlerPlugin) interceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest, hostCallbackID string) (pluginapi.ResponseInterceptResponse, error) {
81 resp := pluginapi.ResponseInterceptResponse{}89 resp := pluginapi.ResponseInterceptResponse{}
82 scriptPaths := p.allScriptPaths()90 scriptPaths := p.allScriptPaths()
83 if len(scriptPaths) == 0 {91 if len(scriptPaths) == 0 {
@@ -98,6 +106,7 @@ func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.R · 14 unmodified lines
98 scriptPath, req.Model, req.SourceFormat,106 scriptPath, req.Model, req.SourceFormat,
99 reqHeadersMap, req.RequestBody,107 reqHeadersMap, req.RequestBody,
100 bodyStr, nil, respHeaders, false, nil,108 bodyStr, nil, respHeaders, false, nil,
109 hostCallbackID,
101 )110 )
102 if errJS != nil {111 if errJS != nil {
103 log.Warnf("failed to execute JS response interceptor [%s]: %v", scriptPath, errJS)112 log.Warnf("failed to execute JS response interceptor [%s]: %v", scriptPath, errJS)
@@ -121,6 +130,10 @@ func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.R · 17 unmodified lines
121}130}
122 131
123func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {132func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {
133 return p.interceptStreamChunk(ctx, req, "")
134}
135
136func (p *jsHandlerPlugin) interceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest, hostCallbackID string) (pluginapi.StreamChunkInterceptResponse, error) {
124 resp := pluginapi.StreamChunkInterceptResponse{}137 resp := pluginapi.StreamChunkInterceptResponse{}
125 scriptPaths := p.allScriptPaths()138 scriptPaths := p.allScriptPaths()
126 if len(scriptPaths) == 0 {139 if len(scriptPaths) == 0 {
@@ -156,6 +169,7 @@ func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginap · 29 unmodified lines
156 scriptPath, req.Model, req.SourceFormat,169 scriptPath, req.Model, req.SourceFormat,
157 reqHeadersMap, req.RequestBody,170 reqHeadersMap, req.RequestBody,
158 "", chunkPtr, respHeaders, !isHeaderInit, historyStrings,171 "", chunkPtr, respHeaders, !isHeaderInit, historyStrings,
172 hostCallbackID,
159 )173 )
160 if errJS != nil {174 if errJS != nil {
161 log.Warnf("failed to execute JS stream chunk interceptor [%s]: %v", scriptPath, errJS)175 log.Warnf("failed to execute JS stream chunk interceptor [%s]: %v", scriptPath, errJS)
@@ -183,13 +197,13 @@ func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginap · 21 unmodified lines
183 return resp, nil197 return resp, nil
184}198}
185 199
186func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes []byte, model, protocol string, headers http.Header) ([]byte, []string, error) {200func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes []byte, model, protocol string, headers http.Header, hostCallbackID string) ([]byte, []string, error) {
187 program, err := getJSProgram(scriptPath)201 program, err := getJSProgram(scriptPath)
188 if err != nil {202 if err != nil {
189 return nil, nil, err203 return nil, nil, err
190 }204 }
191 205
192 engine := newJSEngine()206 engine := newJSEngine(newHostJSConsoleLogger(hostCallbackID))
193 if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {207 if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {
194 return nil, nil, errRun208 return nil, nil, errRun
195 }209 }
@@ -246,13 +260,14 @@ func (p *jsHandlerPlugin) applyJSAfterResponse( · 50 unmodified lines
246 reqHeadersMap map[string]any, reqBody []byte,260 reqHeadersMap map[string]any, reqBody []byte,
247 bodyStr string, chunkStr *string,261 bodyStr string, chunkStr *string,
248 respHeaders http.Header, isStream bool, historyChunks []string,262 respHeaders http.Header, isStream bool, historyChunks []string,
263 hostCallbackID string,
249) (string, *processedHeaders, bool, error) {264) (string, *processedHeaders, bool, error) {
250 program, err := getJSProgram(scriptPath)265 program, err := getJSProgram(scriptPath)
251 if err != nil {266 if err != nil {
252 return bodyStr, nil, false, err267 return bodyStr, nil, false, err
253 }268 }
254 269
255 engine := newJSEngine()270 engine := newJSEngine(newHostJSConsoleLogger(hostCallbackID))
256 if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {271 if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil {
257 return bodyStr, nil, false, errRun272 return bodyStr, nil, false, errRun
258 }273 }
examples/plugin/jshandler/interceptor_test.go+3−0
@@ -31,6 +31,7 @@ function on_before_request(ctx) { · 30 unmodified lines
31 "gpt-test",31 "gpt-test",
32 "openai",32 "openai",
33 headers,33 headers,
34 "",
34 )35 )
35 if errApply != nil {36 if errApply != nil {
36 t.Fatalf("applyJSBeforeRequest() error = %v", errApply)37 t.Fatalf("applyJSBeforeRequest() error = %v", errApply)
@@ -85,6 +86,7 @@ function on_after_stream_response(ctx) { · 48 unmodified lines
85 http.Header{},86 http.Header{},
86 true,87 true,
87 []string{`data: {"choices":[{"delta":{"tool_calls":[{"index":0}]}}]}`},88 []string{`data: {"choices":[{"delta":{"tool_calls":[{"index":0}]}}]}`},
89 "",
88 )90 )
89 if errApply != nil {91 if errApply != nil {
90 t.Fatalf("applyJSAfterResponse() error = %v", errApply)92 t.Fatalf("applyJSAfterResponse() error = %v", errApply)
@@ -123,6 +125,7 @@ function on_after_nonstream_response(ctx) { · 32 unmodified lines
123 http.Header{},125 http.Header{},
124 false,126 false,
125 nil,127 nil,
128 "",
126 )129 )
127 if errApply != nil {130 if errApply != nil {
128 t.Fatalf("applyJSAfterResponse() error = %v", errApply)131 t.Fatalf("applyJSAfterResponse() error = %v", errApply)
internal/pluginhost/host_callbacks_test.go+44−0
@@ -6,13 +6,16 @@ import ( · 5 unmodified lines
6 "encoding/json"6 "encoding/json"
7 "net/http"7 "net/http"
8 "net/http/httptest"8 "net/http/httptest"
9 "strings"
9 "testing"10 "testing"
10 "time"11 "time"
11 12
12 "github.com/gin-gonic/gin"13 "github.com/gin-gonic/gin"
13 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"14 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
15 "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
14 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"16 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
15 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"17 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
18 log "github.com/sirupsen/logrus"
16)19)
17 20
18func TestHostHTTPDoCallbackUsesHostHTTPClient(t *testing.T) {21func TestHostHTTPDoCallbackUsesHostHTTPClient(t *testing.T) {
@@ -213,3 +216,44 @@ func TestHostStreamCallbacksEmitAndClose(t *testing.T) { · 194 unmodified lines
213 t.Fatalf("stream remains open after close")216 t.Fatalf("stream remains open after close")
214 }217 }
215}218}
219
220func TestHostLogCallbackRestoresRegisteredRequestContext(t *testing.T) {
221 host := New()
222 ctx := logging.WithRequestID(context.Background(), "request-123")
223 callbackID, closeCallback := host.openCallbackContext(ctx)
224 defer closeCallback()
225
226 var out bytes.Buffer
227 logger := log.StandardLogger()
228 originalOut := logger.Out
229 originalFormatter := logger.Formatter
230 originalLevel := logger.Level
231 log.SetOutput(&out)
232 log.SetFormatter(&log.TextFormatter{
233 DisableColors: true,
234 DisableTimestamp: true,
235 })
236 log.SetLevel(log.InfoLevel)
237 defer func() {
238 log.SetOutput(originalOut)
239 log.SetFormatter(originalFormatter)
240 log.SetLevel(originalLevel)
241 }()
242
243 rawReq, errMarshal := json.Marshal(rpcHostLogRequest{
244 HostCallbackID: callbackID,
245 Level: "info",
246 Message: "plugin callback message",
247 })
248 if errMarshal != nil {
249 t.Fatalf("marshal log request: %v", errMarshal)
250 }
251 if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostLog, rawReq); errCall != nil {
252 t.Fatalf("log callback error = %v", errCall)
253 }
254
255 got := out.String()
256 if !strings.Contains(got, "plugin callback message") || !strings.Contains(got, "request_id=request-123") {
257 t.Fatalf("log output = %q, want message and request_id field", got)
258 }
259}
internal/pluginhost/host_test.go+69−0
@@ -7,6 +7,7 @@ import ( · 6 unmodified lines
7 7
8 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"8 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
9 "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"9 "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
10 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
10 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"11 "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
11 "github.com/tidwall/gjson"12 "github.com/tidwall/gjson"
12)13)
@@ -212,6 +213,47 @@ func TestInterceptorHelpersReturnErrorsWhenCallbackMissing(t *testing.T) { · 199 unmodified lines
212 }213 }
213}214}
214 215
216func TestRPCInterceptorsIncludeHostCallbackID(t *testing.T) {
217 client := &capturePluginClient{}
218 adapter := &rpcPluginAdapter{
219 host: New(),
220 client: client,
221 }
222
223 if _, errReq := adapter.InterceptRequest(context.Background(), pluginapi.RequestInterceptRequest{Body: []byte("request")}); errReq != nil {
224 t.Fatalf("InterceptRequest() error = %v", errReq)
225 }
226 var req rpcRequestInterceptRequest
227 if errDecode := json.Unmarshal(client.requests[pluginabi.MethodRequestInterceptBefore], &req); errDecode != nil {
228 t.Fatalf("decode request interceptor request: %v", errDecode)
229 }
230 if req.HostCallbackID == "" {
231 t.Fatal("request interceptor host_callback_id is empty")
232 }
233
234 if _, errResp := adapter.InterceptResponse(context.Background(), pluginapi.ResponseInterceptRequest{Body: []byte("response")}); errResp != nil {
235 t.Fatalf("InterceptResponse() error = %v", errResp)
236 }
237 var resp rpcResponseInterceptRequest
238 if errDecode := json.Unmarshal(client.requests[pluginabi.MethodResponseInterceptAfter], &resp); errDecode != nil {
239 t.Fatalf("decode response interceptor request: %v", errDecode)
240 }
241 if resp.HostCallbackID == "" {
242 t.Fatal("response interceptor host_callback_id is empty")
243 }
244
245 if _, errChunk := adapter.InterceptStreamChunk(context.Background(), pluginapi.StreamChunkInterceptRequest{Body: []byte("chunk")}); errChunk != nil {
246 t.Fatalf("InterceptStreamChunk() error = %v", errChunk)
247 }
248 var chunk rpcStreamChunkInterceptRequest
249 if errDecode := json.Unmarshal(client.requests[pluginabi.MethodResponseInterceptStreamChunk], &chunk); errDecode != nil {
250 t.Fatalf("decode stream chunk interceptor request: %v", errDecode)
251 }
252 if chunk.HostCallbackID == "" {
253 t.Fatal("stream chunk interceptor host_callback_id is empty")
254 }
255}
256
215func TestSanitizePluginRequestRemovesNonJSONMetadata(t *testing.T) {257func TestSanitizePluginRequestRemovesNonJSONMetadata(t *testing.T) {
216 req := pluginapi.RequestInterceptRequest{258 req := pluginapi.RequestInterceptRequest{
217 Metadata: map[string]any{259 Metadata: map[string]any{
@@ -257,6 +299,19 @@ func TestSanitizePluginRequestRemovesNonJSONMetadata(t *testing.T) { · 39 unmodified lines
257 if _, errMarshalExec := json.Marshal(sanitizePluginRequest(execReq)); errMarshalExec != nil {299 if _, errMarshalExec := json.Marshal(sanitizePluginRequest(execReq)); errMarshalExec != nil {
258 t.Fatalf("Marshal(sanitized executor request) error = %v", errMarshalExec)300 t.Fatalf("Marshal(sanitized executor request) error = %v", errMarshalExec)
259 }301 }
302
303 wrappedReq := rpcRequestInterceptRequest{
304 RequestInterceptRequest: pluginapi.RequestInterceptRequest{
305 Metadata: map[string]any{
306 "keep": "value",
307 "callback": func(string) {},
308 },
309 },
310 HostCallbackID: "callback-1",
311 }
312 if _, errMarshalWrapped := json.Marshal(sanitizePluginRequest(wrappedReq)); errMarshalWrapped != nil {
313 t.Fatalf("Marshal(sanitized wrapped request interceptor) error = %v", errMarshalWrapped)
314 }
260}315}
261 316
262func TestHostApplyConfig_ReconfigureCalledOnReload(t *testing.T) {317func TestHostApplyConfig_ReconfigureCalledOnReload(t *testing.T) {
@@ -407,3 +462,17 @@ func TestSortRecordsPriorityDescendingAndIDTieBreak(t *testing.T) { · 144 unmodified lines
407 }462 }
408 }463 }
409}464}
465
466type capturePluginClient struct {
467 requests map[string][]byte
468}
469
470func (c *capturePluginClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
471 if c.requests == nil {
472 c.requests = make(map[string][]byte)
473 }
474 c.requests[method] = append([]byte(nil), request...)
475 return marshalRPCResult(rpcEmptyResponse{})
476}
477
478func (c *capturePluginClient) Shutdown() {}
internal/pluginhost/rpc_client.go+27−3
@@ -169,6 +169,15 @@ func sanitizePluginRequest(request any) any { · 168 unmodified lines
169 case pluginapi.StreamChunkInterceptRequest:169 case pluginapi.StreamChunkInterceptRequest:
170 req.Metadata = sanitizePluginMetadata(req.Metadata)170 req.Metadata = sanitizePluginMetadata(req.Metadata)
171 return req171 return req
172 case rpcRequestInterceptRequest:
173 req.Metadata = sanitizePluginMetadata(req.Metadata)
174 return req
175 case rpcResponseInterceptRequest:
176 req.Metadata = sanitizePluginMetadata(req.Metadata)
177 return req
178 case rpcStreamChunkInterceptRequest:
179 req.Metadata = sanitizePluginMetadata(req.Metadata)
180 return req
172 case pluginapi.ExecutorHTTPRequest:181 case pluginapi.ExecutorHTTPRequest:
173 req.HTTPClient = nil182 req.HTTPClient = nil
174 return req183 return req
@@ -424,7 +433,12 @@ func (a *rpcPluginAdapter) NormalizeRequest(ctx context.Context, req pluginapi.R · 249 unmodified lines
424}433}
425 434
426func (a *rpcPluginAdapter) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {435func (a *rpcPluginAdapter) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
427 return callPlugin[pluginapi.RequestInterceptResponse](ctx, a.client, pluginabi.MethodRequestInterceptBefore, req)436 callbackID, closeCallback := a.openHostCallbackContext(ctx)
437 defer closeCallback()
438 return callPlugin[pluginapi.RequestInterceptResponse](ctx, a.client, pluginabi.MethodRequestInterceptBefore, rpcRequestInterceptRequest{
439 RequestInterceptRequest: req,
440 HostCallbackID: callbackID,
441 })
428}442}
429 443
430func (a *rpcPluginAdapter) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {444func (a *rpcPluginAdapter) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
@@ -436,11 +450,21 @@ func (a rpcResponseNormalizer) NormalizeResponse(ctx context.Context, req plugin · 5 unmodified lines
436}450}
437 451
438func (a *rpcPluginAdapter) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {452func (a *rpcPluginAdapter) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {
439 return callPlugin[pluginapi.ResponseInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptAfter, req)453 callbackID, closeCallback := a.openHostCallbackContext(ctx)
454 defer closeCallback()
455 return callPlugin[pluginapi.ResponseInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptAfter, rpcResponseInterceptRequest{
456 ResponseInterceptRequest: req,
457 HostCallbackID: callbackID,
458 })
440}459}
441 460
442func (a *rpcPluginAdapter) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {461func (a *rpcPluginAdapter) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {
443 return callPlugin[pluginapi.StreamChunkInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptStreamChunk, req)462 callbackID, closeCallback := a.openHostCallbackContext(ctx)
463 defer closeCallback()
464 return callPlugin[pluginapi.StreamChunkInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptStreamChunk, rpcStreamChunkInterceptRequest{
465 StreamChunkInterceptRequest: req,
466 HostCallbackID: callbackID,
467 })
444}468}
445 469
446func (a rpcThinkingApplier) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {470func (a rpcThinkingApplier) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {
internal/pluginhost/rpc_schema.go+15−0
@@ -82,6 +82,21 @@ type rpcExecutorHTTPRequest struct { · 81 unmodified lines
82 HostCallbackID string `json:"host_callback_id,omitempty"`82 HostCallbackID string `json:"host_callback_id,omitempty"`
83}83}
84 84
85type rpcRequestInterceptRequest struct {
86 pluginapi.RequestInterceptRequest
87 HostCallbackID string `json:"host_callback_id,omitempty"`
88}
89
90type rpcResponseInterceptRequest struct {
91 pluginapi.ResponseInterceptRequest
92 HostCallbackID string `json:"host_callback_id,omitempty"`
93}
94
95type rpcStreamChunkInterceptRequest struct {
96 pluginapi.StreamChunkInterceptRequest
97 HostCallbackID string `json:"host_callback_id,omitempty"`
98}
99
85type rpcThinkingApplyRequest struct {100type rpcThinkingApplyRequest struct {
86 pluginapi.ThinkingApplyRequest101 pluginapi.ThinkingApplyRequest
87 HostCallbackID string `json:"host_callback_id,omitempty"`102 HostCallbackID string `json:"host_callback_id,omitempty"`