ox-ai-workers 1.0.3.2 → 1.1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40e4c40056a05355df7b66a1c7aacb17bad47f9614d36e6b74123dff5f4a2c32
4
- data.tar.gz: d65bb1c38aeeb5504421fc98055c7a4a03412fd58ced9ad4e8e2d39eb1c2cb7a
3
+ metadata.gz: e3e0b6d4d785c54691cb19806854a74664a48aa512e0e11eb39865cb07df3636
4
+ data.tar.gz: 80bd4adbe5694349bb5710fbc36f72195c181d6781701d49d834a68d48c864b0
5
5
  SHA512:
6
- metadata.gz: 1e475007bbf4da1f6d29e290b073c91476d9ff45d5a0e12d218368de6634d6e66b39ee151163c6d19bbb024bc3147bd2126d4f1faa8a44f649c7837d459ebc05
7
- data.tar.gz: 8564897582fe09d96199dfbd7c805b098057ea69a8aab51912ad483f4323786084488dd41f5ee88c48c8f056cf953b23f1e77f83002b9607ac87814bd3617659
6
+ metadata.gz: 7f017fab582104cced95b80e802a1f06ca5ecbb9bbd6533328ecca9b0d93468c7c873ff28871e9c31ebdff2d51f23ac433eb8b7d27ff14a4cc139f7f4e1808be
7
+ data.tar.gz: 1799968565739b120e1ca012d23cbaf5d16ff2744374c50cc43e5acb7cdd21df781662fc0bb49ad7c34c705e1dc207180abf1e66f6a3c557ec3f1311a939dbd2
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.1
1
+ 3.4.4
data/CHANGELOG.md CHANGED
@@ -1,8 +1,7 @@
1
- ## [1.0.2] - 2025-05-17
1
+ ## [1.1.0] - 2025-05-21
2
2
 
3
- - Added `openai_whisper` model
4
- - Added `openai_transcribe` model
5
- - Added `tool_call_id` for `Iterator`
3
+ - Added `add_base64` and `add_url` for Models
4
+ - Added Anthropic support
6
5
 
7
6
  ## [1.0.1] - 2025-05-13
8
7
 
@@ -65,7 +64,6 @@
65
64
  - Fixed tool calls parsing
66
65
  - Refine Russian locale for Iterator with enhanced step descriptions and planning guidance
67
66
  - Update locale for Iterator
68
- - Added `on_stream` for `Iterator`
69
67
 
70
68
  ## [0.7.10] - 2025-03-31
71
69
 
data/README.md CHANGED
@@ -392,7 +392,7 @@ class MyTool
392
392
 
393
393
  define_function :hello_world, description: "Says hello to someone" do
394
394
  property :name, type: "string", description: "Name to greet" # Default required: true
395
- property :age, type: ["integer", "null"], description: "Age of the person" # Default required: true, can be null so it's optional
395
+ property :age, type: "integer", description: "Age of the person", required: false
396
396
  end
397
397
  end
398
398
 
@@ -409,8 +409,6 @@ class MyTool
409
409
  end
410
410
  ```
411
411
 
412
- The `define_function` method accepts an optional `strict` parameter (defaults to `true`) that controls whether additional properties are allowed in the input. When `strict: true` (default), the schema will include `additionalProperties: false`, enforcing that only defined properties can be used.
413
-
414
412
  Tools can also implement a `context` method that returns information to be included in assistant conversations before each request, which is particularly useful when multiple assistants share a common tool to maintain shared state or history.
415
413
 
416
414
  ### Working with Files and Images
@@ -495,20 +493,6 @@ iterator = OxAiWorkers::Iterator.new(
495
493
  )
496
494
  ```
497
495
 
498
- ### Streaming API Responses
499
-
500
- Enable streaming for real-time feedback:
501
-
502
- ```ruby
503
- worker = OxAiWorkers::Request.new(
504
- on_stream: ->(chunk) {
505
- if chunk.dig('choices', 0, 'delta', 'content')
506
- print chunk.dig('choices', 0, 'delta', 'content')
507
- end
508
- }
509
- )
510
- ```
511
-
512
496
  ### Available Assistant Types
513
497
 
514
498
  OxAiWorkers provides several specialized assistant types:
data/lib/ox-ai-workers.rb CHANGED
@@ -62,7 +62,7 @@ module OxAiWorkers
62
62
 
63
63
  class Configuration
64
64
  attr_accessor :max_tokens, :temperature, :wait_for_complete, :access_token_deepseek, :access_token_openai,
65
- :access_token_stability, :access_token_wolfram, :access_token_anthropic
65
+ :access_token_stability, :access_token_wolfram, :access_token_anthropic, :access_token_gemini
66
66
 
