diff --git a/config.example.yaml b/config.example.yaml index 1e3f1301..bd0b5460 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -510,14 +510,15 @@ nonstream-keepalive-interval: 0 # - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) -# rebuild-mid-system-message: false # optional: default is false; when true, move messages with role "system" into the top-level Claude system field +# rebuild-mid-system-message: false # optional: default is false; when true, keep caller instructions in the top-level Claude system field, including after cloaking # cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients # mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code # # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through # # "never": never apply cloaking # # This "cloak" block applies to this claude-api-key entry only. For Claude OAuth # # credentials, set the same options in the auth/token JSON file via "cloak_mode" / -# # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". The top-level +# # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". OAuth token +# # JSON also accepts "rebuild_mid_system_message": "true" for the option above. The top-level # # "disable-claude-cloak-mode: true" disables cloaking for all Claude credentials at once. # strict-mode: false # false (default): legacy-model whitelist uses a user system-reminder; # # all other and future models use messages[].role=system diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 386a01d0..a91b5ed0 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -102,6 +102,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } + // Cloaking may introduce role=system turns after the caller-requested rebuild + // ran. Apply the same policy to the finished cloaked layout so the option + // actually keeps stable instructions in the top-level system field. + if rebuildMidSystemMessageEnabled(e.cfg, auth) { + body = rebuildMidSystemMessagesToTopLevel(body) + } systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 7f9109f0..02e5d0d6 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -105,6 +105,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } + // Cloaking may introduce role=system turns after the caller-requested rebuild + // ran. Apply the same policy to the finished cloaked layout so the option + // actually keeps stable instructions in the top-level system field. + if rebuildMidSystemMessageEnabled(e.cfg, auth) { + body = rebuildMidSystemMessagesToTopLevel(body) + } systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index 58b0e4f7..c43b887a 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -169,6 +169,9 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy } } body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) + if rebuildMidSystemMessageEnabled(e.cfg, auth) { + body = rebuildMidSystemMessagesToTopLevel(body) + } if len(settings.sensitiveWords) > 0 { body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) } diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go index 650c538f..65db8603 100644 --- a/internal/runtime/executor/claude_mid_system_model_test.go +++ b/internal/runtime/executor/claude_mid_system_model_test.go @@ -362,6 +362,78 @@ func TestClaudeExecutor_LegacyMidSystemMessageOptInStillRebuilds(t *testing.T) { } } +// OAuth credentials store per-credential options in token metadata. Cloaking +// runs after the initial rebuild pass, so the option must also fold the system +// turns introduced by cloaking back into the top-level system field. +func TestClaudeExecutor_OAuthRebuildKeepsCloakedInstructionsTopLevel(t *testing.T) { + tests := []struct { + name string + send func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, auth *cliproxyauth.Auth, payload []byte) error + }{ + { + name: "execute", + send: func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, auth *cliproxyauth.Auth, payload []byte) error { + _, err := ex.Execute(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + return err + }, + }, + { + name: "execute stream", + send: func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, auth *cliproxyauth.Auth, payload []byte) error { + result, err := ex.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-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 + }, + }, + { + name: "count tokens", + send: func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, auth *cliproxyauth.Auth, payload []byte) error { + _, err := ex.CountTokens(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload}, + cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + upstream := &midSystemUpstream{} + auth := midSystemAuth() + auth.Metadata = map[string]any{"rebuild_mid_system_message": "true"} + payload := []byte(`{"model":"claude-opus-5","max_tokens":32,` + + `"system":[{"type":"text","text":"Stable caller rule"}],` + + `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + if err := test.send(t, NewClaudeExecutor(midSystemConfig()), upstream.context(t, nil), auth, payload); err != nil { + t.Fatalf("request error = %v", err) + } + if !upstream.called { + t.Fatal("expected request to reach upstream") + } + if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { + t.Fatalf("cloaked instructions were reinserted into messages: %s", upstream.body) + } + foundRule := false + gjson.GetBytes(upstream.body, "system").ForEach(func(_, block gjson.Result) bool { + foundRule = foundRule || block.Get("text").String() == "Stable caller rule" + return true + }) + if !foundRule { + t.Fatalf("caller instruction missing from top-level system: %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 diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go index 5642bcbe..f7c2ef45 100644 --- a/internal/runtime/executor/claude_signing.go +++ b/internal/runtime/executor/claude_signing.go @@ -9,6 +9,7 @@ import ( "strings" xxHash64 "github.com/pierrec/xxHash/xxHash64" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -546,6 +547,9 @@ func rebuildMidSystemMessageEnabled(cfg *config.Config, auth *cliproxyauth.Auth) if auth != nil && auth.Attributes != nil && strings.EqualFold(strings.TrimSpace(auth.Attributes["rebuild_mid_system_message"]), "true") { return true } + if auth != nil && strings.EqualFold(strings.TrimSpace(claudeauth.ReadMetadataString(&auth.Metadata, "rebuild_mid_system_message")), "true") { + return true + } entry := resolveClaudeKeyConfig(cfg, auth) return entry != nil && entry.RebuildMidSystemMessage }