Commit 3b8fbc0

mo khan <mo@mokhan.ca>
2026-01-07 17:52:45
feat: add consistent #fetch interface to each client
1 parent d0d80a8
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