sloproxy

fix(claude): validate legacy-model system turns before sending master

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 <system-reminder> 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.