mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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":""}`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user