From 63e24623fd24584ea1d25e237f9fafd6973a75fd Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 21 Mar 2025 16:57:47 -0400 Subject: [PATCH] Tool call implementation and test --- app/models/assistant.rb | 55 ++- .../compare_periods.rb | 4 +- .../get_account_balances.rb | 4 +- .../get_balance_sheet.rb | 4 +- .../get_expense_categories.rb | 4 +- .../get_income_statement.rb | 4 +- .../get_transactions.rb | 4 +- .../{function.rb => functions/toolable.rb} | 6 +- app/models/assistant/provideable.rb | 6 +- app/models/message.rb | 2 +- app/models/provider/openai.rb | 72 +++- app/models/tool_call/function.rb | 2 +- db/migrate/20250319212839_create_ai_chats.rb | 4 +- db/schema.rb | 4 +- test/fixtures/tool_calls.yml | 3 +- test/interfaces/llm_interface_test.rb | 23 ++ test/models/assistant_test.rb | 68 ---- test/models/provider/openai_test.rb | 102 +++-- test/vcr_cassettes/open_ai/chat/error.yml | 72 ++++ .../vcr_cassettes/open_ai/chat/tool_calls.yml | 350 ++++++++---------- 20 files changed, 437 insertions(+), 356 deletions(-) rename app/models/assistant/{function => functions}/compare_periods.rb (97%) rename app/models/assistant/{function => functions}/get_account_balances.rb (94%) rename app/models/assistant/{function => functions}/get_balance_sheet.rb (78%) rename app/models/assistant/{function => functions}/get_expense_categories.rb (95%) rename app/models/assistant/{function => functions}/get_income_statement.rb (92%) rename app/models/assistant/{function => functions}/get_transactions.rb (97%) rename app/models/assistant/{function.rb => functions/toolable.rb} (84%) create mode 100644 test/vcr_cassettes/open_ai/chat/error.yml diff --git a/app/models/assistant.rb b/app/models/assistant.rb index ec46f934..3c653a43 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -66,15 +66,50 @@ class Assistant functions: available_functions ) - if response.success? - response.data.messages.each do |message| - message.chat = chat - message.save! - end - else + unless response.success? Rails.logger.error("Assistant failed to respond to user: #{response.error}") chat.update!(error: response.error) + return end + + # If no tool calls, create a plain message for the chat + unless response.data.tool_calls.any? + message = response.data.message + message.save! + return + end + + # Step 1: Saving a "pending" message with incomplete tool call definitions + message = response.data.message + message.status = "pending" + message.save! + + # Step 2: Call the functions, add to message and save + tool_calls = message.tool_calls.map do |tool_call| + result = call_tool_function(tool_call.function_name, tool_call.function_arguments) + tool_call.function_result = result + tool_call + end + + message.tool_calls = tool_calls + message.save! + + # Step 3: Call LLM again with tool call results and update the message with response + second_response = provider.chat_response( + model: latest_message.ai_model, + instructions: instructions, + messages: chat_history, + ) + + unless second_response.success? + Rails.logger.error("Assistant failed to process tool call results: #{second_response.error}") + chat.update!(error: second_response.error) + return + end + + second_message = second_response.data.message + second_message.status = "complete" + second_message.save! end private @@ -82,10 +117,10 @@ class Assistant chat.messages.ordered.where(role: [ :user, :assistant, :developer ], status: "complete", kind: "text") end - def call_function(name, params = {}) - fn = available_functions.find { |fn| fn.name == name } - raise "Assistant does not implement function: #{name}" if fn.nil? - fn.call(params) + def call_tool_function(fn_name, fn_params = {}) + fn = available_functions.find { |fn| fn.name == fn_name } + raise "Assistant does not implement function: #{fn_name}" if fn.nil? + fn.call(fn_params) end def instructions diff --git a/app/models/assistant/function/compare_periods.rb b/app/models/assistant/functions/compare_periods.rb similarity index 97% rename from app/models/assistant/function/compare_periods.rb rename to app/models/assistant/functions/compare_periods.rb index f33b9f1e..927531d3 100644 --- a/app/models/assistant/function/compare_periods.rb +++ b/app/models/assistant/functions/compare_periods.rb @@ -1,4 +1,6 @@ -class Assistant::Function::ComparePeriods < Assistant::Function +class Assistant::Functions::ComparePeriods + include Assistant::Functions::Toolable + class << self def name "compare_periods" diff --git a/app/models/assistant/function/get_account_balances.rb b/app/models/assistant/functions/get_account_balances.rb similarity index 94% rename from app/models/assistant/function/get_account_balances.rb rename to app/models/assistant/functions/get_account_balances.rb index 5613fe18..9c8ae6ea 100644 --- a/app/models/assistant/function/get_account_balances.rb +++ b/app/models/assistant/functions/get_account_balances.rb @@ -1,4 +1,6 @@ -class Assistant::Function::GetAccountBalances < Assistant::Function +class Assistant::Functions::GetAccountBalances + include Assistant::Functions::Toolable + class << self def name "get_account_balances" diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/functions/get_balance_sheet.rb similarity index 78% rename from app/models/assistant/function/get_balance_sheet.rb rename to app/models/assistant/functions/get_balance_sheet.rb index 5e700340..dcd6e0e1 100644 --- a/app/models/assistant/function/get_balance_sheet.rb +++ b/app/models/assistant/functions/get_balance_sheet.rb @@ -1,4 +1,6 @@ -class Assistant::Function::GetBalanceSheet < Assistant::Function +class Assistant::Functions::GetBalanceSheet + include Assistant::Functions::Toolable + class << self def name "get_balance_sheet" diff --git a/app/models/assistant/function/get_expense_categories.rb b/app/models/assistant/functions/get_expense_categories.rb similarity index 95% rename from app/models/assistant/function/get_expense_categories.rb rename to app/models/assistant/functions/get_expense_categories.rb index b8d01d9b..a311a9b5 100644 --- a/app/models/assistant/function/get_expense_categories.rb +++ b/app/models/assistant/functions/get_expense_categories.rb @@ -1,4 +1,6 @@ -class Assistant::Function::GetExpenseCategories < Assistant::Function +class Assistant::Functions::GetExpenseCategories + include Assistant::Functions::Toolable + class << self def name "get_expense_categories" diff --git a/app/models/assistant/function/get_income_statement.rb b/app/models/assistant/functions/get_income_statement.rb similarity index 92% rename from app/models/assistant/function/get_income_statement.rb rename to app/models/assistant/functions/get_income_statement.rb index 62d24312..5a53a2b2 100644 --- a/app/models/assistant/function/get_income_statement.rb +++ b/app/models/assistant/functions/get_income_statement.rb @@ -1,4 +1,6 @@ -class Assistant::Function::GetIncomeStatement < Assistant::Function +class Assistant::Functions::GetIncomeStatement + include Assistant::Functions::Toolable + class << self def name "get_income_statement" diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/functions/get_transactions.rb similarity index 97% rename from app/models/assistant/function/get_transactions.rb rename to app/models/assistant/functions/get_transactions.rb index de7af8b5..1d7657e5 100644 --- a/app/models/assistant/function/get_transactions.rb +++ b/app/models/assistant/functions/get_transactions.rb @@ -1,4 +1,6 @@ -class Assistant::Function::GetTransactions < Assistant::Function +class Assistant::Functions::GetTransactions + include Assistant::Functions::Toolable + class << self def name "get_transactions" diff --git a/app/models/assistant/function.rb b/app/models/assistant/functions/toolable.rb similarity index 84% rename from app/models/assistant/function.rb rename to app/models/assistant/functions/toolable.rb index 8dca84e8..caf2f1ea 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/functions/toolable.rb @@ -1,5 +1,7 @@ -class Assistant::Function - class << self +module Assistant::Functions::Toolable + extend ActiveSupport::Concern + + class_methods do def name raise NotImplementedError, "Subclasses must implement the name class method" end diff --git a/app/models/assistant/provideable.rb b/app/models/assistant/provideable.rb index 279a302c..e0f893de 100644 --- a/app/models/assistant/provideable.rb +++ b/app/models/assistant/provideable.rb @@ -1,11 +1,7 @@ module Assistant::Provideable extend ActiveSupport::Concern - ChatResponse = Data.define(:messages) - - def supports_model?(model) - raise NotImplementedError, "Subclasses must implement #supports_model?" - end + ChatResponse = Data.define(:message, :tool_calls) def chat_response(messages:, model: nil, functions: [], instructions: nil) raise NotImplementedError, "Subclasses must implement #chat_response" diff --git a/app/models/message.rb b/app/models/message.rb index 36a4a376..46b80c57 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -26,7 +26,7 @@ class Message < ApplicationRecord } validates :ai_model, presence: { if: -> { assistant? || user? } } - validates :content, presence: true + validates :content, presence: true, allow_blank: true validate :kind_valid_for_role validate :status_valid_for_role diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 5b73343b..7df62100 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -1,8 +1,6 @@ class Provider::OpenAI < Provider include Assistant::Provideable - AVAILABLE_MODELS = %w[gpt-4o] - def initialize(access_token) @client = ::OpenAI::Client.new(access_token: access_token) end @@ -11,29 +9,26 @@ class Provider::OpenAI < Provider AVAILABLE_MODELS.include?(model) end - def chat_response(messages:, model: nil, functions: [], instructions: nil) + def chat_response(messages:, model: nil, instructions: nil, functions: []) provider_response do - validate_model!(model) - response = client.responses.create( parameters: { model: model, - input: messages.map { |msg| { role: msg.role, content: msg.content } }, - tools: build_tools(functions), + input: build_input(messages), + tools: build_available_tools(functions), instructions: instructions } ) Assistant::Provideable::ChatResponse.new( - messages: response.dig("output").filter { |item| item.dig("type") == "message" }.map do |item| - Message.new( - ai_model: response.dig("model"), - provider_id: item.dig("id"), - status: normalize_status(item.dig("status")), - role: "assistant", - content: item.dig("content").map { |content| content.dig("text") }.join("\n") - ) - end + message: Message.new( + ai_model: response.dig("model"), + provider_id: response.dig("id"), + status: normalize_status(response.dig("status")), + role: "assistant", + content: extract_content(response), + ), + tool_calls: extract_tool_calls(response) ) end end @@ -41,12 +36,27 @@ class Provider::OpenAI < Provider private attr_reader :client, :model, :functions - def validate_model!(model) - raise "Model #{model} not supported for Provider::OpenAI" unless AVAILABLE_MODELS.include?(model) - model + # Builds input based on chat history and nested tool calls within messages + def build_input(messages) + input = [] + + messages.each do |msg| + # Append completed messages. Messages with tool calls will be "pending" + if msg.complete? + input << { role: msg.role, content: msg.content } + end + + # Append both the tool call and the tool call result with its correlation id + msg.tool_calls.each do |tc| + input << { type: "function_call", id: tc.provider_id, call_id: tc.provider_fn_call_id, name: tc.function_name, arguments: tc.function_arguments } + input << { type: "function_call_output", call_id: tc.provider_fn_call_id, output: tc.function_result } + end + end + + input end - def build_tools(functions = []) + def build_available_tools(functions = []) functions.map do |fn| { type: "function", @@ -58,6 +68,28 @@ class Provider::OpenAI < Provider end end + def extract_content(response) + response.dig("output").filter { |item| item.dig("type") == "message" }.map do |item| + item.dig("content").map do |content| + text = content.dig("text") + refusal = content.dig("refusal") + + text || refusal + end + end.flatten.compact.join("\n") + end + + def extract_tool_calls(response) + response.dig("output").filter { |item| item.dig("type") == "function_call" }.map do |item| + ToolCall::Function.new( + provider_id: item.dig("id"), + provider_fn_call_id: item.dig("call_id"), + function_name: item.dig("name"), + function_arguments: item.dig("arguments"), + ) + end + end + # Normalize to our internal message status values def normalize_status(status) case status diff --git a/app/models/tool_call/function.rb b/app/models/tool_call/function.rb index cc3ab15e..108b1078 100644 --- a/app/models/tool_call/function.rb +++ b/app/models/tool_call/function.rb @@ -1,3 +1,3 @@ class ToolCall::Function < ToolCall - validates :function_name, presence: true + validates :function_name, :function_arguments, :function_result, presence: true end diff --git a/db/migrate/20250319212839_create_ai_chats.rb b/db/migrate/20250319212839_create_ai_chats.rb index 50221208..f0fb773a 100644 --- a/db/migrate/20250319212839_create_ai_chats.rb +++ b/db/migrate/20250319212839_create_ai_chats.rb @@ -22,12 +22,14 @@ class CreateAiChats < ActiveRecord::Migration[7.2] create_table :tool_calls, id: :uuid do |t| t.references :message, null: false, foreign_key: true, type: :uuid + t.string :provider_id, null: false + t.string :provider_fn_call_id, null: false t.string :type, null: false - t.string :status, null: false # Function specific fields t.string :function_name t.jsonb :function_arguments + t.jsonb :function_result t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 5e635410..0707a2a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -569,10 +569,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "message_id", null: false + t.string "provider_id", null: false + t.string "provider_fn_call_id", null: false t.string "type", null: false - t.string "status", null: false t.string "function_name" t.jsonb "function_arguments" + t.jsonb "function_result" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" diff --git a/test/fixtures/tool_calls.yml b/test/fixtures/tool_calls.yml index 58105aa2..14ed3d89 100644 --- a/test/fixtures/tool_calls.yml +++ b/test/fixtures/tool_calls.yml @@ -1,6 +1,7 @@ one: type: ToolCall::Function function_name: get_user_info + provider_id: fc_12345xyz + provider_fn_call_id: call_12345xyz function_arguments: {} message: assistant - status: complete diff --git a/test/interfaces/llm_interface_test.rb b/test/interfaces/llm_interface_test.rb index 739ec994..334c11b8 100644 --- a/test/interfaces/llm_interface_test.rb +++ b/test/interfaces/llm_interface_test.rb @@ -2,4 +2,27 @@ require "test_helper" module LLMInterfaceTest extend ActiveSupport::Testing::Declarative + + test "provides basic chat response" do + VCR.use_cassette("#{vcr_key_prefix}/chat/basic_response") do + response = @subject.chat_response( + model: @subject_model, + messages: [ + Message.new( + role: "user", + content: "This is a chat test. If it's working, respond with a single word: Yes" + ) + ] + ) + + assert response.success? + assert_includes response.data.message.ai_model, @subject_model + assert_equal "Yes", response.data.message.content + end + end + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index bfa62d1a..48df818e 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -29,72 +29,4 @@ class AssistantTest < ActiveSupport::TestCase @assistant.respond_to_user end end - - test "can execute get_balance_sheet function" do - result = @financial_assistant.send(:execute_get_balance_sheet) - - assert_kind_of Hash, result - assert_includes result.keys, :net_worth - assert_includes result.keys, :total_assets - assert_includes result.keys, :total_liabilities - end - - test "can execute get_income_statement function" do - result = @financial_assistant.send(:execute_get_income_statement, { "period" => "current_month" }) - - assert_kind_of Hash, result - assert_includes result.keys, :total_income - assert_includes result.keys, :total_expenses - assert_includes result.keys, :net_income - end - - test "processes OpenAI response with direct content" do - response = { - "choices" => [ - { - "message" => { - "content" => "This is a direct response." - } - } - ] - } - - messages = [] - result = @financial_assistant.send(:process_response, response, "Test question", messages) - assert_equal "This is a direct response.", result - end - - test "processes OpenAI response with function calls" do - # This test is a bit tricky since we need to mock both OpenAI API calls - # We'll skip the actual implementation and just test that the class - # has the necessary methods and structure - - assert_respond_to @financial_assistant, :query - assert_respond_to @financial_assistant, :financial_function_definitions - - # Create a direct response for testing - direct_response = "Your net worth is $100,000." - - # Instead of calling the actual method, we'll mock everything - @financial_assistant.stubs(:query).returns(direct_response) - - # Test the query method via our stub - result = @financial_assistant.query("What's my net worth?") - assert_equal direct_response, result - end - - test "handles function calls in OpenAI response" do - # Create a simplified version of the test that mocks the full process_response method - expected_response = "Based on your balance sheet, your net worth is $150,000." - - # Setup the query expectation - this is the top-level method - @financial_assistant.expects(:process_response).returns(expected_response) - @mock_client.expects(:chat).returns("mock_response") - - # Call the query method - result = @financial_assistant.query("What is my net worth?") - - # Verify the result - assert_equal expected_response, result - end end diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb index 79f2ba2d..e9acd760 100644 --- a/test/models/provider/openai_test.rb +++ b/test/models/provider/openai_test.rb @@ -5,57 +5,83 @@ class Provider::OpenAITest < ActiveSupport::TestCase setup do @subject = @openai = Provider::OpenAI.new(ENV.fetch("OPENAI_ACCESS_TOKEN")) + @subject_model = "gpt-4o" end - test "verifies model support" do - assert_not @subject.supports_model?("unknown-for-test-that-returns-false") + test "openai errors are automatically raised" do + VCR.use_cassette("open_ai/chat/error") do + response = @openai.chat_response( + model: "invalid-model-key", + messages: [ Message.new(role: "user", content: "Error test") ] + ) + + assert_not response.success? + assert_kind_of Faraday::BadRequestError, response.error + + # Adheres to openai response schema + assert_equal "model_not_found", response.error.response[:body].dig("error", "code") + end end - test "provides basic chat response" do - VCR.use_cassette("#{vcr_key_prefix}/chat/basic_response") do - response = @subject.chat_response( + test "handles chat response with tool calls" do + VCR.use_cassette("open_ai/chat/tool_calls", record: :all) do + class TestFn + include Assistant::Functions::Toolable + + class << self + def name + "get_net_worth" + end + + def description + "Gets user net worth data" + end + end + + def call(params = {}) + "$124,200" + end + end + + initial_message = Message.new(role: "user", content: "What is my net worth?") + + response = @openai.chat_response( model: "gpt-4o", - messages: [ - Message.new( - role: "user", - content: "This is a chat test. If it's working, respond with a single word: Yes" + instructions: Assistant.instructions, + functions: [ TestFn ], + messages: [ initial_message ] + ) + + assert response.success? + assert response.data.tool_calls.size == 1 + + tool_call = response.data.tool_calls.first + tool_call_result = TestFn.new.call(JSON.parse(tool_call.function_arguments)) + + message_with_tool_calls = Message.new( + role: "assistant", + status: "pending", + content: "", + tool_calls: [ + ToolCall::Function.new( + provider_id: tool_call.provider_id, + provider_fn_call_id: tool_call.provider_fn_call_id, + function_name: tool_call.function_name, + function_arguments: tool_call.function_arguments, + function_result: tool_call_result ) ] ) - assert response.success? - assert response.data.messages.size > 0 - assert_equal "gpt-4o-2024-08-06", response.data.messages.first.ai_model - assert_equal "Yes", response.data.messages.first.content - end - end - - test "provides response with tool calls" do - VCR.use_cassette("#{vcr_key_prefix}/chat/tool_calls") do - # A prompt that should use multiple tools - prompt = <<~PROMPT - Can you show me a breakdown of the following? - - - My net worth over the last 30 days - - My income over the last 30 days - - My spending over the last 30 days - PROMPT - - response = @subject.chat_response( + second_response = @openai.chat_response( model: "gpt-4o", instructions: Assistant.instructions, - functions: Assistant.available_functions, - messages: [ - Message.new(role: "user", content: prompt) - ] + messages: [ initial_message, message_with_tool_calls ] ) - assert response.success? + assert second_response.success? + assert second_response.data.message.complete? + assert second_response.data.message.content.include?(tool_call_result) # Somewhere in the response should be the tool call result value end end - - private - def vcr_key_prefix - @subject.class.name.demodulize.underscore - end end diff --git a/test/vcr_cassettes/open_ai/chat/error.yml b/test/vcr_cassettes/open_ai/chat/error.yml new file mode 100644 index 00000000..e58742aa --- /dev/null +++ b/test/vcr_cassettes/open_ai/chat/error.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"invalid-model-key","input":[{"role":"user","content":"Error + test"}],"tools":[],"instructions":null}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Fri, 21 Mar 2025 18:02:59 GMT + Content-Type: + - application/json + Content-Length: + - '183' + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_8b96d2c4a5fd9f6867321a59f2c83871 + Openai-Processing-Ms: + - '114' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=7QPY9MNmXzC1Cn40g1tf2.vUHSJgLK2H6Qyr6yPu_ag-1742580179-1.0.1.1-i5yiiXqowBLqwvQ6.h3UPAvErH3joLsvg8xOOgP.d6m2KzNfOA8IDSQ95h2qJsQRDuhwtMXlicW2Ua29l7xS4hWFTt4dOV09fotpUz4YKYw; + path=/; expires=Fri, 21-Mar-25 18:32:59 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=OiJrpU7qflYudc_3Ur.z0HcymtnD5.UeNdtTm8Ygi9U-1742580179526-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 923f5d08bf81cf75-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |- + { + "error": { + "message": "The requested model 'invalid-model-key' does not exist.", + "type": "invalid_request_error", + "param": "model", + "code": "model_not_found" + } + } + recorded_at: Fri, 21 Mar 2025 18:02:59 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/open_ai/chat/tool_calls.yml b/test/vcr_cassettes/open_ai/chat/tool_calls.yml index fe9d57f2..eb32108f 100644 --- a/test/vcr_cassettes/open_ai/chat/tool_calls.yml +++ b/test/vcr_cassettes/open_ai/chat/tool_calls.yml @@ -5,25 +5,9 @@ http_interactions: uri: https://api.openai.com/v1/responses body: encoding: UTF-8 - string: '{"model":"gpt-4o","input":[{"role":"user","content":"Can you show me - a breakdown of the following?\n\n- My net worth over the last 30 days\n- My - income over the last 30 days\n- My spending over the last 30 days\n"}],"tools":[{"type":"function","name":"get_balance_sheet","description":"Get - current balance sheet information including net worth, assets, and liabilities","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true},{"type":"function","name":"get_income_statement","description":"Get - income statement data for a specific time period","parameters":{"type":"object","properties":{"period":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"The - time period for the income statement data"}},"required":["period"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_expense_categories","description":"Get - top expense categories for a specific time period","parameters":{"type":"object","properties":{"period":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"The - time period for the expense categories data"},"limit":{"type":"integer","description":"Number - of top categories to return"}},"required":["period","limit"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_account_balances","description":"Get - balances for all accounts or by account type","parameters":{"type":"object","properties":{"account_type":{"type":"string","enum":["asset","liability","all"],"description":"Type - of accounts to get balances for"}},"required":["account_type"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_transactions","description":"Get - transactions filtered by date range and/or category","parameters":{"type":"object","properties":{"start_date":{"type":"string","description":"Start - date for transactions in YYYY-MM-DD format"},"end_date":{"type":"string","description":"End - date for transactions in YYYY-MM-DD format"},"category_name":{"type":"string","description":"Filter - transactions by category name"},"limit":{"type":"integer","description":"Maximum - number of transactions to return (defaults to 10)"}},"required":["start_date","end_date","category_name","limit"],"additionalProperties":false},"strict":true},{"type":"function","name":"compare_periods","description":"Compare - financial data between two periods","parameters":{"type":"object","properties":{"period1":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"First - period for comparison"},"period2":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"Second - period for comparison"}},"required":["period1","period2"],"additionalProperties":false},"strict":true}],"instructions":"You + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"}],"tools":[{"type":"function","name":"get_net_worth","description":"Gets + user net worth data","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"instructions":"You are a helpful financial assistant for Maybe, a personal finance app.\nYou help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.\n\nWhen users ask @@ -51,7 +35,7 @@ http_interactions: message: OK headers: Date: - - Fri, 21 Mar 2025 14:18:42 GMT + - Fri, 21 Mar 2025 20:56:14 GMT Content-Type: - application/json Transfer-Encoding: @@ -63,34 +47,34 @@ http_interactions: Openai-Organization: - "" X-Request-Id: - - req_434f08736b7c91b1ea07bf81cef21507 + - req_d0dcb7a2bb6b188cc992f81b1171ec71 Openai-Processing-Ms: - - '11758' + - '807' Strict-Transport-Security: - max-age=31536000; includeSubDomains; preload Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=TDrrkraYJtMJkBDpwTFRHszMh5.44PObkaR_DPTn7bs-1742566722-1.0.1.1-5yNYBgBtwECMJLuLhfogtiDW1.XdsVBSnit.VpOjHitkB4OOfQLLaHO_dLR4d4HDG8zkNL1RNXI07vOirY10o1FxcZ1oR8YR0UMUrNJ2_nw; - path=/; expires=Fri, 21-Mar-25 14:48:42 GMT; domain=.api.openai.com; HttpOnly; + - __cf_bm=mm1LOM_CqqDbpNxg5U9POkF8mZmbwy93TakM0UNW79Y-1742590574-1.0.1.1-xEzx9bxl_Ql_u0SX6Artx49KLfEaj2odnlOpyzz8igb8wqVDvALU53jeepQtphRu53x4gCnq6Vafxmchv7oh3nb36_iH_i5kU105C10gfyk; + path=/; expires=Fri, 21-Mar-25 21:26:14 GMT; domain=.api.openai.com; HttpOnly; Secure; SameSite=None - - _cfuvid=OKVFDrnX270p7.zncd1f.vstTBf11_gfDGJVz6qq_lQ-1742566722134-0.0.1.1-604800000; + - _cfuvid=OmEvdCnIjJ7f7pONp9es_4f0YJTR7ZzOa5JffY6t7.8-1742590574225-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None X-Content-Type-Options: - nosniff Server: - cloudflare Cf-Ray: - - 923e14334bbc633e-ORD + - 92405acb3c99cf56-CMH Alt-Svc: - h3=":443"; ma=86400 body: encoding: ASCII-8BIT string: |- { - "id": "resp_67dd753662dc8192b5b25f9666f06d940cbbaa24c6c80cad", + "id": "resp_67ddd26d66e081928243bb73ccf4f17d051b843636768b97", "object": "response", - "created_at": 1742566710, + "created_at": 1742590573, "status": "completed", "error": null, "incomplete_details": null, @@ -100,27 +84,11 @@ http_interactions: "output": [ { "type": "function_call", - "id": "fc_67dd75416cf08192b67279b3c06bb84b0cbbaa24c6c80cad", - "call_id": "call_Be2crDZFo1QBcyPpo9SivVVk", - "name": "get_balance_sheet", + "id": "fc_67ddd26de5f881929ee7297fb8cee1db051b843636768b97", + "call_id": "call_58gKqckPCeWSPwYdbP3EBpAY", + "name": "get_net_worth", "arguments": "{}", "status": "completed" - }, - { - "type": "function_call", - "id": "fc_67dd754197b881929875c127b46daea20cbbaa24c6c80cad", - "call_id": "call_ncxXWtAIV0lGpgIeiHUcquAS", - "name": "get_income_statement", - "arguments": "{\"period\":\"current_month\"}", - "status": "completed" - }, - { - "type": "function_call", - "id": "fc_67dd7541b3ec8192bf57bbcf406aeaad0cbbaa24c6c80cad", - "call_id": "call_tgWrZssOt6UE2BOoQMkul3lg", - "name": "get_expense_categories", - "arguments": "{\"period\":\"current_month\",\"limit\":5}", - "status": "completed" } ], "parallel_tool_calls": true, @@ -140,8 +108,8 @@ http_interactions: "tools": [ { "type": "function", - "description": "Get current balance sheet information including net worth, assets, and liabilities", - "name": "get_balance_sheet", + "description": "Gets user net worth data", + "name": "get_net_worth", "parameters": { "type": "object", "properties": {}, @@ -149,172 +117,150 @@ http_interactions: "additionalProperties": false }, "strict": true - }, - { - "type": "function", - "description": "Get income statement data for a specific time period", - "name": "get_income_statement", - "parameters": { - "type": "object", - "properties": { - "period": { - "type": "string", - "enum": [ - "current_month", - "previous_month", - "year_to_date", - "previous_year" - ], - "description": "The time period for the income statement data" - } - }, - "required": [ - "period" - ], - "additionalProperties": false - }, - "strict": true - }, - { - "type": "function", - "description": "Get top expense categories for a specific time period", - "name": "get_expense_categories", - "parameters": { - "type": "object", - "properties": { - "period": { - "type": "string", - "enum": [ - "current_month", - "previous_month", - "year_to_date", - "previous_year" - ], - "description": "The time period for the expense categories data" - }, - "limit": { - "type": "integer", - "description": "Number of top categories to return" - } - }, - "required": [ - "period", - "limit" - ], - "additionalProperties": false - }, - "strict": true - }, - { - "type": "function", - "description": "Get balances for all accounts or by account type", - "name": "get_account_balances", - "parameters": { - "type": "object", - "properties": { - "account_type": { - "type": "string", - "enum": [ - "asset", - "liability", - "all" - ], - "description": "Type of accounts to get balances for" - } - }, - "required": [ - "account_type" - ], - "additionalProperties": false - }, - "strict": true - }, - { - "type": "function", - "description": "Get transactions filtered by date range and/or category", - "name": "get_transactions", - "parameters": { - "type": "object", - "properties": { - "start_date": { - "type": "string", - "description": "Start date for transactions in YYYY-MM-DD format" - }, - "end_date": { - "type": "string", - "description": "End date for transactions in YYYY-MM-DD format" - }, - "category_name": { - "type": "string", - "description": "Filter transactions by category name" - }, - "limit": { - "type": "integer", - "description": "Maximum number of transactions to return (defaults to 10)" - } - }, - "required": [ - "start_date", - "end_date", - "category_name", - "limit" - ], - "additionalProperties": false - }, - "strict": true - }, - { - "type": "function", - "description": "Compare financial data between two periods", - "name": "compare_periods", - "parameters": { - "type": "object", - "properties": { - "period1": { - "type": "string", - "enum": [ - "current_month", - "previous_month", - "year_to_date", - "previous_year" - ], - "description": "First period for comparison" - }, - "period2": { - "type": "string", - "enum": [ - "current_month", - "previous_month", - "year_to_date", - "previous_year" - ], - "description": "Second period for comparison" - } - }, - "required": [ - "period1", - "period2" - ], - "additionalProperties": false - }, - "strict": true } ], "top_p": 1.0, "truncation": "disabled", "usage": { - "input_tokens": 0, + "input_tokens": 400, "input_tokens_details": { "cached_tokens": 0 }, - "output_tokens": 0, + "output_tokens": 13, "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": 0 + "total_tokens": 413 }, "user": null, "metadata": {} } - recorded_at: Fri, 21 Mar 2025 14:18:42 GMT + recorded_at: Fri, 21 Mar 2025 20:56:14 GMT +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"},{"type":"function_call","id":"fc_67ddd26de5f881929ee7297fb8cee1db051b843636768b97","call_id":"call_58gKqckPCeWSPwYdbP3EBpAY","name":"get_net_worth","arguments":"{}"},{"type":"function_call_output","call_id":"call_58gKqckPCeWSPwYdbP3EBpAY","output":"$124,200"}],"tools":[],"instructions":"You + are a helpful financial assistant for Maybe, a personal finance app.\nYou + help users understand their financial data by answering questions about their + accounts, transactions, income, expenses, and net worth.\n\nWhen users ask + financial questions:\n1. Use the provided functions to retrieve the relevant + data\n2. Provide ONLY the most important numbers and insights\n3. Eliminate + all unnecessary words and context\n4. Use simple markdown for formatting\n5. + Ask follow-up questions to keep the conversation going. Help educate the user + about their own data and entice them to ask more questions.\n\nDO NOT:\n- + Add introductions or conclusions\n- Apologize or explain limitations\n\nPresent + monetary values using the format provided by the functions.\n"}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 21 Mar 2025 20:56:15 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_f3be656bce149275f2368834bfd83be9 + Openai-Processing-Ms: + - '632' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=.lL8VlbRrdmeXay2X_ltzgMNGI5tbIvf.LUlQku9BRE-1742590575-1.0.1.1-2c6E2_eVRbrOSWoqnj3ROv59Ay1i9fc.dNT5rDGXOjyjISNTsJSeExl3lnng.4MgQUx0nzt5xAeH3kHonwDS4nNQtHJGtIx2Vyc1qiV.zHk; + path=/; expires=Fri, 21-Mar-25 21:26:15 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=Puw5FtUzW08qv5IvAztCHaMlK0JB3huEVX8tx_G7wJ4-1742590575078-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 92405ad1b9fecf5e-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_67ddd26e6c048192bb1e41606aa6b68e051b843636768b97", + "object": "response", + "created_at": 1742590574, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": "You are a helpful financial assistant for Maybe, a personal finance app.\nYou help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.\n\nWhen users ask financial questions:\n1. Use the provided functions to retrieve the relevant data\n2. Provide ONLY the most important numbers and insights\n3. Eliminate all unnecessary words and context\n4. Use simple markdown for formatting\n5. Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.\n\nDO NOT:\n- Add introductions or conclusions\n- Apologize or explain limitations\n\nPresent monetary values using the format provided by the functions.\n", + "max_output_tokens": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "type": "message", + "id": "msg_67ddd26eb1748192b9f506f5cdd73300051b843636768b97", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Your net worth is **$124,200**.\n\nWould you like to see a breakdown of your assets and liabilities?", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 203, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 25, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 228 + }, + "user": null, + "metadata": {} + } + recorded_at: Fri, 21 Mar 2025 20:56:15 GMT recorded_with: VCR 6.3.1