Files
CLIProxyAPI/internal/pluginhost/websocket_observer_test.go
Luis Pater 4b5f1eab25 feat(plugin): support observing upstream websocket response events
- Introduce `WebSocketResponseObserver` capability and bump plugin ABI schema version to 4.
- Forward upstream WebSocket response frames from Codex and xAI executors to configured observers.
- Wire `WebSocketResponseObserver` across API handlers and plugin host dispatchers.

Closes: #5248
2026-08-27 05:30:19 +08:00

248 lines
7.6 KiB
Go

package pluginhost
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type testWebSocketObserverFunc func(context.Context, pluginapi.WebSocketResponseEvent) error
func (f testWebSocketObserverFunc) ObserveWebSocketResponseEvent(ctx context.Context, event pluginapi.WebSocketResponseEvent) error {
return f(ctx, event)
}
func TestObserveWebSocketResponseEventInvokesPlugin(t *testing.T) {
var got pluginapi.WebSocketResponseEvent
called := false
host := newHostWithRecords(capabilityRecord{
id: "quota-tracker",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
WebSocketResponseObserver: testWebSocketObserverFunc(func(_ context.Context, event pluginapi.WebSocketResponseEvent) error {
got = event
called = true
return nil
}),
},
},
})
rawPayload := []byte(`{"type":"codex.rate_limits","rate_limits":{"primary":{"used_percent":42}}}`)
host.ObserveWebSocketResponseEvent(context.Background(), pluginapi.WebSocketResponseEvent{
RequestID: "req-123",
SourceFormat: "openai",
Model: "gpt-5.3-codex",
RequestedModel: "gpt-5.3-codex",
Provider: "codex",
AuthID: "auth-abc",
AuthLabel: "test-auth",
AuthType: "oauth",
EventType: "codex.rate_limits",
Payload: rawPayload,
})
if !called {
t.Fatal("observer callback was not invoked")
}
if got.RequestID != "req-123" {
t.Fatalf("RequestID = %q, want req-123", got.RequestID)
}
if got.Provider != "codex" {
t.Fatalf("Provider = %q, want codex", got.Provider)
}
if got.AuthID != "auth-abc" || got.AuthLabel != "test-auth" {
t.Fatalf("Auth = (%q, %q), want (auth-abc, test-auth)", got.AuthID, got.AuthLabel)
}
if got.EventType != "codex.rate_limits" {
t.Fatalf("EventType = %q, want codex.rate_limits", got.EventType)
}
if !bytes.Equal(got.Payload, rawPayload) {
t.Fatalf("Payload = %s, want %s", got.Payload, rawPayload)
}
}
func TestObserveWebSocketResponseEventClonesPayloadAndMetadata(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
originalPayload := []byte(`{"type":"codex.rate_limits","rate_limits":{"primary":{"used_percent":1}}}`)
originalMetadata := map[string]any{"key": "value"}
called := false
host := newHostWithRecords(capabilityRecord{
id: "observer-clone",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
WebSocketResponseObserver: testWebSocketObserverFunc(func(_ context.Context, event pluginapi.WebSocketResponseEvent) error {
event.Payload[0] = 'X'
event.Metadata["key"] = "mutated"
called = true
return nil
}),
},
},
})
host.ObserveWebSocketResponseEvent(ctx, pluginapi.WebSocketResponseEvent{
RequestID: "req-clone",
Payload: originalPayload,
Metadata: originalMetadata,
})
if !called {
t.Fatal("observer callback was not invoked")
}
if originalPayload[0] != '{' {
t.Fatalf("original payload was mutated: %s", originalPayload)
}
if originalMetadata["key"] != "value" {
t.Fatalf("original metadata was mutated: %#v", originalMetadata)
}
}
func TestObserveWebSocketResponseEventFusesOnPanic(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "panicking-observer",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
WebSocketResponseObserver: testWebSocketObserverFunc(func(context.Context, pluginapi.WebSocketResponseEvent) error {
panic("observer panic")
}),
},
},
})
if !host.HasWebSocketResponseObservers() {
t.Fatal("HasWebSocketResponseObservers() = false, want true")
}
host.ObserveWebSocketResponseEvent(context.Background(), pluginapi.WebSocketResponseEvent{
RequestID: "req-panic",
Payload: []byte(`{"type":"test"}`),
})
if !host.isPluginFused("panicking-observer") {
t.Fatal("isPluginFused(panicking-observer) = false, want true")
}
if host.HasWebSocketResponseObservers() {
t.Fatal("HasWebSocketResponseObservers() = true after fusing, want false")
}
}
func TestObserveWebSocketResponseEventSkipPlugin(t *testing.T) {
calls := 0
host := newHostWithRecords(capabilityRecord{
id: "skipped-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
WebSocketResponseObserver: testWebSocketObserverFunc(func(context.Context, pluginapi.WebSocketResponseEvent) error {
calls++
return nil
}),
},
},
})
host.ObserveWebSocketResponseEventExcept(context.Background(), pluginapi.WebSocketResponseEvent{
RequestID: "req-skip",
}, "skipped-plugin")
if calls != 0 {
t.Fatalf("observer calls = %d, want 0 when skipped", calls)
}
}
func TestRegisterRPCPluginRegistersWebSocketResponseObserver(t *testing.T) {
lookup := newTestSymbolLookup(&testPlugin{
registerResult: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
WebSocketResponseObserver: testWebSocketObserverFunc(func(context.Context, pluginapi.WebSocketResponseEvent) error {
return nil
}),
},
},
})
registered, errRegister := registerRPCPlugin(context.Background(), nil, "rpc-observer", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v", errRegister)
}
if registered.Capabilities.WebSocketResponseObserver == nil {
t.Fatal("WebSocketResponseObserver = nil, want RPC adapter")
}
}
type rpcObserverRecordingClient struct {
lastMethod string
lastRequest []byte
}
func (c *rpcObserverRecordingClient) Call(_ context.Context, method string, request []byte) ([]byte, error) {
c.lastMethod = method
c.lastRequest = bytes.Clone(request)
return json.Marshal(pluginabi.Envelope{OK: true, Result: json.RawMessage(`{}`)})
}
func (c *rpcObserverRecordingClient) Shutdown() {}
func TestObserveWebSocketResponseEventRPCSanitizesMetadata(t *testing.T) {
client := &rpcObserverRecordingClient{}
adapter := &rpcPluginAdapter{
id: "rpc-observer-sanitize",
client: client,
}
rawPayload := []byte(`{"type":"codex.rate_limits","rate_limits":{"primary":{"used_percent":99}}}`)
unserializableMetadata := map[string]any{
"safe_key": "safe_value",
"func_field": func() {},
"chan_field": make(chan int),
}
err := adapter.ObserveWebSocketResponseEvent(context.Background(), pluginapi.WebSocketResponseEvent{
RequestID: "req-rpc-sanitize",
SourceFormat: "openai",
Model: "gpt-5.3-codex",
RequestedModel: "gpt-5.3-codex",
Provider: "codex",
AuthID: "auth-xyz",
AuthLabel: "test-auth-rpc",
AuthType: "oauth",
EventType: "codex.rate_limits",
Payload: rawPayload,
Metadata: unserializableMetadata,
})
if err != nil {
t.Fatalf("ObserveWebSocketResponseEvent() error = %v", err)
}
if client.lastMethod != pluginabi.MethodWebSocketResponseEvent {
t.Fatalf("lastMethod = %q, want %q", client.lastMethod, pluginabi.MethodWebSocketResponseEvent)
}
var decoded rpcWebSocketResponseEvent
if errUnmarshal := json.Unmarshal(client.lastRequest, &decoded); errUnmarshal != nil {
t.Fatalf("unmarshal rpc request: %v", errUnmarshal)
}
if decoded.RequestID != "req-rpc-sanitize" {
t.Fatalf("decoded RequestID = %q, want req-rpc-sanitize", decoded.RequestID)
}
if decoded.EventType != "codex.rate_limits" {
t.Fatalf("decoded EventType = %q, want codex.rate_limits", decoded.EventType)
}
if decoded.Metadata["safe_key"] != "safe_value" {
t.Fatalf("decoded safe_key = %v, want safe_value", decoded.Metadata["safe_key"])
}
if _, exists := decoded.Metadata["func_field"]; exists {
t.Fatalf("func_field was not sanitized: %#v", decoded.Metadata)
}
if _, exists := decoded.Metadata["chan_field"]; exists {
t.Fatalf("chan_field was not sanitized: %#v", decoded.Metadata)
}
}