github.com/anthropics/anthropic-sdk-go@v1.70.1/messageutil_test.go (about) 1 package anthropic_test 2 3 import ( 4 "encoding/json" 5 "reflect" 6 "testing" 7 8 "github.com/tidwall/gjson" 9 10 "github.com/anthropics/anthropic-sdk-go" 11 "github.com/anthropics/anthropic-sdk-go/shared/constant" 12 ) 13 14 func unmarshalContentBlockParam(t *testing.T, jsonData string) anthropic.ContentBlockParamUnion { 15 t.Helper() 16 var block anthropic.ContentBlockUnion 17 err := json.Unmarshal([]byte(jsonData), &block) 18 if err != nil { 19 t.Fatalf("Failed to unmarshal JSON: %v", err) 20 } 21 result := block.ToParam() 22 return result 23 } 24 25 func TestContentBlockUnionToParam(t *testing.T) { 26 t.Run("TextBlock with text only", func(t *testing.T) { 27 result := unmarshalContentBlockParam(t, `{"type":"text","text":"Hello, world!"}`) 28 if result.OfText == nil { 29 t.Fatal("Expected OfText to be non-nil") 30 } 31 if result.OfText.Text != "Hello, world!" { 32 t.Errorf("Expected text 'Hello, world!', got '%s'", result.OfText.Text) 33 } 34 if result.OfText.Type != constant.Text("text") { 35 t.Errorf("Expected type 'text', got '%s'", result.OfText.Type) 36 } 37 }) 38 39 t.Run("WebSearchToolResultBlock with search results", func(t *testing.T) { 40 result := unmarshalContentBlockParam(t, `{"type":"web_search_tool_result","tool_use_id":"test123","content":[{"type":"web_search_result","title":"Test Web Title","url":"https://test.com","encrypted_content":"abc123","page_age":"1 day ago"}]}`) 41 var block anthropic.ContentBlockUnion 42 if err := json.Unmarshal([]byte(`{"type":"web_search_tool_result","tool_use_id":"test123","content":[{"type":"web_search_result","title":"Test Web Title","url":"https://test.com","encrypted_content":"abc123","page_age":"1 day ago"}]}`), &block); err != nil { 43 t.Fatalf("Failed to unmarshal: %v", err) 44 } 45 46 if len(block.Content.OfWebSearchResultBlockArray) != 1 { 47 t.Errorf("Expected Content.OfWebSearchResultBlockArray to have 1 result, got %d", len(block.Content.OfWebSearchResultBlockArray)) 48 } 49 if len(block.Content.OfWebSearchResultBlockArray) > 0 && block.Content.OfWebSearchResultBlockArray[0].Title != "Test Web Title" { 50 t.Errorf("Expected title '', got '%s'", block.Content.OfWebSearchResultBlockArray[0].Title) 51 } 52 53 if result.OfWebSearchToolResult == nil { 54 t.Fatal("Expected OfWebSearchToolResult to be non-nil") 55 } 56 if len(result.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem) != 1 { 57 t.Errorf("Expected 1 search result in param, got %d", len(result.OfWebSearchToolResult.Content.OfWebSearchToolResultBlockItem)) 58 } 59 }) 60 61 t.Run("ToolUseBlock keeps unknown caller", func(t *testing.T) { 62 result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"f","input":{},"caller":{"type":"future_caller","x":1}}`) 63 b, err := json.Marshal(result) 64 if err != nil { 65 t.Fatalf("Failed to marshal: %v", err) 66 } 67 if got := gjson.GetBytes(b, "caller").Raw; got != `{"type":"future_caller","x":1}` { 68 t.Errorf("Expected unknown caller to survive ToParam, got %s", got) 69 } 70 }) 71 } 72 73 func TestTextCitationToParamKeepsAllFields(t *testing.T) { 74 t.Run("page_location keeps cited_text", func(t *testing.T) { 75 result := unmarshalContentBlockParam(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}]}`) 76 c := result.OfText.Citations[0].OfPageLocation 77 if c == nil { 78 t.Fatal("Expected OfPageLocation to be non-nil") 79 } 80 if c.CitedText != "quoted" { 81 t.Errorf("Expected cited_text to survive ToParam, got %q", c.CitedText) 82 } 83 }) 84 85 t.Run("search_result_location keeps index fields and source", func(t *testing.T) { 86 result := unmarshalContentBlockParam(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}]}`) 87 c := result.OfText.Citations[0].OfSearchResultLocation 88 if c == nil { 89 t.Fatal("Expected OfSearchResultLocation to be non-nil") 90 } 91 if c.Source != "src-1" || c.SearchResultIndex != 5 || c.StartBlockIndex != 1 || c.EndBlockIndex != 2 { 92 t.Errorf("Expected source/index fields to survive ToParam, got source=%q search_result_index=%d start=%d end=%d", 93 c.Source, c.SearchResultIndex, c.StartBlockIndex, c.EndBlockIndex) 94 } 95 }) 96 97 t.Run("web_search_result_location keeps url and encrypted_index", func(t *testing.T) { 98 result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[{"type":"web_search_result_location","cited_text":"quoted","title":"T","url":"https://example.com","encrypted_index":"enc-1"}]}`) 99 c := result.OfText.Citations[0].OfWebSearchResultLocation 100 if c == nil { 101 t.Fatal("Expected OfWebSearchResultLocation to be non-nil") 102 } 103 if c.URL != "https://example.com" || c.EncryptedIndex != "enc-1" { 104 t.Errorf("Expected url/encrypted_index to survive ToParam, got url=%q encrypted_index=%q", c.URL, c.EncryptedIndex) 105 } 106 }) 107 } 108 109 func TestToolUseToParamKeepsToolsetName(t *testing.T) { 110 t.Run("toolset member keeps toolset_name", func(t *testing.T) { 111 result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"screenshot","input":{},"toolset_name":"computer","caller":{"type":"direct"}}`) 112 if result.OfToolUse == nil { 113 t.Fatal("Expected OfToolUse to be non-nil") 114 } 115 b, err := json.Marshal(result) 116 if err != nil { 117 t.Fatalf("Failed to marshal param: %v", err) 118 } 119 if got := gjson.GetBytes(b, "toolset_name").String(); got != "computer" { 120 t.Errorf("Expected toolset_name to survive ToParam, got %q in %s", got, b) 121 } 122 }) 123 124 t.Run("non-member omits toolset_name", func(t *testing.T) { 125 result := unmarshalContentBlockParam(t, `{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}`) 126 b, err := json.Marshal(result) 127 if err != nil { 128 t.Fatalf("Failed to marshal param: %v", err) 129 } 130 if gjson.GetBytes(b, "toolset_name").Exists() { 131 t.Errorf("Expected toolset_name to be omitted, got %s", b) 132 } 133 }) 134 } 135 136 // TestTextCitationToParamExhaustive guards against converter drift: every 137 // citation variant round-trips fully-populated JSON, and every exported 138 // field of the resulting param must be set — a spec-added field that the 139 // converter forgets to copy fails here without a hand-written assertion. 140 func TestTextCitationToParamExhaustive(t *testing.T) { 141 cases := map[string]string{ 142 "char_location": `{"type":"char_location","cited_text":"q","document_index":1,"document_title":"D","start_char_index":2,"end_char_index":3}`, 143 "page_location": `{"type":"page_location","cited_text":"q","document_index":1,"document_title":"D","start_page_number":2,"end_page_number":3}`, 144 "content_block_location": `{"type":"content_block_location","cited_text":"q","document_index":1,"document_title":"D","start_block_index":2,"end_block_index":3}`, 145 "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}`, 146 "web_search_result_location": `{"type":"web_search_result_location","cited_text":"q","title":"T","url":"https://e.com","encrypted_index":"e"}`, 147 } 148 for name, citationJSON := range cases { 149 t.Run(name, func(t *testing.T) { 150 result := unmarshalContentBlockParam(t, `{"type":"text","text":"x","citations":[`+citationJSON+`]}`) 151 assertNoZeroExportedFields(t, result.OfText.Citations[0]) 152 }) 153 } 154 } 155 156 // assertNoZeroExportedFields fails for any exported zero-valued field in the 157 // single set variant of a param union, walking one pointer level. 158 func assertNoZeroExportedFields(t *testing.T, union any) { 159 t.Helper() 160 uv := reflect.ValueOf(union) 161 var variant reflect.Value 162 for i := 0; i < uv.NumField(); i++ { 163 f := uv.Field(i) 164 if f.Kind() == reflect.Pointer && !f.IsNil() { 165 variant = f.Elem() 166 break 167 } 168 } 169 if !variant.IsValid() { 170 t.Fatal("Expected exactly one non-nil variant in the citation param union") 171 } 172 vt := variant.Type() 173 for i := 0; i < vt.NumField(); i++ { 174 field := vt.Field(i) 175 if !field.IsExported() || field.Anonymous { 176 continue 177 } 178 if variant.Field(i).IsZero() { 179 t.Errorf("Expected %s.%s to be populated by toParamUnion, got the zero value", vt.Name(), field.Name) 180 } 181 } 182 } 183 184 // messageStartWithUsage seeds the fields message_delta never re-sends, so a 185 // test can tell "the delta overwrote it" from "the delta left it alone". 186 const messageStartWithUsage = `{"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"}}}` 187 188 // accumulateMessage folds raw stream events into a Message the way a caller of 189 // Messages.NewStreaming does. 190 func accumulateMessage(t *testing.T, events ...string) anthropic.Message { 191 t.Helper() 192 message := anthropic.Message{} 193 for _, raw := range events { 194 event := anthropic.MessageStreamEventUnion{} 195 if err := event.UnmarshalJSON([]byte(raw)); err != nil { 196 t.Fatalf("Failed to unmarshal event %s: %v", raw, err) 197 } 198 if err := message.Accumulate(event); err != nil { 199 t.Fatalf("Accumulate(%s): %v", raw, err) 200 } 201 } 202 return message 203 } 204 205 // TestAccumulateMessageDeltaAppliesEveryField pins the accumulated Message to 206 // what the non-streaming Message would hold: every value the final 207 // message_delta carries reaches it. 208 func TestAccumulateMessageDeltaAppliesEveryField(t *testing.T) { 209 message := accumulateMessage(t, 210 messageStartWithUsage, 211 `{"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"}},"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}}}`, 212 `{"type":"message_stop"}`, 213 ) 214 215 if message.Container.ID != "container_1" { 216 t.Errorf("Expected container from the delta, got %q", message.Container.ID) 217 } 218 if got := gjson.Get(message.RawJSON(), "container.id").String(); got != "container_1" { 219 t.Errorf("Expected container in the accumulated JSON, got %q", got) 220 } 221 if message.StopReason != anthropic.StopReasonEndTurn { 222 t.Errorf("Expected stop_reason end_turn, got %q", message.StopReason) 223 } 224 if message.Usage.OutputTokensDetails.ThinkingTokens != 7 { 225 t.Errorf("Expected output_tokens_details from the delta, got %d", message.Usage.OutputTokensDetails.ThinkingTokens) 226 } 227 if message.Usage.ServerToolUse.WebSearchRequests != 2 || message.Usage.ServerToolUse.WebFetchRequests != 1 { 228 t.Errorf("Expected server_tool_use from the delta, got %+v", message.Usage.ServerToolUse) 229 } 230 // Cumulative totals: the delta's value replaces the message_start one. 231 if message.Usage.InputTokens != 12 || message.Usage.OutputTokens != 99 { 232 t.Errorf("Expected input/output tokens 12/99, got %d/%d", message.Usage.InputTokens, message.Usage.OutputTokens) 233 } 234 if message.Usage.CacheCreationInputTokens != 4 || message.Usage.CacheReadInputTokens != 5 { 235 t.Errorf("Expected cache tokens 4/5, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens) 236 } 237 } 238 239 // TestAccumulateMessageDeltaKeepsOmittedUsage covers the other half of the 240 // contract: message_delta omits the counters that do not apply, and it never 241 // re-sends service_tier or the cache_creation breakdown at all. 242 func TestAccumulateMessageDeltaKeepsOmittedUsage(t *testing.T) { 243 message := accumulateMessage(t, 244 messageStartWithUsage, 245 `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":25}}`, 246 `{"type":"message_stop"}`, 247 ) 248 249 if message.Usage.OutputTokens != 25 { 250 t.Errorf("Expected output_tokens to be overwritten with 25, got %d", message.Usage.OutputTokens) 251 } 252 if message.Usage.InputTokens != 10 { 253 t.Errorf("Expected input_tokens to keep the message_start value 10, got %d", message.Usage.InputTokens) 254 } 255 if message.Usage.CacheCreationInputTokens != 2 || message.Usage.CacheReadInputTokens != 3 { 256 t.Errorf("Expected cache tokens to keep the message_start values 2/3, got %d/%d", message.Usage.CacheCreationInputTokens, message.Usage.CacheReadInputTokens) 257 } 258 if message.Usage.CacheCreation.Ephemeral5mInputTokens != 2 { 259 t.Errorf("Expected cache_creation to survive, got %+v", message.Usage.CacheCreation) 260 } 261 if message.Usage.ServiceTier != anthropic.UsageServiceTierStandard { 262 t.Errorf("Expected service_tier to survive, got %q", message.Usage.ServiceTier) 263 } 264 if got := gjson.Get(message.RawJSON(), "usage.service_tier").String(); got != "standard" { 265 t.Errorf("Expected service_tier in the accumulated JSON, got %q", got) 266 } 267 if message.ID != "msg_1" || message.Role != "assistant" { 268 t.Errorf("Expected id/role to survive, got %q/%q", message.ID, message.Role) 269 } 270 } 271 272 // TestAccumulateMessageDeltaStopDetails checks the refusal detail reaches the 273 // message, and that the last delta wins even when its stop_details is null — 274 // the key is always sent and null is a meaningful final value, as it is for 275 // stop_reason and stop_sequence. 276 func TestAccumulateMessageDeltaStopDetails(t *testing.T) { 277 message := accumulateMessage(t, 278 messageStartWithUsage, 279 `{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`, 280 ) 281 if message.StopDetails.Category != anthropic.RefusalStopDetailsCategoryBio { 282 t.Errorf("Expected stop_details from the delta, got %+v", message.StopDetails) 283 } 284 285 message = accumulateMessage(t, 286 messageStartWithUsage, 287 `{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"bio","explanation":"nope"}},"usage":{"output_tokens":5}}`, 288 `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":9}}`, 289 ) 290 if message.StopDetails.Category != "" { 291 t.Errorf("Expected the last delta's null stop_details to win, got %+v", message.StopDetails) 292 } 293 }