67
67
  def initialize
68
68
  @max_tokens = DEFAULT_MAX_TOKEN
@@ -74,6 +74,7 @@ module OxAiWorkers
74
74
  @access_token_stability = nil
75
75
  @access_token_wolfram = nil
76
76
  @access_token_anthropic = nil
77
+ @access_token_gemini = nil
77
78
 
78
79
  [Array, NilClass, String, Symbol, Hash].each do |c|
79
80
  c.send(:include, OxAiWorkers::PresentCompat) unless c.method_defined?(:present?)
@@ -25,9 +25,9 @@ module OxAiWorkers
25
25
  execute
26
26
  end
27
27
 
28
- def init_worker(delayed: false, model: nil, on_stream: nil)
28
+ def init_worker(delayed: false, model: nil)
29
29
  model ||= OxAiWorkers.default_model
30
- delayed ? DelayedRequest.new(model:) : Request.new(model:, on_stream:)
30
+ delayed ? DelayedRequest.new(model:) : Request.new(model:)
31
31
  end
32
32
 
33
33
  def replace_context(context)
@@ -11,7 +11,7 @@ module OxAiWorkers
11
11
  store_locale
12
12
 
13
13
  @pipeline = Tool::Pipeline.new(
14
- on_message: ->(text:) { @iterator.add_queue text, role: :system }
14
+ on_message: ->(text:) { @iterator.add_queue text }
15
15
  )
16
16
 
17
17
  with_locale do
@@ -18,6 +18,11 @@ module OxAiWorkers
18
18
  @locale = locale || I18n.locale
19
19
  @call_id = 0
20
20
 
21
+ @def_only = def_only || ITERATOR_FUNCTIONS
22
+ @def_except = def_except
23
+
24
+ init_white_list_with available_defs
25
+
21
26
  with_locale do
22
27
  define_function :inner_monologue, description: I18n.t('oxaiworkers.iterator.inner_monologue.description') do
23
28
  property :speach, type: 'string', description: I18n.t('oxaiworkers.iterator.inner_monologue.speach'),
@@ -37,8 +42,6 @@ module OxAiWorkers
37
42
  @tools = tools
38
43
  @role = role
39
44
  @context = []
40
- @def_only = def_only || ITERATOR_FUNCTIONS
41
- @def_except = def_except
42
45
 
43
46
  @on_inner_monologue = on_inner_monologue
44
47
  @on_outer_voice = on_outer_voice
@@ -129,14 +132,14 @@ module OxAiWorkers
129
132
  end
130
133
  @worker.append(messages: @messages)
131
134
  @tasks.each { |task| @worker.append(role: :user, content: "<task>\n#{task}\n</task>") }
132
- @worker.tools = function_schemas.to_openai_format(only: available_defs)
135
+ @worker.tools = [function_schemas]
133
136
  return unless @tools.present?
134
137
 
135
138
  @worker.tools += @tools.map do |tool|
136
139
  if tool.respond_to?(:function_schemas)
137
- tool.function_schemas.to_openai_format
140
+ tool.function_schemas
138
141
  else
139
- tool.class.function_schemas.to_openai_format
142
+ tool.class.function_schemas
140
143
  end
141
144
  end.flatten
142
145
  end
@@ -180,6 +183,8 @@ module OxAiWorkers
180
183
  OxAiWorkers.logger.warn "Iterator::ServerError #{e.message}. Waiting 10 seconds..."
181
184
  sleep(10)
182
185
  external_request
186
+ rescue Faraday::BadRequestError => e
187
+ OxAiWorkers.logger.warn "Iterator::BadRequestError #{e.message}. #{@worker.messages.to_json}"
183
188
  end
184
189
 
185
190
  def tick_or_wait
@@ -228,26 +233,12 @@ module OxAiWorkers
228
233
  @call_id += 1
229
234
  # Add tool call message in the correct format
230
235
  out = tool.send(external_call[:name], **external_call[:args])
231
- @queue << {
232
- role: :assistant,
233
- tool_calls: [{
234
- id: "call_#{@call_id}",
235
- type: 'function',
236
- function: {
237
- name: external_call[:name],
238
- arguments: external_call[:args].to_json
239
- }
240
- }]
241
- }
242
- @queue << if out.present?
243
- { role: :tool,
244
- content: out,
245
- tool_call_id: "call_#{@call_id}" }
246
- else
247
- { role: :tool,
248
- content: "Tool call #{external_call[:name]} successful.",
249
- tool_call_id: "call_#{@call_id}" }
250
- end
236
+ @queue += @worker.model.tool_call(
237
+ name: external_call[:name],
238
+ args: external_call[:args],
239
+ call_id: @call_id,
240
+ out:
241
+ )
251
242
  end
