History

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.

Luis Pater committed 2 months ago

Commit 94d6eb51 parent f702bc14 files · +520 −0Browse files

Unified · Split

4 files
  1. config.example.yaml +8 −0
  2. internal/api/middleware/request_logging_test.go +60 −0
  3. internal/api/server_test.go +261 −0
  4. internal/runtime/executor/helps/payload_mutations_test.go +191 −0
config.example.yaml+8−0
@@ -876,3 +876,11 @@ nonstream-keepalive-interval: 0 · 875 unmodified lines
876876 # params: # JSON paths (gjson/sjson syntax) to remove from the payload
877877 # - "generationConfig.thinkingConfig.thinkingBudget"
878878 # - "generationConfig.responseJsonSchema"
879+# - models:
880+# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
881+# protocol: "codex"
882+# params:
883+# # Flat tools array (standard OpenAI format or Codex CLI < 0.154):
884+# - 'tools.#(name=="apply_patch")'
885+# # Nested additional_tools structure in input[] (Codex CLI >= 0.154 "Responses Lite"):
886+# - 'input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#'
internal/api/middleware/request_logging_test.go+60−0
@@ -262,6 +262,66 @@ func TestRequestLoggingMiddlewareCapturesLargeErrorRequestAndDeferredAPIRequest( · 261 unmodified lines
262262 }
263263 }
264264
265+func TestRequestLoggingMiddleware_StreamingResponsesUpstreamSections(t *testing.T) {
266+ gin.SetMode(gin.TestMode)
267+
268+ logsDir := t.TempDir()
269+ logger := logging.NewFileRequestLogger(true, logsDir, "", 10)
270+ cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}}
271+
272+ router := gin.New()
273+ router.Use(RequestLoggingMiddleware(logger))
274+ router.POST("/v1/responses", func(c *gin.Context) {
275+ c.Header("Content-Type", "text/event-stream")
276+ executorCtx := context.WithValue(context.Background(), "gin", c)
277+ helps.RecordAPIRequest(executorCtx, cfg, helps.UpstreamRequestLog{
278+ URL: "https://api.example.com/v1/responses",
279+ Method: http.MethodPost,
280+ Headers: http.Header{"Content-Type": []string{"application/json"}},
281+ Body: []byte(`{"model":"gpt-5-codex","input":[]}`),
282+ })
283+ helps.AppendAPIResponseChunk(executorCtx, cfg, []byte("data: {\"type\":\"response.output_item.added\"}\n\n"))
284+ _, _ = c.Writer.Write([]byte("data: {\"type\":\"response.output_item.added\"}\n\n"))
285+ if flusher, ok := c.Writer.(http.Flusher); ok {
286+ flusher.Flush()
287+ }
288+ })
289+
290+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-codex","input":[],"stream":true}`))
291+ request.Header.Set("Content-Type", "application/json")
292+ response := httptest.NewRecorder()
293+ router.ServeHTTP(response, request)
294+
295+ if response.Code != http.StatusOK {
296+ t.Fatalf("response status = %d, want %d", response.Code, http.StatusOK)
297+ }
298+
299+ entries, errReadDir := os.ReadDir(logsDir)
300+ if errReadDir != nil {
301+ t.Fatalf("read logs dir: %v", errReadDir)
302+ }
303+ var logPath string
304+ for _, entry := range entries {
305+ if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") {
306+ logPath = logsDir + string(os.PathSeparator) + entry.Name()
307+ break
308+ }
309+ }
310+ if logPath == "" {
311+ t.Fatal("streaming request log was not created")
312+ }
313+ content, errReadLog := os.ReadFile(logPath)
314+ if errReadLog != nil {
315+ t.Fatalf("read log file: %v", errReadLog)
316+ }
317+ if !bytes.Contains(content, []byte("=== API REQUEST 1 ===")) {
318+ t.Fatalf("streaming log missing API REQUEST: %s", string(content))
319+ }
320+ if !bytes.Contains(content, []byte("=== API RESPONSE 1 ===")) {
321+ t.Fatalf("streaming log missing API RESPONSE: %s", string(content))
322+ }
323+}
324+
265325 func TestAttachRequestLogSourcesUsesLoggerLogsDir(t *testing.T) {
266326 gin.SetMode(gin.TestMode)
267327
internal/api/server_test.go+261−0
@@ -26,6 +26,7 @@ import ( · 25 unmodified lines
2626 "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
2727 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
2828 "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
29+ runtimehelps "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
2930 sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
3031 "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
3132 "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
@@ -2707,3 +2708,263 @@ func TestUpdateClientsContext_AntigravityConnectionPoolPurgesTransports(t *testi · 2675 unmodified lines
27072708 t.Fatalf("AntigravityTransportsLen() after reload = %d, want 0", got)
27082709 }
27092710 }
2711+
2712+type mockServerStreamingCaptureExecutor struct {
2713+ cfg *proxyconfig.Config
2714+ capturedCtx context.Context
2715+ executionCalls int
2716+}
2717+
2718+func (e *mockServerStreamingCaptureExecutor) Identifier() string { return "codex-test" }
2719+
2720+func (e *mockServerStreamingCaptureExecutor) Execute(ctx context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) {
2721+ e.capturedCtx = ctx
2722+ return coreexecutor.Response{Payload: []byte(`{"id":"resp-1","status":"completed"}`)}, nil
2723+}
2724+
2725+func (e *mockServerStreamingCaptureExecutor) ExecuteStream(ctx context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
2726+ e.capturedCtx = ctx
2727+ e.executionCalls++
2728+
2729+ runtimehelps.RecordAPIRequest(ctx, e.cfg, runtimehelps.UpstreamRequestLog{
2730+ URL: "https://api.example.com/v1/responses",
2731+ Method: http.MethodPost,
2732+ Headers: http.Header{"Content-Type": []string{"application/json"}},
2733+ Body: []byte(`{"model":"gpt-5-codex","input":[]}`),
2734+ })
2735+
2736+ ch := make(chan coreexecutor.StreamChunk, 2)
2737+ chunkPayload := []byte("event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\"}\n\n")
2738+ runtimehelps.AppendAPIResponseChunk(ctx, e.cfg, chunkPayload)
2739+ ch <- coreexecutor.StreamChunk{Payload: chunkPayload}
2740+
2741+ terminalPayload := []byte("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n")
2742+ runtimehelps.AppendAPIResponseChunk(ctx, e.cfg, terminalPayload)
2743+ ch <- coreexecutor.StreamChunk{Payload: terminalPayload}
2744+ close(ch)
2745+
2746+ return &coreexecutor.StreamResult{Chunks: ch}, nil
2747+}
2748+
2749+func (e *mockServerStreamingCaptureExecutor) Refresh(_ context.Context, auth *auth.Auth) (*auth.Auth, error) {
2750+ return auth, nil
2751+}
2752+
2753+func (e *mockServerStreamingCaptureExecutor) CountTokens(_ context.Context, _ *auth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) {
2754+ return coreexecutor.Response{}, errors.New("not implemented")
2755+}
2756+
2757+func (e *mockServerStreamingCaptureExecutor) HttpRequest(_ context.Context, _ *auth.Auth, _ *http.Request) (*http.Response, error) {
2758+ return nil, errors.New("not implemented")
2759+}
2760+
2761+func TestServerResponsesStreamingRequestLogCapturesUpstreamSections(t *testing.T) {
2762+ gin.SetMode(gin.TestMode)
2763+
2764+ tmpDir := t.TempDir()
2765+ logsDir := filepath.Join(tmpDir, "logs")
2766+ if err := os.MkdirAll(logsDir, 0o700); err != nil {
2767+ t.Fatalf("failed to create logs dir: %v", err)
2768+ }
2769+
2770+ mockLogger := internallogging.NewFileRequestLogger(true, logsDir, "", 10)
2771+ server := newTestServerWithOptions(t, WithRequestLoggerFactory(func(*proxyconfig.Config, string) internallogging.RequestLogger {
2772+ return mockLogger
2773+ }))
2774+ server.cfg.RequestLog = true
2775+ server.cfg.LoggingToFile = true
2776+
2777+ mockExec := &mockServerStreamingCaptureExecutor{cfg: server.cfg}
2778+ server.handlers.AuthManager.RegisterExecutor(mockExec)
2779+
2780+ credential := &auth.Auth{
2781+ ID: "codex-stream-auth",
2782+ Provider: mockExec.Identifier(),
2783+ Status: auth.StatusActive,
2784+ }
2785+ if _, err := server.handlers.AuthManager.Register(context.Background(), credential); err != nil {
2786+ t.Fatalf("register auth: %v", err)
2787+ }
2788+ registry.GetGlobalRegistry().RegisterClient(credential.ID, credential.Provider, []*registry.ModelInfo{{ID: "gpt-5-codex"}})
2789+ t.Cleanup(func() {
2790+ registry.GetGlobalRegistry().UnregisterClient(credential.ID)
2791+ })
2792+
2793+ rr := httptest.NewRecorder()
2794+ body := `{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hi"}],"stream":true}`
2795+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
2796+ req.Header.Set("Authorization", "Bearer test-key")
2797+ req.Header.Set("Content-Type", "application/json")
2798+ server.engine.ServeHTTP(rr, req)
2799+
2800+ if rr.Code != http.StatusOK {
2801+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
2802+ }
2803+ if mockExec.executionCalls != 1 {
2804+ t.Fatalf("executor calls = %d, want 1", mockExec.executionCalls)
2805+ }
2806+
2807+ entries, errReadDir := os.ReadDir(logsDir)
2808+ if errReadDir != nil {
2809+ t.Fatalf("read logs dir: %v", errReadDir)
2810+ }
2811+ var logPath string
2812+ for _, entry := range entries {
2813+ if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") {
2814+ logPath = filepath.Join(logsDir, entry.Name())
2815+ break
2816+ }
2817+ }
2818+ if logPath == "" {
2819+ t.Fatal("streaming request log was not created in logs dir")
2820+ }
2821+ content, errReadLog := os.ReadFile(logPath)
2822+ if errReadLog != nil {
2823+ t.Fatalf("read log file: %v", errReadLog)
2824+ }
2825+ logText := string(content)
2826+ apiRequestIdx := strings.Index(logText, "=== API REQUEST 1 ===")
2827+ if apiRequestIdx == -1 {
2828+ t.Fatalf("streaming log missing API REQUEST 1:\n%s", logText)
2829+ }
2830+ apiResponseIdx := strings.Index(logText, "=== API RESPONSE 1 ===")
2831+ if apiResponseIdx == -1 {
2832+ t.Fatalf("streaming log missing API RESPONSE 1:\n%s", logText)
2833+ }
2834+ downstreamResponseIdx := strings.Index(logText, "=== RESPONSE ===")
2835+ if downstreamResponseIdx == -1 {
2836+ t.Fatalf("streaming log missing downstream RESPONSE:\n%s", logText)
2837+ }
2838+ if apiRequestIdx >= apiResponseIdx || apiResponseIdx >= downstreamResponseIdx {
2839+ t.Fatalf("unexpected section order (req=%d, apiResp=%d, resp=%d):\n%s", apiRequestIdx, apiResponseIdx, downstreamResponseIdx, logText)
2840+ }
2841+ apiRequestSection := logText[apiRequestIdx:apiResponseIdx]
2842+ apiResponseSection := logText[apiResponseIdx:downstreamResponseIdx]
2843+ if !strings.Contains(apiRequestSection, "https://api.example.com/v1/responses") {
2844+ t.Fatalf("API REQUEST section missing upstream URL:\n%s", apiRequestSection)
2845+ }
2846+ if !strings.Contains(apiRequestSection, `"gpt-5-codex"`) {
2847+ t.Fatalf("API REQUEST section missing request body:\n%s", apiRequestSection)
2848+ }
2849+ if !strings.Contains(apiResponseSection, "response.output_item.added") {
2850+ t.Fatalf("API RESPONSE section missing response chunk data:\n%s", apiResponseSection)
2851+ }
2852+}
2853+
2854+func TestServerCodexAPIKeyResponsesStreamingRequestLog(t *testing.T) {
2855+ gin.SetMode(gin.TestMode)
2856+
2857+ upstreamReceived := make(chan struct{}, 1)
2858+ mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2859+ select {
2860+ case upstreamReceived <- struct{}{}:
2861+ default:
2862+ }
2863+ w.Header().Set("Content-Type", "text/event-stream")
2864+ w.WriteHeader(http.StatusOK)
2865+ _, _ = w.Write([]byte("data: {\"type\":\"response.output_item.added\"}\n\n"))
2866+ if f, ok := w.(http.Flusher); ok {
2867+ f.Flush()
2868+ }
2869+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\"}\n\n"))
2870+ if f, ok := w.(http.Flusher); ok {
2871+ f.Flush()
2872+ }
2873+ }))
2874+ defer mockUpstream.Close()
2875+
2876+ tmpDir := t.TempDir()
2877+ logsDir := filepath.Join(tmpDir, "logs")
2878+ if err := os.MkdirAll(logsDir, 0o700); err != nil {
2879+ t.Fatalf("failed to create logs dir: %v", err)
2880+ }
2881+
2882+ mockLogger := internallogging.NewFileRequestLogger(true, logsDir, "", 10)
2883+ server := newTestServerWithOptions(t, WithRequestLoggerFactory(func(*proxyconfig.Config, string) internallogging.RequestLogger {
2884+ return mockLogger
2885+ }))
2886+ server.cfg.RequestLog = true
2887+ server.cfg.LoggingToFile = true
2888+
2889+ codexExec := executor.NewCodexExecutor(server.cfg)
2890+ server.handlers.AuthManager.RegisterExecutor(codexExec)
2891+
2892+ credential := &auth.Auth{
2893+ ID: "codex-api-key-test",
2894+ Provider: "codex",
2895+ Status: auth.StatusActive,
2896+ Attributes: map[string]string{
2897+ auth.AttributeAPIKey: "test-codex-key",
2898+ "base_url": mockUpstream.URL,
2899+ },
2900+ }
2901+ if _, err := server.handlers.AuthManager.Register(context.Background(), credential); err != nil {
2902+ t.Fatalf("register auth: %v", err)
2903+ }
2904+ registry.GetGlobalRegistry().RegisterClient(credential.ID, credential.Provider, []*registry.ModelInfo{{ID: "gpt-5-codex"}})
2905+ t.Cleanup(func() {
2906+ registry.GetGlobalRegistry().UnregisterClient(credential.ID)
2907+ })
2908+
2909+ rr := httptest.NewRecorder()
2910+ body := `{"model":"gpt-5-codex","input":[{"type":"message","role":"user","content":"hello"}],"stream":true}`
2911+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
2912+ req.Header.Set("Authorization", "Bearer test-key")
2913+ req.Header.Set("Content-Type", "application/json")
2914+ server.engine.ServeHTTP(rr, req)
2915+
2916+ if rr.Code != http.StatusOK {
2917+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
2918+ }
2919+ select {
2920+ case <-upstreamReceived:
2921+ case <-time.After(5 * time.Second):
2922+ t.Fatal("timeout waiting for upstream request")
2923+ }
2924+
2925+ entries, errReadDir := os.ReadDir(logsDir)
2926+ if errReadDir != nil {
2927+ t.Fatalf("read logs dir: %v", errReadDir)
2928+ }
2929+ var logPath string
2930+ for _, entry := range entries {
2931+ if strings.HasPrefix(entry.Name(), "v1-responses-") && strings.HasSuffix(entry.Name(), ".log") {
2932+ logPath = filepath.Join(logsDir, entry.Name())
2933+ break
2934+ }
2935+ }
2936+ if logPath == "" {
2937+ t.Fatal("streaming request log was not created in logs dir")
2938+ }
2939+ content, errReadLog := os.ReadFile(logPath)
2940+ if errReadLog != nil {
2941+ t.Fatalf("read log file: %v", errReadLog)
2942+ }
2943+ logText := string(content)
2944+ apiRequestIdx := strings.Index(logText, "=== API REQUEST 1 ===")
2945+ if apiRequestIdx == -1 {
2946+ t.Fatalf("streaming log missing API REQUEST 1:\n%s", logText)
2947+ }
2948+ apiResponseIdx := strings.Index(logText, "=== API RESPONSE 1 ===")
2949+ if apiResponseIdx == -1 {
2950+ t.Fatalf("streaming log missing API RESPONSE 1:\n%s", logText)
2951+ }
2952+ downstreamResponseIdx := strings.Index(logText, "=== RESPONSE ===")
2953+ if downstreamResponseIdx == -1 {
2954+ t.Fatalf("streaming log missing downstream RESPONSE:\n%s", logText)
2955+ }
2956+ if apiRequestIdx >= apiResponseIdx || apiResponseIdx >= downstreamResponseIdx {
2957+ t.Fatalf("unexpected section order (req=%d, apiResp=%d, resp=%d):\n%s", apiRequestIdx, apiResponseIdx, downstreamResponseIdx, logText)
2958+ }
2959+ apiRequestSection := logText[apiRequestIdx:apiResponseIdx]
2960+ apiResponseSection := logText[apiResponseIdx:downstreamResponseIdx]
2961+ if !strings.Contains(apiRequestSection, mockUpstream.URL) {
2962+ t.Fatalf("API REQUEST section missing upstream URL %s:\n%s", mockUpstream.URL, apiRequestSection)
2963+ }
2964+ if !strings.Contains(apiRequestSection, `"gpt-5-codex"`) {
2965+ t.Fatalf("API REQUEST section missing request body:\n%s", apiRequestSection)
2966+ }
2967+ if !strings.Contains(apiResponseSection, "response.output_item.added") {
2968+ t.Fatalf("API RESPONSE section missing response chunk data:\n%s", apiResponseSection)
2969+ }
2970+}
internal/runtime/executor/helps/payload_mutations_test.go+191−0
@@ -3,6 +3,7 @@ package helps · 2 unmodified lines
33 import (
44 "bytes"
55 "encoding/json"
6+ "os"
67 "strings"
78 "testing"
89
@@ -280,3 +281,193 @@ func BenchmarkSetStringIfDifferentLargeCanonicalPayload(b *testing.B) { · 271 unmodified lines
280281 benchmarkPayloadMutationOutput = SetStringIfDifferent(input, "model", "gpt-test")
281282 }
282283 }
284+
285+func TestConfigExampleDocumentsCodexAdditionalToolsPayloadFilter(t *testing.T) {
286+ data, err := os.ReadFile("../../../../config.example.yaml")
287+ if err != nil {
288+ t.Fatalf("failed to read config.example.yaml: %v", err)
289+ }
290+ content := string(data)
291+ const expectedPath = `'input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#'`
292+ if !strings.Contains(content, expectedPath) {
293+ t.Fatalf("config.example.yaml does not contain expected additional_tools path: %s", expectedPath)
294+ }
295+}
296+
297+func TestApplyPayloadConfig_CodexAdditionalToolsFilter(t *testing.T) {
298+ cfg := &config.Config{
299+ Payload: config.PayloadConfig{
300+ Filter: []config.PayloadFilterRule{
301+ {
302+ Models: []config.PayloadModelRule{{Name: "gpt-*", Protocol: "codex"}},
303+ Params: []string{
304+ `input.#(type=="additional_tools")#.tools.#(name=="functions")#.tools.#(name=="apply_patch")#`,
305+ },
306+ },
307+ },
308+ },
309+ }
310+
311+ t.Run("removes target tool and preserves other tools across namespaces", func(t *testing.T) {
312+ input := []byte(`{
313+ "model": "gpt-5-codex",
314+ "input": [
315+ {
316+ "type": "additional_tools",
317+ "role": "developer",
318+ "tools": [
319+ {
320+ "type": "namespace",
321+ "name": "functions",
322+ "tools": [
323+ {"type": "custom", "name": "apply_patch", "description": "patch"},
324+ {"type": "custom", "name": "exec_command", "description": "exec"}
325+ ]
326+ },
327+ {
328+ "type": "namespace",
329+ "name": "collaboration",
330+ "tools": [
331+ {"type": "custom", "name": "share", "description": "share"}
332+ ]
333+ }
334+ ]
335+ }
336+ ]
337+ }`)
338+
339+ output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "")
340+
341+ var structured struct {
342+ Input []struct {
343+ Type string `json:"type"`
344+ Tools []struct {
345+ Name string `json:"name"`
346+ Tools []struct {
347+ Name string `json:"name"`
348+ } `json:"tools"`
349+ } `json:"tools"`
350+ } `json:"input"`
351+ }
352+ if errUnmarshal := json.Unmarshal(output, &structured); errUnmarshal != nil {
353+ t.Fatalf("failed to unmarshal output: %v", errUnmarshal)
354+ }
355+ if len(structured.Input) != 1 {
356+ t.Fatalf("input len = %d, want 1", len(structured.Input))
357+ }
358+ if len(structured.Input[0].Tools) != 2 {
359+ t.Fatalf("namespaces len = %d, want 2", len(structured.Input[0].Tools))
360+ }
361+ fnNamespace := structured.Input[0].Tools[0]
362+ if fnNamespace.Name != "functions" || len(fnNamespace.Tools) != 1 || fnNamespace.Tools[0].Name != "exec_command" {
363+ t.Fatalf("functions namespace tools = %+v, want only exec_command", fnNamespace.Tools)
364+ }
365+ collabNamespace := structured.Input[0].Tools[1]
366+ if collabNamespace.Name != "collaboration" || len(collabNamespace.Tools) != 1 || collabNamespace.Tools[0].Name != "share" {
367+ t.Fatalf("collaboration namespace tools = %+v, want share", collabNamespace.Tools)
368+ }
369+ })
370+
371+ t.Run("non-matching target tool is a no-op", func(t *testing.T) {
372+ input := []byte(`{
373+ "model": "gpt-5-codex",
374+ "input": [
375+ {
376+ "type": "additional_tools",
377+ "role": "developer",
378+ "tools": [
379+ {
380+ "type": "namespace",
381+ "name": "functions",
382+ "tools": [
383+ {"type": "custom", "name": "exec_command", "description": "exec"}
384+ ]
385+ }
386+ ]
387+ }
388+ ]
389+ }`)
390+
391+ output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "")
392+ if !bytes.Equal(output, input) {
393+ t.Fatalf("expected payload to be untouched when rule does not match, got: %s", string(output))
394+ }
395+ })
396+
397+ t.Run("removes matches across multiple additional_tools elements", func(t *testing.T) {
398+ input := []byte(`{
399+ "model": "gpt-5-codex",
400+ "input": [
401+ {
402+ "type": "additional_tools",
403+ "tools": [
404+ {
405+ "type": "namespace",
406+ "name": "functions",
407+ "tools": [{"type": "custom", "name": "apply_patch"}]
408+ }
409+ ]
410+ },
411+ {
412+ "type": "message",
413+ "role": "user",
414+ "content": "hello"
415+ },
416+ {
417+ "type": "additional_tools",
418+ "tools": [
419+ {
420+ "type": "namespace",
421+ "name": "functions",
422+ "tools": [
423+ {"type": "custom", "name": "apply_patch"},
424+ {"type": "custom", "name": "view_image"}
425+ ]
426+ }
427+ ]
428+ }
429+ ]
430+ }`)
431+
432+ output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "")
433+ if strings.Contains(string(output), "apply_patch") {
434+ t.Fatalf("apply_patch remained after multi-element filter: %s", string(output))
435+ }
436+ if !strings.Contains(string(output), "view_image") {
437+ t.Fatalf("view_image was removed: %s", string(output))
438+ }
439+ if !strings.Contains(string(output), "hello") {
440+ t.Fatalf("user message was removed: %s", string(output))
441+ }
442+ })
443+
444+ t.Run("removes multiple matches within the same namespace array", func(t *testing.T) {
445+ input := []byte(`{
446+ "model": "gpt-5-codex",
447+ "input": [
448+ {
449+ "type": "additional_tools",
450+ "tools": [
451+ {
452+ "type": "namespace",
453+ "name": "functions",
454+ "tools": [
455+ {"type": "custom", "name": "apply_patch", "id": "p1"},
456+ {"type": "custom", "name": "exec_command"},
457+ {"type": "custom", "name": "apply_patch", "id": "p2"}
458+ ]
459+ }
460+ ]
461+ }
462+ ]
463+ }`)
464+
465+ output := ApplyPayloadConfigWithRoot(cfg, "gpt-5-codex", "codex", "", input, nil, "", "")
466+ if strings.Contains(string(output), "apply_patch") {
467+ t.Fatalf("apply_patch remained after multi-match array filter: %s", string(output))
468+ }
469+ if !strings.Contains(string(output), "exec_command") {
470+ t.Fatalf("exec_command was removed: %s", string(output))
471+ }
472+ })
473+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t