Skip to content

Commit 996f7bb

Browse files
Merge upstream/main (auto-sync feat/copilot)
- 3c773b6 feat(auto-updater): refactor skip logic and add unit tests for autoUpdateSkipReason - 1ca048a feat(auth, interceptor, jshandler): add post-auth request interceptors and enhance format handling - 58bf645 feat(translator): ensure correct finish_reason handling for all response chunks - dc04d8b feat(translator): enhance response aggregation and annotation handling - 4cbd500 feat(service): add XAIExecutor to home executors registration - ca1f627 feat(executor): refactor executor registration - 9985976 feat(translator, pluginhost): add stream-specific response transformation support - 030c4a9 Merge pull request router-for-me#3795 from router-for-me/webui - ac4017e Merge branch 'dev' of github.com:router-for-me/CLIProxyAPI into dev
2 parents aa6a681 + ac4017e commit 996f7bb

33 files changed

Lines changed: 1559 additions & 276 deletions

examples/plugin/jshandler/README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A CLIProxyAPI plugin that executes external JavaScript scripts to intercept and
44

55
## Features
66

7-
- **Request Interception** (`on_before_request`): Modify request payloads and headers before upstream delivery.
7+
- **Request Interception** (`on_before_request`, `on_after_auth_request`): Modify request payloads and headers before and after credential selection.
88
- **Response Interception** (`on_after_nonstream_response`): Modify non-streaming response bodies and headers.
99
- **Stream Chunk Interception** (`on_after_stream_response`): Modify individual streaming chunks with read-only `history_chunks` context.
1010
- **Hot Reload**: Scripts are automatically reloaded when modified on disk.
@@ -40,7 +40,7 @@ Scripts can export these global functions:
4040

4141
### `on_before_request(ctx)`
4242

43-
Called before the request is sent upstream.
43+
Called before credential selection. At this point the target upstream protocol is not selected yet.
4444

4545
**ctx structure:**
4646
```javascript
@@ -50,7 +50,31 @@ Called before the request is sent upstream.
5050
"headers": {}, // Request headers
5151
"url": "",
5252
"model": "gpt-4",
53-
"protocol": "openai"
53+
"protocol": "openai",
54+
"source_format": "openai",
55+
"sourceFormat": "openai",
56+
"to_format": "",
57+
"toFormat": ""
58+
}
59+
```
60+
61+
### `on_after_auth_request(ctx)`
62+
63+
Called after credential selection and before request translation, request normalization, and built-in payload configuration.
64+
65+
**ctx structure:**
66+
```javascript
67+
{
68+
"id": "request-id",
69+
"body": "...", // Request body string
70+
"headers": {}, // Request headers
71+
"url": "",
72+
"model": "gpt-4",
73+
"protocol": "openai", // Same as source_format
74+
"source_format": "openai",
75+
"sourceFormat": "openai",
76+
"to_format": "codex",
77+
"toFormat": "codex"
5478
}
5579
```
5680

examples/plugin/jshandler/abi.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,14 @@ func handleJSHandlerABIMethod(ctx context.Context, method string, request []byte
218218
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
219219
return nil, errDecode
220220
}
221-
resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, req.HostCallbackID)
221+
resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, "on_before_request", req.HostCallbackID)
222+
return abiOKEnvelopeWithError(resp, errCall)
223+
case pluginabi.MethodRequestInterceptAfter:
224+
var req abiRequestInterceptRequest
225+
if errDecode := json.Unmarshal(request, &req); errDecode != nil {
226+
return nil, errDecode
227+
}
228+
resp, errCall := p.interceptRequest(ctx, req.RequestInterceptRequest, "on_after_auth_request", req.HostCallbackID)
222229
return abiOKEnvelopeWithError(resp, errCall)
223230
case pluginabi.MethodResponseInterceptAfter:
224231
var req abiResponseInterceptRequest

examples/plugin/jshandler/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ require (
1616
golang.org/x/sys v0.38.0 // indirect
1717
golang.org/x/text v0.31.0 // indirect
1818
)
19+
20+
replace github.com/router-for-me/CLIProxyAPI/v7 => ../../..

examples/plugin/jshandler/interceptor.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,15 @@ func (p *jsHandlerPlugin) allScriptPaths() []string {
4444
return paths
4545
}
4646

47-
func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
48-
return p.interceptRequest(ctx, req, "")
47+
func (p *jsHandlerPlugin) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
48+
return p.interceptRequest(ctx, req, "on_before_request", "")
4949
}
5050

