diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index db09a6f9f..50f38bc3a 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -309,6 +309,55 @@ func claudeUsesLegacySystemReminder(payload []byte) bool { return legacy } +// claudeCallerSystemBlockError reports a caller system block that Claude cannot +// carry in any system slot. It is request-scoped: no other credential or upstream +// model can accept the same body, so the request must not be retried. +type claudeCallerSystemBlockError struct { + statusErr +} + +func (claudeCallerSystemBlockError) IsRequestScoped() bool { + return true +} + +func newClaudeCallerSystemBlockError(index int, blockType string) error { + if blockType == "" { + blockType = "unknown" + } + return claudeCallerSystemBlockError{statusErr{ + code: http.StatusBadRequest, + msg: fmt.Sprintf("invalid_request_error: system.%d.type: Input should be 'text'. "+ + "System instructions support text only, but this block has type %q. "+ + "Move non-text content into a user message.", index, blockType), + }} +} + +// validateClaudeCallerSystemBlocks rejects caller system content that cannot keep +// its operator authority. Verified against api.anthropic.com on 2026-08-03: the +// top-level system field answers "system..type: Input should be 'text'" for +// image, document and unknown block types, and a role=system message answers +// "role 'system' supports text, tool_addition, and tool_removal blocks only". +// Cloaking relocates caller blocks into one of those two slots, so a non-text +// block has no destination. Failing here keeps the caller's instructions from +// being silently dropped, and costs no upstream attempt. +func validateClaudeCallerSystemBlocks(system gjson.Result) error { + if !system.IsArray() { + // A string system prompt is text by definition. + return nil + } + var blockErr error + index := 0 + system.ForEach(func(_, part gjson.Result) bool { + if strings.TrimSpace(part.Get("type").String()) != "text" { + blockErr = newClaudeCallerSystemBlockError(index, strings.TrimSpace(part.Get("type").String())) + return false + } + index++ + return true + }) + return blockErr +} + func collectForwardedClaudeSystemPromptBlocks(system gjson.Result) []string { var blocks []string appendText := func(text string) { @@ -725,6 +774,13 @@ func applyCloaking( if !policy.Cloak { return payload, false, nil } + // Strict mode drops caller system prompts entirely, so nothing needs a + // destination and an unusable block cannot lose information. + if !settings.strictMode { + if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(payload, "system")); errSystem != nil { + return nil, false, errSystem + } + } billingVersion := helps.DefaultClaudeVersion(cfg) workload := getWorkloadFromContext(ctx) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index d27c4991d..d1d3e0555 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -5358,3 +5358,146 @@ func TestInjectClaudeCodeContextManagement(t *testing.T) { t.Fatalf("caller context_management was modified: %s", got) } } + +func TestValidateClaudeCallerSystemBlocksAcceptsTextOnly(t *testing.T) { + tests := []struct { + name string + system string + }{ + {name: "string", system: `"S1"`}, + {name: "text blocks", system: `[{"type":"text","text":"S1"},{"type":"text","text":"S2"}]`}, + {name: "absent", system: ``}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload := `{"model":"claude-opus-5"}` + if test.system != "" { + payload = `{"model":"claude-opus-5","system":` + test.system + `}` + } + if err := validateClaudeCallerSystemBlocks(gjson.Get(payload, "system")); err != nil { + t.Fatalf("validateClaudeCallerSystemBlocks() error = %v, want nil", err) + } + }) + } +} + +// Anthropic rejects every non-text block in both system slots, verified live on +// 2026-08-03: the top-level field answers "system..type: Input should be +// 'text'" and a role=system message answers "role 'system' supports text, +// tool_addition, and tool_removal blocks only". Cloaking has no third slot, so +// the request has to fail here instead of losing the caller's instructions. +func TestValidateClaudeCallerSystemBlocksRejectsNonTextBlock(t *testing.T) { + tests := []struct { + name string + system string + wantIndex string + wantType string + }{ + { + name: "image", + system: `[{"type":"text","text":"S1"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAAA"}}]`, + wantIndex: "system.1.type", + wantType: `"image"`, + }, + { + name: "responses marker", + system: `[{"type":"input_file"}]`, + wantIndex: "system.0.type", + wantType: `"input_file"`, + }, + { + name: "missing type", + system: `[{"text":"S1"}]`, + wantIndex: "system.0.type", + wantType: `"unknown"`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateClaudeCallerSystemBlocks(gjson.Parse(test.system)) + if err == nil { + t.Fatal("validateClaudeCallerSystemBlocks() error = nil, want rejection") + } + var statusCoder interface{ StatusCode() int } + if !errors.As(err, &statusCoder) || statusCoder.StatusCode() != http.StatusBadRequest { + t.Fatalf("error status = %v, want 400", err) + } + var scoped interface{ IsRequestScoped() bool } + if !errors.As(err, &scoped) || !scoped.IsRequestScoped() { + t.Fatalf("error %v must be request scoped so no other credential is tried", err) + } + if got := err.Error(); !strings.Contains(got, test.wantIndex) || !strings.Contains(got, test.wantType) { + t.Fatalf("error = %q, want it to name %s and %s", got, test.wantIndex, test.wantType) + } + }) + } +} + +func TestApplyCloakingRejectsNonTextCallerSystemBlock(t *testing.T) { + cfg := &config.Config{} + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}} + payload := []byte(`{"model":"claude-opus-5","system":[{"type":"text","text":"S1"},{"type":"input_image"}],"messages":[{"role":"user","content":[{"type":"text","text":"U1"}]}]}`) + + out, cloaked, errCloaking := applyCloaking(context.Background(), cfg, auth, payload, "key-123", false, true) + if errCloaking == nil { + t.Fatal("applyCloaking() error = nil, want rejection") + } + if out != nil { + t.Fatalf("applyCloaking() payload = %s, want nil", out) + } + if cloaked { + t.Fatal("applyCloaking() cloaked = true, want false") + } +} + +// Strict mode never forwards caller system prompts, so an unusable block cannot +// lose information and must not fail the request. +func TestApplyCloakingStrictModeIgnoresNonTextCallerSystemBlock(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-123", + Cloak: &config.CloakConfig{StrictMode: true}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}} + payload := []byte(`{"model":"claude-opus-5","system":[{"type":"input_image"}],"messages":[{"role":"user","content":[{"type":"text","text":"U1"}]}]}`) + + out, cloaked, errCloaking := applyCloaking(context.Background(), cfg, auth, payload, "key-123", false, true) + if errCloaking != nil { + t.Fatalf("applyCloaking() error = %v, want nil", errCloaking) + } + if !cloaked { + t.Fatal("applyCloaking() cloaked = false, want true") + } + if got := len(gjson.GetBytes(out, "system").Array()); got != 2 { + t.Fatalf("system blocks = %d, want the 2 Claude Code blocks", got) + } +} + +// A cloaked direct-Anthropic count_tokens request relocates caller system blocks +// into messages, so a non-text block has no destination there either and must be +// rejected before any upstream call. +func TestClaudeExecutor_CountTokensRejectsNonTextCallerSystemBlock(t *testing.T) { + upstreamCalled := false + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + upstreamCalled = true + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"input_tokens":1}`)), Request: req}, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-count-system-block"}} + payload := []byte(`{"model":"claude-opus-5","system":[{"type":"text","text":"S1"},{"type":"input_image"}],"messages":[{"role":"user","content":[{"type":"text","text":"x"}]}]}`) + + _, errCount := NewClaudeExecutor(&config.Config{}).countTokensUpstream(ctx, auth, + cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errCount == nil { + t.Fatal("countTokensUpstream() error = nil, want rejection") + } + var statusCoder interface{ StatusCode() int } + if !errors.As(errCount, &statusCoder) || statusCoder.StatusCode() != http.StatusBadRequest { + t.Fatalf("countTokensUpstream() error = %v, want 400", errCount) + } + if upstreamCalled { + t.Fatal("countTokensUpstream() called upstream, want local rejection") + } +} diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index 1b962fa57..ce452d1ac 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -163,6 +163,11 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) cloaked = policy.Cloak if cloaked { + if !settings.strictMode { + if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil { + return cliproxyexecutor.Response{}, errSystem + } + } body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) if len(settings.sensitiveWords) > 0 { body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 9c483598c..ea52f6975 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -171,7 +171,11 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream contentResult := message.Get("content") switch role { - case "system": + // Developer messages rank with system messages in OpenAI's instruction + // hierarchy, so both become top-level Claude system blocks. Dropping the + // developer role, as this translator used to, silently removed operator + // instructions from the upstream request. + case "system", "developer": systemStart := len(systemBlocks) if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { textPart := []byte(`{"type":"text","text":""}`) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 7c19f2466..0b180321a 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -521,3 +521,60 @@ func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *te t.Fatalf("part-level cache_control should win; unexpected ttl: %s", result) } } + +func TestConvertOpenAIRequestToClaude_DeveloperRoleBecomesTopLevelSystem(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "S1"}, + {"role": "developer", "content": [{"type": "text", "text": "D1"}, {"type": "text", "text": "D2"}]}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system").Array() + if len(system) != 3 { + t.Fatalf("system blocks = %d, want 3. system: %s", len(system), resultJSON.Get("system").Raw) + } + for idx, want := range []string{"S1", "D1", "D2"} { + if got := system[idx].Get("type").String(); got != "text" { + t.Fatalf("system[%d].type = %q, want text", idx, got) + } + if got := system[idx].Get("text").String(); got != want { + t.Fatalf("system[%d].text = %q, want %q", idx, got, want) + } + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("messages = %d, want 1. messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("messages[0].role = %q, want user", got) + } +} + +func TestConvertOpenAIRequestToClaude_DeveloperMessageCacheControlAppliesToLastBlock(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "developer", "content": [{"type": "text", "text": "D1"}, {"type": "text", "text": "D2"}], "cache_control": {"type": "ephemeral"}}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + system := gjson.ParseBytes(result).Get("system").Array() + if len(system) != 2 { + t.Fatalf("system blocks = %d, want 2", len(system)) + } + if system[0].Get("cache_control").Exists() { + t.Fatalf("system[0] must not carry cache_control: %s", system[0].Raw) + } + if got := system[1].Get("cache_control.type").String(); got != "ephemeral" { + t.Fatalf("system[1].cache_control.type = %q, want ephemeral", got) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 310ac488e..ff1b9b8be 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -27,13 +27,14 @@ var ( // ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request // into a Claude Messages API request using only gjson/sjson for JSON handling. // It supports: -// - instructions -> system message -// - input[].type==message with input_text/output_text -> user/assistant messages -// - function_call -> assistant tool_use -// - function_call_output -> user tool_result -// - tools[].parameters -> tools[].input_schema -// - max_output_tokens -> max_tokens -// - stream passthrough via parameter +// - instructions, input[].role==system and input[].role==developer -> separate +// top-level system blocks, in source order +// - input[].type==message with input_text/output_text -> user/assistant messages +// - function_call -> assistant tool_use +// - function_call_output -> user tool_result +// - tools[].parameters -> tools[].input_schema +// - max_output_tokens -> max_tokens +// - stream passthrough via parameter func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { rawJSON := inputRawJSON @@ -127,52 +128,60 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // Stream out, _ = sjson.SetBytes(out, "stream", stream) - // instructions -> as a leading message (use role user for Claude API compatibility) + // System-level inputs become canonical top-level Claude system blocks in + // source order: instructions first, then every input item whose role is + // system or developer. Each source block stays a separate Claude block and + // keeps operator authority; the Claude executor decides the final placement + // (mid-conversation role=system messages, or system reminders on legacy + // models), so this layer must not merge, trim or downgrade them to user text. messageCapacity := root.Get("input.#").Int() - if instructions := root.Get("instructions"); instructions.Type == gjson.String && instructions.String() != "" { - messageCapacity++ - } messageBlocks := common.NewRawArrayItems(messageCapacity) - instructionsText := "" - extractedFromSystem := false - if instr := root.Get("instructions"); instr.Exists() && instr.Type == gjson.String { - instructionsText = instr.String() - if instructionsText != "" { - sysMsg := []byte(`{"role":"user","content":""}`) - sysMsg, _ = sjson.SetBytes(sysMsg, "content", instructionsText) - messageBlocks = append(messageBlocks, sysMsg) + systemBlocks := make([][]byte, 0, 4) + appendSystemText := func(text string, cacheSource gjson.Result) { + if text == "" { + return } + block := []byte(`{"type":"text","text":""}`) + block, _ = sjson.SetBytes(block, "text", text) + if cacheSource.Exists() { + block = common.AttachCacheControl(block, cacheSource) + } + systemBlocks = append(systemBlocks, block) } - - if instructionsText == "" { - if input := root.Get("input"); input.Exists() && input.IsArray() { - input.ForEach(func(_, item gjson.Result) bool { - if strings.EqualFold(item.Get("role").String(), "system") { - var builder strings.Builder - if parts := item.Get("content"); parts.Exists() && parts.IsArray() { - parts.ForEach(func(_, part gjson.Result) bool { - textResult := part.Get("text") - text := textResult.String() - if builder.Len() > 0 && text != "" { - builder.WriteByte('\n') - } - builder.WriteString(text) - return true - }) - } else if parts.Type == gjson.String { - builder.WriteString(parts.String()) - } - instructionsText = builder.String() - if instructionsText != "" { - sysMsg := []byte(`{"role":"user","content":""}`) - sysMsg, _ = sjson.SetBytes(sysMsg, "content", instructionsText) - messageBlocks = append(messageBlocks, sysMsg) - extractedFromSystem = true + if instr := root.Get("instructions"); instr.Type == gjson.String { + appendSystemText(instr.String(), gjson.Result{}) + } + if input := root.Get("input"); input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if !isResponsesSystemLevelRole(item.Get("role").String()) { + return true + } + startIdx := len(systemBlocks) + content := item.Get("content") + if content.Type == gjson.String { + appendSystemText(content.String(), gjson.Result{}) + } else if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + switch part.Get("type").String() { + case "input_text", "output_text", "text": + appendSystemText(part.Get("text").String(), part) + default: + if block := responsesSystemUnsupportedBlock(part); len(block) > 0 { + systemBlocks = append(systemBlocks, block) + } } + return true + }) + } + // Item-level cache_control applies to the last block this item produced. + if item.Get("cache_control").Exists() && len(systemBlocks) > startIdx { + lastIdx := len(systemBlocks) - 1 + if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() { + systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], item) } - return instructionsText == "" - }) - } + } + return true + }) } // input array processing @@ -216,7 +225,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if input := root.Get("input"); input.Exists() && input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { - if extractedFromSystem && strings.EqualFold(item.Get("role").String(), "system") { + // System-level items already became top-level system blocks. + if isResponsesSystemLevelRole(item.Get("role").String()) { return true } typ := item.Get("type").String() @@ -321,7 +331,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if role == "" { r := item.Get("role").String() switch r { - case "user", "assistant", "system": + case "user", "assistant": role = r default: role = "user" @@ -361,7 +371,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } msg = common.AttachMessageCacheControl(msg, item) appendMessage(msg) - } else if textAggregate.Len() > 0 || role == "system" { + } else if textAggregate.Len() > 0 { msg := []byte(`{"role":"","content":""}`) msg, _ = sjson.SetBytes(msg, "role", role) msg, _ = sjson.SetBytes(msg, "content", textAggregate.String()) @@ -423,7 +433,15 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } flushPendingReasoning() flushPendingToolUses() + // Preserve a minimal conversational turn for system-only inputs so downstream + // validation still sees a Claude-shaped request. + if len(messageBlocks) == 0 && len(systemBlocks) > 0 { + messageBlocks = append(messageBlocks, []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)) + } out = common.SetRawArrayItems(out, "messages", messageBlocks) + if len(systemBlocks) > 0 { + out, _ = sjson.SetRawBytes(out, "system", common.JoinRawArray(systemBlocks)) + } includedToolNames := map[string]struct{}{} toolNameMap := map[string]string{} @@ -484,6 +502,37 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte return out } +// isResponsesSystemLevelRole reports whether an input item carries system-level +// authority. The Responses API ranks developer and system instructions above +// user content, so both map to Claude's system slot rather than a user turn. +func isResponsesSystemLevelRole(role string) bool { + switch strings.ToLower(strings.TrimSpace(role)) { + case "system", "developer": + return true + default: + return false + } +} + +// responsesSystemUnsupportedBlock represents a system-level content part that +// Claude cannot carry. Anthropic accepts text only in the top-level system field +// ("system..type: Input should be 'text'") and text, tool_addition and +// tool_removal in a role=system message, so images, files and unknown part types +// have no lossless mapping. The part is preserved as a typed marker instead of +// being dropped: silently discarding operator instructions is worse than a +// rejected request, and the marker lets the Claude executor fail the request with +// the offending type named. The original payload is not copied because the +// request can never succeed. +func responsesSystemUnsupportedBlock(part gjson.Result) []byte { + partType := strings.TrimSpace(part.Get("type").String()) + if partType == "" { + return nil + } + block := []byte(`{"type":""}`) + block, _ = sjson.SetBytes(block, "type", partType) + return block +} + func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte { signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, item.Get("encrypted_content").String()) if !ok { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index 556bb2519..ee7428f35 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -383,3 +383,120 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesContentPartCacheControl( t.Fatalf("content.1 should not have cache_control. Output: %s", result) } } + +func TestConvertOpenAIResponsesRequestToClaude_SystemLevelInputsBecomeSeparateSystemBlocks(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "instructions": "I1", + "input": [ + {"type": "message", "role": "system", "content": [{"type": "input_text", "text": "S1"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "U1"}]}, + {"type": "message", "role": "developer", "content": "D1"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "A1"}]}, + {"type": "message", "role": "system", "content": [{"type": "input_text", "text": "S2"}]} + ] + }` + + result := ConvertOpenAIResponsesRequestToClaude("claude-opus-5", []byte(inputJSON), false) + root := gjson.ParseBytes(result) + + system := root.Get("system").Array() + if len(system) != 4 { + t.Fatalf("system blocks = %d, want 4. system: %s", len(system), root.Get("system").Raw) + } + for idx, want := range []string{"I1", "S1", "D1", "S2"} { + if got := system[idx].Get("type").String(); got != "text" { + t.Fatalf("system[%d].type = %q, want text", idx, got) + } + if got := system[idx].Get("text").String(); got != want { + t.Fatalf("system[%d].text = %q, want %q", idx, got, want) + } + } + + messages := root.Get("messages").Array() + if len(messages) != 2 { + t.Fatalf("messages = %d, want 2. messages: %s", len(messages), root.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("messages[0].role = %q, want user", got) + } + if got := messages[1].Get("role").String(); got != "assistant" { + t.Fatalf("messages[1].role = %q, want assistant", got) + } + if strings.Contains(root.Get("messages").Raw, "I1") || + strings.Contains(root.Get("messages").Raw, "S1") || + strings.Contains(root.Get("messages").Raw, "D1") { + t.Fatalf("system-level text must not be downgraded into messages: %s", root.Get("messages").Raw) + } + if strings.Contains(root.Get("messages").Raw, `"role":"system"`) { + t.Fatalf("translator must not emit role=system messages: %s", root.Get("messages").Raw) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) { + inputJSON := `{"model": "gpt-4.1", "instructions": "I1"}` + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-opus-5", []byte(inputJSON), false)) + if got := len(root.Get("system").Array()); got != 1 { + t.Fatalf("system blocks = %d, want 1", got) + } + messages := root.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("messages = %d, want 1. messages: %s", len(messages), root.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("messages[0].role = %q, want user", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_SystemNonTextPartKeptAsTypedMarker(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "input": [ + {"type": "message", "role": "developer", "content": [ + {"type": "input_text", "text": "D1"}, + {"type": "input_image", "image_url": "data:image/png;base64,AAAA"} + ]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "U1"}]} + ] + }` + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-opus-5", []byte(inputJSON), false)) + system := root.Get("system").Array() + if len(system) != 2 { + t.Fatalf("system blocks = %d, want 2. system: %s", len(system), root.Get("system").Raw) + } + if got := system[0].Get("text").String(); got != "D1" { + t.Fatalf("system[0].text = %q, want D1", got) + } + if got := system[1].Get("type").String(); got != "input_image" { + t.Fatalf("system[1].type = %q, want input_image", got) + } + if system[1].Get("source").Exists() { + t.Fatalf("unsupported marker must not copy the payload: %s", system[1].Raw) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_SystemItemCacheControlAppliesToLastBlock(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "input": [ + {"type": "message", "role": "system", "cache_control": {"type": "ephemeral"}, "content": [ + {"type": "input_text", "text": "S1"}, + {"type": "input_text", "text": "S2"} + ]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "U1"}]} + ] + }` + + system := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-opus-5", []byte(inputJSON), false)).Get("system").Array() + if len(system) != 2 { + t.Fatalf("system blocks = %d, want 2", len(system)) + } + if system[0].Get("cache_control").Exists() { + t.Fatalf("system[0] must not carry cache_control: %s", system[0].Raw) + } + if got := system[1].Get("cache_control.type").String(); got != "ephemeral" { + t.Fatalf("system[1].cache_control.type = %q, want ephemeral", got) + } +}