From 94d6eb535eb92daeb807ae32e43cd691cd19a63f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 13 Sep 2026 22:12:28 +0800 Subject: [PATCH] docs(config): document payload filter examples for codex tools - Add example payload filter rules in `config.example.yaml` for stripping tools from both flat and nested `additional_tools` Codex request payloads. Closes: #5792 --- config.example.yaml | 8 + .../api/middleware/request_logging_test.go | 60 ++++ internal/api/server_test.go | 261 ++++++++++++++++++ .../executor/helps/payload_mutations_test.go | 191 +++++++++++++ 4 files changed, 520 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index daab028ad..25d7ee49c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -876,3 +876,11 @@ nonstream-keepalive-interval: 0 # params: # JSON paths (gjson/sjson syntax) to remove from the payload # - "generationConfig.thinkingConfig.thinkingBudget" # - "generationConfig.responseJsonSchema" +# - models: +# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") +# protocol: "codex" +# params: +# # Flat tools array (standard OpenAI format or Codex CLI < 0.154): +# - 'tools.#(name=="apply_patch")' +# # Nested additional_tools structure in input[] (Codex CLI >= 0.154 "Responses Lite"): +# - 'input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#' diff --git a/internal/api/middleware/request_logging_test.go b/internal/api/middleware/request_logging_test.go index 6715fb09d..0178674a0 100644 --- a/internal/api/middleware/request_logging_test.go +++ b/internal/api/middleware/request_logging_test.go @@ -262,6 +262,66 @@ func TestRequestLoggingMiddlewareCapturesLargeErrorRequestAndDeferredAPIRequest( } } +func TestRequestLoggingMiddleware_StreamingResponsesUpstreamSections(t *testing.T) { + gin.SetMode(gin.TestMode) + + logsDir := t.TempDir() + logger := logging.NewFileRequestLogger(true, logsDir, "", 10) + cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}} + + router := gin.New() + router.Use(RequestLoggingMiddleware(logger)) + router.POST("/v1/responses", func(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + executorCtx := context.WithValue(context.Background(), "gin", c) + helps.RecordAPIRequest(executorCtx, cfg, helps.UpstreamRequestLog{ + URL: "https://api.example.com/v1/responses", + Method: http.MethodPost, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + Body: []byte(`{"model":"gpt-5-codex","input":[]}`), + }) + helps.AppendAPIResponseChunk(executorCtx, cfg, []byte("data: {\"type\":\"response.output_item.added\"}\n\n")) + _, _ = c.Writer.Write([]byte("data: {\"type\":\"response.output_item.added\"}\n\n")) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + }) + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-codex","input":[],"stream":true}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("response status = %d, want %d", response.Code, http.StatusOK) + } + + entries, errReadDir := os.ReadDir(logsDir) + if errReadDir != nil { + t.Fatalf("read logs dir: %v", errReadDir) + } + var logPath string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") { + logPath = logsDir + string(os.PathSeparator) + entry.Name() + break + } + } + if logPath == "" { + t.Fatal("streaming request log was not created") + } + content, errReadLog := os.ReadFile(logPath) + if errReadLog != nil { + t.Fatalf("read log file: %v", errReadLog) + } + if !bytes.Contains(content, []byte("=== API REQUEST 1 ===")) { + t.Fatalf("streaming log missing API REQUEST: %s", string(content)) + } + if !bytes.Contains(content, []byte("=== API RESPONSE 1 ===")) { + t.Fatalf("streaming log missing API RESPONSE: %s", string(content)) + } +} + func TestAttachRequestLogSourcesUsesLoggerLogsDir(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 6eb8342cd..a485a5b1b 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -26,6 +26,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + runtimehelps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" @@ -2707,3 +2708,263 @@ func TestUpdateClientsContext_AntigravityConnectionPoolPurgesTransports(t *testi t.Fatalf("AntigravityTransportsLen() after reload = %d, want 0", got) } } + +type mockServerStreamingCaptureExecutor struct { + cfg *proxyconfig.Config + capturedCtx context.Context + executionCalls int +} + +func (e *mockServerStreamingCaptureExecutor) Identifier() string { return "codex-test" } + +func (e *mockServerStreamingCaptureExecutor) Execute(ctx context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) { + e.capturedCtx = ctx + return coreexecutor.Response{Payload: []byte(`{"id":"resp-1","status":"completed"}`)}, nil +} + +func (e *mockServerStreamingCaptureExecutor) ExecuteStream(ctx context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) { + e.capturedCtx = ctx + e.executionCalls++ + + runtimehelps.RecordAPIRequest(ctx, e.cfg, runtimehelps.UpstreamRequestLog{ + URL: "https://api.example.com/v1/responses", + Method: http.MethodPost, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + Body: []byte(`{"model":"gpt-5-codex","input":[]}`), + }) + + ch := make(chan coreexecutor.StreamChunk, 2) + chunkPayload := []byte("event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\"}\n\n") + runtimehelps.AppendAPIResponseChunk(ctx, e.cfg, chunkPayload) + ch <- coreexecutor.StreamChunk{Payload: chunkPayload} + + terminalPayload := []byte("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n") + runtimehelps.AppendAPIResponseChunk(ctx, e.cfg, terminalPayload) + ch <- coreexecutor.StreamChunk{Payload: terminalPayload} + close(ch) + + return &coreexecutor.StreamResult{Chunks: ch}, nil +} + +func (e *mockServerStreamingCaptureExecutor) Refresh(_ context.Context, auth *auth.Auth) (*auth.Auth, error) { + return auth, nil +} + +func (e *mockServerStreamingCaptureExecutor) CountTokens(_ context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, errors.New("not implemented") +} + +func (e *mockServerStreamingCaptureExecutor) HttpRequest(_ context.Context, _ *auth.Auth, _ *http.Request) (*http.Response, error) { + return nil, errors.New("not implemented") +} + +func TestServerResponsesStreamingRequestLogCapturesUpstreamSections(t *testing.T) { + gin.SetMode(gin.TestMode) + + tmpDir := t.TempDir() + logsDir := filepath.Join(tmpDir, "logs") + if err := os.MkdirAll(logsDir, 0o700); err != nil { + t.Fatalf("failed to create logs dir: %v", err) + } + + mockLogger := internallogging.NewFileRequestLogger(true, logsDir, "", 10) + server := newTestServerWithOptions(t, WithRequestLoggerFactory(func(*proxyconfig.Config, string) internallogging.RequestLogger { + return mockLogger + })) + server.cfg.RequestLog = true + server.cfg.LoggingToFile = true + + mockExec := &mockServerStreamingCaptureExecutor{cfg: server.cfg} + server.handlers.AuthManager.RegisterExecutor(mockExec) + + credential := &auth.Auth{ + ID: "codex-stream-auth", + Provider: mockExec.Identifier(), + Status: auth.StatusActive, + } + if _, err := server.handlers.AuthManager.Register(context.Background(), credential); err != nil { + t.Fatalf("register auth: %v", err) + } + registry.GetGlobalRegistry().RegisterClient(credential.ID, credential.Provider, []*registry.ModelInfo{{ID: "gpt-5-codex"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(credential.ID) + }) + + rr := httptest.NewRecorder() + body := `{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hi"}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-key") + req.Header.Set("Content-Type", "application/json") + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if mockExec.executionCalls != 1 { + t.Fatalf("executor calls = %d, want 1", mockExec.executionCalls) + } + + entries, errReadDir := os.ReadDir(logsDir) + if errReadDir != nil { + t.Fatalf("read logs dir: %v", errReadDir) + } + var logPath string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") { + logPath = filepath.Join(logsDir, entry.Name()) + break + } + } + if logPath == "" { + t.Fatal("streaming request log was not created in logs dir") + } + content, errReadLog := os.ReadFile(logPath) + if errReadLog != nil { + t.Fatalf("read log file: %v", errReadLog) + } + logText := string(content) + apiRequestIdx := strings.Index(logText, "=== API REQUEST 1 ===") + if apiRequestIdx == -1 { + t.Fatalf("streaming log missing API REQUEST 1:\n%s", logText) + } + apiResponseIdx := strings.Index(logText, "=== API RESPONSE 1 ===") + if apiResponseIdx == -1 { + t.Fatalf("streaming log missing API RESPONSE 1:\n%s", logText) + } + downstreamResponseIdx := strings.Index(logText, "=== RESPONSE ===") + if downstreamResponseIdx == -1 { + t.Fatalf("streaming log missing downstream RESPONSE:\n%s", logText) + } + if apiRequestIdx >= apiResponseIdx || apiResponseIdx >= downstreamResponseIdx { + t.Fatalf("unexpected section order (req=%d, apiResp=%d, resp=%d):\n%s", apiRequestIdx, apiResponseIdx, downstreamResponseIdx, logText) + } + apiRequestSection := logText[apiRequestIdx:apiResponseIdx] + apiResponseSection := logText[apiResponseIdx:downstreamResponseIdx] + if !strings.Contains(apiRequestSection, "https://api.example.com/v1/responses") { + t.Fatalf("API REQUEST section missing upstream URL:\n%s", apiRequestSection) + } + if !strings.Contains(apiRequestSection, `"gpt-5-codex"`) { + t.Fatalf("API REQUEST section missing request body:\n%s", apiRequestSection) + } + if !strings.Contains(apiResponseSection, "response.output_item.added") { + t.Fatalf("API RESPONSE section missing response chunk data:\n%s", apiResponseSection) + } +} + +func TestServerCodexAPIKeyResponsesStreamingRequestLog(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstreamReceived := make(chan struct{}, 1) + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case upstreamReceived <- struct{}{}: + default: + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: {\"type\":\"response.output_item.added\"}\n\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\"}\n\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + })) + defer mockUpstream.Close() + + tmpDir := t.TempDir() + logsDir := filepath.Join(tmpDir, "logs") + if err := os.MkdirAll(logsDir, 0o700); err != nil { + t.Fatalf("failed to create logs dir: %v", err) + } + + mockLogger := internallogging.NewFileRequestLogger(true, logsDir, "", 10) + server := newTestServerWithOptions(t, WithRequestLoggerFactory(func(*proxyconfig.Config, string) internallogging.RequestLogger { + return mockLogger + })) + server.cfg.RequestLog = true + server.cfg.LoggingToFile = true + + codexExec := executor.NewCodexExecutor(server.cfg) + server.handlers.AuthManager.RegisterExecutor(codexExec) + + credential := &auth.Auth{ + ID: "codex-api-key-test", + Provider: "codex", + Status: auth.StatusActive, + Attributes: map[string]string{ + auth.AttributeAPIKey: "test-codex-key", + "base_url": mockUpstream.URL, + }, + } + if _, err := server.handlers.AuthManager.Register(context.Background(), credential); err != nil { + t.Fatalf("register auth: %v", err) + } + registry.GetGlobalRegistry().RegisterClient(credential.ID, credential.Provider, []*registry.ModelInfo{{ID: "gpt-5-codex"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(credential.ID) + }) + + rr := httptest.NewRecorder() + body := `{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hello"}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-key") + req.Header.Set("Content-Type", "application/json") + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + select { + case <-upstreamReceived: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for upstream request") + } + + entries, errReadDir := os.ReadDir(logsDir) + if errReadDir != nil { + t.Fatalf("read logs dir: %v", errReadDir) + } + var logPath string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") { + logPath = filepath.Join(logsDir, entry.Name()) + break + } + } + if logPath == "" { + t.Fatal("streaming request log was not created in logs dir") + } + content, errReadLog := os.ReadFile(logPath) + if errReadLog != nil { + t.Fatalf("read log file: %v", errReadLog) + } + logText := string(content) + apiRequestIdx := strings.Index(logText, "=== API REQUEST 1 ===") + if apiRequestIdx == -1 { + t.Fatalf("streaming log missing API REQUEST 1:\n%s", logText) + } + apiResponseIdx := strings.Index(logText, "=== API RESPONSE 1 ===") + if apiResponseIdx == -1 { + t.Fatalf("streaming log missing API RESPONSE 1:\n%s", logText) + } + downstreamResponseIdx := strings.Index(logText, "=== RESPONSE ===") + if downstreamResponseIdx == -1 { + t.Fatalf("streaming log missing downstream RESPONSE:\n%s", logText) + } + if apiRequestIdx >= apiResponseIdx || apiResponseIdx >= downstreamResponseIdx { + t.Fatalf("unexpected section order (req=%d, apiResp=%d, resp=%d):\n%s", apiRequestIdx, apiResponseIdx, downstreamResponseIdx, logText) + } + apiRequestSection := logText[apiRequestIdx:apiResponseIdx] + apiResponseSection := logText[apiResponseIdx:downstreamResponseIdx] + if !strings.Contains(apiRequestSection, mockUpstream.URL) { + t.Fatalf("API REQUEST section missing upstream URL %s:\n%s", mockUpstream.URL, apiRequestSection) + } + if !strings.Contains(apiRequestSection, `"gpt-5-codex"`) { + t.Fatalf("API REQUEST section missing request body:\n%s", apiRequestSection) + } + if !strings.Contains(apiResponseSection, "response.output_item.added") { + t.Fatalf("API RESPONSE section missing response chunk data:\n%s", apiResponseSection) + } +} diff --git a/internal/runtime/executor/helps/payload_mutations_test.go b/internal/runtime/executor/helps/payload_mutations_test.go index 500b12923..58df159d4 100644 --- a/internal/runtime/executor/helps/payload_mutations_test.go +++ b/internal/runtime/executor/helps/payload_mutations_test.go @@ -3,6 +3,7 @@ package helps import ( "bytes" "encoding/json" + "os" "strings" "testing" @@ -280,3 +281,193 @@ func BenchmarkSetStringIfDifferentLargeCanonicalPayload(b *testing.B) { benchmarkPayloadMutationOutput = SetStringIfDifferent(input, "model", "gpt-test") } } + +func TestConfigExampleDocumentsCodexAdditionalToolsPayloadFilter(t *testing.T) { + data, err := os.ReadFile("../../../../config.example.yaml") + if err != nil { + t.Fatalf("failed to read config.example.yaml: %v", err) + } + content := string(data) + const expectedPath = `'input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#'` + if !strings.Contains(content, expectedPath) { + t.Fatalf("config.example.yaml does not contain expected additional_tools path: %s", expectedPath) + } +} + +func TestApplyPayloadConfig_CodexAdditionalToolsFilter(t *testing.T) { + cfg := &config.Config{ + Payload: config.PayloadConfig{ + Filter: []config.PayloadFilterRule{ + { + Models: []config.PayloadModelRule{{Name: "gpt-*", Protocol: "codex"}}, + Params: []string{ + `input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#`, + }, + }, + }, + }, + } + + t.Run("removes target tool and preserves other tools across namespaces", func(t *testing.T) { + input := []byte(`{ + "model": "gpt-5-codex", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "apply_patch", "description": "patch"}, + {"type": "custom", "name": "exec_command", "description": "exec"} + ] + }, + { + "type": "namespace", + "name": "collaboration", + "tools": [ + {"type": "custom", "name": "share", "description": "share"} + ] + } + ] + } + ] + }`) + + output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "") + + var structured struct { + Input []struct { + Type string `json:"type"` + Tools []struct { + Name string `json:"name"` + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"tools"` + } `json:"input"` + } + if errUnmarshal := json.Unmarshal(output, &structured); errUnmarshal != nil { + t.Fatalf("failed to unmarshal output: %v", errUnmarshal) + } + if len(structured.Input) != 1 { + t.Fatalf("input len = %d, want 1", len(structured.Input)) + } + if len(structured.Input[0].Tools) != 2 { + t.Fatalf("namespaces len = %d, want 2", len(structured.Input[0].Tools)) + } + fnNamespace := structured.Input[0].Tools[0] + if fnNamespace.Name != "functions" || len(fnNamespace.Tools) != 1 || fnNamespace.Tools[0].Name != "exec_command" { + t.Fatalf("functions namespace tools = %+v, want only exec_command", fnNamespace.Tools) + } + collabNamespace := structured.Input[0].Tools[1] + if collabNamespace.Name != "collaboration" || len(collabNamespace.Tools) != 1 || collabNamespace.Tools[0].Name != "share" { + t.Fatalf("collaboration namespace tools = %+v, want share", collabNamespace.Tools) + } + }) + + t.Run("non-matching target tool is a no-op", func(t *testing.T) { + input := []byte(`{ + "model": "gpt-5-codex", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "exec_command", "description": "exec"} + ] + } + ] + } + ] + }`) + + output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "") + if !bytes.Equal(output, input) { + t.Fatalf("expected payload to be untouched when rule does not match, got: %s", string(output)) + } + }) + + t.Run("removes matches across multiple additional_tools elements", func(t *testing.T) { + input := []byte(`{ + "model": "gpt-5-codex", + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [{"type": "custom", "name": "apply_patch"}] + } + ] + }, + { + "type": "message", + "role": "user", + "content": "hello" + }, + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "apply_patch"}, + {"type": "custom", "name": "view_image"} + ] + } + ] + } + ] + }`) + + output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "") + if strings.Contains(string(output), "apply_patch") { + t.Fatalf("apply_patch remained after multi-element filter: %s", string(output)) + } + if !strings.Contains(string(output), "view_image") { + t.Fatalf("view_image was removed: %s", string(output)) + } + if !strings.Contains(string(output), "hello") { + t.Fatalf("user message was removed: %s", string(output)) + } + }) + + t.Run("removes multiple matches within the same namespace array", func(t *testing.T) { + input := []byte(`{ + "model": "gpt-5-codex", + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "apply_patch", "id": "p1"}, + {"type": "custom", "name": "exec_command"}, + {"type": "custom", "name": "apply_patch", "id": "p2"} + ] + } + ] + } + ] + }`) + + output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "") + if strings.Contains(string(output), "apply_patch") { + t.Fatalf("apply_patch remained after multi-match array filter: %s", string(output)) + } + if !strings.Contains(string(output), "exec_command") { + t.Fatalf("exec_command was removed: %s", string(output)) + } + }) +}