diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 366e7cf1a..97e93a06e 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -9,7 +9,7 @@ module ActsAs extend ActiveSupport::Concern class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall') # rubocop:disable Metrics/MethodLength + def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall') include ChatMethods @message_class = message_class.to_s @@ -21,12 +21,6 @@ def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall') # ruboco dependent: :destroy delegate :complete, - :with_tool, - :with_tools, - :with_model, - :with_temperature, - :on_new_message, - :on_end_message, :add_message, to: :to_llm end @@ -85,6 +79,36 @@ def to_llm .on_end_message { |msg| persist_message_completion(msg) } end + def with_tool(tool) + to_llm.with_tool(tool) + self + end + + def with_tools(*tools) + to_llm.with_tools(*tools) + self + end + + def with_model(model_id, provider: nil) + to_llm.with_model(model_id, provider: provider) + self + end + + def with_temperature(temperature) + to_llm.with_temperature(temperature) + self + end + + def on_new_message(&) + to_llm.on_new_message(&) + self + end + + def on_end_message(&) + to_llm.on_end_message(&) + self + end + def ask(message, &) message = { role: :user, content: message } messages.create!(**message) diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_chainable_methods_persists_messages_after_chaining.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_chainable_methods_persists_messages_after_chaining.yml new file mode 100644 index 000000000..f19c14fd5 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_chainable_methods_persists_messages_after_chaining.yml @@ -0,0 +1,234 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 3 * 3?"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 01 Apr 2025 19:36:02 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '474' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999994' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHbgvcDAN9c07T3B4SZOavAQAneNI", + "object": "chat.completion", + "created": 1743536161, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_sXYPJ5IW8bCK9Wv6A2e4DWLt", + "type": "function", + "function": { + "name": "calculator", + "arguments": "{\"expression\":\"3 * 3\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 53, + "completion_tokens": 18, + "total_tokens": 71, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b376dfbbd5" + } + recorded_at: Tue, 01 Apr 2025 19:36:02 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 3 * 3?"},{"role":"assistant","tool_calls":[{"id":"call_sXYPJ5IW8bCK9Wv6A2e4DWLt","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"3 + * 3\"}"}}]},{"role":"tool","content":"9","tool_call_id":"call_sXYPJ5IW8bCK9Wv6A2e4DWLt"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 01 Apr 2025 19:36:02 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '450' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999992' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHbgwTEH7an6ET4UtdamK9T8v7zEn", + "object": "chat.completion", + "created": 1743536162, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "3 * 3 equals 9.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 78, + "completion_tokens": 10, + "total_tokens": 88, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b376dfbbd5" + } + recorded_at: Tue, 01 Apr 2025 19:36:02 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_maintains_activerecord_model_for_all_chainable_methods.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_maintains_activerecord_model_for_all_chainable_methods.yml new file mode 100644 index 000000000..7dcf3e590 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_maintains_activerecord_model_for_all_chainable_methods.yml @@ -0,0 +1,234 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 3 * 3?"}],"temperature":0.5,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 22:57:53 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '559' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999994' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHIMiuxesfu2f9gsAG2EXJ54KU0pb", + "object": "chat.completion", + "created": 1743461872, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_zXOkpcM0deHk9dcwKTJfsR2p", + "type": "function", + "function": { + "name": "calculator", + "arguments": "{\"expression\":\"3 * 3\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 53, + "completion_tokens": 18, + "total_tokens": 71, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_86d0290411" + } + recorded_at: Mon, 31 Mar 2025 22:57:53 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 3 * 3?"},{"role":"assistant","tool_calls":[{"id":"call_zXOkpcM0deHk9dcwKTJfsR2p","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"3 + * 3\"}"}}]},{"role":"tool","content":"9","tool_call_id":"call_zXOkpcM0deHk9dcwKTJfsR2p"}],"temperature":0.5,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 22:57:53 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '547' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999991' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHIMjPFVZMGq2hBzmBkIaMv4Qjom6", + "object": "chat.completion", + "created": 1743461873, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The result of \\(3 \\times 3\\) is \\(9\\).", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 78, + "completion_tokens": 18, + "total_tokens": 96, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_86d0290411" + } + recorded_at: Mon, 31 Mar 2025 22:57:53 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_persists_user_messages_when_using_with_tools.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_persists_user_messages_when_using_with_tools.yml new file mode 100644 index 000000000..45fe54f48 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_persists_user_messages_when_using_with_tools.yml @@ -0,0 +1,234 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 2 + 2?"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 22:51:58 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '590' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999993' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHIGzMFYWY7XOFICJovqKPT1Qq5sk", + "object": "chat.completion", + "created": 1743461517, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_SbpL5JgaV3PTzpTzcN3WrfbB", + "type": "function", + "function": { + "name": "calculator", + "arguments": "{\"expression\":\"2 + 2\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 53, + "completion_tokens": 18, + "total_tokens": 71, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_86d0290411" + } + recorded_at: Mon, 31 Mar 2025 22:51:58 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 2 + 2?"},{"role":"assistant","tool_calls":[{"id":"call_SbpL5JgaV3PTzpTzcN3WrfbB","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"2 + + 2\"}"}}]},{"role":"tool","content":"4","tool_call_id":"call_SbpL5JgaV3PTzpTzcN3WrfbB"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 31 Mar 2025 22:51:58 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '427' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999991' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHIH0132xJ0PGeU096fO2LQwsZiqZ", + "object": "chat.completion", + "created": 1743461518, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The result of 2 + 2 is 4.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 78, + "completion_tokens": 14, + "total_tokens": 92, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_86d0290411" + } + recorded_at: Mon, 31 Mar 2025 22:51:58 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_with_tools_functionality_persists_user_messages.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_with_tools_functionality_persists_user_messages.yml new file mode 100644 index 000000000..e789daf2b --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_with_tools_functionality_persists_user_messages.yml @@ -0,0 +1,234 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 2 + 2?"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 01 Apr 2025 19:36:00 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '898' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999994' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHbgtqpsUWXf5HnPUlOhRLJHC8VBW", + "object": "chat.completion", + "created": 1743536159, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_D5wDtiixCorQScHvITXzSttV", + "type": "function", + "function": { + "name": "calculator", + "arguments": "{\"expression\":\"2 + 2\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 53, + "completion_tokens": 18, + "total_tokens": 71, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b376dfbbd5" + } + recorded_at: Tue, 01 Apr 2025 19:36:00 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What''s + 2 + 2?"},{"role":"assistant","tool_calls":[{"id":"call_D5wDtiixCorQScHvITXzSttV","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"2 + + 2\"}"}}]},{"role":"tool","content":"4","tool_call_id":"call_D5wDtiixCorQScHvITXzSttV"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"calculator","description":"Performs + basic arithmetic","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math + expression to evaluate"}},"required":["expression"]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 01 Apr 2025 19:36:01 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '835' + Openai-Version: + - '2020-10-01' + X-Ratelimit-Limit-Requests: + - '10000' + X-Ratelimit-Limit-Tokens: + - '10000000' + X-Ratelimit-Remaining-Requests: + - '9999' + X-Ratelimit-Remaining-Tokens: + - '9999991' + X-Ratelimit-Reset-Requests: + - 6ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BHbguq6pAnIgoymhLVc8LJIXpGXzU", + "object": "chat.completion", + "created": 1743536160, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "2 + 2 equals 4.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 78, + "completion_tokens": 10, + "total_tokens": 88, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b376dfbbd5" + } + recorded_at: Tue, 01 Apr 2025 19:36:01 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index da9c86ec5..a746d1d42 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -70,6 +70,24 @@ def execute(expression:) end end + shared_examples 'a chainable chat method' do |method_name, *args| + it "returns a Chat instance for ##{method_name}" do + chat = Chat.create!(model_id: 'gpt-4o-mini') + result = chat.public_send(method_name, *args) + expect(result).to be_a(Chat) + end + end + + shared_examples 'a chainable callback method' do |callback_name| + it "supports #{callback_name} callback" do + chat = Chat.create!(model_id: 'gpt-4o-mini') + result = chat.public_send(callback_name) do + # no-op for testing + end + expect(result).to be_a(Chat) + end + end + it 'persists chat history' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations chat = Chat.create!(model_id: 'gpt-4o-mini') chat.ask("What's your favorite Ruby feature?") @@ -91,4 +109,41 @@ def execute(expression:) expect(chat.messages.count).to be >= 3 # User message, tool call, and final response expect(chat.messages.any?(&:tool_calls)).to be true end + + describe 'with_tools functionality' do + it 'returns a Chat instance when using with_tool' do + chat = Chat.create!(model_id: 'gpt-4o-mini') + with_tool_result = chat.with_tool(Calculator) + expect(with_tool_result).to be_a(Chat) + end + + it 'persists user messages' do + chat = Chat.create!(model_id: 'gpt-4o-mini') + chat.with_tool(Calculator).ask("What's 2 + 2?") + expect(chat.messages.where(role: 'user').first&.content).to eq("What's 2 + 2?") + end + end + + describe 'chainable methods' do + it_behaves_like 'a chainable chat method', :with_tool, Calculator + it_behaves_like 'a chainable chat method', :with_tools, Calculator + it_behaves_like 'a chainable chat method', :with_model, 'gpt-4o-mini' + it_behaves_like 'a chainable chat method', :with_temperature, 0.5 + + it_behaves_like 'a chainable callback method', :on_new_message + it_behaves_like 'a chainable callback method', :on_end_message + + it 'supports method chaining with tools' do + chat = Chat.create!(model_id: 'gpt-4o-mini') + chat.with_tool(Calculator) + .with_temperature(0.5) + expect(chat).to be_a(Chat) + end + + it 'persists messages after chaining' do + chat = Chat.create!(model_id: 'gpt-4o-mini') + chat.with_tool(Calculator).ask("What's 3 * 3?") + expect(chat.messages.where(role: 'user').first&.content).to eq("What's 3 * 3?") + end + end end