Implement support for generic OpenAI api (#213)

* 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 <sokysrm@gmail.com>

* 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 <sokysrm@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-10-22 16:02:50 +02:00
committed by GitHub
parent ea7ce13a7d
commit 8cd109a5b2
24 changed files with 875 additions and 195 deletions

View File

@@ -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)

View File

@@ -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 =

View File

@@ -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

View File

@@ -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?

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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 OpenAIcompatible 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -26,7 +26,7 @@
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::MODELS.join(", ") %>]</pre>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:instructions] %></pre>
</div>
@@ -54,7 +54,7 @@
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]</pre>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %></pre>
</div>
@@ -82,7 +82,7 @@
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]</pre>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %></pre>
</div>

View File

@@ -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 %>
</div>

View File

@@ -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})"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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