fix(claude): carry caller system inputs into Claude system blocks

OpenAI callers can raise instructions above user content in three ways
and Claude has one system slot for all of them, but the translators
disagreed on where each one landed. Responses instructions became a
leading user turn, a role=system item was only demoted for the first
item, role=developer silently became user text, and the Chat translator
dropped developer messages outright. An operator instruction could
therefore lose its authority or disappear without any signal.

All five sources now become separate top-level Claude system blocks in
source order, so the executor cloak decides the final placement on its
own: a mid-conversation role=system message on models that accept one,
an individual <system-reminder> block on legacy models. Blocks are never
merged, trimmed, reordered or demoted.

Anthropic only accepts text in a system slot. Verified against
api.anthropic.com: the top-level system field rejects anything else with
"system.<i>.type: Input should be 'text'", and a role=system message
accepts text, tool_addition and tool_removal only. A non-text system part
is therefore kept as a typed marker without its payload and rejected by
the cloak with a request-scoped 400 that names the offending type. That
keeps the failure local, spends no upstream call on a request that cannot
succeed, and stops caller content from ever reaching the top-level system
field where it would break the Claude Code fingerprint shape.
This commit is contained in:
sususu
2026-08-03 21:45:56 +08:00
parent 3fac4a09d8
commit 6f8f11a324
7 changed files with 482 additions and 51 deletions

View File

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

View File

@@ -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.<i>.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")
}
}

View File

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

View File

@@ -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":""}`)

View File

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

View File

@@ -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.<i>.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 {

View File

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