252
243
  @worker.finish
253
244
  iterate! if can_iterate?
@@ -269,35 +260,22 @@ module OxAiWorkers
269
260
  @queue << { role:, content: text }
270
261
  end
271
262
 
272
- def add_context(text, role: :system)
263
+ def add_context(text, role: :user)
273
264
  add_raw_context({ role:, content: text })
274
265
  end
275
266
 
276
267
  def add_file(pdf:, filename:, text:, role: :user)
277
- content = []
278
- content << { type: 'text', text: } if text.present?
279
- content << {
280
- type: 'file',
281
- file: {
282
- filename:,
283
- file_data: "data:application/pdf;base64,#{Base64.strict_encode64(pdf)}"
284
- }
285
- }
286
-
268
+ content = @worker.model.add_base64(binary: pdf, filename:, text:, mime_type: 'application/pdf')
287
269
  add_raw_context({ role:, content: })
288
270
  end
289
271
 
290
272
  def add_image(text:, url: nil, binary: nil, role: :user, detail: 'auto', mime_type: 'image/png')
291
273
  content = []
292
- content << { type: 'text', text: } if text.present?
293
-
294
- image_url = if binary.present?
295
- "data:#{mime_type};base64,#{Base64.strict_encode64(binary)}"
296
- else
297
- url
298
- end
299
-
300
- content << { type: 'image_url', image_url: { url: image_url, detail: } }
274
+ if binary.present?
275
+ content = @worker.model.add_base64(binary:, filename:, text:, mime_type:, detail:)
276
+ elsif url.present?
277
+ content = @worker.model.add_url(url:, text:, detail:, mime_type:)
278
+ end
301
279
 
302
280
  add_raw_context({ role:, content: })
303
281
  end
@@ -5,9 +5,145 @@ module OxAiWorkers
5
5
  class AnthropicMax < LLMBase
6
6
  def initialize(uri_base: nil, api_key: nil, model: nil, max_tokens: nil, temperature: nil, frequency_penalty: nil)
7
7
  @model = model || 'claude-3-7-sonnet-latest'
8
- @uri_base = uri_base || 'https://api.anthropic.com/v1/'
8
+ @uri_base = uri_base # || 'https://api.anthropic.com/v1/'
9
9
  @api_key = api_key || OxAiWorkers.configuration.access_token_anthropic
