ruby-pi 0.1.1 → 0.1.2
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/ruby_pi/llm/anthropic.rb +163 -3
- data/lib/ruby_pi/llm/openai.rb +105 -3
- data/lib/ruby_pi/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55e144c9f54733981c81f090dad3eb02a4f6cbac0dd4d2a220d972c04133ea97
|
|
4
|
+
data.tar.gz: abdbb72cfafd293dc30f4517c42dd02f147a41e501034efb7eee2b8af20f9577
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 608d20395e47a22f7392150e131e5944bab03aabbcc089525c0fcc43f966e3672f3f2cb66e74d34657acdc108b939cbe80fa6b8ba90b281717fb0e1c43cc7242
|
|
7
|
+
data.tar.gz: d5d0c2f2e1a24928a1b9ee998781f43d1f0d1a5d76aebbcd24d3ed42c11ae132c0c1df10996cbe23676fc94c047d12e456eea5279406484676ddcc3ce3b63068
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.2] - 2026-04-29
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Anthropic provider: tool result messages (`role: "tool"`) are now converted to `role: "user"` with `tool_result` content blocks containing the matching `tool_use_id`, and consecutive tool results from the same turn are grouped into a single user message as the Messages API requires
|
|
13
|
+
- Anthropic provider: assistant messages with `tool_calls` are now preserved as `tool_use` content blocks (`{type: "tool_use", id:, name:, input:}`) instead of being silently discarded, so the API can match them to subsequent tool results
|
|
14
|
+
- Anthropic and OpenAI providers: structured `content` (Arrays/Hashes, e.g. multimodal vision payloads) is preserved as-is instead of being collapsed by `to_s`
|
|
15
|
+
- OpenAI provider: tool messages now include `tool_call_id` and assistant `tool_calls` are emitted in the full OpenAI function-call structure for proper result matching
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Comprehensive unit tests for `build_request_body` in both Anthropic and OpenAI providers covering full agent-loop conversations, consecutive tool result grouping, structured content preservation, and string- vs symbol-keyed message edge cases
|
|
20
|
+
|
|
8
21
|
## [0.1.1] - 2026-04-28
|
|
9
22
|
|
|
10
23
|
### Changed
|
|
@@ -78,12 +78,29 @@ module RubyPi
|
|
|
78
78
|
|
|
79
79
|
# Builds the Anthropic API request body from messages and tools.
|
|
80
80
|
#
|
|
81
|
+
# Handles three critical conversions from the internal message format to
|
|
82
|
+
# Anthropic's API format:
|
|
83
|
+
#
|
|
84
|
+
# 1. System messages (role: "system") are extracted and promoted to the
|
|
85
|
+
# top-level `system:` parameter, since Anthropic does not allow system
|
|
86
|
+
# messages in the messages array.
|
|
87
|
+
#
|
|
88
|
+
# 2. Tool result messages (role: "tool") are converted to role: "user"
|
|
89
|
+
# messages with `tool_result` content blocks. Consecutive tool messages
|
|
90
|
+
# are grouped into a single user message, as Anthropic requires.
|
|
91
|
+
#
|
|
92
|
+
# 3. Assistant messages that include `tool_calls` are converted to include
|
|
93
|
+
# `tool_use` content blocks, so the API can match them to subsequent
|
|
94
|
+
# `tool_result` blocks.
|
|
95
|
+
#
|
|
96
|
+
# Structured content (Arrays, Hashes) is preserved as-is and never
|
|
97
|
+
# coerced via `.to_s`, which would destroy the content block structure.
|
|
98
|
+
#
|
|
81
99
|
# @param messages [Array<Hash>] conversation messages
|
|
82
100
|
# @param tools [Array<Hash>] tool definitions
|
|
83
101
|
# @param stream [Boolean] whether streaming is enabled
|
|
84
102
|
# @return [Hash] the request body
|
|
85
103
|
def build_request_body(messages, tools, stream)
|
|
86
|
-
# Separate system message from conversation messages
|
|
87
104
|
system_message = nil
|
|
88
105
|
conversation = []
|
|
89
106
|
|
|
@@ -91,10 +108,41 @@ module RubyPi
|
|
|
91
108
|
role = (msg[:role] || msg["role"]).to_s
|
|
92
109
|
content = msg[:content] || msg["content"]
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
case role
|
|
112
|
+
when "system"
|
|
113
|
+
# Anthropic requires system prompts as a top-level parameter, not
|
|
114
|
+
# as a message in the conversation array.
|
|
95
115
|
system_message = content.to_s
|
|
116
|
+
|
|
117
|
+
when "tool"
|
|
118
|
+
# Internal tool-result messages must be converted to Anthropic's
|
|
119
|
+
# format: role "user" with a tool_result content block. The
|
|
120
|
+
# tool_use_id links this result back to the assistant's tool_use.
|
|
121
|
+
tool_result_block = build_tool_result_block(msg)
|
|
122
|
+
|
|
123
|
+
# Group consecutive tool results into a single "user" message.
|
|
124
|
+
# Anthropic requires this because alternating user/assistant roles
|
|
125
|
+
# means multiple tool results from one turn must share one user msg.
|
|
126
|
+
if conversation.last && conversation.last[:role] == "user" &&
|
|
127
|
+
conversation.last[:content].is_a?(Array) &&
|
|
128
|
+
conversation.last[:content].all? { |b| b[:type] == "tool_result" }
|
|
129
|
+
# Append to the existing grouped tool_result user message
|
|
130
|
+
conversation.last[:content] << tool_result_block
|
|
131
|
+
else
|
|
132
|
+
conversation << { role: "user", content: [tool_result_block] }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
when "assistant"
|
|
136
|
+
# Build the assistant message with proper content blocks.
|
|
137
|
+
# If the message contains tool_calls, they must be included as
|
|
138
|
+
# tool_use content blocks so Anthropic can match them to the
|
|
139
|
+
# subsequent tool_result blocks.
|
|
140
|
+
conversation << build_assistant_message(msg, content)
|
|
141
|
+
|
|
96
142
|
else
|
|
97
|
-
|
|
143
|
+
# Standard user (or other) messages — preserve structured content
|
|
144
|
+
# as-is and only convert simple values to strings.
|
|
145
|
+
conversation << { role: role, content: format_content(content) }
|
|
98
146
|
end
|
|
99
147
|
end
|
|
100
148
|
|
|
@@ -114,6 +162,118 @@ module RubyPi
|
|
|
114
162
|
body
|
|
115
163
|
end
|
|
116
164
|
|
|
165
|
+
# Builds an Anthropic tool_result content block from an internal tool
|
|
166
|
+
# message. Extracts the tool_call_id and content, handling edge cases
|
|
167
|
+
# like nil IDs or already-structured content.
|
|
168
|
+
#
|
|
169
|
+
# @param msg [Hash] internal tool message with :tool_call_id, :content, :name
|
|
170
|
+
# @return [Hash] Anthropic tool_result content block
|
|
171
|
+
def build_tool_result_block(msg)
|
|
172
|
+
tool_use_id = msg[:tool_call_id] || msg["tool_call_id"]
|
|
173
|
+
content = msg[:content] || msg["content"]
|
|
174
|
+
|
|
175
|
+
block = {
|
|
176
|
+
type: "tool_result",
|
|
177
|
+
tool_use_id: tool_use_id || "unknown"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Content can be a simple string or a structured content array.
|
|
181
|
+
# Preserve structured content as-is; convert simple values to strings.
|
|
182
|
+
if content.is_a?(Array)
|
|
183
|
+
block[:content] = content
|
|
184
|
+
elsif content.is_a?(Hash)
|
|
185
|
+
block[:content] = [content]
|
|
186
|
+
elsif content.nil?
|
|
187
|
+
block[:content] = ""
|
|
188
|
+
else
|
|
189
|
+
block[:content] = content.to_s
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
block
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Builds an Anthropic-formatted assistant message, including tool_use
|
|
196
|
+
# content blocks when the message has tool_calls.
|
|
197
|
+
#
|
|
198
|
+
# Anthropic represents assistant responses as an array of content blocks.
|
|
199
|
+
# Text content becomes `{ type: "text", text: "..." }` blocks, and tool
|
|
200
|
+
# calls become `{ type: "tool_use", id: "...", name: "...", input: {...} }`
|
|
201
|
+
# blocks. Both can appear in the same message.
|
|
202
|
+
#
|
|
203
|
+
# @param msg [Hash] internal assistant message with optional :tool_calls
|
|
204
|
+
# @param content [String, Array, Hash, nil] the message content
|
|
205
|
+
# @return [Hash] Anthropic-formatted assistant message
|
|
206
|
+
def build_assistant_message(msg, content)
|
|
207
|
+
tool_calls = msg[:tool_calls] || msg["tool_calls"]
|
|
208
|
+
content_blocks = []
|
|
209
|
+
|
|
210
|
+
# Add text content block if present. Content may already be a structured
|
|
211
|
+
# array (from a previous Anthropic response) — preserve it as-is.
|
|
212
|
+
if content.is_a?(Array)
|
|
213
|
+
content_blocks.concat(content)
|
|
214
|
+
elsif content.is_a?(Hash)
|
|
215
|
+
content_blocks << content
|
|
216
|
+
elsif content && !content.to_s.empty?
|
|
217
|
+
content_blocks << { type: "text", text: content.to_s }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Convert internal tool_calls into Anthropic tool_use content blocks.
|
|
221
|
+
# Each tool_call has :id, :name, and :arguments from ToolCall#to_h.
|
|
222
|
+
if tool_calls.is_a?(Array) && !tool_calls.empty?
|
|
223
|
+
tool_calls.each do |tc|
|
|
224
|
+
tc_id = tc[:id] || tc["id"]
|
|
225
|
+
tc_name = tc[:name] || tc["name"]
|
|
226
|
+
tc_args = tc[:arguments] || tc["arguments"] || {}
|
|
227
|
+
|
|
228
|
+
# Ensure arguments is a Hash; parse JSON string if needed
|
|
229
|
+
tc_input = if tc_args.is_a?(Hash)
|
|
230
|
+
tc_args
|
|
231
|
+
elsif tc_args.is_a?(String) && !tc_args.empty?
|
|
232
|
+
begin
|
|
233
|
+
JSON.parse(tc_args)
|
|
234
|
+
rescue JSON::ParserError
|
|
235
|
+
{ "_raw" => tc_args }
|
|
236
|
+
end
|
|
237
|
+
else
|
|
238
|
+
{}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
content_blocks << {
|
|
242
|
+
type: "tool_use",
|
|
243
|
+
id: tc_id || "unknown",
|
|
244
|
+
name: tc_name || "unknown",
|
|
245
|
+
input: tc_input
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# If no content blocks were generated (edge case), add an empty text
|
|
251
|
+
# block to satisfy Anthropic's requirement for non-empty content.
|
|
252
|
+
content_blocks << { type: "text", text: "" } if content_blocks.empty?
|
|
253
|
+
|
|
254
|
+
{ role: "assistant", content: content_blocks }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Formats message content for the Anthropic API, preserving structured
|
|
258
|
+
# content (Arrays and Hashes) and only converting simple values to strings.
|
|
259
|
+
#
|
|
260
|
+
# Anthropic accepts both a plain string and an array of content blocks
|
|
261
|
+
# for the `content` field. Calling `.to_s` on structured content would
|
|
262
|
+
# destroy it, so this method passes Arrays and Hashes through unchanged.
|
|
263
|
+
#
|
|
264
|
+
# @param content [String, Array, Hash, nil] the raw content value
|
|
265
|
+
# @return [String, Array, Hash] formatted content suitable for Anthropic
|
|
266
|
+
def format_content(content)
|
|
267
|
+
case content
|
|
268
|
+
when Array, Hash
|
|
269
|
+
content
|
|
270
|
+
when nil
|
|
271
|
+
""
|
|
272
|
+
else
|
|
273
|
+
content.to_s
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
117
277
|
# Converts a tool definition to Anthropic's tool format.
|
|
118
278
|
# Accepts either a RubyPi::Tools::Definition or a plain Hash.
|
|
119
279
|
#
|
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -70,6 +70,20 @@ module RubyPi
|
|
|
70
70
|
|
|
71
71
|
# Builds the OpenAI Chat Completions request body.
|
|
72
72
|
#
|
|
73
|
+
# Handles proper formatting of all message types:
|
|
74
|
+
#
|
|
75
|
+
# 1. System messages pass through with role "system" (OpenAI supports them).
|
|
76
|
+
#
|
|
77
|
+
# 2. Tool result messages (role: "tool") are formatted with the required
|
|
78
|
+
# `tool_call_id` field so OpenAI can match them to the assistant's
|
|
79
|
+
# tool_calls.
|
|
80
|
+
#
|
|
81
|
+
# 3. Assistant messages with `tool_calls` include the proper OpenAI
|
|
82
|
+
# tool_calls structure: `{ id, type: "function", function: { name, arguments } }`.
|
|
83
|
+
#
|
|
84
|
+
# Structured content (Arrays, Hashes) is preserved for multimodal content
|
|
85
|
+
# blocks (e.g., vision messages with image_url content parts).
|
|
86
|
+
#
|
|
73
87
|
# @param messages [Array<Hash>] conversation messages
|
|
74
88
|
# @param tools [Array<Hash>] tool definitions
|
|
75
89
|
# @param stream [Boolean] whether streaming is enabled
|
|
@@ -91,13 +105,101 @@ module RubyPi
|
|
|
91
105
|
|
|
92
106
|
# Converts a normalized message hash to OpenAI's message format.
|
|
93
107
|
#
|
|
108
|
+
# Handles three message types:
|
|
109
|
+
# - Tool messages (role: "tool"): includes tool_call_id for result matching
|
|
110
|
+
# - Assistant messages with tool_calls: includes structured tool_calls array
|
|
111
|
+
# - Standard messages: role + content with structured content preservation
|
|
112
|
+
#
|
|
94
113
|
# @param message [Hash] a message with :role and :content keys
|
|
95
114
|
# @return [Hash] OpenAI-formatted message
|
|
96
115
|
def format_message(message)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
role = (message[:role] || message["role"]).to_s
|
|
117
|
+
content = message[:content] || message["content"]
|
|
118
|
+
|
|
119
|
+
case role
|
|
120
|
+
when "tool"
|
|
121
|
+
# OpenAI accepts role "tool" with a required tool_call_id field
|
|
122
|
+
# to match this result back to the assistant's tool_call.
|
|
123
|
+
tool_call_id = message[:tool_call_id] || message["tool_call_id"]
|
|
124
|
+
{
|
|
125
|
+
role: "tool",
|
|
126
|
+
tool_call_id: tool_call_id || "unknown",
|
|
127
|
+
content: format_content(content)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
when "assistant"
|
|
131
|
+
# Assistant messages may include tool_calls that must be preserved
|
|
132
|
+
# in OpenAI's expected format for conversation continuity.
|
|
133
|
+
build_assistant_message(message, content)
|
|
134
|
+
|
|
135
|
+
else
|
|
136
|
+
# System and user messages — preserve structured content as-is
|
|
137
|
+
# for multimodal support (vision, etc.).
|
|
138
|
+
{ role: role, content: format_content(content) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Builds an OpenAI-formatted assistant message, including tool_calls
|
|
143
|
+
# when present. OpenAI requires tool_calls in a specific structure:
|
|
144
|
+
# `{ id, type: "function", function: { name, arguments } }` where
|
|
145
|
+
# arguments is a JSON string.
|
|
146
|
+
#
|
|
147
|
+
# @param message [Hash] internal assistant message with optional :tool_calls
|
|
148
|
+
# @param content [String, Array, Hash, nil] the message content
|
|
149
|
+
# @return [Hash] OpenAI-formatted assistant message
|
|
150
|
+
def build_assistant_message(message, content)
|
|
151
|
+
tool_calls = message[:tool_calls] || message["tool_calls"]
|
|
152
|
+
|
|
153
|
+
formatted = {
|
|
154
|
+
role: "assistant",
|
|
155
|
+
content: format_content(content)
|
|
100
156
|
}
|
|
157
|
+
|
|
158
|
+
# Include tool_calls in OpenAI's expected format if present
|
|
159
|
+
if tool_calls.is_a?(Array) && !tool_calls.empty?
|
|
160
|
+
formatted[:tool_calls] = tool_calls.map do |tc|
|
|
161
|
+
tc_id = tc[:id] || tc["id"]
|
|
162
|
+
tc_name = tc[:name] || tc["name"]
|
|
163
|
+
tc_args = tc[:arguments] || tc["arguments"] || {}
|
|
164
|
+
|
|
165
|
+
# OpenAI requires arguments as a JSON string
|
|
166
|
+
args_string = if tc_args.is_a?(String)
|
|
167
|
+
tc_args
|
|
168
|
+
elsif tc_args.is_a?(Hash)
|
|
169
|
+
JSON.generate(tc_args)
|
|
170
|
+
else
|
|
171
|
+
"{}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
id: tc_id || "unknown",
|
|
176
|
+
type: "function",
|
|
177
|
+
function: {
|
|
178
|
+
name: tc_name || "unknown",
|
|
179
|
+
arguments: args_string
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
formatted
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Formats message content for the OpenAI API, preserving structured
|
|
189
|
+
# content (Arrays and Hashes) for multimodal messages (e.g., vision)
|
|
190
|
+
# and only converting simple values to strings.
|
|
191
|
+
#
|
|
192
|
+
# @param content [String, Array, Hash, nil] the raw content value
|
|
193
|
+
# @return [String, Array, Hash, nil] formatted content suitable for OpenAI
|
|
194
|
+
def format_content(content)
|
|
195
|
+
case content
|
|
196
|
+
when Array, Hash
|
|
197
|
+
content
|
|
198
|
+
when nil
|
|
199
|
+
nil
|
|
200
|
+
else
|
|
201
|
+
content.to_s
|
|
202
|
+
end
|
|
101
203
|
end
|
|
102
204
|
|
|
103
205
|
# Converts a tool definition to OpenAI's function tool format.
|
data/lib/ruby_pi/version.rb
CHANGED