Skip to content

Commit d1f3eec

Browse files
committed
[WIP] Boil the Ocean - OpenAI Stabilized (Native Format)
1 parent b6ed88d commit d1f3eec

File tree

32 files changed

+831
-498
lines changed

32 files changed

+831
-498
lines changed

lib/active_agent/action_prompt/concerns/provider.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ def provider_load(service_name)
5555
ActiveAgent::Providers.const_get("#{service_name.camelize}Provider")
5656
end
5757

58-
def provider_name = _provider_name
5958
def provider_klass = _provider_klass
6059
end
6160
end

lib/active_agent/providers/common/_base_model.rb

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ def self.attribute(name, type = nil, **options)
6868
required_attributes << name.to_s
6969

7070
define_method("#{name}=") do |value|
71-
next if value == default_value
71+
normalized_value = value.is_a?(String) ? value.to_sym : value
72+
normalized_default = default_value.is_a?(String) ? default_value.to_sym : default_value
73+
74+
next if normalized_value == normalized_default
75+
7276
raise ArgumentError, "Cannot set '#{name}' attribute (read-only with default value)"
7377
end
7478
else
@@ -112,16 +116,15 @@ def self.keys
112116
#
113117
# Settings can be provided as a hash or keyword arguments. Hash keys are
114118
# sorted to prioritize nested objects during initialization for backwards compatibility.
115-
#
116-
# @param hash [Hash, nil] attribute hash
117-
# @param kwargs [Hash] attribute keyword arguments
118-
def initialize(hash = nil, **kwargs)
119-
settings = hash || kwargs
119+
def initialize(kwargs = {})
120+
# To allow us to get a list of attribute defaults without initialized overrides
121+
return super(nil) if kwargs.key?(:'__default_values')
122+
120123
# Backwards Compatibility: This sorts object construction to the top to protect the assignment
121124
# of backward compatibility assignments.
122-
settings = settings.sort_by { |k, v| v.is_a?(Hash) ? 0 : 1 }.to_h if settings.is_a?(Hash)
125+
kwargs = kwargs.sort_by { |k, v| v.is_a?(Hash) ? 0 : 1 }.to_h if kwargs.is_a?(Hash)
123126

124-
super(settings)
127+
super(kwargs)
125128
end
126129

127130
# Merges the given attributes into the current instance.
@@ -131,8 +134,8 @@ def initialize(hash = nil, **kwargs)
131134
# @param hash [Hash, nil] attribute hash to merge
132135
# @param kwargs [Hash] attribute keyword arguments to merge
133136
# @return [BaseModel] self for method chaining
134-
def merge!(hash = nil, **kwargs)
135-
(hash || kwargs).deep_symbolize_keys.each do |key, value|
137+
def merge!(kwargs = {})
138+
kwargs.deep_symbolize_keys.each do |key, value|
136139
public_send("#{key}=", value) if respond_to?("#{key}=")
137140
end
138141

@@ -213,7 +216,7 @@ def to_hash
213216
# message.to_hash_compressed
214217
# #=> { role: "user" } # content omitted (matches default), role included (required)
215218
def to_hash_compressed
216-
default_values = self.class.new.attributes
219+
default_values = self.class.new(__default_values: true).attributes
217220
required_attrs = self.class.required_attributes
218221

