Tool call implementation and test

This commit is contained in:
Zach Gollwitzer
2025-03-21 16:57:47 -04:00
parent a68e5fffdc
commit 63e24623fd
20 changed files with 437 additions and 356 deletions

View File

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

View File

@@ -1,4 +1,6 @@
class Assistant::Function::ComparePeriods < Assistant::Function
class Assistant::Functions::ComparePeriods
include Assistant::Functions::Toolable
class << self
def name
"compare_periods"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
class Assistant::Function::GetTransactions < Assistant::Function
class Assistant::Functions::GetTransactions
include Assistant::Functions::Toolable
class << self
def name
"get_transactions"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
class ToolCall::Function < ToolCall
validates :function_name, presence: true
validates :function_name, :function_arguments, :function_result, presence: true
end

View File

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

4
db/schema.rb generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <OPENAI_ACCESS_TOKEN>
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:
- "<OPENAI_ORGANIZATION_ID>"
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

View File

@@ -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:
- "<OPENAI_ORGANIZATION_ID>"
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 <OPENAI_ACCESS_TOKEN>
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:
- "<OPENAI_ORGANIZATION_ID>"
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