Files
CLIProxyAPI/internal/runtime/executor/claude_mid_system_model_test.go
sususu98 f1b0431c77 feat(claude): add fingerprint-profile=claude-code-cli for API keys and delegated providers (#5047)
* feat(config): add fingerprint-profile to Claude keys and auth JSON

- Add FingerprintProfile to ClaudeKey configuration struct and normalizer
- Track fingerprint-profile in config diff
- Map fingerprint-profile / fingerprint_profile to auth attributes in file and config synthesizers
- Support fingerprint-profile in Management API PatchClaudeKey and normalization
- Add Claude billing attribution string manipulation utilities in internal/util
- Document fingerprint-profile options in config.example.yaml

* feat(claude): add fingerprint policy and request-local CLI identity

- Centralize Claude fingerprint policy resolution in claude_fingerprint_policy.go
- Support stable Claude CLI identity synthesis (UUIDv5 account_uuid and SHA-256 device_id)
  seeded from API keys or stable OAuth IDs, keeping access tokens isolated
- Warn on unrecognized fingerprint-profile values

* feat(claude): apply CLI fingerprint to Messages and keep API keys caller-owned

- Wire centralized fingerprint policy into Claude and Kimi executors
- Keep first-party Anthropic API keys and delegated providers caller-owned by default
- Apply Claude Code CLI wire profile (betas, metadata, diagnostics, MCP aliases)
  when fingerprint-profile=claude-code-cli is configured
- Strictly align CCH signing with native Claude Code 2.1.220: only first-party
  api.anthropic.com and Vertex sign dynamic CCH; third-party gateways and Kimi
  receive billing header without cch= to avoid prompt cache busting
- Respect caller-owned count_tokens bodies by default while aligning CLI shape on opt-in
- Fall back to CLIProxyAPI/<version> User-Agent when caller sends no UA in caller-owned mode
- Scope custom operator header overrides accurately in caller-owned mode
- Add comprehensive test coverage for policy resolution, gateway opt-in, Kimi, and token counting
2026-08-18 17:29:26 +08:00

487 lines
22 KiB
Go

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", "cloak_mode": "always"}}
}
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, "<system-reminder>") ||
!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)
}
})
}
}