diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 29f2b672..0ae55ad3 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -172,6 +172,11 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact result.PromptCacheKey = strings.TrimSpace(v) } + // 提取 input 中 role:"system" 消息至 instructions(OAuth 上游不支持 system role)。 + if extractSystemMessagesFromInput(reqBody) { + result.Modified = true + } + // instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法 if applyInstructions(reqBody, isCodexCLI) { result.Modified = true @@ -301,6 +306,73 @@ func getNormalizedCodexModel(modelID string) string { return "" } +// extractTextFromContent extracts plain text from a content value that is either +// a Go string or a []any of content-part maps with type:"text". +func extractTextFromContent(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, part := range v { + m, ok := part.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "") + default: + return "" + } +} + +// extractSystemMessagesFromInput scans the input array for items with role=="system", +// removes them, and merges their content into reqBody["instructions"]. +// If instructions is already non-empty, extracted content is prepended with "\n\n". +// Returns true if any system messages were extracted. +func extractSystemMessagesFromInput(reqBody map[string]any) bool { + input, ok := reqBody["input"].([]any) + if !ok || len(input) == 0 { + return false + } + + var systemTexts []string + remaining := make([]any, 0, len(input)) + + for _, item := range input { + m, ok := item.(map[string]any) + if !ok { + remaining = append(remaining, item) + continue + } + if role, _ := m["role"].(string); role != "system" { + remaining = append(remaining, item) + continue + } + if text := extractTextFromContent(m["content"]); text != "" { + systemTexts = append(systemTexts, text) + } + } + + if len(systemTexts) == 0 { + return false + } + + extracted := strings.Join(systemTexts, "\n\n") + if existing, ok := reqBody["instructions"].(string); ok && strings.TrimSpace(existing) != "" { + reqBody["instructions"] = extracted + "\n\n" + existing + } else { + reqBody["instructions"] = extracted + } + reqBody["input"] = remaining + return true +} + // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。 func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool { if !isInstructionsEmpty(reqBody) { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index ae6f8555..b52f0566 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -344,6 +344,135 @@ func TestApplyCodexOAuthTransform_StringInputWithToolsField(t *testing.T) { require.Len(t, input, 1) } +func TestExtractSystemMessagesFromInput(t *testing.T) { + t.Run("no system messages", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "user", "content": "hello"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.False(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + _, hasInstructions := reqBody["instructions"] + require.False(t, hasInstructions) + }) + + t.Run("string content system message", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "You are an assistant."}, + map[string]any{"role": "user", "content": "hello"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + msg, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", msg["role"]) + require.Equal(t, "You are an assistant.", reqBody["instructions"]) + }) + + t.Run("array content system message", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{ + "role": "system", + "content": []any{ + map[string]any{"type": "text", "text": "Be helpful."}, + }, + }, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "Be helpful.", reqBody["instructions"]) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 0) + }) + + t.Run("multiple system messages concatenated", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "First."}, + map[string]any{"role": "system", "content": "Second."}, + map[string]any{"role": "user", "content": "hi"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "First.\n\nSecond.", reqBody["instructions"]) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + }) + + t.Run("mixed system and non-system preserves non-system", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "user", "content": "hello"}, + map[string]any{"role": "system", "content": "Sys prompt."}, + map[string]any{"role": "assistant", "content": "Hi there"}, + }, + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 2) + first, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", first["role"]) + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "assistant", second["role"]) + }) + + t.Run("existing instructions prepended", func(t *testing.T) { + reqBody := map[string]any{ + "input": []any{ + map[string]any{"role": "system", "content": "Extracted."}, + map[string]any{"role": "user", "content": "hi"}, + }, + "instructions": "Existing instructions.", + } + result := extractSystemMessagesFromInput(reqBody) + require.True(t, result) + require.Equal(t, "Extracted.\n\nExisting instructions.", reqBody["instructions"]) + }) +} + +func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.1", + "input": []any{ + map[string]any{"role": "system", "content": "You are a coding assistant."}, + map[string]any{"role": "user", "content": "Write a function."}, + }, + } + + result := applyCodexOAuthTransform(reqBody, false, false) + + require.True(t, result.Modified) + + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + msg, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "user", msg["role"]) + + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.Equal(t, "You are a coding assistant.", instructions) +} + func TestIsInstructionsEmpty(t *testing.T) { tests := []struct { name string