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  }