|
449 | 449 | expect(log.request_tokens).to eq(10) |
450 | 450 | expect(log.response_tokens).to eq(25) |
451 | 451 | end |
| 452 | + |
| 453 | + it "can send through thinking tokens via a completion prompt" do |
| 454 | + body = { |
| 455 | + id: "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", |
| 456 | + type: "message", |
| 457 | + role: "assistant", |
| 458 | + content: [{ type: "text", text: "world" }], |
| 459 | + model: "claude-3-7-sonnet-20250219", |
| 460 | + stop_reason: "end_turn", |
| 461 | + usage: { |
| 462 | + input_tokens: 25, |
| 463 | + output_tokens: 40, |
| 464 | + }, |
| 465 | + }.to_json |
| 466 | + |
| 467 | + parsed_body = nil |
| 468 | + stub_request(:post, url).with( |
| 469 | + body: ->(req_body) { parsed_body = JSON.parse(req_body) }, |
| 470 | + headers: { |
| 471 | + "Content-Type" => "application/json", |
| 472 | + "X-Api-Key" => "123", |
| 473 | + "Anthropic-Version" => "2023-06-01", |
| 474 | + }, |
| 475 | + ).to_return(status: 200, body: body) |
| 476 | + |
| 477 | + prompt = DiscourseAi::Completions::Prompt.new("system prompt") |
| 478 | + prompt.push(type: :user, content: "hello") |
| 479 | + prompt.push( |
| 480 | + type: :model, |
| 481 | + id: "user1", |
| 482 | + content: "hello", |
| 483 | + thinking: "I am thinking", |
| 484 | + thinking_signature: "signature", |
| 485 | + redacted_thinking_signature: "redacted_signature", |
| 486 | + ) |
| 487 | + |
| 488 | + result = llm.generate(prompt, user: Discourse.system_user) |
| 489 | + expect(result).to eq("world") |
| 490 | + |
| 491 | + expected_body = { |
| 492 | + "model" => "claude-3-opus-20240229", |
| 493 | + "max_tokens" => 4096, |
| 494 | + "messages" => [ |
| 495 | + { "role" => "user", "content" => "hello" }, |
| 496 | + { |
| 497 | + "role" => "assistant", |
| 498 | + "content" => [ |
| 499 | + { "type" => "thinking", "thinking" => "I am thinking", "signature" => "signature" }, |
| 500 | + { "type" => "redacted_thinking", "data" => "redacted_signature" }, |
| 501 | + { "type" => "text", "text" => "hello" }, |
| 502 | + ], |
| 503 | + }, |
| 504 | + ], |
| 505 | + "system" => "system prompt", |
| 506 | + } |
| 507 | + |
| 508 | + expect(parsed_body).to eq(expected_body) |
| 509 | + end |
| 510 | + |
| 511 | + it "can handle a response with thinking blocks in non-streaming mode" do |
| 512 | + body = { |
| 513 | + id: "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", |
| 514 | + type: "message", |
| 515 | + role: "assistant", |
| 516 | + content: [ |
| 517 | + { |
| 518 | + type: "thinking", |
| 519 | + thinking: "This is my thinking process about prime numbers...", |
| 520 | + signature: "abc123signature", |
| 521 | + }, |
| 522 | + { type: "redacted_thinking", data: "abd456signature" }, |
| 523 | + { type: "text", text: "Yes, there are infinitely many prime numbers where n mod 4 = 3." }, |
| 524 | + ], |
| 525 | + model: "claude-3-7-sonnet-20250219", |
| 526 | + stop_reason: "end_turn", |
| 527 | + usage: { |
| 528 | + input_tokens: 25, |
| 529 | + output_tokens: 40, |
| 530 | + }, |
| 531 | + }.to_json |
| 532 | + |
| 533 | + stub_request(:post, url).with( |
| 534 | + headers: { |
| 535 | + "Content-Type" => "application/json", |
| 536 | + "X-Api-Key" => "123", |
| 537 | + "Anthropic-Version" => "2023-06-01", |
| 538 | + }, |
| 539 | + ).to_return(status: 200, body: body) |
| 540 | + |
| 541 | + result = |
| 542 | + llm.generate( |
| 543 | + "hello", |
| 544 | + user: Discourse.system_user, |
| 545 | + feature_name: "testing", |
| 546 | + output_thinking: true, |
| 547 | + ) |
| 548 | + |
| 549 | + # Result should be an array with both thinking and text content |
| 550 | + expect(result).to be_an(Array) |
| 551 | + expect(result.length).to eq(3) |
| 552 | + |
| 553 | + # First item should be a Thinking object |
| 554 | + expect(result[0]).to be_a(DiscourseAi::Completions::Thinking) |
| 555 | + expect(result[0].message).to eq("This is my thinking process about prime numbers...") |
| 556 | + expect(result[0].signature).to eq("abc123signature") |
| 557 | + |
| 558 | + expect(result[1]).to be_a(DiscourseAi::Completions::Thinking) |
| 559 | + expect(result[1].signature).to eq("abd456signature") |
| 560 | + expect(result[1].redacted).to eq(true) |
| 561 | + |
| 562 | + # Second item should be the text response |
| 563 | + expect(result[2]).to eq("Yes, there are infinitely many prime numbers where n mod 4 = 3.") |
| 564 | + |
| 565 | + # Verify audit log |
| 566 | + log = AiApiAuditLog.order(:id).last |
| 567 | + expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) |
| 568 | + expect(log.feature_name).to eq("testing") |
| 569 | + expect(log.response_tokens).to eq(40) |
| 570 | + end |
| 571 | + |
| 572 | + it "can stream a response with thinking blocks" do |
| 573 | + body = (<<~STRING).strip |
| 574 | + event: message_start |
| 575 | + data: {"type": "message_start", "message": {"id": "msg_01...", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25}}} |
| 576 | +
|
| 577 | + event: content_block_start |
| 578 | + data: {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}} |
| 579 | +
|
| 580 | + event: content_block_delta |
| 581 | + data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "Let me solve this step by step:\\n\\n1. First break down 27 * 453"}} |
| 582 | +
|
| 583 | + event: content_block_delta |
| 584 | + data: {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "\\n2. 453 = 400 + 50 + 3"}} |
| 585 | +
|
| 586 | + event: content_block_delta |
| 587 | + data: {"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds..."}} |
| 588 | +
|
| 589 | + event: content_block_stop |
| 590 | + data: {"type": "content_block_stop", "index": 0} |
| 591 | +
|
| 592 | + event: content_block_start |
| 593 | +data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"AAA=="} } |
| 594 | +
|
| 595 | + event: ping |
| 596 | + data: {"type": "ping"} |
| 597 | +
|
| 598 | + event: content_block_stop |
| 599 | + data: {"type":"content_block_stop","index":0 } |
| 600 | +
|
| 601 | + event: content_block_start |
| 602 | + data: {"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}} |
| 603 | +
|
| 604 | + event: content_block_delta |
| 605 | + data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "27 * 453 = 12,231"}} |
| 606 | +
|
| 607 | + event: content_block_stop |
| 608 | + data: {"type": "content_block_stop", "index": 1} |
| 609 | +
|
| 610 | + event: message_delta |
| 611 | + data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": null, "usage": {"output_tokens": 30}}} |
| 612 | +
|
| 613 | + event: message_stop |
| 614 | + data: {"type": "message_stop"} |
| 615 | + STRING |
| 616 | + |
| 617 | + parsed_body = nil |
| 618 | + |
| 619 | + stub_request(:post, url).with( |
| 620 | + headers: { |
| 621 | + "Content-Type" => "application/json", |
| 622 | + "X-Api-Key" => "123", |
| 623 | + "Anthropic-Version" => "2023-06-01", |
| 624 | + }, |
| 625 | + ).to_return(status: 200, body: body) |
| 626 | + |
| 627 | + thinking_chunks = [] |
| 628 | + text_chunks = [] |
| 629 | + |
| 630 | + llm.generate( |
| 631 | + "hello there", |
| 632 | + user: Discourse.system_user, |
| 633 | + feature_name: "testing", |
| 634 | + output_thinking: true, |
| 635 | + ) do |partial, cancel| |
| 636 | + if partial.is_a?(DiscourseAi::Completions::Thinking) |
| 637 | + thinking_chunks << partial |
| 638 | + else |
| 639 | + text_chunks << partial |
| 640 | + end |
| 641 | + end |
| 642 | + |
| 643 | + expected_thinking = [ |
| 644 | + DiscourseAi::Completions::Thinking.new(message: "", signature: "", partial: true), |
| 645 | + DiscourseAi::Completions::Thinking.new( |
| 646 | + message: "Let me solve this step by step:\n\n1. First break down 27 * 453", |
| 647 | + partial: true, |
| 648 | + ), |
| 649 | + DiscourseAi::Completions::Thinking.new(message: "\n2. 453 = 400 + 50 + 3", partial: true), |
| 650 | + DiscourseAi::Completions::Thinking.new( |
| 651 | + message: |
| 652 | + "Let me solve this step by step:\n\n1. First break down 27 * 453\n2. 453 = 400 + 50 + 3", |
| 653 | + signature: "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds...", |
| 654 | + partial: false, |
| 655 | + ), |
| 656 | + DiscourseAi::Completions::Thinking.new(message: nil, signature: "AAA==", redacted: true), |
| 657 | + ] |
| 658 | + |
| 659 | + expect(thinking_chunks).to eq(expected_thinking) |
| 660 | + expect(text_chunks).to eq(["27 * 453 = 12,231"]) |
| 661 | + |
| 662 | + log = AiApiAuditLog.order(:id).last |
| 663 | + expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) |
| 664 | + expect(log.feature_name).to eq("testing") |
| 665 | + expect(log.response_tokens).to eq(30) |
| 666 | + end |
452 | 667 | end |
0 commit comments