From 8cd109a5b248dce05c40ac6e05b0e38c6534279b Mon Sep 17 00:00:00 2001 From: soky srm Date: Wed, 22 Oct 2025 16:02:50 +0200 Subject: [PATCH] Implement support for generic OpenAI api (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement support for generic OpenAI api - Implements support to route requests to any openAI capable provider ( Deepsek, Qwen, VLLM, LM Studio, Ollama ). - Keeps support for pure OpenAI and uses the new better responses api - Uses the /chat/completions api for the generic providers - If uri_base is not set, uses default implementation. * Fix json handling and indentation * Fix linter error indent * Fix tests to set env vars * Fix updating settings * Change to prefix checking for OAI models * FIX check model if custom uri is set * Change chat to sync calls Some local models don't support streaming. Revert to sync calls for generic OAI api * Fix tests * Fix tests * Fix for gpt5 message extraction - Finds the message output by filtering for "type" == "message" instead of assuming it's at index 0 - Safely extracts the text using safe navigation operators (&.) - Raises a clear error if no message content is found - Parses the JSON as before * Add more langfuse logging - Add Langfuse to auto categorizer and merchant detector - Fix monitoring on streaming chat responses - Add Langfuse traces also for model errors now * Update app/models/provider/openai.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: soky srm * handle nil function results explicitly * Exposing some config vars. * Linter and nitpick comments * Drop back to `gpt-4.1` as default for now * Linter * Fix for strict tool schema in Gemini - This fixes tool calling in Gemini OpenAI api - Fix for getTransactions function, page size is not used. --------- Signed-off-by: soky srm Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Juan José Mata --- .env.example | 9 + .env.local.example | 21 +- .../settings/hostings_controller.rb | 25 +- app/helpers/application_helper.rb | 4 + .../assistant/function/get_transactions.rb | 2 +- app/models/assistant/responder.rb | 15 +- app/models/provider/llm_concept.rb | 2 +- app/models/provider/openai.rb | 419 ++++++++++++++---- .../provider/openai/auto_categorizer.rb | 118 ++++- .../provider/openai/auto_merchant_detector.rb | 114 ++++- app/models/provider/openai/chat_config.rb | 12 +- .../provider/openai/chat_stream_parser.rb | 5 +- .../provider/openai/generic_chat_parser.rb | 60 +++ app/models/provider/registry.rb | 10 +- app/models/setting.rb | 16 + app/models/tool_call/function.rb | 13 + app/views/messages/_chat_form.html.erb | 2 +- app/views/settings/ai_prompts/show.html.erb | 6 +- .../hostings/_openai_settings.html.erb | 26 +- config/locales/views/settings/hostings/en.yml | 12 +- .../settings/hostings_controller_test.rb | 44 ++ test/models/assistant_test.rb | 7 +- test/models/setting_test.rb | 64 +++ test/system/chats_test.rb | 64 +-- 24 files changed, 875 insertions(+), 195 deletions(-) create mode 100644 app/models/provider/openai/generic_chat_parser.rb create mode 100644 test/models/setting_test.rb diff --git a/.env.example b/.env.example index ae54687a9..c6fc1173f 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,15 @@ SECRET_KEY_BASE=secret-value # Optional self-hosting vars # -------------------------------------------------------------------------------------------------------- +# Optional: OpenAI-compatible API endpoint config +OPENAI_ACCESS_TOKEN= +OPENAI_MODEL= +OPENAI_URI_BASE= + +# Optional: Langfuse config +LANGFUSE_HOST=https://cloud.langfuse.com +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= # Optional: Twelve Data API Key for exchange rates + stock prices # (you can also set this in your self-hosted settings page) diff --git a/.env.local.example b/.env.local.example index 0ada891c9..dc20f5a84 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,13 +1,22 @@ # To enable / disable self-hosting features. -SELF_HOSTED=true +SELF_HOSTED = true # Enable Twelve market data (careful, this will use your API credits) -TWELVE_DATA_API_KEY= +TWELVE_DATA_API_KEY = + +# OpenAI-compatible API endpoint config +OPENAI_ACCESS_TOKEN = +OPENAI_URI_BASE = +OPENAI_MODEL = + +# (example: LM Studio/Docker config) OpenAI-compatible API endpoint config +# OPENAI_URI_BASE = http://host.docker.internal:1234/ +# OPENAI_MODEL = qwen/qwen3-vl-4b # Langfuse config -LANGFUSE_PUBLIC_KEY= -LANGFUSE_SECRET_KEY= -LANGFUSE_HOST=https://cloud.langfuse.com +LANGFUSE_PUBLIC_KEY = +LANGFUSE_SECRET_KEY = +LANGFUSE_HOST = https://cloud.langfuse.com # Set to `true` to get error messages rendered in the /chats UI -AI_DEBUG_MODE= +AI_DEBUG_MODE = diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index a3da5d0f4..c86a6dac0 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -31,9 +31,6 @@ class Settings::HostingsController < ApplicationController Setting.twelve_data_api_key = hosting_params[:twelve_data_api_key] end - if hosting_params.key?(:openai_access_token) - Setting.openai_access_token = hosting_params[:openai_access_token] - end if hosting_params.key?(:openai_access_token) token_param = hosting_params[:openai_access_token].to_s.strip # Ignore blanks and redaction placeholders to prevent accidental overwrite @@ -42,9 +39,25 @@ class Settings::HostingsController < ApplicationController end end + # Validate OpenAI configuration before updating + if hosting_params.key?(:openai_uri_base) || hosting_params.key?(:openai_model) + Setting.validate_openai_config!( + uri_base: hosting_params[:openai_uri_base], + model: hosting_params[:openai_model] + ) + end + + if hosting_params.key?(:openai_uri_base) + Setting.openai_uri_base = hosting_params[:openai_uri_base] + end + + if hosting_params.key?(:openai_model) + Setting.openai_model = hosting_params[:openai_model] + end + redirect_to settings_hosting_path, notice: t(".success") - rescue ActiveRecord::RecordInvalid => error - flash.now[:alert] = t(".failure") + rescue Setting::ValidationError => error + flash.now[:alert] = error.message render :show, status: :unprocessable_entity end @@ -55,7 +68,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token) + params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model) end def ensure_admin diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3ec81e824..bfd6bc208 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -83,6 +83,10 @@ module ApplicationHelper cookies[:admin] == "true" end + def default_ai_model + ENV.fetch("OPENAI_MODEL", Setting.openai_model.presence || Provider::Openai::DEFAULT_MODEL) + end + # Renders Markdown text using Redcarpet def markdown(text) return "" if text.blank? diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/function/get_transactions.rb index 7ad081fbc..853f39e80 100644 --- a/app/models/assistant/function/get_transactions.rb +++ b/app/models/assistant/function/get_transactions.rb @@ -68,7 +68,7 @@ class Assistant::Function::GetTransactions < Assistant::Function def params_schema build_schema( - required: [ "order", "page", "page_size" ], + required: [ "order", "page" ], properties: { page: { type: "integer", diff --git a/app/models/assistant/responder.rb b/app/models/assistant/responder.rb index dffcf4dd0..3bc4ec92a 100644 --- a/app/models/assistant/responder.rb +++ b/app/models/assistant/responder.rb @@ -11,6 +11,9 @@ class Assistant::Responder end def respond(previous_response_id: nil) + # Track whether response was handled by streamer + response_handled = false + # For the first response streamer = proc do |chunk| case chunk.type @@ -18,6 +21,7 @@ class Assistant::Responder emit(:output_text, chunk.data) when "response" response = chunk.data + response_handled = true if response.function_requests.any? handle_follow_up_response(response) @@ -27,7 +31,16 @@ class Assistant::Responder end end - get_llm_response(streamer: streamer, previous_response_id: previous_response_id) + response = get_llm_response(streamer: streamer, previous_response_id: previous_response_id) + + # For synchronous (non-streaming) responses, handle function requests if not already handled by streamer + unless response_handled + if response && response.function_requests.any? + handle_follow_up_response(response) + elsif response + emit(:response, { id: response.id }) + end + end end private diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index be0b46a07..dbd6f0458 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -14,7 +14,7 @@ module Provider::LlmConcept end ChatMessage = Data.define(:id, :output_text) - ChatStreamChunk = Data.define(:type, :data) + ChatStreamChunk = Data.define(:type, :data, :usage) ChatResponse = Data.define(:id, :model, :messages, :function_requests) ChatFunctionRequest = Data.define(:id, :call_id, :function_name, :function_args) diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index ddde08bad..1955bdc9b 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -4,33 +4,55 @@ class Provider::Openai < Provider # Subclass so errors caught in this provider are raised as Provider::Openai::Error Error = Class.new(Provider::Error) - MODELS = %w[gpt-4.1] + # Supported OpenAI model prefixes (e.g., "gpt-4" matches "gpt-4", "gpt-4.1", "gpt-4-turbo", etc.) + DEFAULT_OPENAI_MODEL_PREFIXES = %w[gpt-4 gpt-5 o1 o3] + DEFAULT_MODEL = "gpt-4.1" - def initialize(access_token) - @client = ::OpenAI::Client.new(access_token: access_token) + def initialize(access_token, uri_base: nil, model: nil) + client_options = { access_token: access_token } + client_options[:uri_base] = uri_base if uri_base.present? + + @client = ::OpenAI::Client.new(**client_options) + @uri_base = uri_base + if custom_provider? && model.blank? + raise Error, "Model is required when using a custom OpenAI‑compatible provider" + end + @default_model = model.presence || DEFAULT_MODEL end def supports_model?(model) - MODELS.include?(model) + # If using custom uri_base, support any model + return true if custom_provider? + + # Otherwise, check if model starts with any supported OpenAI prefix + DEFAULT_OPENAI_MODEL_PREFIXES.any? { |prefix| model.start_with?(prefix) } + end + + def custom_provider? + @uri_base.present? end def auto_categorize(transactions: [], user_categories: [], model: "") with_provider_response do raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25 + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.auto_categorize", + input: { transactions: transactions, user_categories: user_categories } + ) + result = AutoCategorizer.new( client, - model: model, + model: effective_model, transactions: transactions, - user_categories: user_categories + user_categories: user_categories, + custom_provider: custom_provider?, + langfuse_trace: trace ).auto_categorize - log_langfuse_generation( - name: "auto_categorize", - model: model, - input: { transactions: transactions, user_categories: user_categories }, - output: result.map(&:to_h) - ) + trace&.update(output: result.map(&:to_h)) result end @@ -40,19 +62,23 @@ class Provider::Openai < Provider with_provider_response do raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.auto_detect_merchants", + input: { transactions: transactions, user_merchants: user_merchants } + ) + result = AutoMerchantDetector.new( client, - model: model, + model: effective_model, transactions: transactions, - user_merchants: user_merchants + user_merchants: user_merchants, + custom_provider: custom_provider?, + langfuse_trace: trace ).auto_detect_merchants - log_langfuse_generation( - name: "auto_detect_merchants", - model: model, - input: { transactions: transactions, user_merchants: user_merchants }, - output: result.map(&:to_h) - ) + trace&.update(output: result.map(&:to_h)) result end @@ -69,97 +95,316 @@ class Provider::Openai < Provider session_id: nil, user_identifier: nil ) - with_provider_response do - chat_config = ChatConfig.new( - functions: functions, - function_results: function_results - ) - - collected_chunks = [] - - # Proxy that converts raw stream to "LLM Provider concept" stream - stream_proxy = if streamer.present? - proc do |chunk| - parsed_chunk = ChatStreamParser.new(chunk).parsed - - unless parsed_chunk.nil? - streamer.call(parsed_chunk) - collected_chunks << parsed_chunk - end - end - else - nil - end - - input_payload = chat_config.build_input(prompt) - - raw_response = client.responses.create(parameters: { + if custom_provider? + generic_chat_response( + prompt: prompt, model: model, - input: input_payload, instructions: instructions, - tools: chat_config.tools, + functions: functions, + function_results: function_results, + streamer: streamer, + session_id: session_id, + user_identifier: user_identifier + ) + else + native_chat_response( + prompt: prompt, + model: model, + instructions: instructions, + functions: functions, + function_results: function_results, + streamer: streamer, previous_response_id: previous_response_id, - stream: stream_proxy - }) - - # If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search - # for the "response chunk" in the stream and return it (it is already parsed) - if stream_proxy.present? - response_chunk = collected_chunks.find { |chunk| chunk.type == "response" } - response = response_chunk.data - log_langfuse_generation( - name: "chat_response", - model: model, - input: input_payload, - output: response.messages.map(&:output_text).join("\n"), - session_id: session_id, - user_identifier: user_identifier - ) - response - else - parsed = ChatParser.new(raw_response).parsed - log_langfuse_generation( - name: "chat_response", - model: model, - input: input_payload, - output: parsed.messages.map(&:output_text).join("\n"), - usage: raw_response["usage"], - session_id: session_id, - user_identifier: user_identifier - ) - parsed - end + session_id: session_id, + user_identifier: user_identifier + ) end end private attr_reader :client + def native_chat_response( + prompt:, + model:, + instructions: nil, + functions: [], + function_results: [], + streamer: nil, + previous_response_id: nil, + session_id: nil, + user_identifier: nil + ) + with_provider_response do + chat_config = ChatConfig.new( + functions: functions, + function_results: function_results + ) + + collected_chunks = [] + + # Proxy that converts raw stream to "LLM Provider concept" stream + stream_proxy = if streamer.present? + proc do |chunk| + parsed_chunk = ChatStreamParser.new(chunk).parsed + + unless parsed_chunk.nil? + streamer.call(parsed_chunk) + collected_chunks << parsed_chunk + end + end + else + nil + end + + input_payload = chat_config.build_input(prompt) + + begin + raw_response = client.responses.create(parameters: { + model: model, + input: input_payload, + instructions: instructions, + tools: chat_config.tools, + previous_response_id: previous_response_id, + stream: stream_proxy + }) + + # If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search + # for the "response chunk" in the stream and return it (it is already parsed) + if stream_proxy.present? + response_chunk = collected_chunks.find { |chunk| chunk.type == "response" } + response = response_chunk.data + usage = response_chunk.usage + log_langfuse_generation( + name: "chat_response", + model: model, + input: input_payload, + output: response.messages.map(&:output_text).join("\n"), + usage: usage, + session_id: session_id, + user_identifier: user_identifier + ) + response + else + parsed = ChatParser.new(raw_response).parsed + log_langfuse_generation( + name: "chat_response", + model: model, + input: input_payload, + output: parsed.messages.map(&:output_text).join("\n"), + usage: raw_response["usage"], + session_id: session_id, + user_identifier: user_identifier + ) + parsed + end + rescue => e + log_langfuse_generation( + name: "chat_response", + model: model, + input: input_payload, + error: e, + session_id: session_id, + user_identifier: user_identifier + ) + raise + end + end + end + + def generic_chat_response( + prompt:, + model:, + instructions: nil, + functions: [], + function_results: [], + streamer: nil, + session_id: nil, + user_identifier: nil + ) + with_provider_response do + messages = build_generic_messages( + prompt: prompt, + instructions: instructions, + function_results: function_results + ) + + tools = build_generic_tools(functions) + + # Force synchronous calls for generic chat (streaming not supported for custom providers) + params = { + model: model, + messages: messages + } + params[:tools] = tools if tools.present? + + begin + raw_response = client.chat(parameters: params) + + parsed = GenericChatParser.new(raw_response).parsed + + log_langfuse_generation( + name: "chat_response", + model: model, + input: messages, + output: parsed.messages.map(&:output_text).join("\n"), + usage: raw_response["usage"], + session_id: session_id, + user_identifier: user_identifier + ) + + # If a streamer was provided, manually call it with the parsed response + # to maintain the same contract as the streaming version + if streamer.present? + # Emit output_text chunks for each message + parsed.messages.each do |message| + if message.output_text.present? + streamer.call(Provider::LlmConcept::ChatStreamChunk.new(type: "output_text", data: message.output_text, usage: nil)) + end + end + + # Emit response chunk + streamer.call(Provider::LlmConcept::ChatStreamChunk.new(type: "response", data: parsed, usage: raw_response["usage"])) + end + + parsed + rescue => e + log_langfuse_generation( + name: "chat_response", + model: model, + input: messages, + error: e, + session_id: session_id, + user_identifier: user_identifier + ) + raise + end + end + end + + def build_generic_messages(prompt:, instructions: nil, function_results: []) + messages = [] + + # Add system message if instructions present + if instructions.present? + messages << { role: "system", content: instructions } + end + + # Add user prompt + messages << { role: "user", content: prompt } + + # If there are function results, we need to add the assistant message that made the tool calls + # followed by the tool messages with the results + if function_results.any? + # Build assistant message with tool_calls + tool_calls = function_results.map do |fn_result| + { + id: fn_result[:call_id], + type: "function", + function: { + name: fn_result[:name], + arguments: fn_result[:arguments] + } + } + end + + messages << { + role: "assistant", + content: nil, + tool_calls: tool_calls + } + + # Add function results as tool messages + function_results.each do |fn_result| + # Convert output to JSON string if it's not already a string + # OpenAI API requires content to be either a string or array of objects + # Handle nil explicitly to avoid serializing to "null" + output = fn_result[:output] + content = if output.nil? + "" + elsif output.is_a?(String) + output + else + output.to_json + end + + messages << { + role: "tool", + tool_call_id: fn_result[:call_id], + name: fn_result[:name], + content: content + } + end + end + + messages + end + + def build_generic_tools(functions) + return [] if functions.blank? + + functions.map do |fn| + { + type: "function", + function: { + name: fn[:name], + description: fn[:description], + parameters: fn[:params_schema], + strict: fn[:strict] + } + } + end + end + def langfuse_client return unless ENV["LANGFUSE_PUBLIC_KEY"].present? && ENV["LANGFUSE_SECRET_KEY"].present? @langfuse_client = Langfuse.new end - def log_langfuse_generation(name:, model:, input:, output:, usage: nil, session_id: nil, user_identifier: nil) + def create_langfuse_trace(name:, input:, session_id: nil, user_identifier: nil) return unless langfuse_client - trace = langfuse_client.trace( + langfuse_client.trace( + name: name, + input: input, + session_id: session_id, + user_id: user_identifier + ) + rescue => e + Rails.logger.warn("Langfuse trace creation failed: #{e.message}") + nil + end + + def log_langfuse_generation(name:, model:, input:, output: nil, usage: nil, error: nil, session_id: nil, user_identifier: nil) + return unless langfuse_client + + trace = create_langfuse_trace( name: "openai.#{name}", input: input, session_id: session_id, - user_id: user_identifier + user_identifier: user_identifier ) - trace.generation( + + generation = trace&.generation( name: name, model: model, - input: input, - output: output, - usage: usage, - session_id: session_id, - user_id: user_identifier + input: input ) - trace.update(output: output) + + if error + generation&.end( + output: { error: error.message, details: error.respond_to?(:details) ? error.details : nil }, + level: "ERROR" + ) + trace&.update( + output: { error: error.message }, + level: "ERROR" + ) + else + generation&.end(output: output, usage: usage) + trace&.update(output: output) + end rescue => e Rails.logger.warn("Langfuse logging failed: #{e.message}") end diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 63e6e1e21..e4e54544d 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -1,31 +1,19 @@ class Provider::Openai::AutoCategorizer - DEFAULT_MODEL = "gpt-4.1-mini" - - def initialize(client, model: "", transactions: [], user_categories: []) + def initialize(client, model: "", transactions: [], user_categories: [], custom_provider: false, langfuse_trace: nil) @client = client @model = model @transactions = transactions @user_categories = user_categories + @custom_provider = custom_provider + @langfuse_trace = langfuse_trace end def auto_categorize - response = client.responses.create(parameters: { - model: model.presence || DEFAULT_MODEL, - input: [ { role: "developer", content: developer_message } ], - text: { - format: { - type: "json_schema", - name: "auto_categorize_personal_finance_transactions", - strict: true, - schema: json_schema - } - }, - instructions: instructions - }) - - Rails.logger.info("Tokens used to auto-categorize transactions: #{response.dig("usage").dig("total_tokens")}") - - build_response(extract_categorizations(response)) + if custom_provider + auto_categorize_openai_generic + else + auto_categorize_openai_native + end end def instructions @@ -50,7 +38,75 @@ class Provider::Openai::AutoCategorizer end private - attr_reader :client, :model, :transactions, :user_categories + + def auto_categorize_openai_native + span = langfuse_trace&.span(name: "auto_categorize_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + transactions: transactions, + user_categories: user_categories + }) + + response = client.responses.create(parameters: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + input: [ { role: "developer", content: developer_message } ], + text: { + format: { + type: "json_schema", + name: "auto_categorize_personal_finance_transactions", + strict: true, + schema: json_schema + } + }, + instructions: instructions + }) + Rails.logger.info("Tokens used to auto-categorize transactions: #{response.dig("usage", "total_tokens")}") + + categorizations = extract_categorizations_native(response) + result = build_response(categorizations) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def auto_categorize_openai_generic + span = langfuse_trace&.span(name: "auto_categorize_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + transactions: transactions, + user_categories: user_categories + }) + + response = client.chat(parameters: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + messages: [ + { role: "system", content: instructions }, + { role: "user", content: developer_message } + ], + response_format: { + type: "json_schema", + json_schema: { + name: "auto_categorize_personal_finance_transactions", + strict: true, + schema: json_schema + } + } + }) + + Rails.logger.info("Tokens used to auto-categorize transactions: #{response.dig("usage", "total_tokens")}") + + categorizations = extract_categorizations_generic(response) + result = build_response(categorizations) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + attr_reader :client, :model, :transactions, :user_categories, :custom_provider, :langfuse_trace AutoCategorization = Provider::LlmConcept::AutoCategorization @@ -69,9 +125,23 @@ class Provider::Openai::AutoCategorizer category_name end - def extract_categorizations(response) - response_json = JSON.parse(response.dig("output")[0].dig("content")[0].dig("text")) - response_json.dig("categorizations") + def extract_categorizations_native(response) + # Find the message output (not reasoning output) + message_output = response["output"]&.find { |o| o["type"] == "message" } + raw = message_output&.dig("content", 0, "text") + + raise Provider::Openai::Error, "No message content found in response" if raw.nil? + + JSON.parse(raw).dig("categorizations") + rescue JSON::ParserError => e + raise Provider::Openai::Error, "Invalid JSON in native categorization: #{e.message}" + end + + def extract_categorizations_generic(response) + raw = response.dig("choices", 0, "message", "content") + JSON.parse(raw).dig("categorizations") + rescue JSON::ParserError => e + raise Provider::Openai::Error, "Invalid JSON in generic categorization: #{e.message}" end def json_schema diff --git a/app/models/provider/openai/auto_merchant_detector.rb b/app/models/provider/openai/auto_merchant_detector.rb index d724d4281..c0579fe94 100644 --- a/app/models/provider/openai/auto_merchant_detector.rb +++ b/app/models/provider/openai/auto_merchant_detector.rb @@ -1,31 +1,19 @@ class Provider::Openai::AutoMerchantDetector - DEFAULT_MODEL = "gpt-4.1-mini" - - def initialize(client, model: "", transactions:, user_merchants:) + def initialize(client, model: "", transactions:, user_merchants:, custom_provider: false, langfuse_trace: nil) @client = client @model = model @transactions = transactions @user_merchants = user_merchants + @custom_provider = custom_provider + @langfuse_trace = langfuse_trace end def auto_detect_merchants - response = client.responses.create(parameters: { - model: model.presence || DEFAULT_MODEL, - input: [ { role: "developer", content: developer_message } ], - text: { - format: { - type: "json_schema", - name: "auto_detect_personal_finance_merchants", - strict: true, - schema: json_schema - } - }, - instructions: instructions - }) - - Rails.logger.info("Tokens used to auto-detect merchants: #{response.dig("usage").dig("total_tokens")}") - - build_response(extract_categorizations(response)) + if custom_provider + auto_detect_merchants_openai_generic + else + auto_detect_merchants_openai_native + end end def instructions @@ -70,7 +58,76 @@ class Provider::Openai::AutoMerchantDetector end private - attr_reader :client, :model, :transactions, :user_merchants + + def auto_detect_merchants_openai_native + span = langfuse_trace&.span(name: "auto_detect_merchants_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + transactions: transactions, + user_merchants: user_merchants + }) + + response = client.responses.create(parameters: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + input: [ { role: "developer", content: developer_message } ], + text: { + format: { + type: "json_schema", + name: "auto_detect_personal_finance_merchants", + strict: true, + schema: json_schema + } + }, + instructions: instructions + }) + + Rails.logger.info("Tokens used to auto-detect merchants: #{response.dig("usage", "total_tokens")}") + + merchants = extract_merchants_native(response) + result = build_response(merchants) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def auto_detect_merchants_openai_generic + span = langfuse_trace&.span(name: "auto_detect_merchants_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + transactions: transactions, + user_merchants: user_merchants + }) + + response = client.chat(parameters: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + messages: [ + { role: "system", content: instructions }, + { role: "user", content: developer_message } + ], + response_format: { + type: "json_schema", + json_schema: { + name: "auto_detect_personal_finance_merchants", + strict: true, + schema: json_schema + } + } + }) + + Rails.logger.info("Tokens used to auto-detect merchants: #{response.dig("usage", "total_tokens")}") + + merchants = extract_merchants_generic(response) + result = build_response(merchants) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + attr_reader :client, :model, :transactions, :user_merchants, :custom_provider, :langfuse_trace AutoDetectedMerchant = Provider::LlmConcept::AutoDetectedMerchant @@ -90,9 +147,18 @@ class Provider::Openai::AutoMerchantDetector ai_value end - def extract_categorizations(response) - response_json = JSON.parse(response.dig("output")[0].dig("content")[0].dig("text")) - response_json.dig("merchants") + def extract_merchants_native(response) + raw = response.dig("output", 0, "content", 0, "text") + JSON.parse(raw).dig("merchants") + rescue JSON::ParserError => e + raise Provider::Openai::Error, "Invalid JSON in native merchant detection: #{e.message}" + end + + def extract_merchants_generic(response) + raw = response.dig("choices", 0, "message", "content") + JSON.parse(raw).dig("merchants") + rescue JSON::ParserError => e + raise Provider::Openai::Error, "Invalid JSON in generic merchant detection: #{e.message}" end def json_schema diff --git a/app/models/provider/openai/chat_config.rb b/app/models/provider/openai/chat_config.rb index 5aca6aeb4..5e98a66ce 100644 --- a/app/models/provider/openai/chat_config.rb +++ b/app/models/provider/openai/chat_config.rb @@ -18,10 +18,20 @@ class Provider::Openai::ChatConfig def build_input(prompt) results = function_results.map do |fn_result| + # Handle nil explicitly to avoid serializing to "null" + output = fn_result[:output] + serialized_output = if output.nil? + "" + elsif output.is_a?(String) + output + else + output.to_json + end + { type: "function_call_output", call_id: fn_result[:call_id], - output: fn_result[:output].to_json + output: serialized_output } end diff --git a/app/models/provider/openai/chat_stream_parser.rb b/app/models/provider/openai/chat_stream_parser.rb index 0b91940cc..dcfe44207 100644 --- a/app/models/provider/openai/chat_stream_parser.rb +++ b/app/models/provider/openai/chat_stream_parser.rb @@ -10,10 +10,11 @@ class Provider::Openai::ChatStreamParser case type when "response.output_text.delta", "response.refusal.delta" - Chunk.new(type: "output_text", data: object.dig("delta")) + Chunk.new(type: "output_text", data: object.dig("delta"), usage: nil) when "response.completed" raw_response = object.dig("response") - Chunk.new(type: "response", data: parse_response(raw_response)) + usage = raw_response.dig("usage") + Chunk.new(type: "response", data: parse_response(raw_response), usage: usage) end end diff --git a/app/models/provider/openai/generic_chat_parser.rb b/app/models/provider/openai/generic_chat_parser.rb new file mode 100644 index 000000000..45057af00 --- /dev/null +++ b/app/models/provider/openai/generic_chat_parser.rb @@ -0,0 +1,60 @@ +class Provider::Openai::GenericChatParser + Error = Class.new(StandardError) + + def initialize(object) + @object = object + end + + def parsed + ChatResponse.new( + id: response_id, + model: response_model, + messages: messages, + function_requests: function_requests + ) + end + + private + attr_reader :object + + ChatResponse = Provider::LlmConcept::ChatResponse + ChatMessage = Provider::LlmConcept::ChatMessage + ChatFunctionRequest = Provider::LlmConcept::ChatFunctionRequest + + def response_id + object.dig("id") + end + + def response_model + object.dig("model") + end + + def message_choice + object.dig("choices", 0, "message") + end + + def messages + content = message_choice&.dig("content") + return [] if content.blank? + + [ + ChatMessage.new( + id: response_id, + output_text: content + ) + ] + end + + def function_requests + tool_calls = message_choice&.dig("tool_calls") || [] + + tool_calls.map do |tool_call| + ChatFunctionRequest.new( + id: tool_call.dig("id"), + call_id: tool_call.dig("id"), + function_name: tool_call.dig("function", "name"), + function_args: tool_call.dig("function", "arguments") + ) + end + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index aeb77a81a..3f5795d2b 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -65,7 +65,15 @@ class Provider::Registry return nil unless access_token.present? - Provider::Openai.new(access_token) + uri_base = ENV.fetch("OPENAI_URI_BASE", Setting.openai_uri_base) + model = ENV.fetch("OPENAI_MODEL", Setting.openai_model) + + if uri_base.present? && model.blank? + Rails.logger.error("Custom OpenAI provider configured without a model; please set OPENAI_MODEL or Setting.openai_model") + return nil + end + + Provider::Openai.new(access_token, uri_base: uri_base, model: model) end end diff --git a/app/models/setting.rb b/app/models/setting.rb index 1be168aa9..fbbe65256 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -1,11 +1,27 @@ # Dynamic settings the user can change within the app (helpful for self-hosting) class Setting < RailsSettings::Base + class ValidationError < StandardError; end + cache_prefix { "v1" } field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"] field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"] + field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] + field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"] field :require_invite_for_signup, type: :boolean, default: false field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" + + # Validates OpenAI configuration requires model when custom URI base is set + def self.validate_openai_config!(uri_base: nil, model: nil) + # Use provided values or current settings + uri_base_value = uri_base.nil? ? openai_uri_base : uri_base + model_value = model.nil? ? openai_model : model + + # If custom URI base is set, model must also be set + if uri_base_value.present? && model_value.blank? + raise ValidationError, "OpenAI model is required when custom URI base is configured" + end + end end diff --git a/app/models/tool_call/function.rb b/app/models/tool_call/function.rb index 8cdccce11..e91dc5aae 100644 --- a/app/models/tool_call/function.rb +++ b/app/models/tool_call/function.rb @@ -18,7 +18,20 @@ class ToolCall::Function < ToolCall def to_result { call_id: provider_call_id, + name: function_name, + arguments: function_arguments, output: function_result } end + + def to_tool_call + { + id: provider_call_id, + type: "function", + function: { + name: function_name, + arguments: function_arguments + } + } + end end diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb index 0f7c1ce9f..a7aeec81b 100644 --- a/app/views/messages/_chat_form.html.erb +++ b/app/views/messages/_chat_form.html.erb @@ -8,7 +8,7 @@ data: { chat_target: "form" } do |f| %> <%# In the future, this will be a dropdown with different AI models %> - <%= f.hidden_field :ai_model, value: "gpt-4.1" %> + <%= f.hidden_field :ai_model, value: default_ai_model %> <%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint, class: "w-full border-0 focus:ring-0 text-sm resize-none px-1 bg-transparent", diff --git a/app/views/settings/ai_prompts/show.html.erb b/app/views/settings/ai_prompts/show.html.erb index 230deb1a0..518ade36e 100644 --- a/app/views/settings/ai_prompts/show.html.erb +++ b/app/views/settings/ai_prompts/show.html.erb @@ -26,7 +26,7 @@ <%= t(".prompt_instructions") %> <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> -
[<%= Provider::Openai::MODELS.join(", ") %>]
+
[<%= Provider::Openai::DEFAULT_MODEL %>]
<%= @assistant_config[:instructions] %>
@@ -54,7 +54,7 @@ <%= t(".prompt_instructions") %> <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> -
[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]
+
[<%= Provider::Openai::DEFAULT_MODEL %>]
<%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %>
@@ -82,7 +82,7 @@ <%= t(".prompt_instructions") %> <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> -
[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]
+
[<%= Provider::Openai::DEFAULT_MODEL %>]
<%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %>
diff --git a/app/views/settings/hostings/_openai_settings.html.erb b/app/views/settings/hostings/_openai_settings.html.erb index 0fcdfaedd..59bfba8b1 100644 --- a/app/views/settings/hostings/_openai_settings.html.erb +++ b/app/views/settings/hostings/_openai_settings.html.erb @@ -16,8 +16,8 @@ "auto-submit-form-trigger-event-value": "blur" } do |form| %> <%= form.password_field :openai_access_token, - label: t(".label"), - placeholder: t(".placeholder"), + label: t(".access_token_label"), + placeholder: t(".access_token_placeholder"), value: (Setting.openai_access_token.present? ? "********" : nil), autocomplete: "off", autocapitalize: "none", @@ -25,5 +25,27 @@ inputmode: "text", disabled: ENV["OPENAI_ACCESS_TOKEN"].present?, data: { "auto-submit-form-target": "auto" } %> + + <%= form.text_field :openai_uri_base, + label: t(".uri_base_label"), + placeholder: t(".uri_base_placeholder"), + value: Setting.openai_uri_base, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "url", + disabled: ENV["OPENAI_URI_BASE"].present?, + data: { "auto-submit-form-target": "auto" } %> + + <%= form.text_field :openai_model, + label: t(".model_label"), + placeholder: t(".model_placeholder"), + value: Setting.openai_model, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["OPENAI_MODEL"].present?, + data: { "auto-submit-form-target": "auto" } %> <% end %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 1c3f8a086..551eea0a0 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -27,10 +27,14 @@ en: placeholder: Enter your Client ID here title: Brand Fetch openai_settings: - description: Enter the access token provided by OpenAI - env_configured_message: Successfully configured through the OPENAI_ACCESS_TOKEN environment variable. - label: Access Token - placeholder: Enter your access token here + description: Enter the access token and optionally configure a custom OpenAI-compatible provider + env_configured_message: Successfully configured through environment variables. + access_token_label: Access Token + access_token_placeholder: Enter your access token here + uri_base_label: API Base URL (Optional) + uri_base_placeholder: "https://api.openai.com/v1 (default)" + model_label: Model (Optional) + model_placeholder: "gpt-4.1 (default)" title: OpenAI twelve_data_settings: api_calls_used: "%{used} / %{limit} API daily calls used (%{percentage})" diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 72d92847c..ecc380ffe 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -20,6 +20,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "cannot edit when self hosting is disabled" do + @provider.stubs(:usage).returns(@usage_response) + with_env_overrides SELF_HOSTED: "false" do get settings_hosting_url assert_response :forbidden @@ -54,6 +56,48 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + test "can update openai uri base and model together when self hosting is enabled" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1", openai_model: "gpt-4" } } + + assert_equal "https://api.example.com/v1", Setting.openai_uri_base + assert_equal "gpt-4", Setting.openai_model + end + end + + test "cannot update openai uri base without model when self hosting is enabled" do + with_self_hosting do + Setting.openai_model = "" + + patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1" } } + + assert_response :unprocessable_entity + assert_match(/OpenAI model is required/, flash[:alert]) + assert_nil Setting.openai_uri_base + end + end + + test "can update openai model alone when self hosting is enabled" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { openai_model: "gpt-4" } } + + assert_equal "gpt-4", Setting.openai_model + end + end + + test "cannot clear openai model when custom uri base is set" do + with_self_hosting do + Setting.openai_uri_base = "https://api.example.com/v1" + Setting.openai_model = "gpt-4" + + patch settings_hosting_url, params: { setting: { openai_model: "" } } + + assert_response :unprocessable_entity + assert_match(/OpenAI model is required/, flash[:alert]) + assert_equal "gpt-4", Setting.openai_model + end + end + test "can clear data cache when self hosting is enabled" do account = accounts(:investment) holding = account.holdings.first diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index b6c3e9087..5b44361e6 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -141,10 +141,10 @@ class AssistantTest < ActiveSupport::TestCase end def provider_text_chunk(text) - Provider::LlmConcept::ChatStreamChunk.new(type: "output_text", data: text) + Provider::LlmConcept::ChatStreamChunk.new(type: "output_text", data: text, usage: nil) end - def provider_response_chunk(id:, model:, messages:, function_requests:) + def provider_response_chunk(id:, model:, messages:, function_requests:, usage: nil) Provider::LlmConcept::ChatStreamChunk.new( type: "response", data: Provider::LlmConcept::ChatResponse.new( @@ -152,7 +152,8 @@ class AssistantTest < ActiveSupport::TestCase model: model, messages: messages, function_requests: function_requests - ) + ), + usage: usage ) end end diff --git a/test/models/setting_test.rb b/test/models/setting_test.rb new file mode 100644 index 000000000..bb0ffe8df --- /dev/null +++ b/test/models/setting_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class SettingTest < ActiveSupport::TestCase + setup do + # Clear settings before each test + Setting.openai_uri_base = nil + Setting.openai_model = nil + end + + test "validate_openai_config! passes when both uri base and model are set" do + assert_nothing_raised do + Setting.validate_openai_config!(uri_base: "https://api.example.com", model: "gpt-4") + end + end + + test "validate_openai_config! passes when neither uri base nor model are set" do + assert_nothing_raised do + Setting.validate_openai_config!(uri_base: "", model: "") + end + end + + test "validate_openai_config! passes when uri base is blank and model is set" do + assert_nothing_raised do + Setting.validate_openai_config!(uri_base: "", model: "gpt-4") + end + end + + test "validate_openai_config! raises error when uri base is set but model is blank" do + error = assert_raises(Setting::ValidationError) do + Setting.validate_openai_config!(uri_base: "https://api.example.com", model: "") + end + + assert_match(/OpenAI model is required/, error.message) + end + + test "validate_openai_config! uses current settings when parameters are nil" do + Setting.openai_uri_base = "https://api.example.com" + Setting.openai_model = "gpt-4" + + assert_nothing_raised do + Setting.validate_openai_config!(uri_base: nil, model: nil) + end + end + + test "validate_openai_config! raises error when current uri base is set but new model is blank" do + Setting.openai_uri_base = "https://api.example.com" + Setting.openai_model = "gpt-4" + + error = assert_raises(Setting::ValidationError) do + Setting.validate_openai_config!(uri_base: nil, model: "") + end + + assert_match(/OpenAI model is required/, error.message) + end + + test "validate_openai_config! passes when new uri base is blank and current model exists" do + Setting.openai_uri_base = "https://api.example.com" + Setting.openai_model = "gpt-4" + + assert_nothing_raised do + Setting.validate_openai_config!(uri_base: "", model: nil) + end + end +end diff --git a/test/system/chats_test.rb b/test/system/chats_test.rb index 98d6be617..ff4a42f98 100644 --- a/test/system/chats_test.rb +++ b/test/system/chats_test.rb @@ -17,50 +17,58 @@ class ChatsTest < ApplicationSystemTestCase end test "sidebar shows index when enabled and chats are empty" do - @user.update!(ai_enabled: true) - @user.chats.destroy_all + with_env_overrides OPENAI_ACCESS_TOKEN: "test-token" do + @user.update!(ai_enabled: true) + @user.chats.destroy_all - visit root_url + visit root_url - within "#chat-container" do - assert_selector "h1", text: "Chats" + within "#chat-container" do + assert_selector "h1", text: "Chats" + end end end test "sidebar shows last viewed chat" do - @user.update!(ai_enabled: true) + with_env_overrides OPENAI_ACCESS_TOKEN: "test-token" do + @user.update!(ai_enabled: true) - click_on @user.chats.first.title + visit root_url - # Page refresh - visit root_url + click_on @user.chats.first.title - # After page refresh, we're still on the last chat we were viewing - within "#chat-container" do - assert_selector "h1", text: @user.chats.first.title + # Page refresh + visit root_url + + # After page refresh, we're still on the last chat we were viewing + within "#chat-container" do + assert_selector "h1", text: @user.chats.first.title + end end end test "create chat and navigate chats sidebar" do - @user.chats.destroy_all + with_env_overrides OPENAI_ACCESS_TOKEN: "test-token" do + @user.chats.destroy_all - visit root_url + visit root_url - Chat.any_instance.expects(:ask_assistant_later).once + Chat.any_instance.expects(:ask_assistant_later).once - within "#chat-form" do - fill_in "chat[content]", with: "Can you help with my finances?" - find("button[type='submit']").click + within "#chat-form" do + fill_in "chat[content]", with: "Can you help with my finances?" + find("button[type='submit']").click + end + + assert_text "Can you help with my finances?" + + find("#chat-nav-back").click + + assert_selector "h1", text: "Chats" + + click_on @user.chats.reload.first.title + + assert_text "Can you help with my finances?" end - - assert_text "Can you help with my finances?" - - find("#chat-nav-back").click - - assert_selector "h1", text: "Chats" - - click_on @user.chats.reload.first.title - - assert_text "Can you help with my finances?" end end