10
- super(uri_base:, api_key: @api_key, model: @model, max_tokens:, temperature:, frequency_penalty:)
10
+ super(uri_base: @uri_base, api_key: @api_key, model: @model, max_tokens:, temperature:, frequency_penalty:)
11
+ end
12
+
13
+ def client
14
+ @client ||= Anthropic::Client.new(
15
+ access_token: @api_key,
16
+ uri_base: @uri_base,
17
+ log_errors: true
18
+ )
19
+ end
20
+
21
+ def request(parameters)
22
+ client.messages(parameters:)
23
+ end
24
+
25
+ def build_parameters(messages:, tools: [], filtered_functions: [], tool_choice: nil)
26
+ puts "messages: #{messages.inspect}"
27
+ parameters = {
28
+ model: @model,
29
+ system: messages.select { |m| m[:role] == :system }.map { |m| m[:content] }.join("\n\n"),
30
+ messages: messages.reject { |m| m[:role] == :system },
31
+ temperature: @temperature,
32
+ max_tokens: @max_tokens
33
+ }
34
+ if tools.present?
35
+ functions = tools.map(&:to_anthropic_format).flatten
36
+
37
+ @names = functions.map { |f| f[:name] }
38
+
39
+ parameters[:tools] = functions.reject { |f| filtered_functions.include?(f[:name]) }
40
+
41
+ parameters[:tool_choice] =
42
+ tool_choice.nil? ? { type: 'any' } : { type: 'tool', name: tool_choice }
43
+ end
44
+ parameters
45
+ end
46
+
47
+ def tool_call(name:, args:, call_id:, out:)
48
+ [
49
+ {
50
+ role: :assistant,
51
+ content: [
52
+ {
53
+ type: 'tool_use',
54
+ id: "call_#{call_id}",
55
+ name:,
56
+ input: args
57
+ }
58
+ ]
59
+ },
60
+ {
61
+ role: :user,
62
+ content: [
63
+ {
64
+ type: 'tool_result',
65
+ tool_use_id: "call_#{call_id}",
66
+ content: out.present? ? out : "Tool call #{name} successful."
67
+ }
68
+ ]
69
+ }
70
+ ]
71
+ end
72
+
73
+ def add_base64(binary:, filename:, text:, mime_type:)
74
+ content = []
75
+ content << { type: 'text', text: } if text.present?
76
+ content << {
77
+ type: mime_type.include?('image') ? 'image' : 'document',
78
+ source: {
79
+ type: 'base64',
80
+ media_type: mime_type,
81
+ data: Base64.strict_encode64(binary)
82
+ }
83
+ }
84
+ content
85
+ end
86
+
87
+ def add_url(url:, text:, mime_type:)
88
+ content = []
89
+ content << { type: 'text', text: } if text.present?
90
+ content << {
91
+ type: mime_type.include?('image') ? 'image' : 'document',
92
+ source: {
93
+ type: 'url',
94
+ url: url
95
+ }
96
+ }
97
+ content
98
+ end
99
+
100
+ def parse_response(response, &)
101
+ choices = response['content']
102
+ @is_truncated = (response['stop_reason'] == 'max_tokens')
103
+ return if choices.nil? || choices.empty?
104
+
105
+ choices.each do |choice|
106
+ result, tool_calls = parse_one_choice(choice)
107
+ yield(result, @is_truncated, tool_calls)
108
+ end
109
+ end
110
+
111
+ def parse_one_choice(choice)
112
+ return unless choice # Skip if there's no choice
113
+
114
+ # Initialize result variables
115
+ @result = nil
116
+ @tool_calls = []
117
+
118
+ # Process content item
119
+ if choice['type'] == 'tool_use'
120
+ # Handle tool use
121
+ begin
122
+ # Attempt to parse arguments, handle potential JSON errors
123
+ args = JSON.parse(choice['input'].to_json, symbolize_names: true)
124
+ rescue JSON::ParserError => e
125
+ OxAiWorkers.logger.error("Failed to parse tool call arguments: #{e.message}", for: self.class)
126
+ OxAiWorkers.logger.debug("Raw arguments: #{choice['input']}", for: self.class)
127
+ end
128
+
129
+ fname = @names.find { |n| n.end_with?(choice['name']) }
130
+ if fname != choice['name']
131
+ OxAiWorkers.logger.error("Tool call name #{choice['name']} not found. Using #{fname} instead.",
132
+ for: self.class)
133
+ end
134
+
135
+ tool_call = {
136
+ class: fname.split('__').first,
137
+ name: fname.split('__').last,
138
+ args:
139
+ }
140
+ @tool_calls << tool_call
141
+ elsif choice['type'] == 'text'
142
+ # Handle text content
143
+ @result = choice['text']
144
+ end
145
+
146
+ [@result, @tool_calls]
11
147
  end
12
148
  end
13
149
  end
@@ -13,6 +13,148 @@ module OxAiWorkers
13
13
  @uri_base = uri_base
14
14
  @model = model
15
15
  end
