diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 472968f523..79f9a7c438 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -391,6 +391,7 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) systemRewritten := false if systemPromptInjectionEnabled { + systemPromptBlocks = claudeOAuthSystemPromptBlocksForModel(model, systemPromptBlocks) body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) systemRewritten = true } @@ -698,6 +699,24 @@ type claudeOAuthSystemPromptBlocksEnvelope struct { Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` } +// claudeFableOAuthSystemPromptBlocks keeps the Claude Code identity required by +// OAuth credentials without the generic CLI expansion block. Fable 5 rejects +// that expansion upstream with stop_reason=refusal and zero output tokens, +// while the native billing + identity shape is accepted. Original client +// system instructions are still migrated into the message history by +// rewriteSystemForNonClaudeCodeWithPromptBlocks. +const claudeFableOAuthSystemPromptBlocks = `[ + {"type":"text","text":"{billing_header}"}, + {"type":"text","text":"{claude_code_system_prompt}"} +]` + +func claudeOAuthSystemPromptBlocksForModel(model, configured string) string { + if isAnthropicFableModel(model) { + return claudeFableOAuthSystemPromptBlocks + } + return configured +} + func defaultClaudeOAuthExpansionPrompt(expansionPrompt string) string { expansionPrompt = strings.TrimSpace(expansionPrompt) if expansionPrompt == "" { diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index b7ab3e9429..57ae438309 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -358,6 +358,25 @@ func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) { require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) } +func TestApplyClaudeCodeOAuthMimicryToBody_FableOmitsRefusedExpansion(t *testing.T) { + account := &Account{ID: 406, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + body := []byte(`{"model":"claude-fable-5","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + + out := svc.applyClaudeCodeOAuthMimicryToBody( + context.Background(), nil, account, body, "Project instructions", "claude-fable-5", + ) + + system := gjson.GetBytes(out, "system").Array() + require.Len(t, system, 2) + require.Contains(t, system[0].Get("text").String(), "x-anthropic-billing-header:") + require.Equal(t, claudeCodeSystemPrompt, system[1].Get("text").String()) + require.NotContains(t, string(out), claudeCodeSystemPromptExpansion) + require.Contains(t, gjson.GetBytes(out, "messages.0.content.0.text").String(), "Project instructions") + require.Equal(t, "Understood. I will follow these instructions.", gjson.GetBytes(out, "messages.1.content.0.text").String()) + require.Equal(t, "hello", gjson.GetBytes(out, "messages.2.content").String()) +} + // ============================================================================ // passthrough 集成测试:buildUpstreamRequest- // AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough