diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b658787d..9029e0e8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -12,6 +12,8 @@ en: gpt_3_5_turbo: GPT 3.5 Turbo claude_2: Claude 2 gemini_pro: Gemini Pro + claude_3_opus: Claude 3 Opus + claude_3_sonnet: Claude 3 Sonnet scriptables: llm_report: fields: @@ -227,6 +229,9 @@ en: context: "Interactions to share:" bot_names: + fake: "Fake Test Bot" + claude-3-opus: "Claude 3 Opus" + claude-3-sonnet: "Claude 3 Sonnet" gpt-4: "GPT-4" gpt-4-turbo: "GPT-4 Turbo" gpt-3: diff --git a/config/settings.yml b/config/settings.yml index 78df0ebd..e3ff88b9 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -231,7 +231,7 @@ discourse_ai: choices: - "llava" - "open_ai:gpt-4-vision-preview" - + ai_embeddings_enabled: default: false @@ -313,6 +313,8 @@ discourse_ai: - claude-2 - gemini-pro - mixtral-8x7B-Instruct-V0.1 + - claude-3-opus + - claude-3-sonnet ai_bot_add_to_header: default: true client: true diff --git a/lib/ai_bot/bot.rb b/lib/ai_bot/bot.rb index d4db4d0d..4b969a0b 100644 --- a/lib/ai_bot/bot.rb +++ b/lib/ai_bot/bot.rb @@ -171,6 +171,10 @@ module DiscourseAi "google:gemini-pro" when DiscourseAi::AiBot::EntryPoint::FAKE_ID "fake:fake" + when DiscourseAi::AiBot::EntryPoint::CLAUDE_3_OPUS_ID + "anthropic:claude-3-opus" + when DiscourseAi::AiBot::EntryPoint::CLAUDE_3_SONNET_ID + "anthropic:claude-3-sonnet" else nil end diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index a30b616e..29cfe063 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -12,6 +12,8 @@ module DiscourseAi MIXTRAL_ID = -114 GEMINI_ID = -115 FAKE_ID = -116 # only used for dev and test + CLAUDE_3_OPUS_ID = -117 + CLAUDE_3_SONNET_ID = -118 BOTS = [ [GPT4_ID, "gpt4_bot", "gpt-4"], @@ -21,6 +23,8 @@ module DiscourseAi [MIXTRAL_ID, "mixtral_bot", "mixtral-8x7B-Instruct-V0.1"], [GEMINI_ID, "gemini_bot", "gemini-pro"], [FAKE_ID, "fake_bot", "fake"], + [CLAUDE_3_OPUS_ID, "claude_3_opus_bot", "claude-3-opus"], + [CLAUDE_3_SONNET_ID, "claude_3_sonnet_bot", "claude-3-sonnet"], ] BOT_USER_IDS = BOTS.map(&:first) @@ -41,6 +45,10 @@ module DiscourseAi GEMINI_ID in "fake" FAKE_ID + in "claude-3-opus" + CLAUDE_3_OPUS_ID + in "claude-3-sonnet" + CLAUDE_3_SONNET_ID else nil end diff --git a/lib/ai_bot/personas/researcher.rb b/lib/ai_bot/personas/researcher.rb index 5c7f8998..6b7e7eaa 100644 --- a/lib/ai_bot/personas/researcher.rb +++ b/lib/ai_bot/personas/researcher.rb @@ -14,11 +14,19 @@ module DiscourseAi def system_prompt <<~PROMPT - You are research bot. With access to the internet you can find information for users. + You are research bot. With access to Google you can find information for users. - - You fully understand Discourse Markdown and generate it. - - When generating responses you always cite your sources. + - You are conversing with: {participants} + - You understand **Discourse Markdown** and generate it. + - When generating responses you always cite your sources using Markdown footnotes. - When possible you also quote the sources. + + Example: + + **This** is a content[^1] with two footnotes[^2]. + + [^1]: https://www.example.com + [^2]: https://www.example2.com PROMPT end end diff --git a/lib/ai_bot/playground.rb b/lib/ai_bot/playground.rb index 671122f2..96ba9b0e 100644 --- a/lib/ai_bot/playground.rb +++ b/lib/ai_bot/playground.rb @@ -109,7 +109,6 @@ module DiscourseAi .pluck(:raw, :username, "post_custom_prompts.custom_prompt") result = [] - first = true context.reverse_each do |raw, username, custom_prompt| custom_prompt_translation = @@ -129,12 +128,7 @@ module DiscourseAi end if custom_prompt.present? - if first - custom_prompt.each(&custom_prompt_translation) - first = false - else - custom_prompt.first(2).each(&custom_prompt_translation) - end + custom_prompt.each(&custom_prompt_translation) else context = { content: raw, diff --git a/lib/automation.rb b/lib/automation.rb index 39e910fc..5be5c4d1 100644 --- a/lib/automation.rb +++ b/lib/automation.rb @@ -8,6 +8,8 @@ module DiscourseAi { id: "gpt-3.5-turbo", name: "discourse_automation.ai_models.gpt_3_5_turbo" }, { id: "claude-2", name: "discourse_automation.ai_models.claude_2" }, { id: "gemini-pro", name: "discourse_automation.ai_models.gemini_pro" }, + { id: "claude-3-sonnet", name: "discourse_automation.ai_models.claude_3_sonnet" }, + { id: "claude-3-opus", name: "discourse_automation.ai_models.claude_3_opus" }, ] end end diff --git a/lib/automation/report_runner.rb b/lib/automation/report_runner.rb index 7713c5c4..c8086602 100644 --- a/lib/automation/report_runner.rb +++ b/lib/automation/report_runner.rb @@ -211,12 +211,13 @@ Follow the provided writing composition instructions carefully and precisely ste def translate_model(model) return "google:gemini-pro" if model == "gemini-pro" - return "open_ai:#{model}" if model != "claude-2" + return "open_ai:#{model}" if model.start_with? "gpt" + return "anthropic:#{model}" if model.start_with? "claude-3" if DiscourseAi::Completions::Endpoints::AwsBedrock.correctly_configured?("claude-2") - "aws_bedrock:claude-2" + "aws_bedrock:#{model}" else - "anthropic:claude-2" + "anthropic:#{model}" end end end diff --git a/lib/completions/dialects/claude_messages.rb b/lib/completions/dialects/claude_messages.rb new file mode 100644 index 00000000..c0e9feb0 --- /dev/null +++ b/lib/completions/dialects/claude_messages.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module DiscourseAi + module Completions + module Dialects + class ClaudeMessages < Dialect + class << self + def can_translate?(model_name) + # TODO: add haiku not released yet as of 2024-03-05 + %w[claude-3-sonnet claude-3-opus].include?(model_name) + end + + def tokenizer + DiscourseAi::Tokenizer::AnthropicTokenizer + end + end + + class ClaudePrompt + attr_reader :system_prompt + attr_reader :messages + + def initialize(system_prompt, messages) + @system_prompt = system_prompt + @messages = messages + end + end + + def translate + messages = prompt.messages + system_prompt = +"" + + messages = + trim_messages(messages) + .map do |msg| + case msg[:type] + when :system + system_prompt << msg[:content] + nil + when :tool_call + { role: "assistant", content: tool_call_to_xml(msg) } + when :tool + { role: "user", content: tool_result_to_xml(msg) } + when :model + { role: "assistant", content: msg[:content] } + when :user + content = +"" + content << "#{msg[:id]}: " if msg[:id] + content << msg[:content] + + { role: "user", content: content } + end + end + .compact + + if prompt.tools.present? + system_prompt << "\n\n" + system_prompt << build_tools_prompt + end + + interleving_messages = [] + + previous_message = nil + messages.each do |message| + if previous_message + if previous_message[:role] == "user" && message[:role] == "user" + interleving_messages << { role: "assistant", content: "OK" } + elsif previous_message[:role] == "assistant" && message[:role] == "assistant" + interleving_messages << { role: "user", content: "OK" } + end + end + interleving_messages << message + previous_message = message + end + + ClaudePrompt.new(system_prompt.presence, interleving_messages) + end + + def max_prompt_tokens + # Longer term it will have over 1 million + 200_000 # Claude-3 has a 200k context window for now + end + end + end + end +end diff --git a/lib/completions/dialects/dialect.rb b/lib/completions/dialects/dialect.rb index bed1c884..e4ab940f 100644 --- a/lib/completions/dialects/dialect.rb +++ b/lib/completions/dialects/dialect.rb @@ -17,6 +17,7 @@ module DiscourseAi DiscourseAi::Completions::Dialects::OrcaStyle, DiscourseAi::Completions::Dialects::Gemini, DiscourseAi::Completions::Dialects::Mixtral, + DiscourseAi::Completions::Dialects::ClaudeMessages, ] if Rails.env.test? || Rails.env.development? @@ -64,6 +65,38 @@ module DiscourseAi raise NotImplemented end + def tool_result_to_xml(message) + (<<~TEXT).strip + + + #{message[:id]} + + #{message[:content]} + + + + TEXT + end + + def tool_call_to_xml(message) + parsed = JSON.parse(message[:content], symbolize_names: true) + parameters = +"" + + if parsed[:arguments] + parameters << "\n" + parsed[:arguments].each { |k, v| parameters << "<#{k}>#{v}\n" } + parameters << "\n" + end + + (<<~TEXT).strip + + + #{parsed[:name]} + #{parameters} + + TEXT + end + def tools tools = +"" diff --git a/lib/completions/endpoints/anthropic_messages.rb b/lib/completions/endpoints/anthropic_messages.rb new file mode 100644 index 00000000..74e71e1f --- /dev/null +++ b/lib/completions/endpoints/anthropic_messages.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module DiscourseAi + module Completions + module Endpoints + class AnthropicMessages < Base + class << self + def can_contact?(endpoint_name, model_name) + endpoint_name == "anthropic" && %w[claude-3-opus claude-3-sonnet].include?(model_name) + end + + def dependant_setting_names + %w[ai_anthropic_api_key] + end + + def correctly_configured?(_model_name) + SiteSetting.ai_anthropic_api_key.present? + end + + def endpoint_name(model_name) + "Anthropic - #{model_name}" + end + end + + def normalize_model_params(model_params) + # max_tokens, temperature, stop_sequences are already supported + model_params + end + + def default_options + { model: model + "-20240229", max_tokens: 3_000, stop_sequences: [""] } + end + + def provider_id + AiApiAuditLog::Provider::Anthropic + end + + private + + # this is an approximation, we will update it later if request goes through + def prompt_size(prompt) + super(prompt.system_prompt.to_s + " " + prompt.messages.to_s) + end + + def model_uri + @uri ||= URI("https://api.anthropic.com/v1/messages") + end + + def prepare_payload(prompt, model_params, _dialect) + payload = default_options.merge(model_params).merge(messages: prompt.messages) + + payload[:system] = prompt.system_prompt if prompt.system_prompt.present? + payload[:stream] = true if @streaming_mode + + payload + end + + def prepare_request(payload) + headers = { + "anthropic-version" => "2023-06-01", + "x-api-key" => SiteSetting.ai_anthropic_api_key, + "content-type" => "application/json", + } + + Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload } + end + + def final_log_update(log) + log.request_tokens = @input_tokens if @input_tokens + log.response_tokens = @output_tokens if @output_tokens + end + + def extract_completion_from(response_raw) + result = "" + parsed = JSON.parse(response_raw, symbolize_names: true) + + if @streaming_mode + if parsed[:type] == "content_block_start" || parsed[:type] == "content_block_delta" + result = parsed.dig(:delta, :text).to_s + elsif parsed[:type] == "message_start" + @input_tokens = parsed.dig(:message, :usage, :input_tokens) + elsif parsed[:type] == "message_delta" + @output_tokens = parsed.dig(:delta, :usage, :output_tokens) + end + else + result = parsed.dig(:content, 0, :text).to_s + @input_tokens = parsed.dig(:usage, :input_tokens) + @output_tokens = parsed.dig(:usage, :output_tokens) + end + + result + end + + def partials_from(decoded_chunk) + decoded_chunk.split("\n").map { |line| line.split("data: ", 2)[1] }.compact + end + end + end + end +end diff --git a/lib/completions/endpoints/base.rb b/lib/completions/endpoints/base.rb index 718d021d..39b80d01 100644 --- a/lib/completions/endpoints/base.rb +++ b/lib/completions/endpoints/base.rb @@ -16,6 +16,7 @@ module DiscourseAi DiscourseAi::Completions::Endpoints::HuggingFace, DiscourseAi::Completions::Endpoints::Gemini, DiscourseAi::Completions::Endpoints::Vllm, + DiscourseAi::Completions::Endpoints::AnthropicMessages, ] if Rails.env.test? || Rails.env.development? @@ -165,8 +166,9 @@ module DiscourseAi begin partial = extract_completion_from(raw_partial) - next if response_data.empty? && partial.blank? next if partial.nil? + # empty vs blank... we still accept " " + next if response_data.empty? && partial.empty? partials_raw << partial.to_s # Stop streaming the response as soon as you find a tool. @@ -213,6 +215,7 @@ module DiscourseAi if log log.raw_response_payload = response_raw log.response_tokens = tokenizer.size(partials_raw) + final_log_update(log) log.save! if Rails.env.development? @@ -223,6 +226,10 @@ module DiscourseAi end end + def final_log_update(log) + # for people that need to override + end + def default_options raise NotImplementedError end diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index 07b5d9bd..ba4bdf34 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -24,7 +24,7 @@ module DiscourseAi @models_by_provider ||= { aws_bedrock: %w[claude-instant-1 claude-2], - anthropic: %w[claude-instant-1 claude-2], + anthropic: %w[claude-instant-1 claude-2 claude-3-sonnet claude-3-opus], vllm: %w[ mistralai/Mixtral-8x7B-Instruct-v0.1 mistralai/Mistral-7B-Instruct-v0.2 diff --git a/spec/lib/completions/dialects/claude_messages_spec.rb b/spec/lib/completions/dialects/claude_messages_spec.rb new file mode 100644 index 00000000..7e1f1edc --- /dev/null +++ b/spec/lib/completions/dialects/claude_messages_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Completions::Dialects::ClaudeMessages do + describe "#translate" do + it "can insert OKs to make stuff interleve properly" do + messages = [ + { type: :user, id: "user1", content: "1" }, + { type: :model, content: "2" }, + { type: :user, id: "user1", content: "4" }, + { type: :user, id: "user1", content: "5" }, + { type: :model, content: "6" }, + ] + + prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages) + + dialectKlass = DiscourseAi::Completions::Dialects::Dialect.dialect_for("claude-3-opus") + dialect = dialectKlass.new(prompt, "claude-3-opus") + translated = dialect.translate + + expected_messages = [ + { role: "user", content: "user1: 1" }, + { role: "assistant", content: "2" }, + { role: "user", content: "user1: 4" }, + { role: "assistant", content: "OK" }, + { role: "user", content: "user1: 5" }, + { role: "assistant", content: "6" }, + ] + + expect(translated.messages).to eq(expected_messages) + end + + it "can properly translate a prompt" do + dialect = DiscourseAi::Completions::Dialects::Dialect.dialect_for("claude-3-opus") + + tools = [ + { + name: "echo", + description: "echo a string", + parameters: [ + { name: "text", type: "string", description: "string to echo", required: true }, + ], + }, + ] + + tool_call_prompt = { name: "echo", arguments: { text: "something" } } + + messages = [ + { type: :user, id: "user1", content: "echo something" }, + { type: :tool_call, content: tool_call_prompt.to_json }, + { type: :tool, id: "tool_id", content: "something".to_json }, + { type: :model, content: "I did it" }, + { type: :user, id: "user1", content: "echo something else" }, + ] + + prompt = + DiscourseAi::Completions::Prompt.new( + "You are a helpful bot", + messages: messages, + tools: tools, + ) + + dialect = dialect.new(prompt, "claude-3-opus") + translated = dialect.translate + + expect(translated.system_prompt).to start_with("You are a helpful bot") + expect(translated.system_prompt).to include("echo a string") + + expected = [ + { role: "user", content: "user1: echo something" }, + { + role: "assistant", + content: + "\n\necho\n\nsomething\n\n\n", + }, + { + role: "user", + content: + "\n\ntool_id\n\n\"something\"\n\n\n", + }, + { role: "assistant", content: "I did it" }, + { role: "user", content: "user1: echo something else" }, + ] + + expect(translated.messages).to eq(expected) + end + end +end diff --git a/spec/lib/completions/endpoints/anthropic_messages_spec.rb b/spec/lib/completions/endpoints/anthropic_messages_spec.rb new file mode 100644 index 00000000..8f17a85f --- /dev/null +++ b/spec/lib/completions/endpoints/anthropic_messages_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Completions::Endpoints::AnthropicMessages do + let(:llm) { DiscourseAi::Completions::Llm.proxy("anthropic:claude-3-opus") } + + let(:prompt) do + DiscourseAi::Completions::Prompt.new( + "You are hello bot", + messages: [type: :user, id: "user1", content: "hello"], + ) + 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 + + stub_request(:post, "https://api.anthropic.com/v1/messages").to_return(status: 200, body: body) + + result = +"" + llm.generate(prompt, user: Discourse.system_user) { |partial| result << partial } + + expected = (<<~TEXT).strip + + + google + google + + top 10 things to do in japan for tourists + + + + 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, + stop_sequences: [""], + 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 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, + stop_sequences: [""], + 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 diff --git a/spec/lib/completions/endpoints/open_ai_spec.rb b/spec/lib/completions/endpoints/open_ai_spec.rb index 9b40fc8c..a83ed0a6 100644 --- a/spec/lib/completions/endpoints/open_ai_spec.rb +++ b/spec/lib/completions/endpoints/open_ai_spec.rb @@ -147,7 +147,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do described_class.new("gpt-3.5-turbo", DiscourseAi::Tokenizer::OpenAiTokenizer) end - fab!(:user) { Fabricate(:user) } + fab!(:user) let(:open_ai_mock) { OpenAiMock.new(endpoint) } diff --git a/spec/lib/modules/ai_bot/playground_spec.rb b/spec/lib/modules/ai_bot/playground_spec.rb index 1c9ac350..f9d444de 100644 --- a/spec/lib/modules/ai_bot/playground_spec.rb +++ b/spec/lib/modules/ai_bot/playground_spec.rb @@ -412,7 +412,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do ) end - it "include replies generated from tools only once" do + it "include replies generated from tools" do custom_prompt = [ [ { args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json, @@ -424,7 +424,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do "time", "tool_call", ], - ["I replied this thanks to the time command", bot_user.username], + ["I replied", bot_user.username], ] PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt) PostCustomPrompt.create!(post: first_post, custom_prompt: custom_prompt) @@ -439,6 +439,7 @@ RSpec.describe DiscourseAi::AiBot::Playground do { type: :tool, id: "time", content: custom_prompt.first.first }, { type: :tool_call, content: custom_prompt.second.first, id: "time" }, { type: :tool, id: "time", content: custom_prompt.first.first }, + { type: :model, content: "I replied" }, ], ) end