|
1 | 1 | package anthropic |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
4 | 6 | "errors" |
| 7 | + "fmt" |
| 8 | + "net/http" |
| 9 | + "net/http/httptest" |
5 | 10 | "testing" |
| 11 | + "time" |
6 | 12 |
|
7 | 13 | "charm.land/fantasy" |
8 | 14 | "github.com/stretchr/testify/require" |
@@ -401,3 +407,223 @@ func TestParseContextTooLargeError(t *testing.T) { |
401 | 407 | }) |
402 | 408 | } |
403 | 409 | } |
| 410 | + |
| 411 | +func TestParseOptions_Effort(t *testing.T) { |
| 412 | + t.Parallel() |
| 413 | + |
| 414 | + options, err := ParseOptions(map[string]any{ |
| 415 | + "send_reasoning": true, |
| 416 | + "thinking": map[string]any{"budget_tokens": int64(2048)}, |
| 417 | + "effort": "medium", |
| 418 | + "disable_parallel_tool_use": true, |
| 419 | + }) |
| 420 | + require.NoError(t, err) |
| 421 | + require.NotNil(t, options.SendReasoning) |
| 422 | + require.True(t, *options.SendReasoning) |
| 423 | + require.NotNil(t, options.Thinking) |
| 424 | + require.Equal(t, int64(2048), options.Thinking.BudgetTokens) |
| 425 | + require.NotNil(t, options.Effort) |
| 426 | + require.Equal(t, EffortMedium, *options.Effort) |
| 427 | + require.NotNil(t, options.DisableParallelToolUse) |
| 428 | + require.True(t, *options.DisableParallelToolUse) |
| 429 | +} |
| 430 | + |
| 431 | +func TestGenerate_SendsOutputConfigEffort(t *testing.T) { |
| 432 | + t.Parallel() |
| 433 | + |
| 434 | + server, calls := newAnthropicJSONServer(mockAnthropicGenerateResponse()) |
| 435 | + defer server.Close() |
| 436 | + |
| 437 | + provider, err := New( |
| 438 | + WithAPIKey("test-api-key"), |
| 439 | + WithBaseURL(server.URL), |
| 440 | + ) |
| 441 | + require.NoError(t, err) |
| 442 | + |
| 443 | + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") |
| 444 | + require.NoError(t, err) |
| 445 | + |
| 446 | + effort := EffortMedium |
| 447 | + _, err = model.Generate(context.Background(), fantasy.Call{ |
| 448 | + Prompt: testPrompt(), |
| 449 | + ProviderOptions: NewProviderOptions(&ProviderOptions{ |
| 450 | + Effort: &effort, |
| 451 | + }), |
| 452 | + }) |
| 453 | + require.NoError(t, err) |
| 454 | + |
| 455 | + call := awaitAnthropicCall(t, calls) |
| 456 | + require.Equal(t, "POST", call.method) |
| 457 | + require.Equal(t, "/v1/messages", call.path) |
| 458 | + requireAnthropicEffort(t, call.body, EffortMedium) |
| 459 | +} |
| 460 | + |
| 461 | +func TestStream_SendsOutputConfigEffort(t *testing.T) { |
| 462 | + t.Parallel() |
| 463 | + |
| 464 | + server, calls := newAnthropicStreamingServer([]string{ |
| 465 | + "event: message_start\n", |
| 466 | + "data: {\"type\":\"message_start\",\"message\":{}}\n\n", |
| 467 | + "event: message_stop\n", |
| 468 | + "data: {\"type\":\"message_stop\"}\n\n", |
| 469 | + }) |
| 470 | + defer server.Close() |
| 471 | + |
| 472 | + provider, err := New( |
| 473 | + WithAPIKey("test-api-key"), |
| 474 | + WithBaseURL(server.URL), |
| 475 | + ) |
| 476 | + require.NoError(t, err) |
| 477 | + |
| 478 | + model, err := provider.LanguageModel(context.Background(), "claude-sonnet-4-20250514") |
| 479 | + require.NoError(t, err) |
| 480 | + |
| 481 | + effort := EffortHigh |
| 482 | + stream, err := model.Stream(context.Background(), fantasy.Call{ |
| 483 | + Prompt: testPrompt(), |
| 484 | + ProviderOptions: NewProviderOptions(&ProviderOptions{ |
| 485 | + Effort: &effort, |
| 486 | + }), |
| 487 | + }) |
| 488 | + require.NoError(t, err) |
| 489 | + |
| 490 | + stream(func(fantasy.StreamPart) bool { return true }) |
| 491 | + |
| 492 | + call := awaitAnthropicCall(t, calls) |
| 493 | + require.Equal(t, "POST", call.method) |
| 494 | + require.Equal(t, "/v1/messages", call.path) |
| 495 | + requireAnthropicEffort(t, call.body, EffortHigh) |
| 496 | +} |
| 497 | + |
| 498 | +type anthropicCall struct { |
| 499 | + method string |
| 500 | + path string |
| 501 | + body map[string]any |
| 502 | +} |
| 503 | + |
| 504 | +func newAnthropicJSONServer(response map[string]any) (*httptest.Server, <-chan anthropicCall) { |
| 505 | + calls := make(chan anthropicCall, 4) |
| 506 | + |
| 507 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 508 | + var body map[string]any |
| 509 | + if r.Body != nil { |
| 510 | + _ = json.NewDecoder(r.Body).Decode(&body) |
| 511 | + } |
| 512 | + |
| 513 | + calls <- anthropicCall{ |
| 514 | + method: r.Method, |
| 515 | + path: r.URL.Path, |
| 516 | + body: body, |
| 517 | + } |
| 518 | + |
| 519 | + w.Header().Set("Content-Type", "application/json") |
| 520 | + _ = json.NewEncoder(w).Encode(response) |
| 521 | + })) |
| 522 | + |
| 523 | + return server, calls |
| 524 | +} |
| 525 | + |
| 526 | +func newAnthropicStreamingServer(chunks []string) (*httptest.Server, <-chan anthropicCall) { |
| 527 | + calls := make(chan anthropicCall, 4) |
| 528 | + |
| 529 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 530 | + var body map[string]any |
| 531 | + if r.Body != nil { |
| 532 | + _ = json.NewDecoder(r.Body).Decode(&body) |
| 533 | + } |
| 534 | + |
| 535 | + calls <- anthropicCall{ |
| 536 | + method: r.Method, |
| 537 | + path: r.URL.Path, |
| 538 | + body: body, |
| 539 | + } |
| 540 | + |
| 541 | + w.Header().Set("Content-Type", "text/event-stream") |
| 542 | + w.Header().Set("Cache-Control", "no-cache") |
| 543 | + w.Header().Set("Connection", "keep-alive") |
| 544 | + w.WriteHeader(http.StatusOK) |
| 545 | + |
| 546 | + for _, chunk := range chunks { |
| 547 | + _, _ = fmt.Fprint(w, chunk) |
| 548 | + if flusher, ok := w.(http.Flusher); ok { |
| 549 | + flusher.Flush() |
| 550 | + } |
| 551 | + } |
| 552 | + })) |
| 553 | + |
| 554 | + return server, calls |
| 555 | +} |
| 556 | + |
| 557 | +func awaitAnthropicCall(t *testing.T, calls <-chan anthropicCall) anthropicCall { |
| 558 | + t.Helper() |
| 559 | + |
| 560 | + select { |
| 561 | + case call := <-calls: |
| 562 | + return call |
| 563 | + case <-time.After(2 * time.Second): |
| 564 | + t.Fatal("timed out waiting for Anthropic request") |
| 565 | + return anthropicCall{} |
| 566 | + } |
| 567 | +} |
| 568 | + |
| 569 | +func assertNoAnthropicCall(t *testing.T, calls <-chan anthropicCall) { |
| 570 | + t.Helper() |
| 571 | + |
| 572 | + select { |
| 573 | + case call := <-calls: |
| 574 | + t.Fatalf("expected no Anthropic API call, but got %s %s", call.method, call.path) |
| 575 | + case <-time.After(200 * time.Millisecond): |
| 576 | + } |
| 577 | +} |
| 578 | + |
| 579 | +func requireAnthropicEffort(t *testing.T, body map[string]any, expected Effort) { |
| 580 | + t.Helper() |
| 581 | + |
| 582 | + outputConfig, ok := body["output_config"].(map[string]any) |
| 583 | + thinking, ok := body["thinking"].(map[string]any) |
| 584 | + require.True(t, ok) |
| 585 | + require.Equal(t, string(expected), outputConfig["effort"]) |
| 586 | + require.Equal(t, "adaptive", thinking["type"]) |
| 587 | +} |
| 588 | + |
| 589 | +func testPrompt() fantasy.Prompt { |
| 590 | + return fantasy.Prompt{ |
| 591 | + { |
| 592 | + Role: fantasy.MessageRoleUser, |
| 593 | + Content: []fantasy.MessagePart{ |
| 594 | + fantasy.TextPart{Text: "Hello"}, |
| 595 | + }, |
| 596 | + }, |
| 597 | + } |
| 598 | +} |
| 599 | + |
| 600 | +func mockAnthropicGenerateResponse() map[string]any { |
| 601 | + return map[string]any{ |
| 602 | + "id": "msg_01Test", |
| 603 | + "type": "message", |
| 604 | + "role": "assistant", |
| 605 | + "model": "claude-sonnet-4-20250514", |
| 606 | + "content": []any{ |
| 607 | + map[string]any{ |
| 608 | + "type": "text", |
| 609 | + "text": "Hi there", |
| 610 | + }, |
| 611 | + }, |
| 612 | + "stop_reason": "end_turn", |
| 613 | + "stop_sequence": "", |
| 614 | + "usage": map[string]any{ |
| 615 | + "cache_creation": map[string]any{ |
| 616 | + "ephemeral_1h_input_tokens": 0, |
| 617 | + "ephemeral_5m_input_tokens": 0, |
| 618 | + }, |
| 619 | + "cache_creation_input_tokens": 0, |
| 620 | + "cache_read_input_tokens": 0, |
| 621 | + "input_tokens": 5, |
| 622 | + "output_tokens": 2, |
| 623 | + "server_tool_use": map[string]any{ |
| 624 | + "web_search_requests": 0, |
| 625 | + }, |
| 626 | + "service_tier": "standard", |
| 627 | + }, |
| 628 | + } |
| 629 | +} |
0 commit comments