github.com/anthropics/anthropic-sdk-go@v1.70.1/betaparse_test.go (about) 1 package anthropic 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net/http" 9 "net/http/httptest" 10 "testing" 11 12 "github.com/anthropics/anthropic-sdk-go/option" 13 ) 14 15 type testOrder struct { 16 Items []testOrderItem `json:"items" jsonschema:"description=List of items in the order"` 17 Total float64 `json:"total"` 18 Currency string `json:"currency" jsonschema:"enum=USD,enum=EUR"` 19 } 20 21 type testOrderItem struct { 22 Name string `json:"name"` 23 Quantity int `json:"quantity"` 24 Price float64 `json:"price"` 25 } 26 27 func TestSchemaToRaw(t *testing.T) { 28 t.Run("struct pointer generates schema with additionalProperties false", func(t *testing.T) { 29 order := &testOrder{} 30 raw, err := schemaToRaw(order) 31 if err != nil { 32 t.Fatalf("unexpected error: %v", err) 33 } 34 if raw == nil { 35 t.Fatal("expected non-nil raw schema") 36 } 37 38 var schemaMap map[string]any 39 if err := json.Unmarshal(raw, &schemaMap); err != nil { 40 t.Fatalf("unexpected unmarshal error: %v", err) 41 } 42 43 schemaType, ok := schemaMap["type"].(string) 44 if !ok || schemaType != "object" { 45 t.Fatalf("expected schema type 'object', got %v", schemaMap["type"]) 46 } 47 48 props, ok := schemaMap["properties"].(map[string]any) 49 if !ok { 50 t.Fatal("expected properties to be a map") 51 } 52 for _, key := range []string{"items", "total", "currency"} { 53 if _, ok := props[key]; !ok { 54 t.Fatalf("expected '%s' property in schema", key) 55 } 56 } 57 58 if ap, ok := schemaMap["additionalProperties"]; !ok || ap != false { 59 t.Fatalf("expected additionalProperties=false, got %v", ap) 60 } 61 }) 62 63 t.Run("struct pointer preserves map field additionalProperties schema", func(t *testing.T) { 64 type payloadWithLabels struct { 65 Labels map[string]string `json:"labels"` 66 } 67 68 raw, err := schemaToRaw(&payloadWithLabels{}) 69 if err != nil { 70 t.Fatalf("unexpected error: %v", err) 71 } 72 if raw == nil { 73 t.Fatal("expected non-nil raw schema") 74 } 75 76 var schemaMap map[string]any 77 if err := json.Unmarshal(raw, &schemaMap); err != nil { 78 t.Fatalf("unexpected unmarshal error: %v", err) 79 } 80 got, _ := json.Marshal(schemaMap["properties"].(map[string]any)["labels"]) 81 want := `{"type":"object","additionalProperties":{"type":"string"}}` 82 if normalizeJSON(string(got)) != normalizeJSON(want) { 83 t.Fatalf("expected labels schema %s, got %s", want, got) 84 } 85 }) 86 87 t.Run("map[string]any marshals to json.RawMessage", func(t *testing.T) { 88 m := map[string]any{"type": "object"} 89 raw, err := schemaToRaw(m) 90 if err != nil { 91 t.Fatalf("unexpected error: %v", err) 92 } 93 if raw == nil { 94 t.Fatal("expected non-nil raw for map[string]any input") 95 } 96 var result map[string]any 97 if err := json.Unmarshal(raw, &result); err != nil { 98 t.Fatalf("unexpected unmarshal error: %v", err) 99 } 100 if result["type"] != "object" { 101 t.Fatalf("expected type 'object', got %v", result["type"]) 102 } 103 }) 104 105 t.Run("json.RawMessage returned as-is", func(t *testing.T) { 106 input := json.RawMessage(`{"type":"object"}`) 107 raw, err := schemaToRaw(input) 108 if err != nil { 109 t.Fatalf("unexpected error: %v", err) 110 } 111 if string(raw) != string(input) { 112 t.Fatalf("expected raw to be returned as-is, got %s", raw) 113 } 114 }) 115 116 t.Run("non-pointer returns error", func(t *testing.T) { 117 _, err := schemaToRaw(testOrder{}) 118 if err == nil { 119 t.Fatal("expected error for non-pointer") 120 } 121 }) 122 123 t.Run("nil pointer returns error", func(t *testing.T) { 124 _, err := schemaToRaw((*testOrder)(nil)) 125 if err == nil { 126 t.Fatal("expected error for nil pointer") 127 } 128 }) 129 130 t.Run("pointer to non-struct returns error", func(t *testing.T) { 131 s := "hello" 132 _, err := schemaToRaw(&s) 133 if err == nil { 134 t.Fatal("expected error for pointer to string") 135 } 136 }) 137 } 138 139 func clearSchemaCache() { 140 schemaCache.Range(func(key, _ any) bool { 141 schemaCache.Delete(key) 142 return true 143 }) 144 } 145 146 func TestSchemaCaching(t *testing.T) { 147 t.Run("same type returns cached result", func(t *testing.T) { 148 clearSchemaCache() 149 150 raw1, err := schemaToRaw(&testOrder{}) 151 if err != nil { 152 t.Fatalf("unexpected error: %v", err) 153 } 154 raw2, err := schemaToRaw(&testOrder{}) 155 if err != nil { 156 t.Fatalf("unexpected error: %v", err) 157 } 158 if string(raw1) != string(raw2) { 159 t.Fatalf("expected identical results, got %s and %s", raw1, raw2) 160 } 161 }) 162 163 t.Run("different types produce different schemas", func(t *testing.T) { 164 clearSchemaCache() 165 166 type Alpha struct { 167 Name string `json:"name"` 168 } 169 type Beta struct { 170 Count int `json:"count"` 171 } 172 173 raw1, err := schemaToRaw(&Alpha{}) 174 if err != nil { 175 t.Fatalf("unexpected error: %v", err) 176 } 177 raw2, err := schemaToRaw(&Beta{}) 178 if err != nil { 179 t.Fatalf("unexpected error: %v", err) 180 } 181 if string(raw1) == string(raw2) { 182 t.Fatal("expected different schemas for different types") 183 } 184 }) 185 186 t.Run("struct field values do not affect cached schema", func(t *testing.T) { 187 clearSchemaCache() 188 189 raw1, err := schemaToRaw(&testOrder{Total: 100, Currency: "USD"}) 190 if err != nil { 191 t.Fatalf("unexpected error: %v", err) 192 } 193 raw2, err := schemaToRaw(&testOrder{Total: 200, Currency: "EUR"}) 194 if err != nil { 195 t.Fatalf("unexpected error: %v", err) 196 } 197 if string(raw1) != string(raw2) { 198 t.Fatalf("expected identical schemas regardless of field values, got %s and %s", raw1, raw2) 199 } 200 }) 201 202 t.Run("non-struct types are not cached", func(t *testing.T) { 203 clearSchemaCache() 204 205 m1 := map[string]any{"type": "object"} 206 raw1, err := schemaToRaw(m1) 207 if err != nil { 208 t.Fatalf("unexpected error: %v", err) 209 } 210 211 m2 := map[string]any{"type": "string"} 212 raw2, err := schemaToRaw(m2) 213 if err != nil { 214 t.Fatalf("unexpected error: %v", err) 215 } 216 217 if string(raw1) == string(raw2) { 218 t.Fatal("expected different results for different maps") 219 } 220 }) 221 } 222 223 func TestParseOutputContent(t *testing.T) { 224 t.Run("parses text block into struct", func(t *testing.T) { 225 msg := &BetaMessage{ 226 Content: []BetaContentBlockUnion{ 227 { 228 Type: "text", 229 Text: `{"items":[{"name":"Widget","quantity":2,"price":9.99}],"total":19.98,"currency":"USD"}`, 230 }, 231 }, 232 } 233 234 var order testOrder 235 err := parseOutputContent(msg, &order) 236 if err != nil { 237 t.Fatalf("unexpected error: %v", err) 238 } 239 if len(order.Items) != 1 || order.Items[0].Name != "Widget" { 240 t.Errorf("unexpected items: %+v", order.Items) 241 } 242 if order.Total != 19.98 { 243 t.Errorf("expected total 19.98, got %f", order.Total) 244 } 245 if order.Currency != "USD" { 246 t.Errorf("expected currency 'USD', got '%s'", order.Currency) 247 } 248 }) 249 250 t.Run("skips non-text blocks", func(t *testing.T) { 251 msg := &BetaMessage{ 252 Content: []BetaContentBlockUnion{ 253 {Type: "thinking", Thinking: "Let me think..."}, 254 {Type: "text", Text: `{"items":[],"total":0,"currency":"EUR"}`}, 255 }, 256 } 257 258 var order testOrder 259 err := parseOutputContent(msg, &order) 260 if err != nil { 261 t.Fatalf("unexpected error: %v", err) 262 } 263 if order.Currency != "EUR" { 264 t.Errorf("expected currency 'EUR', got '%s'", order.Currency) 265 } 266 }) 267 268 t.Run("returns error when no text block found", func(t *testing.T) { 269 msg := &BetaMessage{ 270 Content: []BetaContentBlockUnion{ 271 {Type: "thinking", Thinking: "hmm"}, 272 }, 273 } 274 275 var order testOrder 276 err := parseOutputContent(msg, &order) 277 if err == nil { 278 t.Fatal("expected error for missing text block") 279 } 280 }) 281 282 t.Run("returns error for invalid JSON", func(t *testing.T) { 283 msg := &BetaMessage{ 284 Content: []BetaContentBlockUnion{ 285 {Type: "text", Text: "not valid json"}, 286 }, 287 } 288 289 var order testOrder 290 err := parseOutputContent(msg, &order) 291 if err == nil { 292 t.Fatal("expected error for invalid JSON") 293 } 294 if !errors.Is(err, ErrStructuredOutputParse) { 295 t.Errorf("expected error to wrap ErrStructuredOutputParse, got %v", err) 296 } 297 }) 298 299 t.Run("missing text block error wraps sentinel", func(t *testing.T) { 300 msg := &BetaMessage{ 301 Content: []BetaContentBlockUnion{ 302 {Type: "thinking", Thinking: "hmm"}, 303 }, 304 } 305 306 var order testOrder 307 err := parseOutputContent(msg, &order) 308 if !errors.Is(err, ErrStructuredOutputParse) { 309 t.Errorf("expected error to wrap ErrStructuredOutputParse, got %v", err) 310 } 311 }) 312 } 313 314 func TestNewAutoParseWithMockServer(t *testing.T) { 315 responseJSON := `{ 316 "id": "msg_123", 317 "type": "message", 318 "role": "assistant", 319 "model": "claude-sonnet-4-5-20250514", 320 "stop_reason": "end_turn", 321 "stop_sequence": null, 322 "usage": {"input_tokens": 100, "output_tokens": 50}, 323 "content": [{ 324 "type": "text", 325 "text": "{\"items\":[{\"name\":\"Laptop\",\"quantity\":1,\"price\":999.99}],\"total\":999.99,\"currency\":\"USD\"}" 326 }] 327 }` 328 329 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 330 // Verify the request body includes the generated schema 331 var body map[string]any 332 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 333 t.Errorf("failed to decode request body: %v", err) 334 } 335 336 outputFormat, ok := body["output_format"].(map[string]any) 337 if !ok { 338 t.Error("expected output_format in request body") 339 } else { 340 if outputFormat["type"] != "json_schema" { 341 t.Errorf("expected type 'json_schema', got %v", outputFormat["type"]) 342 } 343 schema, ok := outputFormat["schema"].(map[string]any) 344 if !ok || schema == nil { 345 t.Error("expected schema in output_format") 346 } else { 347 if schema["additionalProperties"] != false { 348 t.Error("expected additionalProperties=false in schema") 349 } 350 } 351 } 352 353 w.Header().Set("Content-Type", "application/json") 354 fmt.Fprint(w, responseJSON) 355 })) 356 defer server.Close() 357 358 client := NewClient( 359 option.WithBaseURL(server.URL), 360 option.WithAPIKey("test-key"), 361 ) 362 363 var order testOrder 364 msg, err := client.Beta.Messages.New(context.Background(), BetaMessageNewParams{ 365 Model: ModelClaudeSonnet4_5, 366 MaxTokens: 1024, 367 Messages: []BetaMessageParam{ 368 NewBetaUserMessage(NewBetaTextBlock("Order a laptop")), 369 }, 370 OutputFormat: BetaJSONOutputFormatParam{ 371 Schema: &order, 372 }, 373 }) 374 375 if err != nil { 376 t.Fatalf("unexpected error: %v", err) 377 } 378 379 if msg.ID != "msg_123" { 380 t.Errorf("expected message ID 'msg_123', got '%s'", msg.ID) 381 } 382 383 if len(order.Items) != 1 || order.Items[0].Name != "Laptop" { 384 t.Fatalf("expected 1 item named 'Laptop', got %+v", order.Items) 385 } 386 if order.Total != 999.99 { 387 t.Errorf("expected total 999.99, got %f", order.Total) 388 } 389 } 390 391 func TestStreamingWithParseOutput(t *testing.T) { 392 events := []string{ 393 `event: message_start`, 394 `data: {"type":"message_start","message":{"id":"msg_456","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":0}}}`, 395 ``, 396 `event: content_block_start`, 397 `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, 398 ``, 399 `event: content_block_delta`, 400 `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{\"items\":[{\"name\":\"Phone\""}}`, 401 ``, 402 `event: content_block_delta`, 403 `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":",\"quantity\":2,\"price\":499.99}],\"total\":999.98,\"currency\":\"EUR\"}"}}`, 404 ``, 405 `event: content_block_stop`, 406 `data: {"type":"content_block_stop","index":0}`, 407 ``, 408 `event: message_delta`, 409 `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":30}}`, 410 ``, 411 `event: message_stop`, 412 `data: {"type":"message_stop"}`, 413 ``, 414 } 415 416 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 417 w.Header().Set("Content-Type", "text/event-stream") 418 w.Header().Set("Cache-Control", "no-cache") 419 w.Header().Set("Connection", "keep-alive") 420 421 flusher, ok := w.(http.Flusher) 422 if !ok { 423 t.Fatal("expected http.Flusher") 424 } 425 426 for _, event := range events { 427 fmt.Fprintf(w, "%s\n", event) 428 } 429 flusher.Flush() 430 })) 431 defer server.Close() 432 433 client := NewClient( 434 option.WithBaseURL(server.URL), 435 option.WithAPIKey("test-key"), 436 ) 437 438 var order testOrder 439 stream := client.Beta.Messages.NewStreaming(context.Background(), BetaMessageNewParams{ 440 Model: ModelClaudeSonnet4_5, 441 MaxTokens: 1024, 442 Messages: []BetaMessageParam{ 443 NewBetaUserMessage(NewBetaTextBlock("Order a phone")), 444 }, 445 OutputFormat: BetaJSONOutputFormatParam{ 446 Schema: &order, 447 }, 448 }) 449 450 var msg BetaMessage 451 for stream.Next() { 452 msg.Accumulate(stream.Current()) 453 } 454 455 if err := stream.Err(); err != nil { 456 t.Fatalf("unexpected stream error: %v", err) 457 } 458 459 if err := msg.ParseOutput(&order); err != nil { 460 t.Fatalf("unexpected parse error: %v", err) 461 } 462 463 if msg.ID != "msg_456" { 464 t.Errorf("expected message ID 'msg_456', got '%s'", msg.ID) 465 } 466 467 if len(order.Items) != 1 || order.Items[0].Name != "Phone" { 468 t.Fatalf("expected 1 item named 'Phone', got %+v", order.Items) 469 } 470 if order.Items[0].Quantity != 2 { 471 t.Errorf("expected quantity 2, got %d", order.Items[0].Quantity) 472 } 473 if order.Total != 999.98 { 474 t.Errorf("expected total 999.98, got %f", order.Total) 475 } 476 if order.Currency != "EUR" { 477 t.Errorf("expected currency 'EUR', got '%s'", order.Currency) 478 } 479 } 480 481 func TestMarshalJSONSerializesSchemaNotValues(t *testing.T) { 482 type Order struct { 483 Total float64 `json:"total"` 484 } 485 486 data, _ := json.Marshal(BetaJSONOutputFormatParam{Schema: &Order{Total: 42}}) 487 488 var got map[string]any 489 json.Unmarshal(data, &got) 490 schema := got["schema"].(map[string]any) 491 492 if schema["type"] != "object" { 493 t.Fatalf("expected JSON Schema, got struct values: %s", data) 494 } 495 if schema["additionalProperties"] != false { 496 t.Fatalf("expected additionalProperties=false: %s", data) 497 } 498 props := schema["properties"].(map[string]any) 499 total := props["total"].(map[string]any) 500 if total["type"] != "number" { 501 t.Fatalf("expected properties.total.type='number': %s", data) 502 } 503 } 504 505 func TestMarshalJSONSchemaStructPointer(t *testing.T) { 506 type simple struct { 507 Name string `json:"name"` 508 Age int `json:"age"` 509 } 510 511 param := BetaJSONOutputFormatParam{ 512 Schema: &simple{}, 513 } 514 515 data, err := json.Marshal(param) 516 if err != nil { 517 t.Fatalf("unexpected marshal error: %v", err) 518 } 519 520 var result map[string]any 521 if err := json.Unmarshal(data, &result); err != nil { 522 t.Fatalf("unexpected unmarshal error: %v", err) 523 } 524 525 if result["type"] != "json_schema" { 526 t.Errorf("expected type 'json_schema', got %v", result["type"]) 527 } 528 529 schema, ok := result["schema"].(map[string]any) 530 if !ok { 531 t.Fatal("expected schema to be a map") 532 } 533 534 if schema["type"] != "object" { 535 t.Errorf("expected schema type 'object', got %v", schema["type"]) 536 } 537 538 props, ok := schema["properties"].(map[string]any) 539 if !ok { 540 t.Fatal("expected properties to be a map") 541 } 542 if _, ok := props["name"]; !ok { 543 t.Error("expected 'name' property in schema") 544 } 545 if _, ok := props["age"]; !ok { 546 t.Error("expected 'age' property in schema") 547 } 548 549 if schema["additionalProperties"] != false { 550 t.Error("expected additionalProperties=false") 551 } 552 } 553 554 func TestMarshalJSONSchemaMapPassthrough(t *testing.T) { 555 // Backward compat: map[string]any as Schema still works 556 schemaMap := map[string]any{ 557 "type": "object", 558 "properties": map[string]any{ 559 "name": map[string]any{"type": "string"}, 560 }, 561 } 562 563 param := BetaJSONOutputFormatParam{ 564 Schema: schemaMap, 565 } 566 567 data, err := json.Marshal(param) 568 if err != nil { 569 t.Fatalf("unexpected marshal error: %v", err) 570 } 571 572 var result map[string]any 573 if err := json.Unmarshal(data, &result); err != nil { 574 t.Fatalf("unexpected unmarshal error: %v", err) 575 } 576 577 schema, ok := result["schema"].(map[string]any) 578 if !ok { 579 t.Fatal("expected schema to be a map") 580 } 581 if schema["type"] != "object" { 582 t.Errorf("expected schema type 'object', got %v", schema["type"]) 583 } 584 }