16
+
17
+ def request(parameters)
18
+ client.chat(parameters:)
19
+ end
20
+
21
+ def client
22
+ @client ||= OpenAI::Client.new(
23
+ access_token: @api_key,
24
+ uri_base: @uri_base,
25
+ log_errors: true # Highly recommended in development, so you can see what errors OpenAI is returning. Not recommended in production because it could leak private data to your logs.
26
+ )
27
+ end
28
+
29
+ def build_parameters(messages:, tools: [], filtered_functions: [], tool_choice: nil)
30
+ parameters = {
31
+ model: @model,
32
+ messages:,
33
+ temperature: @temperature,
34
+ max_completion_tokens: @max_tokens,
35
+ frequency_penalty: @frequency_penalty
36
+ }
37
+ if tools.present?
38
+ functions = tools.map(&:to_openai_format).flatten
39
+
40
+ parameters[:tools] = functions.reject { |f| filtered_functions.include?(f[:name]) }
41
+
42
+ parameters[:tool_choice] =
43
+ tool_choice.nil? ? 'required' : { type: 'function', function: { name: tool_choice } }
44
+ end
45
+ parameters
46
+ end
47
+
48
+ def tool_call(name:, args:, call_id:, out:)
49
+ [
50
+ {
51
+ role: :assistant,
52
+ tool_calls: [{
53
+ id: "call_#{call_id}",
54
+ type: 'function',
55
+ function: {
56
+ name:,
57
+ arguments: args.to_json
58
+ }
59
+ }]
60
+ },
61
+ {
62
+ role: :tool,
63
+ content: out.present? ? out : "Tool call #{name} successful.",
64
+ tool_call_id: "call_#{call_id}"
65
+ }
66
+ ]
67
+ end
68
+
69
+ def add_base64(binary:, filename:, text:, mime_type:, detail: 'auto')
70
+ content = []
71
+ content << { type: 'text', text: } if text.present?
72
+ content << if mime_type.include?('image')
73
+ { type: 'image_url',
74
+ image_url: {
75
+ url: "data:#{mime_type};base64,#{Base64.strict_encode64(binary)}",
76
+ detail:
77
+ }
78
+ }
79
+ else
80
+ {
81
+ type: 'file',
82
+ file: {
83
+ filename:,
84
+ file_data: "data:#{mime_type};base64,#{Base64.strict_encode64(binary)}"
85
+ }
86
+ }
87
+ end
88
+ content
89
+ end
90
+
91
+ def add_url(url:, text:, detail: 'auto')
92
+ content = []
93
+ content << { type: 'text', text: } if text.present?
94
+ content << { type: 'image_url', image_url: { url:, detail: } }
95
+ content
96
+ end
97
+
98
+ def parse_response(response, &)
99
+ choices = response['choices']
100
+ return if choices.nil? || choices.empty?
101
+
102
+ choices.each do |choice|
103
+ arr = parse_one_choice(choice)
104
+ yield(arr)
105
+ end
106
+ end
107
+
108
+ def parse_one_choice(choice)
109
+ message = choice['message']
110
+ return unless message # Skip if there's no message in this choice
111
+
112
+ # Accumulate raw tool calls if present
113
+ tool_calls_raw = message['tool_calls']
114
+
115
+ # Update result with the content if present
116
+ current_result = message['content']
117
+ @result = current_result if current_result.present?
118
+
119
+ # Update finish reason and truncation status based on the *last* choice's reason
120
+ current_finish_reason = choice['finish_reason']
121
+ @is_truncated = (current_finish_reason == 'length')
122
+
123
+ @tool_calls = _parse_tool_calls(tool_calls_raw) unless @is_truncated || tool_calls_raw.empty?
124
+ [@result, @is_truncated, @tool_calls]
125
+ end
126
+
127
+ def _parse_tool_calls(tool_calls_raw)
128
+ tool_calls = [] # Ensure it's clean before parsing
129
+ tool_calls_raw.each do |tool_call|
130
+ next unless tool_call['type'] == 'function' # Ensure it's a function call
131
+
132
+ function = tool_call['function']
133
+ next unless function && function['name'] && function['arguments']
134
+
135
+ begin
136
+ # Attempt to parse arguments, handle potential JSON errors
137
+ args = JSON.parse(function['arguments'], symbolize_names: true)
138
+ rescue JSON::ParserError => e
139
+ OxAiWorkers.logger.error("Failed to parse tool call arguments: #{e.message}", for: self.class)
140
+ OxAiWorkers.logger.debug("Raw arguments: #{function['arguments']}", for: self.class)
141
+ # Decide how to handle parsing errors, e.g., skip this call or add with empty args
142
+ # Skipping for now, as partial args are likely useless.
143
+ next
144
+ end
145
+ OxAiWorkers.logger.debug("function: #{function.inspect}", for: self.class)
146
+ # Accumulate parsed tool calls
147
+ next if function['name'].empty?
148
+
149
+ tool_calls << {
150
+ class: function['name'].split('__').first,
151
+ name: function['name'].split('__').last,
152
+ args:
153
+ }
154
+ # @last_call = function['name'].to_s
155
+ end
156
+ tool_calls
157
+ end
16
158
  end
17
159
  end
18
160
  end
@@ -3,16 +3,14 @@
3
3
  module OxAiWorkers
4
4
  class ModuleRequest
5
5
  attr_accessor :result, :client, :messages, :model, :custom_id, :tools, :errors,
6
- :tool_calls_raw, :tool_calls, :is_truncated, :finish_reason,
7
- :on_stream_proc, :call_stack, :last_call, :stop_double_calls
6
+ :tool_calls, :is_truncated,
7
+ :call_stack, :last_call, :stop_double_calls
8
8
 
9
- def initialize_requests(model:, on_stream: nil, call_stack: nil)
9
+ def initialize_requests(model:, call_stack: nil)
10
10
  @custom_id = SecureRandom.uuid
11
11
  @model = model
12
12
  @client = nil
13
13
  @is_truncated = false
14
- @finish_reason = nil
15
- @on_stream_proc = on_stream
16
14
  @call_stack = call_stack
17
15
  @last_call = nil
18
16
 
@@ -25,18 +23,12 @@ module OxAiWorkers
25
23
  end
26
24
 
27
25
  def cleanup
