From 189776aab1fc7523229633a830850b8375079849 Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 11 Aug 2026 20:42:19 +0800 Subject: [PATCH] fix(claude): validate legacy-model system turns before sending A caller can put a {"role":"system"} turn inside messages. Models older than the role=system turn reject it outright, verified against api.anthropic.com on both /v1/messages and /v1/messages/count_tokens: 400 role 'system' is not supported on this model claude-haiku-4-5, claude-sonnet-4-5, claude-sonnet-4-6 and claude-opus-4-6 answer that way, while claude-sonnet-5 and claude-opus-5 accept the turn. claudeLegacySystemReminderModels already enumerates that boundary, which is why claudeCodeCLIBetas withholds mid-conversation-system-2026-04-07 for those models, but caller-provided turns were forwarded unchanged and always spent an upstream call on a guaranteed rejection. The native client does not produce the pairing either: it gates the turn on the model. In 314 captured native requests the turn appears only on claude-opus-5 and claude-sonnet-5, and on none of the 43 requests addressed to a model in that set. That is an observation about the captures rather than a proof about the upstream, so it only corroborates the measured rejection. Validate the finished body, and only inside the evidence that produced the rule: - The check runs after the body is finalized and before http.NewRequestWithContext. Payload rules can rewrite model and messages long after translation, so an earlier check would not describe what is sent. - Only Anthropic's first-party origin is covered, matching the reasoning shouldUseClaudeUpstreamTokenCount already applies to count_tokens. A third-party gateway may map these model IDs onto something that accepts the turn and therefore decides for itself. - A confirmed native caller keeps the passthrough. It gates the turn itself, so its body is forwarded untouched and the upstream error reaches it unchanged. - Unknown and future model IDs stay optimistic and are forwarded, matching how checkSystemInstructions treats them. - rebuild_mid_system_message still folds caller turns into the system slot; the final check therefore preserves the explicit escape hatch. Cloaking adds one extra ordering case: it can place a caller's top-level system prompt into a role=system turn for a modern model before a payload rule changes the model to legacy. Track only the exact contiguous turns that CPA inserted, using both their position and the corresponding message-count increase as provenance. After payload rules settle the model, replay those turns through the existing legacy path. Pre-existing caller turns, even if they have identical content, remain caller-owned and are still rejected. If payload rules also rewrite the tracked messages, reconciliation fails closed and final validation returns 400 rather than guessing provenance. The 400 is request-scoped like claudeCallerSystemBlockError: the invalid body/model pairing is independent of first-party credential health, so no credential is cooled or retried. Translated requests never carry the pairing, because every non-Claude source format hoists system content into the top-level system field. Tests pin that for OpenAI, Gemini, Responses and Interactions, and separately drive Execute, ExecuteStream and CountTokens through an injected first-party transport. --- .../executor/claude_executor_cloaking.go | 162 ++++++ .../executor/claude_executor_execute.go | 9 + .../executor/claude_executor_request.go | 18 + .../executor/claude_executor_stream.go | 9 + .../executor/claude_executor_tokens.go | 6 + .../executor/claude_mid_system_model_test.go | 486 ++++++++++++++++++ 6 files changed, 690 insertions(+) create mode 100644 internal/runtime/executor/claude_mid_system_model_test.go diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 307f7ed1..59038241 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -332,6 +332,79 @@ func newClaudeCallerSystemBlockError(index int, blockType string) error { }} } +// claudeMidSystemMessageModelError reports a mid-conversation +// {"role":"system"} turn addressed to a first-party model that cannot carry +// it. It is request-scoped for the same reason as claudeCallerSystemBlockError: +// the body is incompatible with the model rather than evidence of unhealthy +// credentials, so no credential should be cooled or retried. +type claudeMidSystemMessageModelError struct { + statusErr +} + +func (claudeMidSystemMessageModelError) IsRequestScoped() bool { + return true +} + +// The turn is not always the caller's. CPA normally reconciles a cloaked turn +// when a payload rule changes the model to legacy, but it deliberately gives up +// if the rule also rewrites the tracked messages and their provenance is no +// longer exact. The wording therefore states the model's requirement instead of +// assuming the caller created the turn. +func newClaudeMidSystemMessageModelError(model string) error { + if model == "" { + model = "unknown" + } + return claudeMidSystemMessageModelError{statusErr{ + code: http.StatusBadRequest, + msg: fmt.Sprintf("invalid_request_error: role 'system' is not supported on this model. "+ + "Model %q predates mid-conversation system turns, so system instructions must "+ + "stay in the top-level system field for it.", model), + }} +} + +// validateClaudeMidSystemMessageModel rejects a request that pairs a legacy +// model with a caller's mid-conversation {"role":"system"} turn. +// +// Anthropic answers that pairing with a guaranteed rejection, verified on both +// /v1/messages and /v1/messages/count_tokens: +// +// 400 role 'system' is not supported on this model +// +// The native client never produces it either: it gates the turn on the model, +// which is also why claudeCodeCLIBetas withholds +// mid-conversation-system-2026-04-07 for these IDs. In 314 captured native +// requests the turn appears only on claude-opus-5 and claude-sonnet-5, and on +// none of the 43 requests addressed to a model in +// claudeLegacySystemReminderModels. +// +// Three conditions keep the check inside the evidence that produced it: +// +// - firstPartyAnthropic, because the rejection was measured against +// api.anthropic.com. A third-party gateway may map these model IDs onto +// something that accepts the turn, and answering locally would also stop +// failover to another credential or base URL. +// - confirmedClaudeCode, because a client that still matches the native +// fingerprint owns its wire. It gates the turn itself, so its body is +// forwarded untouched and any upstream error reaches it unchanged. +// - the pairing itself, so unknown and future model IDs stay optimistic in +// the same way checkSystemInstructions treats them. +// +// Operators who prefer the turn folded into the system slot can still set +// rebuild_mid_system_message, which runs before this check. +// +// The error is request-scoped: the body/model pairing is invalid independently +// of first-party credential health, so no credential should be cooled or +// retried. +func validateClaudeMidSystemMessageModel(payload []byte, confirmedClaudeCode, firstPartyAnthropic bool) error { + if confirmedClaudeCode || !firstPartyAnthropic { + return nil + } + if !claudeUsesLegacySystemReminder(payload) || !claudePayloadHasMidSystemMessage(payload) { + return nil + } + return newClaudeMidSystemMessageModelError(gjson.GetBytes(payload, "model").String()) +} + // 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 @@ -539,6 +612,95 @@ func claudeMessageContentText(content gjson.Result) string { return strings.Join(parts, "\n\n") } +// claudeCodeSystemPlacementState identifies only the role=system turns that CPA +// itself inserted while cloaking. Caller-owned turns are deliberately excluded: +// if one is paired with a legacy model, validateClaudeMidSystemMessageModel must +// still return 400 instead of silently rewriting the caller's wire. +type claudeCodeSystemPlacementState struct { + insertAt int + insertedRaw []string + texts []string +} + +// captureClaudeCodeSystemPlacement records CPA's modern-model system placement +// immediately after cloaking. The message-count increase is part of the proof: +// insertClaudeMidConversationSystemMessages returns without inserting when the +// same turns already exist, and those pre-existing turns belong to the caller. +func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claudeCodeSystemPlacementState { + if !cloaked || claudeUsesLegacySystemReminder(before) { + return claudeCodeSystemPlacementState{} + } + texts := collectForwardedClaudeSystemPromptBlocks(gjson.GetBytes(before, "system")) + if len(texts) == 0 { + return claudeCodeSystemPlacementState{} + } + + beforeMessages := gjson.GetBytes(before, "messages").Array() + afterMessages := gjson.GetBytes(after, "messages").Array() + if len(afterMessages) != len(beforeMessages)+len(texts) { + return claudeCodeSystemPlacementState{} + } + firstUserIdx := firstClaudeUserMessageIndex(before) + if firstUserIdx < 0 { + return claudeCodeSystemPlacementState{} + } + insertAt := firstUserIdx + 1 + for insertAt < len(beforeMessages) && beforeMessages[insertAt].Get("role").String() == "user" { + insertAt++ + } + if insertAt+len(texts) > len(afterMessages) { + return claudeCodeSystemPlacementState{} + } + + insertedRaw := make([]string, len(texts)) + for idx, text := range texts { + message := afterMessages[insertAt+idx] + if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text { + return claudeCodeSystemPlacementState{} + } + insertedRaw[idx] = message.Raw + } + return claudeCodeSystemPlacementState{ + insertAt: insertAt, + insertedRaw: insertedRaw, + texts: append([]string(nil), texts...), + } +} + +// reconcileClaudeCodeSystemPlacementAfterPayload repairs an otherwise stale +// placement decision when payload rules change the final model from modern to +// legacy. It removes only the exact contiguous turns captured above and replays +// their text through the existing legacy path. If any payload +// rule also changed those messages, reconciliation fails closed and leaves the +// final validation guard to return 400. +func reconcileClaudeCodeSystemPlacementAfterPayload(payload []byte, state claudeCodeSystemPlacementState) []byte { + if len(state.insertedRaw) == 0 || !claudeUsesLegacySystemReminder(payload) { + return payload + } + messages := gjson.GetBytes(payload, "messages").Array() + if state.insertAt < 0 || state.insertAt+len(state.insertedRaw) > len(messages) { + return payload + } + for idx, raw := range state.insertedRaw { + if messages[state.insertAt+idx].Raw != raw { + return payload + } + } + + rawMessages := make([]string, 0, len(messages)-len(state.insertedRaw)) + for idx, message := range messages { + if idx >= state.insertAt && idx < state.insertAt+len(state.insertedRaw) { + continue + } + rawMessages = append(rawMessages, message.Raw) + } + updated, errSet := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]")) + if errSet != nil { + return payload + } + return prependClaudeSystemRemindersToFirstUserMessage(updated, state.texts) +} + // claudeCodeLocalDate reproduces Claude Code 2.1.220's wcs() helper: // new Date(), local calendar fields, and zero-padded YYYY-MM-DD components. func claudeCodeLocalDate(now time.Time) string { diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 24812eae..53a54dc0 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -73,6 +73,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. + bodyBeforeCloaking := body var cloaked bool body, cloaked, err = applyCloaking( ctx, @@ -86,6 +87,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } + systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. diagnosticsState := claudeDiagnosticsRequestState{} @@ -103,6 +105,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body, contextManagementState.payloadRuleTouched = helps.ApplyPayloadConfigWithRequestTracked(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers, "context_management") + body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -179,6 +182,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, fmt.Errorf("finalize Claude CCH: %w", err) } } + // Runs on the finished body: payload rules can rewrite model and messages + // long after translation, so an earlier check would not describe the request + // that is about to be sent. + if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { + return resp, errMidSystem + } reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 51bfbe30..8064ab3b 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -888,6 +888,24 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } +// claudePayloadHasMidSystemMessage reports whether the caller placed a +// {"role":"system"} turn inside messages. +func claudePayloadHasMidSystemMessage(payload []byte) bool { + messages := gjson.GetBytes(payload, "messages") + if !messages.IsArray() { + return false + } + found := false + messages.ForEach(func(_, message gjson.Result) bool { + if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") { + found = true + return false + } + return true + }) + return found +} + func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte { messages := gjson.GetBytes(payload, "messages") if !messages.IsArray() { diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 8f5171e0..6807d50f 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -77,6 +77,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. + bodyBeforeCloaking := body var cloaked bool body, cloaked, err = applyCloaking( ctx, @@ -90,6 +91,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } + systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. diagnosticsState := claudeDiagnosticsRequestState{} @@ -107,6 +109,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body, contextManagementState.payloadRuleTouched = helps.ApplyPayloadConfigWithRequestTracked(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers, "context_management") + body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -172,6 +175,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return nil, fmt.Errorf("finalize Claude CCH: %w", err) } } + // Runs on the finished body: payload rules can rewrite model and messages + // long after translation, so an earlier check would not describe the request + // that is about to be sent. + if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { + return nil, errMidSystem + } reporter.SetTranslatedReasoningEffort(bodyForUpstream, to.String()) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index 8af3f418..42865067 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -211,6 +211,12 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy body, _ = sjson.DeleteBytes(body, "context_management") body, _ = sjson.DeleteBytes(body, "diagnostics") } + // Runs on the finished body: payload rules can rewrite model and messages + // long after translation, so an earlier check would not describe the request + // that is about to be sent. + if errMidSystem := validateClaudeMidSystemMessageModel(body, confirmedClaudeCode, directAnthropic); errMidSystem != nil { + return cliproxyexecutor.Response{}, errMidSystem + } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return cliproxyexecutor.Response{}, err diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go new file mode 100644 index 00000000..0a79dba2 --- /dev/null +++ b/internal/runtime/executor/claude_mid_system_model_test.go @@ -0,0 +1,486 @@ +package executor + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +// midSystemLegacyPayload is a caller body pairing a legacy model with a +// mid-conversation role=system turn. The turn ends the array, so the shape is +// rejected by the model rather than by Anthropic's ordering rule. +func midSystemLegacyPayload(model string) []byte { + return []byte(`{"model":"` + model + `","max_tokens":32,` + + `"system":[{"type":"text","text":"Top rule"}],` + + `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},` + + `{"role":"system","content":[{"type":"text","text":"Mid rule"}]}],` + + `"metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"account_uuid\":\"\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"}}`) +} + +// midSystemUpstream intercepts the transport instead of standing up a test +// server, so the executor keeps the default https://api.anthropic.com base URL. +// The guard only fires on Anthropic's first-party origin, which a httptest +// server address would not satisfy. +type midSystemUpstream struct { + body []byte + called bool + headers http.Header +} + +func (u *midSystemUpstream) context(t *testing.T, headers http.Header) context.Context { + t.Helper() + gin.SetMode(gin.TestMode) + ginCtx, _ := gin.CreateTestContext(nil) + ginCtx.Request = httptest_NewRequest() + ginCtx.Request.Header = headers.Clone() + if ginCtx.Request.Header == nil { + ginCtx.Request.Header = make(http.Header) + } + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + payload, errRead := io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + u.body = payload + u.called = true + u.headers = req.Header.Clone() + contentType := "application/json" + responseBody := `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` + if strings.Contains(req.URL.Path, "count_tokens") { + responseBody = `{"input_tokens":18}` + } else if gjson.GetBytes(payload, "stream").Bool() { + contentType = "text/event-stream" + // A translated caller aggregates the stream back into one message, so + // the stub has to complete the block and report a stop reason. + responseBody = strings.Join([]string{ + `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`, + `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`, + `event: message_stop` + "\n" + `data: {"type":"message_stop"}`, + }, "\n\n") + "\n\n" + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + Request: req, + }, nil + }) + ctx := context.WithValue(context.Background(), "gin", ginCtx) + return context.WithValue(ctx, "cliproxy.roundtripper", http.RoundTripper(transport)) +} + +func httptest_NewRequest() *http.Request { + req, _ := http.NewRequest(http.MethodPost, "http://example.invalid/", nil) + return req +} + +func midSystemAuth() *cliproxyauth.Auth { + // No base_url, so the executor keeps Anthropic's first-party origin. + return &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}} +} + +func midSystemConfig() *config.Config { + return &config.Config{ClaudeKey: []config.ClaudeKey{{APIKey: "key-123"}}} +} + +func assertMidSystemRejected(t *testing.T, err error, upstream *midSystemUpstream) { + t.Helper() + if err == nil { + t.Fatal("error = nil, want the legacy pairing rejected") + } + if upstream.called { + t.Fatalf("upstream must not be called for a guaranteed rejection; got %s", upstream.body) + } + var statusCoder interface{ StatusCode() int } + if !errors.As(err, &statusCoder) || statusCoder.StatusCode() != http.StatusBadRequest { + t.Fatalf("error = %v, want a 400 status error", err) + } + var scoped interface{ IsRequestScoped() bool } + if !errors.As(err, &scoped) || !scoped.IsRequestScoped() { + t.Fatalf("error = %v, want a request-scoped error so no credential is retried", err) + } + if !strings.Contains(err.Error(), "role 'system' is not supported on this model") { + t.Fatalf("error = %v, want Anthropic's wording preserved", err) + } +} + +// Every executor path that can send the pairing to Anthropic must answer it +// locally instead of spending an upstream call on a guaranteed 400. +func TestClaudeExecutor_LegacyMidSystemMessageRejectedOnEveryUpstreamPath(t *testing.T) { + for _, test := range []struct { + name string + model string + send func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, model string) error + }{ + {name: "execute", model: "claude-haiku-4-5-20251001", send: sendMidSystemExecute}, + {name: "execute stream", model: "claude-haiku-4-5-20251001", send: sendMidSystemStream}, + {name: "count tokens", model: "claude-haiku-4-5-20251001", send: sendMidSystemCountTokens}, + {name: "execute legacy sonnet", model: "claude-sonnet-4-6", send: sendMidSystemExecute}, + } { + t.Run(test.name, func(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + err := test.send(t, ex, upstream.context(t, nil), test.model) + assertMidSystemRejected(t, err, upstream) + }) + } +} + +func sendMidSystemExecute(t *testing.T, ex *ClaudeExecutor, ctx context.Context, model string) error { + t.Helper() + _, err := ex.Execute(ctx, midSystemAuth(), cliproxyexecutor.Request{ + Model: model, Payload: midSystemLegacyPayload(model), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + return err +} + +func sendMidSystemStream(t *testing.T, ex *ClaudeExecutor, ctx context.Context, model string) error { + t.Helper() + result, err := ex.ExecuteStream(ctx, midSystemAuth(), cliproxyexecutor.Request{ + Model: model, Payload: midSystemLegacyPayload(model), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + return err + } + for chunk := range result.Chunks { + if chunk.Err != nil { + return chunk.Err + } + } + return nil +} + +func sendMidSystemCountTokens(t *testing.T, ex *ClaudeExecutor, ctx context.Context, model string) error { + t.Helper() + _, err := ex.CountTokens(ctx, midSystemAuth(), cliproxyexecutor.Request{ + Model: model, Payload: midSystemLegacyPayload(model), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + return err +} + +// Payload rules run long after translation and can rewrite model and messages, +// so the guard has to read the finished body rather than an intermediate one. +func TestClaudeExecutor_PayloadOverrideCannotSmuggleLegacyMidSystemMessage(t *testing.T) { + upstream := &midSystemUpstream{} + cfg := midSystemConfig() + cfg.Payload.Override = []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "*"}}, + Params: map[string]any{"model": "claude-haiku-4-5-20251001"}, + }} + ex := NewClaudeExecutor(cfg) + + // The caller addresses a model that accepts the turn; only the payload rule + // turns it into the rejected pairing. + _, err := ex.Execute(upstream.context(t, nil), midSystemAuth(), cliproxyexecutor.Request{ + Model: "claude-sonnet-5", Payload: midSystemLegacyPayload("claude-sonnet-5"), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + assertMidSystemRejected(t, err, upstream) +} + +// A caller may already have the exact role=system turn that cloaking would +// otherwise insert. The message-count proof must keep that turn caller-owned, +// so a later legacy model rewrite is rejected instead of silently consuming it. +func TestClaudeExecutor_PayloadOverrideDoesNotClaimMatchingCallerTurn(t *testing.T) { + upstream := &midSystemUpstream{} + cfg := midSystemConfig() + cfg.Payload.Override = []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "*"}}, + Params: map[string]any{"model": "claude-haiku-4-5-20251001"}, + }} + ex := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":32,` + + `"system":[{"type":"text","text":"Same rule"}],` + + `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},` + + `{"role":"system","content":[{"type":"text","text":"Same rule"}]}]}`) + + _, err := ex.Execute(upstream.context(t, nil), midSystemAuth(), cliproxyexecutor.Request{ + Model: "claude-sonnet-5", Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + assertMidSystemRejected(t, err, upstream) +} + +// Cloaking relocates a caller's system prompt into a role=system turn for models +// that accept one. A payload rule can then rewrite the model to one that does +// not. Because the caller never wrote that turn, CPA must reconcile its own +// placement through the legacy reminder path instead of returning 400. +func TestClaudeExecutor_PayloadOverrideReconcilesRelocatedSystemPrompt(t *testing.T) { + for _, test := range []struct { + name string + send func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, payload []byte) error + }{ + {name: "execute", send: func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, payload []byte) error { + _, err := ex.Execute(ctx, midSystemAuth(), cliproxyexecutor.Request{ + Model: "claude-sonnet-5", Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + return err + }}, + {name: "execute stream", send: func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, payload []byte) error { + result, err := ex.ExecuteStream(ctx, midSystemAuth(), cliproxyexecutor.Request{ + Model: "claude-sonnet-5", Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + return err + } + for chunk := range result.Chunks { + if chunk.Err != nil { + return chunk.Err + } + } + return nil + }}, + } { + t.Run(test.name, func(t *testing.T) { + upstream := &midSystemUpstream{} + cfg := midSystemConfig() + cfg.Payload.Override = []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "*"}}, + Params: map[string]any{"model": "claude-haiku-4-5-20251001"}, + }} + ex := NewClaudeExecutor(cfg) + + // Only a top-level system prompt: the caller never writes a + // role=system turn. + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":32,` + + `"system":[{"type":"text","text":"Caller top"}],` + + `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + if err := test.send(t, ex, upstream.context(t, nil), payload); err != nil { + t.Fatalf("request error = %v, want CPA's inserted turn reconciled", err) + } + if !upstream.called { + t.Fatal("expected reconciled request to reach upstream") + } + if got := gjson.GetBytes(upstream.body, "model").String(); got != "claude-haiku-4-5-20251001" { + t.Fatalf("upstream model = %q, want payload override preserved", got) + } + if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { + t.Fatalf("reconciled body still carries role=system; body=%s", upstream.body) + } + if !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "") || + !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "Caller top") { + t.Fatalf("caller system prompt was not replayed as a legacy reminder; body=%s", upstream.body) + } + }) + } +} + +// A confirmed native caller owns its wire. It gates the turn on the model +// itself, so CPA forwards the body untouched and lets the upstream answer. +func TestClaudeExecutor_ConfirmedNativeLegacyMidSystemMessageForwarded(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + headers := claudeNativeHelperHeaders("claude-code-20250219,"+claudeNativeHelperCoreBetas, "gzip", false) + + if _, err := ex.Execute(upstream.context(t, headers), midSystemAuth(), cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: midSystemLegacyPayload("claude-haiku-4-5-20251001"), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, Headers: headers}); err != nil { + t.Fatalf("Execute() error = %v, want the native body forwarded", err) + } + if !upstream.called { + t.Fatal("expected the native request to reach the upstream") + } + if !gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { + t.Fatalf("confirmed native caller lost its role=system turn; body=%s", upstream.body) + } +} + +// The rejection was measured against api.anthropic.com. A third-party gateway +// may map the same model ID onto something that accepts the turn, and answering +// locally would also stop failover to another credential or base URL. +func TestClaudeExecutor_LegacyMidSystemMessageForwardedToThirdPartyGateway(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(&config.Config{ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-123", BaseURL: "https://gateway.example", + }}}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", "base_url": "https://gateway.example", + }} + + if _, err := ex.Execute(upstream.context(t, nil), auth, cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: midSystemLegacyPayload("claude-haiku-4-5-20251001"), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil { + t.Fatalf("Execute() error = %v, want a third-party gateway to decide for itself", err) + } + if !upstream.called { + t.Fatal("expected the request to reach the third-party gateway") + } +} + +// A model outside claudeLegacySystemReminderModels stays optimistic, matching +// how checkSystemInstructions treats unknown and future IDs. +func TestClaudeExecutor_SupportedModelMidSystemMessageForwarded(t *testing.T) { + for _, model := range []string{"claude-sonnet-5", "claude-sonnet-9"} { + t.Run(model, func(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + if _, err := ex.Execute(upstream.context(t, nil), midSystemAuth(), cliproxyexecutor.Request{ + Model: model, Payload: midSystemLegacyPayload(model), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil { + t.Fatalf("Execute() error = %v, want the request forwarded", err) + } + if !upstream.called { + t.Fatal("expected the request to reach the upstream") + } + }) + } +} + +// The opt-in rescues the pairing by folding the turn into the system slot, so +// the guard must run after it rather than rejecting the request outright. +func TestClaudeExecutor_LegacyMidSystemMessageOptInStillRebuilds(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + auth := midSystemAuth() + auth.Attributes["rebuild_mid_system_message"] = "true" + + if _, err := ex.Execute(upstream.context(t, nil), auth, cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: midSystemLegacyPayload("claude-haiku-4-5-20251001"), + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil { + t.Fatalf("Execute() error = %v, want the opt-in rebuild to rescue the request", err) + } + if !upstream.called { + t.Fatal("expected the rebuilt request to reach the upstream") + } + if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { + t.Fatalf("opt-in rebuild left a role=system turn; body=%s", upstream.body) + } +} + +// The pairing must never originate inside CPA. A non-Claude caller reaches the +// Claude executor through a translator, and every translator hoists system +// content into the top-level system field, so no translated body can carry a +// role=system turn to a legacy model. This pins that guarantee: the guard is for +// callers that speak Claude natively, never for a translated request. +func TestTranslatedRequestNeverPairsLegacyModelWithMidSystemMessage(t *testing.T) { + const legacyModel = "claude-haiku-4-5-20251001" + for _, test := range []struct { + name string + format sdktranslator.Format + payload string + }{ + {name: "openai chat with a mid conversation system message", format: sdktranslator.FormatOpenAI, + payload: `{"model":"` + legacyModel + `","messages":[{"role":"system","content":"Top rule"},{"role":"user","content":"hi"},{"role":"system","content":"Mid rule"},{"role":"assistant","content":"ok"},{"role":"user","content":"go"}]}`}, + {name: "openai chat ending on a system message", format: sdktranslator.FormatOpenAI, + payload: `{"model":"` + legacyModel + `","messages":[{"role":"user","content":"hi"},{"role":"system","content":"Mid rule"}]}`}, + {name: "gemini with a system instruction", format: sdktranslator.FormatGemini, + payload: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"systemInstruction":{"parts":[{"text":"Top rule"}]}}`}, + {name: "openai responses with instructions", format: sdktranslator.FormatOpenAIResponse, + payload: `{"model":"` + legacyModel + `","instructions":"Top rule","input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}]}`}, + {name: "interactions with a system instruction", format: sdktranslator.FormatInteractions, + payload: `{"model":"` + legacyModel + `","system_instruction":"Top rule","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}]}`}, + } { + t.Run(test.name, func(t *testing.T) { + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + + if _, err := ex.Execute(upstream.context(t, nil), midSystemAuth(), cliproxyexecutor.Request{ + Model: legacyModel, Payload: []byte(test.payload), + }, cliproxyexecutor.Options{SourceFormat: test.format}); err != nil { + t.Fatalf("Execute() error = %v, want the translated request forwarded", err) + } + if !upstream.called { + t.Fatal("expected the translated request to reach the upstream") + } + // Without these the subject under test could drift away: a body that + // no longer addresses the legacy model, or that lost the caller's + // turns, would satisfy the role assertions for the wrong reason. + if got := gjson.GetBytes(upstream.body, "model").String(); got != legacyModel { + t.Fatalf("upstream model = %q, want the legacy model %q under test", got, legacyModel) + } + if got := len(gjson.GetBytes(upstream.body, "messages").Array()); got == 0 { + t.Fatalf("upstream messages are empty, so the role assertions prove nothing; body=%s", upstream.body) + } + for _, role := range gjson.GetBytes(upstream.body, "messages.#.role").Array() { + if strings.EqualFold(role.String(), "system") { + t.Fatalf("translated body carries a system role; body=%s", upstream.body) + } + } + }) + } +} + +func TestClaudePayloadHasMidSystemMessage(t *testing.T) { + for _, test := range []struct { + name string + payload string + want bool + }{ + {name: "mid conversation turn", want: true, + payload: `{"messages":[{"role":"user","content":"a"},{"role":"system","content":"s"}]}`}, + {name: "role casing is ignored", want: true, + payload: `{"messages":[{"role":"SySTeM","content":"s"}]}`}, + {name: "surrounding whitespace is ignored", want: true, + payload: `{"messages":[{"role":" system ","content":"s"}]}`}, + {name: "only user and assistant turns", + payload: `{"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"}]}`}, + {name: "top level system field is not a turn", + payload: `{"system":[{"type":"text","text":"s"}],"messages":[{"role":"user","content":"a"}]}`}, + {name: "messages missing", payload: `{"model":"claude-haiku-4-5"}`}, + {name: "messages is not an array", payload: `{"messages":"system"}`}, + {name: "messages holds a bare string", payload: `{"messages":["system"]}`}, + {name: "system appears only in content", payload: `{"messages":[{"role":"user","content":"role: system"}]}`}, + } { + t.Run(test.name, func(t *testing.T) { + if got := claudePayloadHasMidSystemMessage([]byte(test.payload)); got != test.want { + t.Fatalf("claudePayloadHasMidSystemMessage = %v, want %v", got, test.want) + } + }) + } +} + +func TestValidateClaudeMidSystemMessageModel(t *testing.T) { + const turn = `,"messages":[{"role":"user","content":"a"},{"role":"system","content":"s"}]}` + for _, test := range []struct { + name string + payload string + confirmed bool + thirdParty bool + wantError bool + }{ + {name: "legacy model is rejected", wantError: true, + payload: `{"model":"claude-haiku-4-5-20251001"` + turn}, + {name: "vendor prefixed legacy model is rejected", wantError: true, + payload: `{"model":"anthropic/claude-sonnet-4-6"` + turn}, + {name: "model casing is ignored", wantError: true, + payload: `{"model":"Claude-Haiku-4-5-20251001"` + turn}, + {name: "confirmed native keeps the passthrough", confirmed: true, + payload: `{"model":"claude-haiku-4-5-20251001"` + turn}, + {name: "third party gateway decides for itself", thirdParty: true, + payload: `{"model":"claude-haiku-4-5-20251001"` + turn}, + {name: "supported model is forwarded", + payload: `{"model":"claude-sonnet-5"` + turn}, + {name: "unknown model stays optimistic", + payload: `{"model":"claude-sonnet-9"` + turn}, + {name: "legacy model without the turn is forwarded", + payload: `{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"a"}]}`}, + } { + t.Run(test.name, func(t *testing.T) { + err := validateClaudeMidSystemMessageModel([]byte(test.payload), test.confirmed, !test.thirdParty) + if test.wantError != (err != nil) { + t.Fatalf("validateClaudeMidSystemMessageModel error = %v, want error %v", err, test.wantError) + } + if err == nil { + return + } + if !strings.Contains(err.Error(), gjson.Get(test.payload, "model").String()) { + t.Fatalf("error = %v, want the offending model named", err) + } + }) + } +} -- 2.51.2