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
This commit is contained in:
Luis Pater
2026-09-13 22:12:28 +08:00
parent f702bc1ac2
commit 94d6eb535e
4 changed files with 520 additions and 0 deletions

View File

@@ -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")#'

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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))
}
})
}