28
- @client ||= OpenAI::Client.new(
29
- access_token: @model.api_key,
30
- uri_base: @model.uri_base,
31
- log_errors: true # Highly recommended in development, so you can see what errors OpenAI is returning. Not recommended in production because it could leak private data to your logs.
32
- )
26
+ @client ||= @model.client
33
27
  @result = nil
34
28
  @errors = nil
35
29
  @messages = []
36
30
  @tool_calls = nil
37
- @tool_calls_raw = nil
38
31
  @is_truncated = false
39
- @finish_reason = nil
40
32
  # @last_call = nil
41
33
  end
42
34
 
@@ -46,32 +38,23 @@ module OxAiWorkers
46
38
  end
47
39
 
48
40
  def params
49
- parameters = {
50
- model: @model.model,
41
+ filtered_functions = []
42
+ filtered_functions << @last_call if @stop_double_calls.include?(@last_call)
43
+
44
+ tool_choice = if @call_stack&.any?
45
+ func1 = @call_stack.first
46
+ @call_stack = @call_stack.drop(1)
47
+ func1
48
+ else
49
+ nil
50
+ end
51
+
52
+ @model.build_parameters(
51
53
  messages: @messages,
52
- temperature: @model.temperature,
53
- max_completion_tokens: @model.max_tokens,
54
- frequency_penalty: @model.frequency_penalty
55
- }
56
- if @tools.present?
57
- parameters[:tools] = @tools.reject do |f|
58
- tool_name = f[:function][:name]
59
- tool_name == @last_call && @stop_double_calls.include?(tool_name)
60
- end
61
-
62
- if @call_stack&.any?
63
- func1 = @call_stack.first
64
- @call_stack = @call_stack.drop(1)
65
- parameters[:tool_choice] = { type: 'function', function: { name: func1 } }
66
- else
67
- parameters[:tool_choice] = 'required'
68
- end
69
- end
70
- if @on_stream_proc.present?
71
- parameters[:stream] = @on_stream_proc
72
- parameters[:stream_options] = { include_usage: true }
73
- end
74
- parameters
54
+ tools: @tools,
55
+ filtered_functions:,
56
+ tool_choice:
57
+ )
75
58
  end
76
59
 
77
60
  def not_found_is_ok
@@ -83,75 +66,15 @@ module OxAiWorkers
83
66
  def parse_choices(response)
84
67
  # Reset instance variables before processing choices
85
68
  @result = nil
86
- @tool_calls_raw = []
87
69
  @tool_calls = []
88
- @finish_reason = nil
89
70
  @is_truncated = false
90
71
 
91
- choices = response['choices']
92
- return if choices.nil? || choices.empty?
93
-
94
- choices.each do |choice|
95
- # Parse basic info and accumulate raw tool calls
96
- parse_one_choice(choice)
97
- end
98
-
99
- # Only parse tool calls if the response wasn't truncated
100
- _parse_tool_calls unless @is_truncated || @tool_calls_raw.empty?
101
- end
102
-
103
- def parse_one_choice(choice)
104
- message = choice['message']
105
- return unless message # Skip if there's no message in this choice
106
-
107
- # Accumulate raw tool calls if present
108
- @tool_calls_raw.concat(message['tool_calls']) if message['tool_calls']
109
-
110
- # Update result with the content if present
111
- current_result = message['content']
112
- @result = current_result if current_result.present?
113
-
114
- # Update finish reason and truncation status based on the *last* choice's reason
115
- current_finish_reason = choice['finish_reason']
116
- if current_finish_reason.present?
117
- @finish_reason = current_finish_reason
118
- @is_truncated = (@finish_reason == 'length')
119
- end
120
- end
121
-
122
- private
123
-
124
- # Parses the accumulated raw tool calls into the structured @tool_calls array.
125
- # This should only be called after confirming the response is not truncated.
126
- def _parse_tool_calls
127
- @tool_calls = [] # Ensure it's clean before parsing
128
- @tool_calls_raw.each do |tool_call|
129
- next unless tool_call['type'] == 'function' # Ensure it's a function call
130
-
131
- function = tool_call['function']
132
- next unless function && function['name'] && function['arguments']
133
-
134
- begin
135
- # Attempt to parse arguments, handle potential JSON errors
136
- args = JSON.parse(function['arguments'], symbolize_names: true)
137
- rescue JSON::ParserError => e
138
- OxAiWorkers.logger.error("Failed to parse tool call arguments: #{e.message}", for: self.class)
139
- OxAiWorkers.logger.debug("Raw arguments: #{function['arguments']}", for: self.class)
140
- # Decide how to handle parsing errors, e.g., skip this call or add with empty args
141
- # Skipping for now, as partial args are likely useless.
142
- next
143
- end
144
- OxAiWorkers.logger.debug("function: #{function.inspect}", for: self.class)
145
- # Accumulate parsed tool calls
146
- next if function['name'].empty?
147
-
148
- @tool_calls << {
149
- class: function['name'].split('__').first,
150
- name: function['name'].split('__').last,
151
- args: args
152
- }
153
- @last_call = function['name'].to_s
72
+ @model.parse_response(response) do |result, is_truncated, tool_calls|
73
+ @result = result
74
+ @is_truncated = is_truncated
75
+ @tool_calls += tool_calls
154
76
  end
