mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
60
app/models/provider/openai/generic_chat_parser.rb
Normal file
60
app/models/provider/openai/generic_chat_parser.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
64
test/models/setting_test.rb
Normal file
64
test/models/setting_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user