51-
func (p *jsHandlerPlugin) interceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest, hostCallbackID string) (pluginapi.RequestInterceptResponse, error) {
51+
func (p *jsHandlerPlugin) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
52+
return p.interceptRequest(ctx, req, "on_after_auth_request", "")
53+
}
54+
55+
func (p *jsHandlerPlugin) interceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest, hookName, hostCallbackID string) (pluginapi.RequestInterceptResponse, error) {
5256
resp := pluginapi.RequestInterceptResponse{}
5357
scriptPaths := p.allScriptPaths()
5458
if len(scriptPaths) == 0 {
@@ -64,7 +68,7 @@ func (p *jsHandlerPlugin) interceptRequest(ctx context.Context, req pluginapi.Re
6468
if scriptPath == "" {
6569
continue
6670
}
67-
processed, cleared, errJS := p.applyJSBeforeRequest(scriptPath, []byte(body), req.Model, req.SourceFormat, headers, hostCallbackID)
71+
processed, cleared, errJS := p.applyJSRequestHook(scriptPath, hookName, []byte(body), req.Model, req.SourceFormat, req.ToFormat, headers, hostCallbackID)
6872
if errJS != nil {
6973
log.Warnf("failed to execute JS request interceptor [%s]: %v", scriptPath, errJS)
7074
continue
@@ -197,7 +201,7 @@ func (p *jsHandlerPlugin) interceptStreamChunk(ctx context.Context, req pluginap
197201
return resp, nil
198202
}
199203

200-
func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes []byte, model, protocol string, headers http.Header, hostCallbackID string) ([]byte, []string, error) {
204+
func (p *jsHandlerPlugin) applyJSRequestHook(scriptPath, hookName string, payloadBytes []byte, model, sourceFormat, toFormat string, headers http.Header, hostCallbackID string) ([]byte, []string, error) {
201205
program, err := getJSProgram(scriptPath)
202206
if err != nil {
203207
return nil, nil, err
@@ -211,20 +215,24 @@ func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes [
211215
headersMap := headerToAnyMap(headers)
212216

213217
jsCtx := map[string]any{
214-
"id": generateRequestID(),
215-
"body": string(payloadBytes),
216-
"headers": headersMap,
217-
"url": "",
218-
"model": model,
219-
"protocol": protocol,
218+
"id": generateRequestID(),
219+
"body": string(payloadBytes),
220+
"headers": headersMap,
221+
"url": "",
222+
"model": model,
223+
"protocol": sourceFormat,
224+
"source_format": sourceFormat,
225+
"to_format": toFormat,
226+
"sourceFormat": sourceFormat,
227+
"toFormat": toFormat,
220228
}
221229

222-
jsVal, errCall := engine.callFunction("on_before_request", p.cfg.Timeout, jsCtx)
230+
jsVal, errCall := engine.callFunction(hookName, p.cfg.Timeout, jsCtx)
223231
if errCall != nil {
224232
if errors.Is(errCall, ErrFunctionNotFound) {
225233
return payloadBytes, nil, nil
226234
}
227-
return nil, nil, fmt.Errorf("on_before_request failed for %s: %w", scriptPath, errCall)
235+
return nil, nil, fmt.Errorf("%s failed for %s: %w", hookName, scriptPath, errCall)
228236
}
229237

230238
if jsVal == nil || goja.IsUndefined(jsVal) || goja.IsNull(jsVal) {

examples/plugin/jshandler/interceptor_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,18 @@ function on_before_request(ctx) {
2525

2626
plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()}
2727
headers := http.Header{"X-Plugin": []string{"original"}}
28-
processed, _, errApply := plugin.applyJSBeforeRequest(
28+
processed, _, errApply := plugin.applyJSRequestHook(
2929
scriptPath,
30+
"on_before_request",
3031
[]byte(`{"messages":[{"role":"user","content":"contains sensitive_word"}]}`),
3132
"gpt-test",
3233
"openai",
34+
"",
3335
headers,
3436
"",
3537
)
3638
if errApply != nil {
37-
t.Fatalf("applyJSBeforeRequest() error = %v", errApply)
39+
t.Fatalf("applyJSRequestHook() error = %v", errApply)
3840
}
3941
if body := string(processed); !strings.Contains(body, "safe_word") || strings.Contains(body, "sensitive_word") {
4042
t.Fatalf("processed body = %q, want sensitive word rewritten", body)
@@ -44,6 +46,50 @@ function on_before_request(ctx) {
4446
}
4547
}
4648

49+
func TestApplyJSAfterAuthRequestReceivesFormats(t *testing.T) {
50+
scriptPath := filepath.Join(t.TempDir(), "after_auth.js")
51+
script := `
52+
function on_after_auth_request(ctx) {
53+
if (ctx.source_format !== "openai" || ctx.to_format !== "codex") {
54+
throw new Error("unexpected formats: " + ctx.source_format + " -> " + ctx.to_format);
55+
}
56+
if (ctx.sourceFormat !== "openai" || ctx.toFormat !== "codex") {
57+
throw new Error("unexpected camel formats: " + ctx.sourceFormat + " -> " + ctx.toFormat);
58+
}
59+
var req = JSON.parse(ctx.body);
60+
req.after_auth = ctx.source_format + "_to_" + ctx.to_format;
61+
ctx.headers["X-Protocol"] = req.after_auth;
62+
ctx.body = JSON.stringify(req);
63+
return ctx;
64+
}
65+
`
66+
if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil {
67+
t.Fatalf("os.WriteFile() error = %v", errWrite)
68+
}
69+
70+
plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()}
71+
headers := http.Header{}
72+
processed, _, errApply := plugin.applyJSRequestHook(
73+
scriptPath,
74+
"on_after_auth_request",
75+
[]byte(`{"model":"gpt-test"}`),
76+
"gpt-test",
77+
"openai",
78+
"codex",
79+
headers,
80+
"",
81+
)
82+
if errApply != nil {
83+
t.Fatalf("applyJSRequestHook() error = %v", errApply)
84+
}
85+
if body := string(processed); !strings.Contains(body, `"after_auth":"openai_to_codex"`) {
86+
t.Fatalf("processed body = %q, want after_auth marker", body)
87+
}
88+
if got := headers.Get("X-Protocol"); got != "openai_to_codex" {
89+
t.Fatalf("header X-Protocol = %q, want openai_to_codex", got)
90+
}
91+
}
92+
4793
func TestApplyJSAfterResponseUsesFrozenNativeHistoryChunks(t *testing.T) {
4894
scriptPath := filepath.Join(t.TempDir(), "stream.js")
4995
script := `

examples/plugin/jshandler/scripts/copilot_handler.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ function on_before_request(ctx) {
1717
return ctx;
1818
}
1919

20+
function on_after_auth_request(ctx) {
21+
console.log("[" + ctx.id + "] Selected request protocol: " + ctx.source_format + " -> " + ctx.to_format);
22+
if (ctx.source_format === "openai" && ctx.to_format === "codex") {
23+
ctx.headers["X-JS-Handler-Protocol"] = "openai-to-codex";
24+
}
25+
return ctx;
26+
}
27+
2028
function parse_stream_chunk(chunk) {
2129
var leading = "";
2230
var payload = chunk.trim();

internal/managementasset/updater.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,8 @@ func runAutoUpdater(ctx context.Context) {
8181

8282
runOnce := func() {
8383
cfg := currentConfigPtr.Load()
84-
if cfg == nil {
85-
log.Debug("management asset auto-updater skipped: config not yet available")
86-
return
87-
}
88-
if cfg.RemoteManagement.DisableControlPanel {
89-
log.Debug("management asset auto-updater skipped: control panel disabled")
90-
return
91-
}
92-
if cfg.RemoteManagement.DisableAutoUpdatePanel {
93-
log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled")
84+
if reason, skip := autoUpdateSkipReason(cfg); skip {
85+
log.Debugf("management asset auto-updater skipped: %s", reason)
9486
return
9587
}
9688

@@ -111,6 +103,22 @@ func runAutoUpdater(ctx context.Context) {
111103
}
112104
}
113105

106+
func autoUpdateSkipReason(cfg *config.Config) (string, bool) {
107+
if cfg == nil {
108+
return "config not yet available", true
109+
}
110+
if cfg.Home.Enabled {
111+
return "cluster mode enabled", true
112+
}
113+
if cfg.RemoteManagement.DisableControlPanel {
114+
return "control panel disabled", true
115+
}
116+
if cfg.RemoteManagement.DisableAutoUpdatePanel {
117+
return "disable-auto-update-panel is enabled", true
118+
}
119+
return "", false
120+
}
121+
114122
func newHTTPClient(proxyURL string) *http.Client {
115123
client := &http.Client{Timeout: 15 * time.Second}
116124

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package managementasset
2+
3+
import (
4+
"testing"
5+
6+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
7+
)
8+
9+
func TestAutoUpdateSkipReason(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
cfg *config.Config
13+
wantReason string
14+
wantSkip bool
15+
}{
16+
{
17+
name: "nil config",
18+
cfg: nil,
19+
wantReason: "config not yet available",
20+
wantSkip: true,
21+
},
22+
{
23+
name: "cluster mode",
24+
cfg: &config.Config{
25+
Home: config.HomeConfig{Enabled: true},
26+
},
27+
wantReason: "cluster mode enabled",
28+
wantSkip: true,
29+
},
30+
{
31+
name: "control panel disabled",
32+
cfg: &config.Config{
33+
RemoteManagement: config.RemoteManagement{DisableControlPanel: true},
34+
},
35+
wantReason: "control panel disabled",
36+
wantSkip: true,
37+
},
38+
{
39+
name: "auto update disabled",
40+
cfg: &config.Config{
41+
RemoteManagement: config.RemoteManagement{DisableAutoUpdatePanel: true},
42+
},
43+
wantReason: "disable-auto-update-panel is enabled",
44+
wantSkip: true,
45+
},
46+
{
47+
name: "enabled",
48+
cfg: &config.Config{},
49+
wantReason: "",
50+
wantSkip: false,
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
gotReason, gotSkip := autoUpdateSkipReason(tt.cfg)
57+
if gotReason != tt.wantReason || gotSkip != tt.wantSkip {
58+
t.Fatalf("autoUpdateSkipReason() = (%q, %t), want (%q, %t)", gotReason, gotSkip, tt.wantReason, tt.wantSkip)
59+
}
60+
})
61+
}
62+
}

0 commit comments

Comments
 (0)