77
+ @last_call = @tool_calls.last[:name] if @tool_calls.any?
155
78
  end
156
79
  end
157
80
  end
@@ -10,7 +10,7 @@ module OxAiWorkers
10
10
  end
11
11
 
12
12
  def request!
13
- response = @client.chat(parameters: params)
13
+ response = @model.request(params)
14
14
  parse_choices(response)
15
15
  end
16
16
 
@@ -17,8 +17,8 @@ module OxAiWorkers
17
17
  required: true
18
18
  property :result, type: 'string', description: I18n.t('oxaiworkers.tool.pipeline.send_message.result'),
19
19
  required: true
20
- property :example, type: %w[string null], description: I18n.t('oxaiworkers.tool.pipeline.send_message.example'),
21
- required: true
20
+ property :example, type: 'string', description: I18n.t('oxaiworkers.tool.pipeline.send_message.example'),
21
+ required: false
22
22
  property :to_id, type: 'string', description: I18n.t('oxaiworkers.tool.pipeline.send_message.to_id'),
23
23
  required: true
24
24
  end
@@ -19,8 +19,8 @@ module OxAiWorkers
19
19
  property :prompt, type: 'string', description: I18n.t('oxaiworkers.tool.pixels.generate_image.prompt'),
20
20
  required: true
21
21
  if worker.sizes.length > 1
22
- property :size, type: %w[string null], description: I18n.t('oxaiworkers.tool.pixels.generate_image.size'),
23
- enum: worker.sizes
22
+ property :size, type: 'string', description: I18n.t('oxaiworkers.tool.pixels.generate_image.size'),
23
+ enum: worker.sizes, required: false
24
24
  end
25
25
  if current_dir.present?
26
26
  property :file_name, type: 'string',
@@ -28,8 +28,8 @@ module OxAiWorkers
28
28
  required: true
29
29
  end
30
30
  if worker.qualities.length > 1
31
- property :quality, type: %w[string null], description: I18n.t('oxaiworkers.tool.pixels.generate_image.quality'),
32
- enum: worker.qualities
31
+ property :quality, type: 'string', description: I18n.t('oxaiworkers.tool.pixels.generate_image.quality'),
32
+ enum: worker.qualities, required: false
33
33
  end
34
34
  end
35
35
 
@@ -19,8 +19,8 @@ module OxAiWorkers
19
19
  property :prompt, type: 'string', description: I18n.t('oxaiworkers.tool.wolfram_alpha.ask.prompt'),
20
20
  required: true
21
21
  if location.nil?
22
- property :location, type: %w[string null], description: I18n.t('oxaiworkers.tool.wolfram_alpha.ask.location'),
23
- required: true
22
+ property :location, type: 'string', description: I18n.t('oxaiworkers.tool.wolfram_alpha.ask.location'),
23
+ required: false
24
24
  end
25
25
  end
26
26
 
@@ -109,7 +109,7 @@ module OxAiWorkers
109
109
  name = function_name(method_name)
110
110
 
111
111
  if block_given?
112
- parameters = ParameterBuilder.new(parent_type: 'object', strict:).build(&)
112
+ parameters = ParameterBuilder.new(parent_type: 'object').build(&)
113
113
 
114
114
  if parameters[:properties].empty?
115
115
  raise ArgumentError,
@@ -118,11 +118,11 @@ module OxAiWorkers
118
118
  else
119
119
  # Create an empty parameters object with additionalProperties: false when strict is true
120
120
  parameters = { type: 'object', properties: {} }
121
- parameters[:additionalProperties] = false if strict
121
+ # parameters[:additionalProperties] = false if strict
122
122
  end
123
123
 
124
124
  function_params = { name:, description:, parameters: }
125
- function_params[:strict] = true if strict
125
+ function_params[:strict] = strict
126
126
 
