# frozen_string_literal: true require_relative "endpoint_compliance" RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do let(:llm) { DiscourseAi::Completions::Llm.proxy("anthropic:claude-3-opus") } let(:image100x100) { plugin_file_from_fixtures("100x100.jpg") } let(:upload100x100) do UploadCreator.new(image100x100, "image.jpg").create_for(Discourse.system_user.id) end let(:prompt) do DiscourseAi::Completions::Prompt.new( "You are hello bot", messages: [type: :user, id: "user1", content: "hello"], ) end let(:echo_tool) do { name: "echo", description: "echo something", parameters: [{ name: "text", type: "string", description: "text to echo", required: true }], } end let(:google_tool) do { name: "google", description: "google something", parameters: [ { name: "query", type: "string", description: "text to google", required: true }, ], } end let(:prompt_with_echo_tool) do prompt_with_tools = prompt prompt.tools = [echo_tool] prompt_with_tools end let(:prompt_with_google_tool) do prompt_with_tools = prompt prompt.tools = [echo_tool] prompt_with_tools end before { SiteSetting.ai_anthropic_api_key = "123" } it "does not eat spaces with tool calls" do body = <<~STRING event: message_start data: {"type":"message_start","message":{"id":"msg_019kmW9Q3GqfWmuFJbePJTBR","type":"message","role":"assistant","content":[],"model":"claude-3-opus-20240229","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":347,"output_tokens":1}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: ping data: {"type": "ping"} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"google"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"top"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" "}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"10"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" "}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"things"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" to"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" do"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" in"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" japan"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" tourists"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\\n"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":""},"usage":{"output_tokens":57}} event: message_stop data: {"type":"message_stop"} STRING result = +"" body = body.scan(/.*\n/) EndpointMock.with_chunk_array_support do stub_request(:post, "https://api.anthropic.com/v1/messages").to_return( status: 200, body: body, ) llm.generate(prompt_with_google_tool, user: Discourse.system_user) do |partial| result << partial end end expected = (<<~TEXT).strip google top 10 things to do in japan for tourists tool_0 TEXT expect(result.strip).to eq(expected) end it "can stream a response" do body = (<<~STRING).strip event: message_start data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-opus-20240229", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} event: content_block_start data: {"type": "content_block_start", "index":0, "content_block": {"type": "text", "text": ""}} event: ping data: {"type": "ping"} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}} event: content_block_delta data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}} event: content_block_stop data: {"type": "content_block_stop", "index": 0} event: message_delta data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null, "usage":{"output_tokens": 15}}} event: message_stop data: {"type": "message_stop"} STRING parsed_body = nil stub_request(:post, "https://api.anthropic.com/v1/messages").with( body: proc do |req_body| parsed_body = JSON.parse(req_body, symbolize_names: true) true end, headers: { "Content-Type" => "application/json", "X-Api-Key" => "123", "Anthropic-Version" => "2023-06-01", }, ).to_return(status: 200, body: body) result = +"" llm.generate(prompt, user: Discourse.system_user) { |partial, cancel| result << partial } expect(result).to eq("Hello!") expected_body = { model: "claude-3-opus-20240229", max_tokens: 3000, messages: [{ role: "user", content: "user1: hello" }], system: "You are hello bot", stream: true, } expect(parsed_body).to eq(expected_body) log = AiApiAuditLog.order(:id).last expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) expect(log.request_tokens).to eq(25) expect(log.response_tokens).to eq(15) end it "can return multiple function calls" do functions = <<~FUNCTIONS echo something echo something else FUNCTIONS body = <<~STRING { "content": [ { "text": "Hello!\n\n#{functions}\njunk", "type": "text" } ], "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", "model": "claude-3-opus-20240229", "role": "assistant", "stop_reason": "end_turn", "stop_sequence": null, "type": "message", "usage": { "input_tokens": 10, "output_tokens": 25 } } STRING stub_request(:post, "https://api.anthropic.com/v1/messages").to_return(status: 200, body: body) result = llm.generate(prompt_with_echo_tool, user: Discourse.system_user) expected = (<<~EXPECTED).strip echo something tool_0 echo something else tool_1 EXPECTED expect(result.strip).to eq(expected) end it "can send images via a completion prompt" do prompt = DiscourseAi::Completions::Prompt.new( "You are image bot", messages: [type: :user, id: "user1", content: "hello", upload_ids: [upload100x100.id]], ) encoded = prompt.encoded_uploads(prompt.messages.last) request_body = { model: "claude-3-opus-20240229", max_tokens: 3000, messages: [ { role: "user", content: [ { type: "image", source: { type: "base64", media_type: "image/jpeg", data: encoded[0][:base64], }, }, { type: "text", text: "user1: hello" }, ], }, ], system: "You are image bot", } response_body = <<~STRING { "content": [ { "text": "What a cool image", "type": "text" } ], "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", "model": "claude-3-opus-20240229", "role": "assistant", "stop_reason": "end_turn", "stop_sequence": null, "type": "message", "usage": { "input_tokens": 10, "output_tokens": 25 } } STRING requested_body = nil stub_request(:post, "https://api.anthropic.com/v1/messages").with( body: proc do |req_body| requested_body = JSON.parse(req_body, symbolize_names: true) true end, ).to_return(status: 200, body: response_body) result = llm.generate(prompt, user: Discourse.system_user) expect(result).to eq("What a cool image") expect(requested_body).to eq(request_body) end it "can operate in regular mode" do body = <<~STRING { "content": [ { "text": "Hello!", "type": "text" } ], "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", "model": "claude-3-opus-20240229", "role": "assistant", "stop_reason": "end_turn", "stop_sequence": null, "type": "message", "usage": { "input_tokens": 10, "output_tokens": 25 } } STRING parsed_body = nil stub_request(:post, "https://api.anthropic.com/v1/messages").with( body: proc do |req_body| parsed_body = JSON.parse(req_body, symbolize_names: true) true end, headers: { "Content-Type" => "application/json", "X-Api-Key" => "123", "Anthropic-Version" => "2023-06-01", }, ).to_return(status: 200, body: body) result = llm.generate(prompt, user: Discourse.system_user) expect(result).to eq("Hello!") expected_body = { model: "claude-3-opus-20240229", max_tokens: 3000, messages: [{ role: "user", content: "user1: hello" }], system: "You are hello bot", } expect(parsed_body).to eq(expected_body) log = AiApiAuditLog.order(:id).last expect(log.provider_id).to eq(AiApiAuditLog::Provider::Anthropic) expect(log.request_tokens).to eq(10) expect(log.response_tokens).to eq(25) end end