Commit 3b8fbc0
Changed files (4)
lib
net
lib/net/llm/anthropic.rb
@@ -22,6 +22,16 @@ module Net
end
end
+ def fetch(messages, tools = [], &block)
+ anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
+
+ if block_given?
+ fetch_streaming(messages, anthropic_tools, &block)
+ else
+ fetch_non_streaming(messages, anthropic_tools)
+ end
+ end
+
private
def build_payload(messages, system, max_tokens, tools, stream)
@@ -96,6 +106,121 @@ module Net
event
end
+
+ def fetch_non_streaming(messages, tools)
+ result = self.messages(messages, tools: tools)
+ return result if result["code"]
+
+ {
+ type: :complete,
+ content: extract_text_content(result["content"]),
+ thinking: extract_thinking_content(result["content"]),
+ tool_calls: extract_tool_calls(result["content"]),
+ stop_reason: map_stop_reason(result["stop_reason"])
+ }
+ end
+
+ def fetch_streaming(messages, tools, &block)
+ content = ""
+ thinking = ""
+ tool_calls = []
+ stop_reason = :end_turn
+ current_block_type = nil
+
+ self.messages(messages, tools: tools) do |event|
+ case event["type"]
+ when "content_block_start"
+ current_block_type = event.dig("content_block", "type")
+ if current_block_type == "tool_use"
+ tool_calls << {
+ id: event.dig("content_block", "id"),
+ name: event.dig("content_block", "name"),
+ arguments: {}
+ }
+ end
+ when "content_block_delta"
+ delta = event["delta"]
+ case delta["type"]
+ when "text_delta"
+ text = delta["text"]
+ content += text
+ block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
+ when "thinking_delta"
+ text = delta["thinking"]
+ thinking += text if text
+ block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
+ when "input_json_delta"
+ if tool_calls.any?
+ tool_calls.last[:arguments_json] ||= ""
+ tool_calls.last[:arguments_json] += delta["partial_json"] || ""
+ end
+ end
+ when "message_delta"
+ stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
+ when "message_stop"
+ tool_calls.each do |tc|
+ if tc[:arguments_json]
+ tc[:arguments] = JSON.parse(tc[:arguments_json]) rescue {}
+ tc.delete(:arguments_json)
+ end
+ end
+ block.call({
+ type: :complete,
+ content: content,
+ thinking: thinking.empty? ? nil : thinking,
+ tool_calls: tool_calls,
+ stop_reason: stop_reason
+ })
+ end
+ end
+ end
+
+ def extract_text_content(content_blocks)
+ return nil unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "text" }
+ .map { |b| b["text"] }
+ .join
+ end
+
+ def extract_thinking_content(content_blocks)
+ return nil unless content_blocks
+
+ thinking = content_blocks
+ .select { |b| b["type"] == "thinking" }
+ .map { |b| b["thinking"] }
+ .join
+
+ thinking.empty? ? nil : thinking
+ end
+
+ def extract_tool_calls(content_blocks)
+ return [] unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "tool_use" }
+ .map do |b|
+ { id: b["id"], name: b["name"], arguments: b["input"] || {} }
+ end
+ end
+
+ def normalize_tool_for_anthropic(tool)
+ if tool[:function]
+ { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
+ else
+ tool
+ end
+ end
+
+ def map_stop_reason(reason)
+ case reason
+ when "end_turn" then :end_turn
+ when "tool_use" then :tool_use
+ when "max_tokens" then :max_tokens
+ else :end_turn
+ end
+ end
end
end
end
lib/net/llm/ollama.rb
@@ -18,6 +18,51 @@ module Net
execute(build_url("/api/chat"), payload, &block)
end
+ def fetch(messages, tools = [], &block)
+ content = ""
+ thinking = ""
+ tool_calls = []
+
+ if block_given?
+ chat(messages, tools) do |chunk|
+ msg = chunk["message"] || {}
+ delta_content = msg["content"]
+ delta_thinking = msg["thinking"]
+
+ content += delta_content if delta_content
+ thinking += delta_thinking if delta_thinking
+
+ if chunk["done"]
+ tool_calls = normalize_tool_calls(msg["tool_calls"])
+ block.call({
+ type: :complete,
+ content: content,
+ thinking: thinking.empty? ? nil : thinking,
+ tool_calls: tool_calls,
+ stop_reason: map_stop_reason(chunk["done_reason"])
+ })
+ else
+ block.call({
+ type: :delta,
+ content: delta_content,
+ thinking: delta_thinking,
+ tool_calls: nil
+ })
+ end
+ end
+ else
+ result = chat(messages, tools)
+ msg = result["message"] || {}
+ {
+ type: :complete,
+ content: msg["content"],
+ thinking: msg["thinking"],
+ tool_calls: normalize_tool_calls(msg["tool_calls"]),
+ stop_reason: map_stop_reason(result["done_reason"])
+ }
+ end
+ end
+
def generate(prompt, &block)
execute(build_url("/api/generate"), {
model: model,
@@ -100,6 +145,27 @@ module Net
buffer.replace(buffer[(message_end + 1)..-1] || "")
message
end
+
+ def normalize_tool_calls(tool_calls)
+ return [] if tool_calls.nil? || tool_calls.empty?
+
+ tool_calls.map do |tc|
+ {
+ id: tc["id"] || tc.dig("function", "id"),
+ name: tc.dig("function", "name"),
+ arguments: tc.dig("function", "arguments") || {}
+ }
+ end
+ end
+
+ def map_stop_reason(reason)
+ case reason
+ when "stop" then :end_turn
+ when "tool_calls", "tool_use" then :tool_use
+ when "length" then :max_tokens
+ else :end_turn
+ end
+ end
end
end
end
lib/net/llm/openai.rb
@@ -20,6 +20,14 @@ module Net
))
end
+ def fetch(messages, tools = [], &block)
+ if block_given?
+ fetch_streaming(messages, tools, &block)
+ else
+ fetch_non_streaming(messages, tools)
+ end
+ end
+
def models
handle_response(http.get("#{base_url}/models", headers: headers))
end
@@ -45,6 +53,120 @@ module Net
{ "code" => response.code, "body" => response.body }
end
end
+
+ def fetch_non_streaming(messages, tools)
+ body = { model: model, messages: messages }
+ body[:tools] = tools unless tools.empty?
+ body[:tool_choice] = "auto" unless tools.empty?
+
+ result = handle_response(http.post("#{base_url}/chat/completions", headers: headers, body: body))
+ return result if result["code"]
+
+ msg = result.dig("choices", 0, "message") || {}
+ {
+ type: :complete,
+ content: msg["content"],
+ thinking: nil,
+ tool_calls: normalize_tool_calls(msg["tool_calls"]),
+ stop_reason: map_stop_reason(result.dig("choices", 0, "finish_reason"))
+ }
+ end
+
+ def fetch_streaming(messages, tools, &block)
+ body = { model: model, messages: messages, stream: true }
+ body[:tools] = tools unless tools.empty?
+ body[:tool_choice] = "auto" unless tools.empty?
+
+ content = ""
+ tool_calls = {}
+ stop_reason = :end_turn
+
+ http.post("#{base_url}/chat/completions", headers: headers, body: body) do |response|
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+ buffer = ""
+ response.read_body do |chunk|
+ buffer += chunk
+
+ while (line = extract_line(buffer))
+ next if line.empty? || !line.start_with?("data: ")
+
+ data = line[6..]
+ break if data == "[DONE]"
+
+ json = JSON.parse(data)
+ delta = json.dig("choices", 0, "delta") || {}
+
+ if delta["content"]
+ content += delta["content"]
+ block.call({ type: :delta, content: delta["content"], thinking: nil, tool_calls: nil })
+ end
+
+ if delta["tool_calls"]
+ delta["tool_calls"].each do |tc|
+ idx = tc["index"]
+ tool_calls[idx] ||= { id: nil, name: nil, arguments_json: "" }
+ tool_calls[idx][:id] = tc["id"] if tc["id"]
+ tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
+ tool_calls[idx][:arguments_json] += tc.dig("function", "arguments") || ""
+ end
+ end
+
+ if json.dig("choices", 0, "finish_reason")
+ stop_reason = map_stop_reason(json.dig("choices", 0, "finish_reason"))
+ end
+ end
+ end
+ end
+
+ final_tool_calls = tool_calls.values.map do |tc|
+ args = begin
+ JSON.parse(tc[:arguments_json])
+ rescue
+ {}
+ end
+ { id: tc[:id], name: tc[:name], arguments: args }
+ end
+
+ block.call({
+ type: :complete,
+ content: content,
+ thinking: nil,
+ tool_calls: final_tool_calls,
+ stop_reason: stop_reason
+ })
+ end
+
+ def extract_line(buffer)
+ line_end = buffer.index("\n")
+ return nil unless line_end
+
+ line = buffer[0...line_end]
+ buffer.replace(buffer[(line_end + 1)..] || "")
+ line
+ end
+
+ def normalize_tool_calls(tool_calls)
+ return [] if tool_calls.nil? || tool_calls.empty?
+
+ tool_calls.map do |tc|
+ args = tc.dig("function", "arguments")
+ {
+ id: tc["id"],
+ name: tc.dig("function", "name"),
+ arguments: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
+ }
+ end
+ end
+
+ def map_stop_reason(reason)
+ case reason
+ when "stop" then :end_turn
+ when "tool_calls" then :tool_use
+ when "length" then :max_tokens
+ else :end_turn
+ end
+ end
end
end
end
lib/net/llm/vertex_ai.rb
@@ -22,6 +22,16 @@ module Net
end
end
+ def fetch(messages, tools = [], &block)
+ anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
+
+ if block_given?
+ fetch_streaming(messages, anthropic_tools, &block)
+ else
+ fetch_non_streaming(messages, anthropic_tools)
+ end
+ end
+
private
def build_payload(messages, system, max_tokens, tools, stream)
@@ -101,6 +111,117 @@ module Net
event
end
+
+ def fetch_non_streaming(messages, tools)
+ result = self.messages(messages, tools: tools)
+ return result if result["code"]
+
+ {
+ type: :complete,
+ content: extract_text_content(result["content"]),
+ thinking: extract_thinking_content(result["content"]),
+ tool_calls: extract_tool_calls(result["content"]),
+ stop_reason: map_stop_reason(result["stop_reason"])
+ }
+ end
+
+ def fetch_streaming(messages, tools, &block)
+ content = ""
+ thinking = ""
+ tool_calls = []
+ stop_reason = :end_turn
+
+ self.messages(messages, tools: tools) do |event|
+ case event["type"]
+ when "content_block_start"
+ if event.dig("content_block", "type") == "tool_use"
+ tool_calls << {
+ id: event.dig("content_block", "id"),
+ name: event.dig("content_block", "name"),
+ arguments: {}
+ }
+ end
+ when "content_block_delta"
+ delta = event["delta"]
+ case delta["type"]
+ when "text_delta"
+ text = delta["text"]
+ content += text
+ block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
+ when "thinking_delta"
+ text = delta["thinking"]
+ thinking += text if text
+ block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
+ when "input_json_delta"
+ if tool_calls.any?
+ tool_calls.last[:arguments_json] ||= ""
+ tool_calls.last[:arguments_json] += delta["partial_json"] || ""
+ end
+ end
+ when "message_delta"
+ stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
+ when "message_stop"
+ tool_calls.each do |tc|
+ if tc[:arguments_json]
+ tc[:arguments] = JSON.parse(tc[:arguments_json]) rescue {}
+ tc.delete(:arguments_json)
+ end
+ end
+ block.call({
+ type: :complete,
+ content: content,
+ thinking: thinking.empty? ? nil : thinking,
+ tool_calls: tool_calls,
+ stop_reason: stop_reason
+ })
+ end
+ end
+ end
+
+ def extract_text_content(content_blocks)
+ return nil unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "text" }
+ .map { |b| b["text"] }
+ .join
+ end
+
+ def extract_thinking_content(content_blocks)
+ return nil unless content_blocks
+
+ thinking = content_blocks
+ .select { |b| b["type"] == "thinking" }
+ .map { |b| b["thinking"] }
+ .join
+
+ thinking.empty? ? nil : thinking
+ end
+
+ def extract_tool_calls(content_blocks)
+ return [] unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "tool_use" }
+ .map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {} } }
+ end
+
+ def normalize_tool_for_anthropic(tool)
+ if tool[:function]
+ { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
+ else
+ tool
+ end
+ end
+
+ def map_stop_reason(reason)
+ case reason
+ when "end_turn" then :end_turn
+ when "tool_use" then :tool_use
+ when "max_tokens" then :max_tokens
+ else :end_turn
+ end
+ end
end
end
end