From c1dbb515538ecb5a699e754c4571628ecf70e599 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 25 May 2026 16:29:53 +0200 Subject: [PATCH] feat(ai): add Anthropic provider with chat parity (1/5) Introduces Provider::Anthropic alongside Provider::Openai, implementing the LlmConcept chat_response contract over the official anthropic Ruby SDK. Batch ops, PDF, and RAG land in follow-up PRs. - Provider::Anthropic uses Messages API for sync and streaming responses - ChatConfig builds requests with ephemeral prompt-cache markers on the system prompt and the last tool definition - MessageFormatter reconstructs multi-turn history (text + tool_use + tool_result blocks) from raw Message records, including the paired user-role tool_result turn Anthropic requires after every tool_use - ChatParser maps Anthropic Message into the shared ChatResponse Data - Registry, Setting, User, Chat default model wired for ANTHROPIC_* envs and Setting.anthropic_*; LLM_PROVIDER selects between providers - Responder forwards raw conversation_history (Array) so providers without hosted conversation state can rebuild context - OpenAI provider accepts and ignores the new kwarg (no behavior change) Tests cover provider init, model gating, MessageFormatter for all turn shapes, ChatConfig request building (max_tokens, system cache, tool conversion), ChatParser for text / tool_use / mixed blocks, Registry discovery, and mocked chat_response success / error / function_request paths. Live VCR cassettes recorded in a follow-up with a real key. Stacked PRs: 2/5 batch ops + cost ledger, 3/5 PDF, 4/5 pgvector RAG, 5/5 settings UI + disclosure. --- Gemfile | 1 + Gemfile.lock | 6 + app/models/assistant/responder.rb | 15 + app/models/chat.rb | 11 +- app/models/provider/anthropic.rb | 320 ++++++++++++++++++ app/models/provider/anthropic/chat_config.rb | 83 +++++ app/models/provider/anthropic/chat_parser.rb | 74 ++++ .../provider/anthropic/message_formatter.rb | 118 +++++++ app/models/provider/llm_concept.rb | 1 + app/models/provider/openai.rb | 1 + app/models/provider/registry.rb | 17 +- app/models/setting.rb | 4 + app/models/user.rb | 12 +- .../provider/anthropic/chat_config_test.rb | 68 ++++ .../provider/anthropic/chat_parser_test.rb | 84 +++++ .../anthropic/message_formatter_test.rb | 129 +++++++ test/models/provider/anthropic_test.rb | 145 ++++++++ test/models/provider/registry_test.rb | 47 ++- 18 files changed, 1128 insertions(+), 8 deletions(-) create mode 100644 app/models/provider/anthropic.rb create mode 100644 app/models/provider/anthropic/chat_config.rb create mode 100644 app/models/provider/anthropic/chat_parser.rb create mode 100644 app/models/provider/anthropic/message_formatter.rb create mode 100644 test/models/provider/anthropic/chat_config_test.rb create mode 100644 test/models/provider/anthropic/chat_parser_test.rb create mode 100644 test/models/provider/anthropic/message_formatter_test.rb create mode 100644 test/models/provider/anthropic_test.rb diff --git a/Gemfile b/Gemfile index 19fdf8a17..5d159c093 100644 --- a/Gemfile +++ b/Gemfile @@ -101,6 +101,7 @@ gem "after_commit_everywhere", "~> 1.0" # AI gem "ruby-openai" +gem "anthropic", "~> 1.0" gem "langfuse-ruby", "~> 0.1.4", require: "langfuse" group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index 2b3ab3c5f..6bc0b988d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -87,6 +87,10 @@ GEM activerecord (>= 4.2) activesupport android_key_attestation (0.3.0) + anthropic (1.43.0) + cgi + connection_pool + standardwebhooks ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) @@ -759,6 +763,7 @@ GEM faraday (>= 1.0.1, < 3.0) faraday-multipart (~> 1.0, >= 1.0.4) stackprof (0.2.27) + standardwebhooks (1.1.0) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) @@ -859,6 +864,7 @@ DEPENDENCIES aasm activerecord-import after_commit_everywhere (~> 1.0) + anthropic (~> 1.0) aws-sdk-s3 (~> 1.208.0) bcrypt (~> 3.1) benchmark-ips diff --git a/app/models/assistant/responder.rb b/app/models/assistant/responder.rb index 480c69c22..406993ab7 100644 --- a/app/models/assistant/responder.rb +++ b/app/models/assistant/responder.rb @@ -80,6 +80,7 @@ class Assistant::Responder functions: function_tool_caller.function_definitions, function_results: function_results, messages: conversation_history, + conversation_history: chat_message_records, streamer: streamer, previous_response_id: previous_response_id, session_id: chat_session_id, @@ -116,6 +117,20 @@ class Assistant::Responder @chat ||= message.chat end + # Raw Message records preceding the current turn — providers that build + # their own native message shape (Anthropic) consume this directly so they + # do not have to round-trip through the OpenAI-shaped `conversation_history`. + def chat_message_records + return [] unless chat&.messages + + chat.messages + .where(type: [ "UserMessage", "AssistantMessage" ], status: "complete") + .where.not(id: message.id) + .includes(:tool_calls) + .ordered + .to_a + end + def conversation_history messages = [] return messages unless chat&.messages diff --git a/app/models/chat.rb b/app/models/chat.rb index 1198ee4be..f85f31d12 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -51,10 +51,15 @@ class Chat < ApplicationRecord prompt.first(80) end - # Returns the default AI model to use for chats - # Priority: AI Config > Setting + # Returns the default AI model to use for chats. + # Resolved from the configured llm_provider so installs that swap providers + # don't have to manually update every chat default. def default_model - Provider::Openai.effective_model.presence || Setting.openai_model + if Setting.llm_provider == "anthropic" + Provider::Anthropic.effective_model.presence || Setting.anthropic_model + else + Provider::Openai.effective_model.presence || Setting.openai_model + end end end diff --git a/app/models/provider/anthropic.rb b/app/models/provider/anthropic.rb new file mode 100644 index 000000000..2b73d2211 --- /dev/null +++ b/app/models/provider/anthropic.rb @@ -0,0 +1,320 @@ +class Provider::Anthropic < Provider + include LlmConcept + + # Subclass so errors caught in this provider are raised as Provider::Anthropic::Error + Error = Class.new(Provider::Error) + + # Supported Anthropic model prefixes + DEFAULT_ANTHROPIC_MODEL_PREFIXES = %w[claude].freeze + DEFAULT_MODEL = "claude-sonnet-4-6" + + # All Claude 3.5+ and 4.x models accept native document content blocks. + VISION_CAPABLE_MODEL_PREFIXES = %w[claude].freeze + + def self.effective_model + configured_model = ENV.fetch("ANTHROPIC_MODEL", Setting.anthropic_model) + configured_model.presence || DEFAULT_MODEL + end + + def initialize(access_token, base_url: nil, model: nil) + client_options = { api_key: access_token } + client_options[:base_url] = base_url if base_url.present? + client_options[:timeout] = ENV.fetch("ANTHROPIC_REQUEST_TIMEOUT", 600).to_i + + @client = ::Anthropic::Client.new(**client_options) + @base_url = base_url + @default_model = model.presence || DEFAULT_MODEL + end + + def supports_model?(model) + DEFAULT_ANTHROPIC_MODEL_PREFIXES.any? { |prefix| model.to_s.start_with?(prefix) } + end + + def provider_name + custom_endpoint? ? "Custom Anthropic-compatible (#{@base_url})" : "Anthropic" + end + + def supported_models_description + if custom_endpoint? + "configured model: #{@default_model}" + else + "models starting with: #{DEFAULT_ANTHROPIC_MODEL_PREFIXES.join(', ')}" + end + end + + def custom_endpoint? + @base_url.present? + end + + # Batch operations land in PR2 — keep the LlmConcept contract honest by + # surfacing a clear error if a caller routes here too early. + def auto_categorize(transactions: [], user_categories: [], model: "", family: nil, json_mode: nil) + raise Error, "auto_categorize not yet implemented for Provider::Anthropic" + end + + def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil) + raise Error, "auto_detect_merchants not yet implemented for Provider::Anthropic" + end + + def enhance_provider_merchants(merchants: [], model: "", family: nil, json_mode: nil) + raise Error, "enhance_provider_merchants not yet implemented for Provider::Anthropic" + end + + def supports_pdf_processing?(model: @default_model) + VISION_CAPABLE_MODEL_PREFIXES.any? { |prefix| model.to_s.start_with?(prefix) } + end + + def process_pdf(pdf_content:, model: "", family: nil) + raise Error, "process_pdf not yet implemented for Provider::Anthropic" + end + + def extract_bank_statement(pdf_content:, model: "", family: nil) + raise Error, "extract_bank_statement not yet implemented for Provider::Anthropic" + end + + def chat_response( + prompt, + model:, + instructions: nil, + functions: [], + function_results: [], + conversation_history: [], + streamer: nil, + previous_response_id: nil, + session_id: nil, + user_identifier: nil, + family: nil + ) + with_provider_response do + chat_config = ChatConfig.new( + prompt: prompt, + instructions: instructions, + functions: functions, + function_results: function_results, + conversation_history: conversation_history, + default_max_tokens: default_max_tokens + ) + + request_params = chat_config.build_request(model: model) + + trace = create_langfuse_trace( + name: "anthropic.chat_response", + input: { messages: request_params[:messages], system: request_params[:system_] }, + session_id: session_id, + user_identifier: user_identifier + ) + + begin + parsed, usage = + if streamer.present? + stream_chat_response(streamer: streamer, request_params: request_params) + else + sync_chat_response(request_params: request_params) + end + + log_langfuse_generation( + name: "chat_response", + model: model, + input: request_params[:messages], + output: parsed.messages.map(&:output_text).join("\n"), + usage: usage, + trace: trace + ) + record_llm_usage(family: family, model: model, operation: "chat", usage: usage) + + parsed + rescue => e + log_langfuse_generation( + name: "chat_response", + model: model, + input: request_params[:messages], + error: e, + trace: trace + ) + record_llm_usage(family: family, model: model, operation: "chat", error: e) + raise + end + end + end + + private + attr_reader :client + + def default_max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def sync_chat_response(request_params:) + raw = client.messages.create(**request_params) + parsed = ChatParser.new(raw).parsed + usage = build_usage_hash(raw.usage) + [ parsed, usage ] + end + + def stream_chat_response(streamer:, request_params:) + final_message = nil + stream = client.messages.stream(**request_params) + + stream.each do |event| + case event + when ::Anthropic::Streaming::TextEvent + streamer.call( + Provider::LlmConcept::ChatStreamChunk.new(type: "output_text", data: event.text, usage: nil) + ) + when ::Anthropic::Streaming::MessageStopEvent + final_message = event.message + end + end + + final_message ||= stream.accumulated_message + parsed = ChatParser.new(final_message).parsed + usage = build_usage_hash(final_message.usage) + + streamer.call( + Provider::LlmConcept::ChatStreamChunk.new(type: "response", data: parsed, usage: usage) + ) + + [ parsed, usage ] + end + + def build_usage_hash(raw_usage) + return {} unless raw_usage + + input = raw_usage.input_tokens.to_i + output = raw_usage.output_tokens.to_i + hash = { + "input_tokens" => input, + "output_tokens" => output, + "total_tokens" => input + output + } + + if raw_usage.respond_to?(:cache_creation_input_tokens) && raw_usage.cache_creation_input_tokens + hash["cache_creation_input_tokens"] = raw_usage.cache_creation_input_tokens + end + if raw_usage.respond_to?(:cache_read_input_tokens) && raw_usage.cache_read_input_tokens + hash["cache_read_input_tokens"] = raw_usage.cache_read_input_tokens + end + + hash + end + + def langfuse_client + return unless ENV["LANGFUSE_PUBLIC_KEY"].present? && ENV["LANGFUSE_SECRET_KEY"].present? + + @langfuse_client = Langfuse.new + end + + def create_langfuse_trace(name:, input:, session_id: nil, user_identifier: nil) + return unless langfuse_client + + langfuse_client.trace( + name: name, + input: input, + session_id: session_id, + user_id: user_identifier, + environment: Rails.env + ) + rescue => e + Rails.logger.warn("Langfuse trace creation failed: #{e.message}\n#{e.full_message}") + nil + end + + def log_langfuse_generation(name:, model:, input:, trace:, output: nil, usage: nil, error: nil) + return unless langfuse_client + + generation = trace&.generation( + name: name, + model: model, + input: input + ) + + if error + generation&.end( + output: { error: error.message, details: error.respond_to?(:details) ? error.details : nil }, + level: "ERROR" + ) + upsert_langfuse_trace(trace: trace, output: { error: error.message }, level: "ERROR") + else + generation&.end(output: output, usage: usage) + upsert_langfuse_trace(trace: trace, output: output) + end + rescue => e + Rails.logger.warn("Langfuse logging failed: #{e.message}\n#{e.full_message}") + end + + def upsert_langfuse_trace(trace:, output:, level: nil) + return unless langfuse_client && trace&.id + + payload = { id: trace.id, output: output } + payload[:level] = level if level.present? + + langfuse_client.trace(**payload) + rescue => e + Rails.logger.warn("Langfuse trace upsert failed for trace_id=#{trace&.id}: #{e.message}\n#{e.full_message}") + nil + end + + def record_llm_usage(family:, model:, operation:, usage: nil, error: nil) + return unless family + + if error.present? + http_status_code = extract_http_status_code(error) + + family.llm_usages.create!( + provider: "anthropic", + model: model, + operation: operation, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + estimated_cost: nil, + metadata: { + error: safe_error_message(error), + http_status_code: http_status_code + } + ) + return + end + + return unless usage + + prompt_tokens = usage["input_tokens"] || 0 + completion_tokens = usage["output_tokens"] || 0 + total_tokens = usage["total_tokens"] || (prompt_tokens + completion_tokens) + + estimated_cost = LlmUsage.calculate_cost( + model: model, + prompt_tokens: prompt_tokens, + completion_tokens: completion_tokens + ) + + family.llm_usages.create!( + provider: "anthropic", + model: model, + operation: operation, + prompt_tokens: prompt_tokens, + completion_tokens: completion_tokens, + total_tokens: total_tokens, + estimated_cost: estimated_cost, + metadata: usage.slice("cache_creation_input_tokens", "cache_read_input_tokens").compact + ) + rescue => e + Rails.logger.error("Failed to record LLM usage: #{e.message}") + end + + def extract_http_status_code(error) + if error.respond_to?(:status) + error.status + elsif error.respond_to?(:http_status) + error.http_status + elsif safe_error_message(error) =~ /(\d{3})/ + $1.to_i + end + end + + def safe_error_message(error) + error&.message + rescue => e + "(message unavailable: #{e.class})" + end +end diff --git a/app/models/provider/anthropic/chat_config.rb b/app/models/provider/anthropic/chat_config.rb new file mode 100644 index 000000000..fd7597611 --- /dev/null +++ b/app/models/provider/anthropic/chat_config.rb @@ -0,0 +1,83 @@ +class Provider::Anthropic::ChatConfig + def initialize( + prompt:, + instructions: nil, + functions: [], + function_results: [], + conversation_history: [], + default_max_tokens: 4096 + ) + @prompt = prompt + @instructions = instructions + @functions = functions + @function_results = function_results + @conversation_history = conversation_history + @default_max_tokens = default_max_tokens + end + + def build_request(model:) + params = { + model: model, + max_tokens: @default_max_tokens, + messages: build_messages + } + + system_blocks = build_system_blocks + params[:system_] = system_blocks if system_blocks.present? + + tool_blocks = build_tools + params[:tools] = tool_blocks if tool_blocks.present? + + params + end + + private + def build_messages + Provider::Anthropic::MessageFormatter.new( + prompt: @prompt, + conversation_history: @conversation_history, + function_results: @function_results + ).build + end + + def build_system_blocks + return nil if @instructions.blank? + + # System prompts are cached aggressively — they rarely change within a session + # and re-using them via prompt caching cuts input cost ~10x on cache hits. + [ + { + type: "text", + text: @instructions, + cache_control: { type: "ephemeral" } + } + ] + end + + def build_tools + return [] if @functions.blank? + + tools = @functions.map do |fn| + { + name: fn[:name], + description: fn[:description], + input_schema: anthropic_input_schema(fn[:params_schema]) + } + end + + # Cache tool definitions alongside the system prompt: same TTL behaviour and + # they almost never change between turns. + tools.last[:cache_control] = { type: "ephemeral" } if tools.any? + + tools + end + + # OpenAI strict schemas frequently include `additionalProperties: false`, which + # Anthropic also accepts. The shapes are otherwise JSON Schema 2020-12 compatible. + # `strict` is OpenAI-only and must not be forwarded. + def anthropic_input_schema(schema) + schema = schema.deep_dup + schema.delete(:strict) if schema.is_a?(Hash) + schema + end +end diff --git a/app/models/provider/anthropic/chat_parser.rb b/app/models/provider/anthropic/chat_parser.rb new file mode 100644 index 000000000..1f22c465d --- /dev/null +++ b/app/models/provider/anthropic/chat_parser.rb @@ -0,0 +1,74 @@ +class Provider::Anthropic::ChatParser + Error = Class.new(StandardError) + + def initialize(message) + @message = message + end + + def parsed + ChatResponse.new( + id: response_id, + model: response_model, + messages: messages, + function_requests: function_requests + ) + end + + private + ChatResponse = Provider::LlmConcept::ChatResponse + ChatMessage = Provider::LlmConcept::ChatMessage + ChatFunctionRequest = Provider::LlmConcept::ChatFunctionRequest + + attr_reader :message + + def response_id + message.id + end + + def response_model + message.model.to_s + end + + def messages + text_blocks = content_blocks.select { |block| block_type(block) == :text } + return [] if text_blocks.empty? + + [ + ChatMessage.new( + id: response_id, + output_text: text_blocks.map { |b| block_value(b, :text) }.compact.join("\n") + ) + ] + end + + def function_requests + content_blocks + .select { |block| block_type(block) == :tool_use } + .map do |block| + input = block_value(block, :input) + ChatFunctionRequest.new( + id: block_value(block, :id), + call_id: block_value(block, :id), + function_name: block_value(block, :name), + function_args: input.is_a?(String) ? input : input.to_json + ) + end + end + + def content_blocks + Array(message.content) + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_value(block, key) + if block.respond_to?(key) + block.public_send(key) + elsif block.is_a?(Hash) + block[key] || block[key.to_s] + end + end +end diff --git a/app/models/provider/anthropic/message_formatter.rb b/app/models/provider/anthropic/message_formatter.rb new file mode 100644 index 000000000..e9288e376 --- /dev/null +++ b/app/models/provider/anthropic/message_formatter.rb @@ -0,0 +1,118 @@ +class Provider::Anthropic::MessageFormatter + # Builds the `messages` array Anthropic expects. + # + # Inputs: + # - prompt: text of the current user turn + # - conversation_history: chronologically-ordered Message records preceding + # the current user message (UserMessage / AssistantMessage) + # - function_results: tool-result entries for the in-flight follow-up call + # (the responder feeds these back after executing the tool_use blocks + # returned by the previous request) + def initialize(prompt:, conversation_history: [], function_results: []) + @prompt = prompt + @conversation_history = conversation_history + @function_results = function_results + end + + def build + messages = [] + + @conversation_history.each do |historical| + case historical + when UserMessage + messages << { role: "user", content: historical.content.to_s } if historical.content.present? + when AssistantMessage + messages.concat(assistant_history_blocks(historical)) + end + end + + messages << { role: "user", content: @prompt.to_s } + + if @function_results.present? + tool_use_blocks = @function_results.map { |fr| tool_use_block_from_result(fr) } + tool_result_blocks = @function_results.map { |fr| tool_result_block(fr) } + + messages << { role: "assistant", content: tool_use_blocks } + messages << { role: "user", content: tool_result_blocks } + end + + messages + end + + private + def assistant_history_blocks(assistant_message) + blocks = [] + blocks.concat(assistant_message.tool_calls.map { |tc| tool_use_block_from_record(tc) }) if assistant_message.tool_calls.any? + blocks << { type: "text", text: assistant_message.content.to_s } if assistant_message.content.present? + + return [] if blocks.empty? + + result = [ { role: "assistant", content: blocks } ] + + # If the assistant turn used tools, Anthropic requires a user turn with + # matching tool_result blocks before the next assistant turn. + if assistant_message.tool_calls.any? + result << { + role: "user", + content: assistant_message.tool_calls.map { |tc| tool_result_block_from_record(tc) } + } + end + + result + end + + def tool_use_block_from_record(tool_call) + { + type: "tool_use", + id: tool_call.provider_call_id || tool_call.provider_id, + name: tool_call.function_name, + input: parse_arguments(tool_call.function_arguments) + } + end + + def tool_result_block_from_record(tool_call) + { + type: "tool_result", + tool_use_id: tool_call.provider_call_id || tool_call.provider_id, + content: serialize_output(tool_call.function_result) + } + end + + def tool_use_block_from_result(function_result) + { + type: "tool_use", + id: function_result[:call_id], + name: function_result[:name], + input: parse_arguments(function_result[:arguments]) + } + end + + def tool_result_block(function_result) + { + type: "tool_result", + tool_use_id: function_result[:call_id], + content: serialize_output(function_result[:output]) + } + end + + def parse_arguments(arguments) + case arguments + when nil then {} + when Hash then arguments + when String + return {} if arguments.blank? + JSON.parse(arguments) + else arguments + end + rescue JSON::ParserError + {} + end + + def serialize_output(output) + case output + when nil then "" + when String then output + else output.to_json + end + end +end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index 52550111f..c4ee70ef7 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -41,6 +41,7 @@ module Provider::LlmConcept functions: [], function_results: [], messages: nil, + conversation_history: [], streamer: nil, previous_response_id: nil, session_id: nil, diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 0c04f63e1..8a28402f8 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -260,6 +260,7 @@ class Provider::Openai < Provider functions: [], function_results: [], messages: nil, + conversation_history: [], streamer: nil, previous_response_id: nil, session_id: nil, diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 4782c1ee1..085c31b50 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -78,6 +78,19 @@ class Provider::Registry Provider::Openai.new(access_token, uri_base: uri_base, model: model) end + def anthropic + access_token = ENV["ANTHROPIC_ACCESS_TOKEN"].presence || + ENV["ANTHROPIC_API_KEY"].presence || + Setting.anthropic_access_token + + return nil unless access_token.present? + + base_url = ENV["ANTHROPIC_BASE_URL"].presence || Setting.anthropic_base_url + model = ENV["ANTHROPIC_MODEL"].presence || Setting.anthropic_model + + Provider::Anthropic.new(access_token, base_url: base_url, model: model) + end + def yahoo_finance Provider::YahooFinance.new end @@ -147,9 +160,9 @@ class Provider::Registry when :securities %i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public] when :llm - %i[openai] + %i[openai anthropic] else - %i[plaid_us plaid_eu github openai] + %i[plaid_us plaid_eu github openai anthropic] end end end diff --git a/app/models/setting.rb b/app/models/setting.rb index c5aa08d7e..35dec641e 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -10,6 +10,10 @@ class Setting < RailsSettings::Base field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"] + field :anthropic_access_token, type: :string, default: ENV["ANTHROPIC_ACCESS_TOKEN"].presence || ENV["ANTHROPIC_API_KEY"] + field :anthropic_model, type: :string, default: ENV["ANTHROPIC_MODEL"] + field :anthropic_base_url, type: :string, default: ENV["ANTHROPIC_BASE_URL"] + field :llm_provider, type: :string, default: ENV.fetch("LLM_PROVIDER", "openai") # LLM token budget (applies to every outbound LLM call: chat, auto-categorize, # merchant detection, enhance-merchants, PDF processing). Defaults track diff --git a/app/models/user.rb b/app/models/user.rb index 74016d755..f6f059eb5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -157,10 +157,20 @@ class User < ApplicationRecord when "external" Assistant::External.available_for?(self) else - ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + openai_configured? || anthropic_configured? end end + def openai_configured? + ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + end + + def anthropic_configured? + ENV["ANTHROPIC_ACCESS_TOKEN"].present? || + ENV["ANTHROPIC_API_KEY"].present? || + Setting.anthropic_access_token.present? + end + def ai_enabled? ai_enabled && ai_available? end diff --git a/test/models/provider/anthropic/chat_config_test.rb b/test/models/provider/anthropic/chat_config_test.rb new file mode 100644 index 000000000..eef796e48 --- /dev/null +++ b/test/models/provider/anthropic/chat_config_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class Provider::Anthropic::ChatConfigTest < ActiveSupport::TestCase + test "builds request with default max_tokens and prompt message" do + config = Provider::Anthropic::ChatConfig.new(prompt: "hello") + + req = config.build_request(model: "claude-sonnet-4-6") + + assert_equal "claude-sonnet-4-6", req[:model] + assert_equal 4096, req[:max_tokens] + assert_equal [ { role: "user", content: "hello" } ], req[:messages] + assert_nil req[:system_] + assert_nil req[:tools] + end + + test "honors caller-provided default_max_tokens" do + config = Provider::Anthropic::ChatConfig.new(prompt: "hi", default_max_tokens: 8192) + + req = config.build_request(model: "claude-sonnet-4-6") + + assert_equal 8192, req[:max_tokens] + end + + test "wraps instructions as cacheable system block" do + config = Provider::Anthropic::ChatConfig.new(prompt: "hi", instructions: "Be terse.") + + req = config.build_request(model: "claude-sonnet-4-6") + + assert_equal [ { + type: "text", + text: "Be terse.", + cache_control: { type: "ephemeral" } + } ], req[:system_] + end + + test "converts function definitions to Anthropic tool blocks and caches the last one" do + config = Provider::Anthropic::ChatConfig.new( + prompt: "hi", + functions: [ + { + name: "get_net_worth", + description: "Returns net worth", + params_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true + }, + { + name: "get_accounts", + description: "Returns accounts", + params_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true + } + ] + ) + + req = config.build_request(model: "claude-sonnet-4-6") + + assert_equal 2, req[:tools].size + assert_equal "get_net_worth", req[:tools][0][:name] + assert_equal "Returns net worth", req[:tools][0][:description] + assert_equal({ type: "object", properties: {}, required: [], additionalProperties: false }, req[:tools][0][:input_schema]) + assert_nil req[:tools][0][:cache_control] + + assert_equal({ type: "ephemeral" }, req[:tools][1][:cache_control]) + + # Anthropic schemas must not carry the OpenAI-specific `strict` flag. + req[:tools].each { |t| assert_not t[:input_schema].key?(:strict) } + end +end diff --git a/test/models/provider/anthropic/chat_parser_test.rb b/test/models/provider/anthropic/chat_parser_test.rb new file mode 100644 index 000000000..6e8cfcac9 --- /dev/null +++ b/test/models/provider/anthropic/chat_parser_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class Provider::Anthropic::ChatParserTest < ActiveSupport::TestCase + test "parses text-only message into ChatResponse with single output_text" do + raw = build_message( + id: "msg_1", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :text, text: "Hello"), + OpenStruct.new(type: :text, text: "world") + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_equal "msg_1", parsed.id + assert_equal "claude-sonnet-4-6", parsed.model + assert_equal 1, parsed.messages.size + assert_equal "Hello\nworld", parsed.messages.first.output_text + assert_empty parsed.function_requests + end + + test "parses tool_use blocks into ChatFunctionRequest" do + raw = build_message( + id: "msg_2", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new( + type: :tool_use, + id: "toolu_abc", + name: "get_transactions", + input: { "page" => 1, "order" => "asc" } + ) + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_empty parsed.messages + assert_equal 1, parsed.function_requests.size + req = parsed.function_requests.first + assert_equal "toolu_abc", req.id + assert_equal "toolu_abc", req.call_id + assert_equal "get_transactions", req.function_name + assert_equal({ "page" => 1, "order" => "asc" }.to_json, req.function_args) + end + + test "parses mixed content blocks" do + raw = build_message( + id: "msg_3", + model: "claude-sonnet-4-6", + content: [ + OpenStruct.new(type: :text, text: "Looking up your transactions..."), + OpenStruct.new(type: :tool_use, id: "toolu_42", name: "get_transactions", input: {}) + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_equal 1, parsed.messages.size + assert_equal "Looking up your transactions...", parsed.messages.first.output_text + assert_equal 1, parsed.function_requests.size + assert_equal "toolu_42", parsed.function_requests.first.call_id + end + + test "accepts hash-shaped content blocks" do + raw = OpenStruct.new( + id: "msg_4", + model: "claude-sonnet-4-6", + content: [ + { type: :text, text: "from hash" } + ] + ) + + parsed = Provider::Anthropic::ChatParser.new(raw).parsed + + assert_equal "from hash", parsed.messages.first.output_text + end + + private + def build_message(id:, model:, content:) + OpenStruct.new(id: id, model: model, content: content) + end +end diff --git a/test/models/provider/anthropic/message_formatter_test.rb b/test/models/provider/anthropic/message_formatter_test.rb new file mode 100644 index 000000000..9b4b8914d --- /dev/null +++ b/test/models/provider/anthropic/message_formatter_test.rb @@ -0,0 +1,129 @@ +require "test_helper" + +class Provider::Anthropic::MessageFormatterTest < ActiveSupport::TestCase + test "builds a single user turn from prompt alone" do + formatter = Provider::Anthropic::MessageFormatter.new(prompt: "hi") + + messages = formatter.build + + assert_equal 1, messages.size + assert_equal({ role: "user", content: "hi" }, messages.first) + end + + test "skips empty content from history" do + history = [ stub_user_message("") ] + + messages = Provider::Anthropic::MessageFormatter.new(prompt: "next", conversation_history: history).build + + assert_equal [ { role: "user", content: "next" } ], messages + end + + test "renders text-only assistant history with no tool calls" do + history = [ + stub_user_message("first question"), + stub_assistant_message("first answer") + ] + + messages = Provider::Anthropic::MessageFormatter.new(prompt: "second question", conversation_history: history).build + + assert_equal({ role: "user", content: "first question" }, messages[0]) + assert_equal "assistant", messages[1][:role] + assert_equal [ { type: "text", text: "first answer" } ], messages[1][:content] + assert_equal({ role: "user", content: "second question" }, messages[2]) + end + + test "renders assistant tool_call history with paired tool_result turn" do + tool_call = stub_tool_call( + id: "toolu_1", + name: "get_net_worth", + arguments: { "currency" => "USD" }, + result: { "amount" => 12345, "currency" => "USD" } + ) + assistant = stub_assistant_message("Your net worth is $12,345.", tool_calls: [ tool_call ]) + history = [ stub_user_message("net worth?"), assistant ] + + messages = Provider::Anthropic::MessageFormatter.new(prompt: "anything else?", conversation_history: history).build + + assert_equal({ role: "user", content: "net worth?" }, messages[0]) + assert_equal "assistant", messages[1][:role] + assert_equal "tool_use", messages[1][:content].first[:type] + assert_equal "toolu_1", messages[1][:content].first[:id] + assert_equal "get_net_worth", messages[1][:content].first[:name] + assert_equal({ "currency" => "USD" }, messages[1][:content].first[:input]) + assert_equal "text", messages[1][:content].last[:type] + + assert_equal "user", messages[2][:role] + assert_equal "tool_result", messages[2][:content].first[:type] + assert_equal "toolu_1", messages[2][:content].first[:tool_use_id] + assert_equal({ "amount" => 12345, "currency" => "USD" }.to_json, messages[2][:content].first[:content]) + + assert_equal({ role: "user", content: "anything else?" }, messages[3]) + end + + test "renders in-flight function_results as assistant tool_use + user tool_result" do + formatter = Provider::Anthropic::MessageFormatter.new( + prompt: "what is my net worth?", + function_results: [ { + call_id: "toolu_42", + name: "get_net_worth", + arguments: { "currency" => "USD" }.to_json, + output: { amount: 99, currency: "USD" } + } ] + ) + + messages = formatter.build + + assert_equal({ role: "user", content: "what is my net worth?" }, messages[0]) + assert_equal "assistant", messages[1][:role] + assert_equal "tool_use", messages[1][:content].first[:type] + assert_equal "toolu_42", messages[1][:content].first[:id] + assert_equal({ "currency" => "USD" }, messages[1][:content].first[:input]) + + assert_equal "user", messages[2][:role] + assert_equal "tool_result", messages[2][:content].first[:type] + assert_equal "toolu_42", messages[2][:content].first[:tool_use_id] + assert_includes messages[2][:content].first[:content], "99" + end + + test "parses string arguments and nil outputs gracefully" do + formatter = Provider::Anthropic::MessageFormatter.new( + prompt: "go", + function_results: [ { + call_id: "toolu_x", + name: "noop", + arguments: "", + output: nil + } ] + ) + + messages = formatter.build + + assert_equal({}, messages[1][:content].first[:input]) + assert_equal "", messages[2][:content].first[:content] + end + + private + def stub_user_message(content) + msg = UserMessage.new(content: content, ai_model: "claude-sonnet-4-6") + msg.id = SecureRandom.uuid + msg + end + + def stub_assistant_message(content, tool_calls: []) + msg = AssistantMessage.new(content: content, ai_model: "claude-sonnet-4-6") + msg.id = SecureRandom.uuid + msg.stubs(:tool_calls).returns(tool_calls) + msg + end + + def stub_tool_call(id:, name:, arguments:, result:) + tc = ToolCall::Function.new( + function_name: name, + function_arguments: arguments, + function_result: result + ) + tc.stubs(:provider_call_id).returns(id) + tc.stubs(:provider_id).returns(id) + tc + end +end diff --git a/test/models/provider/anthropic_test.rb b/test/models/provider/anthropic_test.rb new file mode 100644 index 000000000..3ffe19033 --- /dev/null +++ b/test/models/provider/anthropic_test.rb @@ -0,0 +1,145 @@ +require "test_helper" + +class Provider::AnthropicTest < ActiveSupport::TestCase + include LLMInterfaceTest + + setup do + @subject = @anthropic = Provider::Anthropic.new( + ENV.fetch("ANTHROPIC_API_KEY", "test-anthropic-token") + ) + @subject_model = "claude-sonnet-4-6" + end + + test "provider_name returns Anthropic for standard provider" do + assert_equal "Anthropic", @subject.provider_name + end + + test "provider_name returns custom info for custom base_url" do + custom = Provider::Anthropic.new( + "test-token", + base_url: "https://bedrock.example.com/anthropic", + model: "claude-opus-4-7" + ) + + assert_equal "Custom Anthropic-compatible (https://bedrock.example.com/anthropic)", custom.provider_name + end + + test "supports_model? returns true for claude prefix" do + assert @subject.supports_model?("claude-sonnet-4-6") + assert @subject.supports_model?("claude-opus-4-7") + assert @subject.supports_model?("claude-haiku-4-5") + assert_not @subject.supports_model?("gpt-4.1") + end + + test "supported_models_description returns prefixes for standard provider" do + assert_equal "models starting with: claude", @subject.supported_models_description + end + + test "supports_pdf_processing? true for claude models" do + assert @subject.supports_pdf_processing?(model: "claude-sonnet-4-6") + assert_not @subject.supports_pdf_processing?(model: "gpt-4o") + end + + test "effective_model defers to ENV when set" do + ClimateControl.modify("ANTHROPIC_MODEL" => "claude-haiku-4-5") do + assert_equal "claude-haiku-4-5", Provider::Anthropic.effective_model + end + end + + test "effective_model falls back to default when nothing set" do + ClimateControl.modify("ANTHROPIC_MODEL" => nil) do + Setting.stubs(:anthropic_model).returns(nil) + assert_equal Provider::Anthropic::DEFAULT_MODEL, Provider::Anthropic.effective_model + end + end + + test "chat_response wraps Anthropic SDK errors in Provider::Anthropic::Error" do + fake_client = mock + @subject.instance_variable_set(:@client, fake_client) + messages = mock + fake_client.stubs(:messages).returns(messages) + messages.expects(:create).raises(StandardError.new("rate limit exceeded")) + + response = @subject.chat_response("hi", model: @subject_model) + + assert_not response.success? + assert_kind_of Provider::Anthropic::Error, response.error + assert_match(/rate limit/i, response.error.message) + end + + test "chat_response returns parsed ChatResponse on success" do + fake_client = stub_anthropic_client_with( + build_anthropic_message( + id: "msg_abc", + model: @subject_model, + text_blocks: [ "Hello there." ], + tool_use_blocks: [], + usage: { input_tokens: 12, output_tokens: 5 } + ) + ) + @subject.instance_variable_set(:@client, fake_client) + + response = @subject.chat_response("hi", model: @subject_model) + + assert response.success? + assert_equal "msg_abc", response.data.id + assert_equal @subject_model, response.data.model + assert_equal 1, response.data.messages.size + assert_equal "Hello there.", response.data.messages.first.output_text + assert_empty response.data.function_requests + end + + test "chat_response surfaces tool_use blocks as function_requests" do + fake_client = stub_anthropic_client_with( + build_anthropic_message( + id: "msg_xyz", + model: @subject_model, + text_blocks: [], + tool_use_blocks: [ { id: "toolu_1", name: "get_net_worth", input: { currency: "USD" } } ], + usage: { input_tokens: 20, output_tokens: 8 } + ) + ) + @subject.instance_variable_set(:@client, fake_client) + + response = @subject.chat_response( + "What is my net worth?", + model: @subject_model, + functions: [ { + name: "get_net_worth", + description: "Gets a user's net worth", + params_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, + strict: true + } ] + ) + + assert response.success? + assert_equal 1, response.data.function_requests.size + + req = response.data.function_requests.first + assert_equal "toolu_1", req.call_id + assert_equal "get_net_worth", req.function_name + assert_equal({ currency: "USD" }.to_json, req.function_args) + end + + private + def stub_anthropic_client_with(message) + messages = mock + messages.stubs(:create).returns(message) + client = mock + client.stubs(:messages).returns(messages) + client + end + + def build_anthropic_message(id:, model:, text_blocks:, tool_use_blocks:, usage:) + OpenStruct.new( + id: id, + model: model, + content: text_blocks.map { |t| OpenStruct.new(type: :text, text: t) } + + tool_use_blocks.map { |t| OpenStruct.new(type: :tool_use, id: t[:id], name: t[:name], input: t[:input]) }, + usage: OpenStruct.new( + input_tokens: usage[:input_tokens], + output_tokens: usage[:output_tokens] + ) + ) + end +end diff --git a/test/models/provider/registry_test.rb b/test/models/provider/registry_test.rb index e30c7d139..bcf0509c5 100644 --- a/test/models/provider/registry_test.rb +++ b/test/models/provider/registry_test.rb @@ -2,9 +2,14 @@ require "test_helper" class Provider::RegistryTest < ActiveSupport::TestCase test "providers filters out nil values when provider is not configured" do - # Ensure OpenAI is not configured - ClimateControl.modify("OPENAI_ACCESS_TOKEN" => nil) do + # Ensure no LLM provider is configured + ClimateControl.modify( + "OPENAI_ACCESS_TOKEN" => nil, + "ANTHROPIC_ACCESS_TOKEN" => nil, + "ANTHROPIC_API_KEY" => nil + ) do Setting.stubs(:openai_access_token).returns(nil) + Setting.stubs(:anthropic_access_token).returns(nil) registry = Provider::Registry.for_concept(:llm) @@ -45,6 +50,44 @@ class Provider::RegistryTest < ActiveSupport::TestCase end end + test "anthropic provider returns nil when no credentials are configured" do + ClimateControl.modify( + "ANTHROPIC_ACCESS_TOKEN" => nil, + "ANTHROPIC_API_KEY" => nil + ) do + Setting.stubs(:anthropic_access_token).returns(nil) + + assert_nil Provider::Registry.get_provider(:anthropic) + end + end + + test "anthropic provider initializes from ANTHROPIC_API_KEY env" do + ClimateControl.modify("ANTHROPIC_API_KEY" => "sk-ant-test", "ANTHROPIC_ACCESS_TOKEN" => nil) do + Setting.stubs(:anthropic_access_token).returns(nil) + + provider = Provider::Registry.get_provider(:anthropic) + + assert_instance_of Provider::Anthropic, provider + end + end + + test "anthropic provider falls back to Setting when ENV is empty" do + ClimateControl.modify( + "ANTHROPIC_ACCESS_TOKEN" => "", + "ANTHROPIC_API_KEY" => "", + "ANTHROPIC_BASE_URL" => "", + "ANTHROPIC_MODEL" => "" + ) do + Setting.stubs(:anthropic_access_token).returns("sk-ant-from-setting") + Setting.stubs(:anthropic_base_url).returns(nil) + Setting.stubs(:anthropic_model).returns(nil) + + provider = Provider::Registry.get_provider(:anthropic) + + assert_instance_of Provider::Anthropic, provider + end + end + test "openai provider falls back to Setting when ENV is empty string" do # Mock ENV to return empty string (common in Docker/env files) # Use stub_env helper which properly stubs ENV access