219222
deep_compact(attribute_names.each_with_object({}) do |name, hash|

lib/active_agent/providers/open_ai/_base_provider.rb

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,64 @@
77
module ActiveAgent
88
module Providers
99
module OpenAI
10+
# Base provider implementation for OpenAI API integration.
11+
#
12+
# This class serves as the foundation for OpenAI-based providers, handling
13+
# message management, streaming responses, and function/tool callbacks.
14+
#
15+
# @abstract Subclass and override {#client_request_create} to implement specific provider behavior
16+
#
17+
# @example Basic usage
18+
# provider = BaseProvider.new(
19+
# stream_callback: ->(chunk) { puts chunk },
20+
# function_callback: ->(name, **kwargs) { execute_function(name, kwargs) }
21+
# )
22+
# response = provider.call
23+
#
24+
# @attr_internal request [ActiveAgent::Request] The current request being processed
25+
# @attr_internal message_stack [Array] Stack of messages to be applied to the request
26+
# @attr_internal stream_callback [Proc] Callback invoked for each streaming chunk
27+
# @attr_internal stream_finished [Boolean] Flag indicating if streaming has completed
28+
# @attr_internal function_callback [Proc] Callback for handling function/tool calls
29+
#
30+
# @see ActiveAgent::Providers::BaseProvider
1031
class BaseProvider < ActiveAgent::Providers::BaseProvider
11-
attr_internal :stream_callback, :function_callback
32+
attr_internal :request, :message_stack,
33+
:stream_callback, :stream_finished,
34+
:function_callback
1235

1336
def initialize(kwargs = {})
1437
self.stream_callback = kwargs.delete(:stream_callback)
1538
self.function_callback = kwargs.delete(:function_callback)
39+
self.message_stack = []
1640

1741
super(kwargs)
1842
end
1943

20-
# @return [OpenAI::Client]
21-
def client
22-
::OpenAI::Client.new(options.to_hc)
23-
end
24-
44+
# Main entry point for executing the provider call.
45+
#
46+
# This method orchestrates the provider execution by wrapping the prompt
47+
# resolution in error handling logic. It serves as the primary interface
48+
# for initiating provider operations.
49+
#
50+
# @return [ActiveAgent::Providers::Response] The result of the prompt resolution
51+
# @raise [StandardError] Any errors that occur during execution will be
52+
# handled by the error handling wrapper
53+
#
54+
# @example Execute the provider call
55+
# provider.call
56+
# # => <result of prompt resolution>
2557
def call
2658
with_error_handling do
2759
resolve_prompt
2860
end
2961
end
3062

63+
# @return [OpenAI::Client] a configured OpenAI client instance
64+
def client
65+
::OpenAI::Client.new(options.to_hc)
66+
end
67+
3168
# @return [String] Name of service, e.g., Anthropic
3269
def service_name = "OpenAI"
3370

@@ -36,11 +73,62 @@ def service_name = "OpenAI"
3673
# @return [Class] The Options class for the specific provider, e.g., Anthropic::Options
3774
def options_type = OpenAI::Options
3875

76+
# @return response [ActiveAgent::Providers::Response]
77+
def resolve_prompt
78+
# Apply Tool/Function Messages and Reset Processing Buffer
79+
self.request.messages = [ *request.messages, *message_stack ]
80+
self.message_stack = []
81+
# @todo Validate Request
82+
83+
## Prepare Executation Environment
84+
parameters = request.to_hc
85+
if request.stream
86+
parameters[:stream] = process_stream
87+
self.stream_finished = false
88+
end
89+
90+
## Execute
91+
api_response = client_request_create(parameters:)
92+
process_finished(api_response.presence&.deep_symbolize_keys)
93+
end
94+
95+
def client_request_create(parameters:)
96+
fail(NotImplementedError)
97+
end
98+
99+
# @return [Proc] a Proc that accepts an API response chunk and processes it
100+
# @see #process_stream_chunk
101+
#
102+
# @example
103+
# stream_processor = process_stream
104+
# api_client.stream(params, &stream_processor)
39105
def process_stream
40106
proc do |api_response_chunk|
41107
process_stream_chunk(api_response_chunk.deep_symbolize_keys)
42108
end
43109
end
110+
111+
# Processes a tool call function from the API response.
112+
#
113+
# This method extracts the function name and arguments from an API function call,
114+
# parses the arguments as JSON, and invokes the function callback with the parsed parameters.
115+
#
116+
# @param api_function_call [Hash] The function call data from the API response
117+
# @option api_function_call [String] :name The name of the function to call
118+
# @option api_function_call [String] :arguments JSON string containing the function arguments
119+
#
120+
# @return [Object] The result of the function callback invocation
121+
#
122+
# @example Processing a tool call
123+
# api_call = { name: "get_weather", arguments: '{"location":"NYC"}' }
124+
# process_tool_call_function(api_call)
125+
# # => calls function_callback.call("get_weather", location: "NYC")
126+
def process_tool_call_function(api_function_call)
127+
name = api_function_call[:name]
128+
kwargs = JSON.parse(api_function_call[:arguments], symbolize_names: true) if api_function_call[:arguments]
129+
130+
function_callback.call(name, **kwargs)
131+
end
44132
end
45133
end
46134
end

lib/active_agent/providers/open_ai/chat_provider.rb

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,16 @@ module ActiveAgent
55
module Providers
66
module OpenAI
77
class ChatProvider < BaseProvider
8-
attr_internal :request, :message_stack, :stream_finished
9-
108
def initialize(...)
119
super
1210

13-
self.request = Chat::Request.new(context)
14-
self.message_stack = []
11+
self.request = Chat::Request.new(context)
1512
end
1613

1714
protected
1815

19-
# @return response [ActiveAgent::Providers::Response]
20-
def resolve_prompt
21-
# Apply Tool/Function Messages
22-
request.messages = message_stack unless message_stack.empty?
23-
# @todo Validate Request
24-
25-
## Prepare Executation Environment
26-
parameters = request.to_hc
27-
if request.stream
28-
parameters[:stream] = process_stream
29-
self.stream_finished = false
30-
end
31-
message_stack.replace(parameters[:messages])
32-
33-
## Execute
34-
api_response = client.chat(parameters:)
35-
process_finished(api_response.presence&.deep_symbolize_keys)
16+
def client_request_create(parameters:)
17+
client.chat(parameters:)
3618
end
3719

3820
# @return void
@@ -72,19 +54,15 @@ def process_tool_calls(api_tool_calls)
7254
fail "Unexpected Tool Call Type: #{api_tool_call[:type]}"
7355
end
7456

75-
message = Chat::Requests::Messages::Tool.new(tool_call_id: api_tool_call[:id], content: content.to_json)
57+
message = Chat::Requests::Messages::Tool.new(
58+
tool_call_id: api_tool_call[:id],
59+
content: content.to_json
60+
)
61+
7662
message_stack.push(message.to_hc)
7763
end
7864
end
7965

80-
# @return result [Unknown]
81-
def process_tool_call_function(api_function_call)
82-
name = api_function_call[:name]
83-
kwargs = JSON.parse(api_function_call[:arguments], symbolize_names: true) if api_function_call[:arguments]
84-
85-
function_callback.call(name, **kwargs)
86-
end
87-
8866
# @return response [ActiveAgent::Providers::Response]
8967
def process_finished(api_response = nil)
9068
if (api_message = api_response&.dig(:choices, 0, :message))
@@ -104,16 +82,6 @@ def process_finished(api_response = nil)
10482
end
10583
end
10684

107-
# ActiveAgent::ActionPrompt::Message.new(
108-
# generation_id: api_message[:id] || api_response[:id],
109-
# content: api_message[:content],
110-
# role: api_message[:role].intern,
111-
# action_requested: api_message[:finish_reason] == "tool_calls",
112-
# raw_actions: api_message[:tool_calls] || [],
113-
# requested_actions: handle_actions(api_message[:tool_calls]),
114-
# content_type: context[:output_schema].present? ? "application/json" : "text/plain"
115-
# )
116-
11785
# def embeddings_response(response, request_params = nil)
11886
# message = ActiveAgent::ActionPrompt::Message.new(content: response.dig("data", 0, "embedding"), role: "assistant")
11987

lib/active_agent/providers/open_ai/responses/request.rb

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -143,40 +143,21 @@ def input=(value)
143143
def input
144144
value = super
145145

146-
if value && value.one? && value.first.is_a?(Requests::Inputs::UserMessage)
146+
if value && value.one? && value.first.is_a?(Requests::Inputs::UserMessage) && value.first.content.is_a?(String)
147147
value.first.content
148148
else
149149
value
150150
end
151151
end
152152

153+
153154
# For the message stack
154-
def input_list
155+
def messages
155156
attributes["input"]&.map { |it| it.to_h }
156157
end
157158

158-
# To handle Common Message [input] format
159-
def message=(value)
160-
case value.try(:role) || value[:role]
161-
when :system
162-
self.instructions = value.try(:content) || value[:content]
163-
when :user
164-
self.input = value.try(:content) || value[:content]
165-
end
166-
167-
# self.input = [
168-
# {
169-
# role: value.role,
170-
# content: message_content(value.content)
171-
# }
172-
# ]
173-
end
174-
175-
# To handle Common Messages [input] format
176-
def messages=(value)
177-
value.each do |message|
178-
self.message = message
179-
end
159+
def messages=(values)
160+
self.input = values
180161
end
181162

182163
private

lib/active_agent/providers/open_ai/responses/requests/input.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
require_relative "inputs/base"
44
require_relative "inputs/user_message"
55
require_relative "inputs/system_message"
6+
require_relative "inputs/developer_message"
67
require_relative "inputs/assistant_message"
78
require_relative "inputs/tool_message"
9+
require_relative "inputs/function_call_output"
10+
require_relative "inputs/reasoning"
11+
require_relative "inputs/item_reference"
812
require_relative "inputs/content_parts/base"
913
require_relative "inputs/content_parts/input_text"
1014
require_relative "inputs/content_parts/input_image"

lib/active_agent/providers/open_ai/responses/requests/inputs/base.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "../../../../common/_base_model"
4+
require_relative "../types"
45

56
module ActiveAgent
67
module Providers
@@ -11,12 +12,20 @@ module Inputs
1112
# Base class for input items in Responses API
1213
class Base < Common::BaseModel
1314
attribute :role, :string
14-
attribute :content # Can be string or array of content parts
15+
attribute :content, Types::ContentType.new
1516

1617
validates :role, inclusion: {
17-
in: %w[system user assistant tool],
18+
in: %w[system user assistant developer tool],
1819
allow_nil: true
1920
}
21+
22+
def to_hc
23+
super.tap do |hash|
24+
if content.is_a?(Array) && content.one? && content.first.type == "input_text"
25+
hash[:content] = content.first.text
26+
end
27+
end
28+
end
2029
end
2130
end
2231
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "base"
4+
5+
module ActiveAgent
6+
module Providers
7+
module OpenAI
8+
module Responses
9+
module Requests
10+
module Inputs
11+
module ContentParts
12+
# Audio content part
13+
class InputAudio < Base
14+
attribute :type, :string, as: "input_audio"
15+
attribute :input_audio # Object containing audio properties
16+
17+
validates :type, presence: true
18+
validates :input_audio, presence: true
19+
end
20+
end
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end

0 commit comments

Comments
 (0)