127
127
  @schemas[method_name] = {
128
128
  type: 'function',
@@ -133,36 +133,47 @@ module OxAiWorkers
133
133
  # Converts schemas to OpenAI-compatible format
134
134
  #
135
135
  # @return [String] JSON string of schemas in OpenAI format
136
- def to_openai_format(only: nil)
137
- valid_schemas(only:).values
136
+ def to_openai_format
137
+ @schemas_openai ||= Marshal.load(Marshal.dump(valid_schemas.values))
138
+ @schemas_openai.map do |schema|
139
+ if schema[:function][:strict] == true
140
+ parameters = schema[:function][:parameters]
141
+ parameters[:additionalProperties] = false
142
+ parameters[:properties].each do |key, param|
143
+ param[:type] = parameters[:required].include?(key.to_s) ? param[:type] : [param[:type], 'null']
144
+ end
145
+ parameters[:required] = parameters[:properties].keys.map(&:to_s)
146
+ else
147
+ schema[:function].delete(:strict)
148
+ end
149
+ schema
150
+ end
138
151
  end
139
152
 
140
153
  # Returns a subset of schemas based on the provided filter.
141
154
  #
142
155
  # @param only [Array<Symbol>] An optional array of schema names to filter by.
143
156
  # @return [Hash<Symbol, Hash>] A hash of schemas with their corresponding names as keys.
144
- def valid_schemas(only: nil)
145
- if only.nil?
146
- @schemas
147
- else
148
- @schemas.select { |name, _schema| only.include?(name) }
149
- end
157
+ def valid_schemas
158
+ @schemas
150
159
  end
151
160
 
152
161
  # Converts schemas to Anthropic-compatible format
153
162
  #
154
163
  # @return [String] JSON string of schemas in Anthropic format
155
- def to_anthropic_format(only: nil)
156
- valid_schemas(only:).values.map do |schema|
157
- schema[:function].transform_keys('parameters' => 'input_schema')
164
+ def to_anthropic_format
165
+ @schemas_anthropic ||= Marshal.load(Marshal.dump(valid_schemas.values))
166
+ @schemas_anthropic.map do |schema|
167
+ schema[:function].delete(:strict)
168
+ schema[:function].transform_keys(parameters: :input_schema)
158
169
  end
159
170
  end
160
171
 
161
172
  # Converts schemas to Google Gemini-compatible format
162
173
  #
163
174
  # @return [String] JSON string of schemas in Google Gemini format
164
- def to_google_gemini_format(only: nil)
165
- valid_schemas(only:).values.map { |schema| schema[:function] }
175
+ def to_google_gemini_format
176
+ valid_schemas.values.map { |schema| schema[:function] }
166
177
  end
167
178
  end
168
179
 
@@ -170,10 +181,9 @@ module OxAiWorkers
170
181
  class ParameterBuilder
171
182
  VALID_TYPES = %w[object array string number integer boolean null].freeze
172
183
 
173
- def initialize(parent_type:, strict: true)
184
+ def initialize(parent_type:)
174
185
  @schema = parent_type == 'object' ? { type: 'object', properties: {}, required: [] } : {}
175
186
  @parent_type = parent_type
176
- @strict = strict
177
187
  end
178
188
 
179
189
  # Builds the parameter schema
@@ -200,7 +210,7 @@ module OxAiWorkers
200
210
  prop = { type:, description:, enum: }.compact
201
211
 
202
212
  if block_given?
203
- nested_schema = ParameterBuilder.new(parent_type: type, strict: @strict).build(&)
213
+ nested_schema = ParameterBuilder.new(parent_type: type).build(&)
204
214
 
205
215
  case type
206
216
  when 'object'
@@ -222,7 +232,7 @@ module OxAiWorkers
222
232
  if @parent_type == 'object'
223
233
  @schema[:properties][name] = prop
224
234
  @schema[:required] << name.to_s if required
225
- @schema[:additionalProperties] = false if @strict
235
+ # @schema[:additionalProperties] = false if @strict
226
236
  else
227
237
  @schema = prop
228
238
  end
@@ -246,7 +256,7 @@ module OxAiWorkers
246
256
  raise ArgumentError, "Invalid name '#{name}'. Name must be a symbol" unless name.is_a?(Symbol)
247
257
  end
248
258
 
249
- unless VALID_TYPES.include?(type) || type.is_a?(Array) && type.all? { |t| VALID_TYPES.include?(t) }
259
+ unless VALID_TYPES.include?(type)
250
260
  raise ArgumentError, "Invalid type '#{type}'. Valid types are: #{VALID_TYPES.join(', ')}"
251
261
  end
252
262
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OxAiWorkers
4
- VERSION = '1.0.3.2'
4
+ VERSION = '1.1.0.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ox-ai-workers
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3.2
4
+ version: 1.1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Smolev