github.com/anthropics/anthropic-sdk-go@v1.70.1/betamessageutil_test.go (about)

     1  package anthropic_test
     2  
     3  import (
     4  	"encoding/json"
     5  	"strings"
     6  	"testing"
     7  
     8  	"github.com/tidwall/gjson"
     9  
    10  	"github.com/anthropics/anthropic-sdk-go"
    11  )
    12  
    13  func unmarshalBetaContentBlockParam(t *testing.T, jsonData string) anthropic.BetaContentBlockParamUnion {
    14  	t.Helper()
    15  	var block anthropic.BetaContentBlockUnion
    16  	if err := json.Unmarshal([]byte(jsonData), &block); err != nil {
    17  		t.Fatalf("Failed to unmarshal JSON: %v", err)
    18  	}
    19  	return block.ToParam()
    20  }
    21  
    22  func TestBetaTextCitationToParamKeepsAllFields(t *testing.T) {
    23  	t.Run("page_location keeps cited_text", func(t *testing.T) {
    24  		result := unmarshalBetaContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"page_location","cited_text":"quoted","document_index":2,"document_title":"Doc","start_page_number":3,"end_page_number":4}]}`)
    25  		c := result.OfText.Citations[0].OfPageLocation
    26  		if c == nil {
    27  			t.Fatal("Expected OfPageLocation to be non-nil")
    28  		}
    29  		if c.CitedText != "quoted" {
    30  			t.Errorf("Expected cited_text to survive ToParam, got %q", c.CitedText)
    31  		}
    32  	})
    33  
    34  	t.Run("search_result_location keeps search_result_index", func(t *testing.T) {
    35  		result := unmarshalBetaContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"search_result_location","cited_text":"quoted","title":"T","source":"src-1","search_result_index":5,"start_block_index":1,"end_block_index":2}]}`)
    36  		c := result.OfText.Citations[0].OfSearchResultLocation
    37  		if c == nil {
    38  			t.Fatal("Expected OfSearchResultLocation to be non-nil")
    39  		}
    40  		if c.Source != "src-1" || c.SearchResultIndex != 5 || c.StartBlockIndex != 1 || c.EndBlockIndex != 2 {
    41  			t.Errorf("Expected source/index fields to survive ToParam, got source=%q search_result_index=%d start=%d end=%d",
    42  				c.Source, c.SearchResultIndex, c.StartBlockIndex, c.EndBlockIndex)
    43  		}
    44  	})
    45  
    46  	t.Run("web_search_result_location keeps url and encrypted_index", func(t *testing.T) {
    47  		result := unmarshalBetaContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"web_search_result_location","cited_text":"quoted","title":"T","url":"https://example.com","encrypted_index":"enc-1"}]}`)
    48  		c := result.OfText.Citations[0].OfWebSearchResultLocation
    49  		if c == nil {
    50  			t.Fatal("Expected OfWebSearchResultLocation to be non-nil")
    51  		}
    52  		if c.URL != "https://example.com" || c.EncryptedIndex != "enc-1" {
    53  			t.Errorf("Expected url/encrypted_index to survive ToParam, got url=%q encrypted_index=%q", c.URL, c.EncryptedIndex)
    54  		}
    55  	})
    56  }
    57  
    58  func TestBetaToolUseToParamKeepsToolsetName(t *testing.T) {
    59  	t.Run("toolset member keeps toolset_name", func(t *testing.T) {
    60  		result := unmarshalBetaContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"screenshot","input":{},"toolset_name":"computer","caller":{"type":"direct"}}`)
    61  		if result.OfToolUse == nil {
    62  			t.Fatal("Expected OfToolUse to be non-nil")
    63  		}
    64  		b, err := json.Marshal(result)
    65  		if err != nil {
    66  			t.Fatalf("Failed to marshal param: %v", err)
    67  		}
    68  		if got := gjson.GetBytes(b, "toolset_name").String(); got != "computer" {
    69  			t.Errorf("Expected toolset_name to survive ToParam, got %q in %s", got, b)
    70  		}
    71  	})
    72  
    73  	t.Run("non-member omits toolset_name", func(t *testing.T) {
    74  		result := unmarshalBetaContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}`)
    75  		b, err := json.Marshal(result)
    76  		if err != nil {
    77  			t.Fatalf("Failed to marshal param: %v", err)
    78  		}
    79  		if gjson.GetBytes(b, "toolset_name").Exists() {
    80  			t.Errorf("Expected toolset_name to be omitted, got %s", b)
    81  		}
    82  	})
    83  }
    84  
    85  func TestBetaAccumulatePreservesWireJSON(t *testing.T) {
    86  	toolResult := `{"type":"bash_code_execution_tool_result","tool_use_id":"srvtoolu_01","content":{"type":"bash_code_execution_result","stdout":"","stderr":"","return_code":0,"content":[{"type":"bash_code_execution_output","file_id":"file_011ABC"}]}}`
    87  	events := []string{
    88  		`{"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-haiku-4-5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}`,
    89  		`{"type":"content_block_start","index":0,"content_block":` + toolResult + `}`,
    90  		`{"type":"content_block_stop","index":0}`,
    91  		`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01","name":"create_pdf","input":{}}}`,
    92  		`{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"filename\": \"bla"}}`,
    93  		`{"type":"content_block_stop","index":1}`,
    94  		`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":4096}}`,
    95  		`{"type":"message_stop"}`,
    96  	}
    97  
    98  	var message anthropic.BetaMessage
    99  	for _, eventJSON := range events {
   100  		var event anthropic.BetaRawMessageStreamEventUnion
   101  		if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
   102  			t.Fatalf("Failed to unmarshal event: %v", err)
   103  		}
   104  		if err := message.Accumulate(event); err != nil {
   105  			t.Errorf("Accumulate(%s) returned an error: %v", event.Type, err)
   106  		}
   107  	}
   108  
   109  	if got := message.Content[0].RawJSON(); got != toolResult {
   110  		t.Errorf("Expected content block 0 to keep its wire JSON\n got: %s\nwant: %s", got, toolResult)
   111  	}
   112  
   113  	wantMessage := `{"id":"msg_01","type":"message","role":"assistant","model":"claude-haiku-4-5","content":[` + toolResult +
   114  		`,{"type":"tool_use","id":"toolu_01","name":"create_pdf","input":{}}],"stop_reason":"max_tokens","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":4096}}`
   115  	if got := message.RawJSON(); got != wantMessage {
   116  		t.Errorf("Expected the accumulated message JSON to match the wire\n got: %s\nwant: %s", got, wantMessage)
   117  	}
   118  
   119  	block, ok := message.Content[0].AsAny().(anthropic.BetaBashCodeExecutionToolResultBlock)
   120  	if !ok {
   121  		t.Fatalf("Expected BetaBashCodeExecutionToolResultBlock, got %T", message.Content[0].AsAny())
   122  	}
   123  	if len(block.Content.Content) != 1 || block.Content.Content[0].FileID != "file_011ABC" {
   124  		t.Errorf("Expected typed access to yield file_011ABC, got %+v", block.Content.Content)
   125  	}
   126  }
   127  
   128  func TestNewBetaSystemMessageMarshalsContent(t *testing.T) {
   129  	config := anthropic.BetaSystemMessageOutputConfigParam{Effort: anthropic.BetaSystemMessageOutputConfigEffortHigh}
   130  
   131  	t.Run("directive-only emits empty content array", func(t *testing.T) {
   132  		msg := anthropic.NewBetaSystemMessage(config)
   133  		if msg.Content == nil {
   134  			t.Fatal("Expected Content to be a non-nil empty slice so the required key is emitted")
   135  		}
   136  		got, err := json.Marshal(msg)
   137  		if err != nil {
   138  			t.Fatalf("marshal error: %v", err)
   139  		}
   140  		want := `{"content":[],"role":"system","output_config":{"effort":"high"}}`
   141  		if string(got) != want {
   142  			t.Errorf("Expected directive-only system message JSON\n got: %s\nwant: %s", got, want)
   143  		}
   144  	})
   145  
   146  	t.Run("with blocks", func(t *testing.T) {
   147  		msg := anthropic.NewBetaSystemMessage(config, anthropic.NewBetaTextBlock("Answer tersely."))
   148  		got, err := json.Marshal(msg)
   149  		if err != nil {
   150  			t.Fatalf("marshal error: %v", err)
   151  		}
   152  		want := `{"content":[{"text":"Answer tersely.","type":"text"}],"role":"system","output_config":{"effort":"high"}}`
   153  		if string(got) != want {
   154  			t.Errorf("Expected system message JSON with content\n got: %s\nwant: %s", got, want)
   155  		}
   156  	})
   157  
   158  	t.Run("inside request params", func(t *testing.T) {
   159  		params := anthropic.BetaMessageNewParams{
   160  			MaxTokens: 1,
   161  			Model:     "m",
   162  			Messages: []anthropic.BetaMessageParam{
   163  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("hi")),
   164  				anthropic.NewBetaSystemMessage(config),
   165  			},
   166  		}
   167  		got, err := json.Marshal(params)
   168  		if err != nil {
   169  			t.Fatalf("marshal error: %v", err)
   170  		}
   171  		if !strings.Contains(string(got), `{"content":[],"role":"system","output_config":{"effort":"high"}}`) {
   172  			t.Errorf("Expected request body to carry the directive-only system message with content:[], got %s", got)
   173  		}
   174  	})
   175  }
   176  
   177  // TestBetaTextCitationToParamExhaustive mirrors the non-beta drift guard for
   178  // the beta citation converters.
   179  func TestBetaTextCitationToParamExhaustive(t *testing.T) {
   180  	cases := map[string]string{
   181  		"char_location":              `{"type":"char_location","cited_text":"q","document_index":1,"document_title":"D","start_char_index":2,"end_char_index":3}`,
   182  		"page_location":              `{"type":"page_location","cited_text":"q","document_index":1,"document_title":"D","start_page_number":2,"end_page_number":3}`,
   183  		"content_block_location":     `{"type":"content_block_location","cited_text":"q","document_index":1,"document_title":"D","start_block_index":2,"end_block_index":3}`,
   184  		"search_result_location":     `{"type":"search_result_location","cited_text":"q","title":"T","source":"s","search_result_index":1,"start_block_index":2,"end_block_index":3}`,
   185  		"web_search_result_location": `{"type":"web_search_result_location","cited_text":"q","title":"T","url":"https://e.com","encrypted_index":"e"}`,
   186  	}
   187  	for name, citationJSON := range cases {
   188  		t.Run(name, func(t *testing.T) {
   189  			result := unmarshalBetaContentBlockParam(t, `{"type":"text","text":"x","citations":[`+citationJSON+`]}`)
   190  			assertNoZeroExportedFields(t, result.OfText.Citations[0])
   191  		})
   192  	}
   193  }
   194  
   195  // betaMessageStartWithUsage seeds the fields message_delta never re-sends, so a
   196  // test can tell "the delta overwrote it" from "the delta left it alone".
   197  const betaMessageStartWithUsage = `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"output_tokens":1,"cache_creation_input_tokens":2,"cache_read_input_tokens":3,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":0},"service_tier":"standard","inference_geo":"us"}}}`
   198  
   199  // accumulateBetaMessage folds raw stream events into a BetaMessage the way a
   200  // caller of Beta.Messages.NewStreaming does.
   201  func accumulateBetaMessage(t *testing.T, events ...string) anthropic.BetaMessage {
   202  	t.Helper()
   203  	message := anthropic.BetaMessage{}
   204  	for _, raw := range events {
   205  		event := anthropic.BetaRawMessageStreamEventUnion{}
   206  		if err := event.UnmarshalJSON([]byte(raw)); err != nil {
   207  			t.Fatalf("Failed to unmarshal event %s: %v", raw, err)
   208  		}
   209  		if err := message.Accumulate(event); err != nil {
   210  			t.Fatalf("Accumulate(%s): %v", raw, err)
   211  		}
   212  	}
   213  	return message
   214  }
   215  
   216  // TestBetaAccumulateMessageDeltaAppliesEveryField pins the accumulated
   217  // BetaMessage to what the non-streaming BetaMessage would hold, including the
   218  // event-level context_management report, which message_start never carries.
   219  func TestBetaAccumulateMessageDeltaAppliesEveryField(t *testing.T) {
   220  	message := accumulateBetaMessage(t,
   221  		betaMessageStartWithUsage,
   222  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":{"id":"container_1","expires_at":"2025-01-01T00:00:00Z"}},"context_management":{"applied_edits":[{"type":"clear_tool_uses_20250919","cleared_input_tokens":120,"cleared_tool_uses":3}]},"usage":{"input_tokens":12,"output_tokens":99,"cache_creation_input_tokens":4,"cache_read_input_tokens":5,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":1},"output_tokens_details":{"thinking_tokens":7},"iterations":[{"type":"message","model":"model","input_tokens":12,"output_tokens":99,"cache_creation_input_tokens":4,"cache_read_input_tokens":5}]}}`,
   223  		`{"type":"message_stop"}`,
   224  	)
   225  
   226  	if len(message.ContextManagement.AppliedEdits) != 1 {
   227  		t.Fatalf("Expected context_management from the delta event, got %+v", message.ContextManagement)
   228  	}
   229  	if edit := message.ContextManagement.AppliedEdits[0]; edit.ClearedToolUses != 3 {
   230  		t.Errorf("Expected the applied edit to survive, got %+v", edit)
   231  	}
   232  	if got := gjson.Get(message.RawJSON(), "context_management.applied_edits.0.cleared_tool_uses").Int(); got != 3 {
   233  		t.Errorf("Expected context_management in the accumulated JSON, got %d", got)
   234  	}
   235  	if message.Container.ID != "container_1" {
   236  		t.Errorf("Expected container from the delta, got %q", message.Container.ID)
   237  	}
   238  	if message.Usage.OutputTokensDetails.ThinkingTokens != 7 {
   239  		t.Errorf("Expected output_tokens_details from the delta, got %d", message.Usage.OutputTokensDetails.ThinkingTokens)
   240  	}
   241  	if message.Usage.ServerToolUse.WebSearchRequests != 2 || message.Usage.ServerToolUse.WebFetchRequests != 1 {
   242  		t.Errorf("Expected server_tool_use from the delta, got %+v", message.Usage.ServerToolUse)
   243  	}
   244  	if len(message.Usage.Iterations) != 1 {
   245  		t.Errorf("Expected iterations from the delta, got %+v", message.Usage.Iterations)
   246  	}
   247  	if message.Usage.InputTokens != 12 || message.Usage.OutputTokens != 99 {
   248  		t.Errorf("Expected input/output tokens 12/99, got %d/%d", message.Usage.InputTokens, message.Usage.OutputTokens)
   249  	}
   250  	if message.Usage.CacheCreationInputTokens != 4 || message.Usage.CacheReadInputTokens != 5 {
   251  		t.Errorf("Expected cache tokens 4/5, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens)
   252  	}
   253  }
   254  
   255  // TestBetaAccumulateMessageDeltaKeepsOmittedUsage covers the other half of the
   256  // contract: message_delta omits the counters that do not apply, and it never
   257  // re-sends service_tier, inference_geo or the cache_creation breakdown at all.
   258  func TestBetaAccumulateMessageDeltaKeepsOmittedUsage(t *testing.T) {
   259  	message := accumulateBetaMessage(t,
   260  		betaMessageStartWithUsage,
   261  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":25}}`,
   262  		`{"type":"message_stop"}`,
   263  	)
   264  
   265  	if message.Usage.OutputTokens != 25 {
   266  		t.Errorf("Expected output_tokens to be overwritten with 25, got %d", message.Usage.OutputTokens)
   267  	}
   268  	if message.Usage.InputTokens != 10 {
   269  		t.Errorf("Expected input_tokens to keep the message_start value 10, got %d", message.Usage.InputTokens)
   270  	}
   271  	if message.Usage.CacheCreationInputTokens != 2 || message.Usage.CacheReadInputTokens != 3 {
   272  		t.Errorf("Expected cache tokens to keep the message_start values 2/3, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens)
   273  	}
   274  	if message.Usage.CacheCreation.Ephemeral5mInputTokens != 2 {
   275  		t.Errorf("Expected cache_creation to survive, got %+v", message.Usage.CacheCreation)
   276  	}
   277  	if message.Usage.ServiceTier != anthropic.BetaUsageServiceTierStandard {
   278  		t.Errorf("Expected service_tier to survive, got %q", message.Usage.ServiceTier)
   279  	}
   280  	if message.Usage.InferenceGeo != "us" {
   281  		t.Errorf("Expected inference_geo to survive, got %q", message.Usage.InferenceGeo)
   282  	}
   283  	if message.ID != "msg_1" || message.Role != "assistant" {
   284  		t.Errorf("Expected id/role to survive, got %q/%q", message.ID, message.Role)
   285  	}
   286  }
   287  
   288  // TestBetaAccumulateMessageDeltaStopDetails checks the refusal detail reaches
   289  // the message, and that the last delta wins even when its stop_details is null
   290  // — the key is always sent and null is a meaningful final value, as it is for
   291  // stop_reason and stop_sequence.
   292  func TestBetaAccumulateMessageDeltaStopDetails(t *testing.T) {
   293  	message := accumulateBetaMessage(t,
   294  		betaMessageStartWithUsage,
   295  		`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`,
   296  	)
   297  	if message.StopDetails.Category != anthropic.BetaRefusalStopDetailsCategoryBio {
   298  		t.Errorf("Expected stop_details from the delta, got %+v", message.StopDetails)
   299  	}
   300  
   301  	message = accumulateBetaMessage(t,
   302  		betaMessageStartWithUsage,
   303  		`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`,
   304  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":9}}`,
   305  	)
   306  	if message.StopDetails.Category != "" {
   307  		t.Errorf("Expected the last delta's null stop_details to win, got %+v", message.StopDetails)
   308  	}
   309  }
   310  
   311  // TestBetaAccumulateMessageDeltaInputTransformations checks the list from
   312  // message_start survives a delta that omits it or sends null, and that a delta
   313  // carrying one (a mid-stream model fallback) replaces it, even when empty.
   314  func TestBetaAccumulateMessageDeltaInputTransformations(t *testing.T) {
   315  	const start = `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"model_binding_mismatch"}],"usage":{"input_tokens":10,"output_tokens":1}}}`
   316  
   317  	message := accumulateBetaMessage(t,
   318  		start,
   319  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":5}}`,
   320  		`{"type":"message_stop"}`,
   321  	)
   322  	if len(message.InputTransformations) != 1 || message.InputTransformations[0].Path != "messages.1.content.0" {
   323  		t.Errorf("Expected input_transformations to keep the message_start value, got %+v", message.InputTransformations)
   324  	}
   325  
   326  	message = accumulateBetaMessage(t,
   327  		start,
   328  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":5},"input_transformations":null}`,
   329  	)
   330  	if len(message.InputTransformations) != 1 || gjson.Get(message.RawJSON(), "input_transformations.0.path").String() != "messages.1.content.0" {
   331  		t.Errorf("Expected a null input_transformations to keep the message_start value, got %+v / %s", message.InputTransformations, message.RawJSON())
   332  	}
   333  
   334  	message = accumulateBetaMessage(t,
   335  		start,
   336  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":5},"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"prefix_binding_mismatch"},{"type":"thinking_dropped","path":"messages.1.content.1","reason":"prefix_binding_mismatch"}]}`,
   337  		`{"type":"message_stop"}`,
   338  	)
   339  	if len(message.InputTransformations) != 2 || message.InputTransformations[1].Reason != anthropic.BetaThinkingDroppedInputTransformationReasonPrefixBindingMismatch {
   340  		t.Errorf("Expected input_transformations from the delta, got %+v", message.InputTransformations)
   341  	}
   342  	if got := gjson.Get(message.RawJSON(), "input_transformations.1.path").String(); got != "messages.1.content.1" {
   343  		t.Errorf("Expected input_transformations in the accumulated JSON, got %q from %s", got, message.RawJSON())
   344  	}
   345  
   346  	message = accumulateBetaMessage(t,
   347  		start,
   348  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":5},"input_transformations":[]}`,
   349  	)
   350  	if len(message.InputTransformations) != 0 {
   351  		t.Errorf("Expected the delta's empty input_transformations to win, got %+v", message.InputTransformations)
   352  	}
   353  	if got := gjson.Get(message.RawJSON(), "input_transformations").Raw; got != "[]" {
   354  		t.Errorf("Expected input_transformations [] in the accumulated JSON, got %s", got)
   355  	}
   356  	if got := message.JSON.InputTransformations.Raw(); got != "[]" {
   357  		t.Errorf("Expected JSON.InputTransformations to track the delta, got %s", got)
   358  	}
   359  
   360  	// A non-thinking primary omits the field on message_start; the fallback's
   361  	// delta must still surface it through the JSON metadata.
   362  	message = accumulateBetaMessage(t,
   363  		`{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"output_tokens":1}}}`,
   364  		`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":5},"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"prefix_binding_mismatch"}]}`,
   365  		`{"type":"message_stop"}`,
   366  	)
   367  	if !message.JSON.InputTransformations.Valid() || len(message.InputTransformations) != 1 {
   368  		t.Errorf("Expected JSON.InputTransformations to be valid after a delta carrying the field, got valid=%v %+v", message.JSON.InputTransformations.Valid(), message.InputTransformations)
   369  	}
   370  }