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 }