Commit 94db6df

mo khan <mo@mokhan.ca>
2026-01-07 18:48:18
refactor: extract a Claude class
1 parent 57f4f6f
lib/net/llm/anthropic.rb
@@ -3,224 +3,21 @@
 module Net
   module Llm
     class Anthropic
-      attr_reader :api_key, :model, :http
+      attr_reader :api_key, :model
 
       def initialize(api_key:, model: "claude-sonnet-4-20250514", http: Net::Llm.http)
         @api_key = api_key
         @model = model
-        @http = http
-      end
-
-      def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
-        url = "https://api.anthropic.com/v1/messages"
-        payload = build_payload(messages, system, max_tokens, tools, block_given?)
-
-        if block_given?
-          stream_request(url, payload, &block)
-        else
-          post_request(url, payload)
-        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)
-        payload = {
+        @claude = Claude.new(
+          endpoint: "https://api.anthropic.com/v1/messages",
+          headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
           model: model,
-          max_tokens: max_tokens,
-          messages: messages,
-          stream: stream
-        }
-        payload[:system] = system if system
-        payload[:tools] = tools if tools
-        payload
+          http: http
+        )
       end
 
-      def headers
-        {
-          "x-api-key" => api_key,
-          "anthropic-version" => "2023-06-01"
-        }
-      end
-
-      def post_request(url, payload)
-        handle_response(http.post(url, headers: headers, body: payload))
-      end
-
-      def handle_response(response)
-        if response.is_a?(Net::HTTPSuccess)
-          JSON.parse(response.body)
-        else
-          { "code" => response.code, "body" => response.body }
-        end
-      end
-
-      def stream_request(url, payload, &block)
-        http.post(url, headers: headers, body: payload) do |response|
-          raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
-          buffer = ""
-          response.read_body do |chunk|
-            buffer += chunk
-
-            while (event = extract_sse_event(buffer))
-              next if event[:data].nil? || event[:data].empty?
-              next if event[:data] == "[DONE]"
-
-              json = JSON.parse(event[:data])
-              block.call(json)
-
-              break if json["type"] == "message_stop"
-            end
-          end
-        end
-      end
-
-      def extract_sse_event(buffer)
-        event_end = buffer.index("\n\n")
-        return nil unless event_end
-
-        event_data = buffer[0...event_end]
-        buffer.replace(buffer[(event_end + 2)..-1] || "")
-
-        event = {}
-        event_data.split("\n").each do |line|
-          if line.start_with?("event: ")
-            event[:event] = line[7..-1]
-          elsif line.start_with?("data: ")
-            event[:data] = line[6..-1]
-          elsif line == "data:"
-            event[:data] = ""
-          end
-        end
-
-        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
+      def messages(...) = @claude.messages(...)
+      def fetch(...) = @claude.fetch(...)
     end
   end
 end
lib/net/llm/claude.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+module Net
+  module Llm
+    class Claude
+      attr_reader :endpoint, :headers, :model, :http, :anthropic_version
+
+      def initialize(endpoint:, headers:, http:, model: nil, anthropic_version: nil)
+        @endpoint = endpoint
+        @headers_source = headers
+        @model = model
+        @http = http
+        @anthropic_version = anthropic_version
+      end
+
+      def headers
+        @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
+      end
+
+      def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
+        payload = build_payload(messages, system, max_tokens, tools, block_given?)
+
+        if block_given?
+          stream_request(payload, &block)
+        else
+          post_request(payload)
+        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)
+        payload = { max_tokens: max_tokens, messages: messages, stream: stream }
+        payload[:model] = model if model
+        payload[:anthropic_version] = anthropic_version if anthropic_version
+        payload[:system] = system if system
+        payload[:tools] = tools if tools
+        payload
+      end
+
+      def post_request(payload)
+        handle_response(http.post(endpoint, headers: headers, body: payload))
+      end
+
+      def handle_response(response)
+        if response.is_a?(Net::HTTPSuccess)
+          JSON.parse(response.body)
+        else
+          { "code" => response.code, "body" => response.body }
+        end
+      end
+
+      def stream_request(payload, &block)
+        http.post(endpoint, headers: headers, body: payload) do |response|
+          raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+          buffer = ""
+          response.read_body do |chunk|
+            buffer += chunk
+
+            while (event = extract_sse_event(buffer))
+              next if event[:data].nil? || event[:data].empty?
+              next if event[:data] == "[DONE]"
+
+              json = JSON.parse(event[:data])
+              block.call(json)
+
+              break if json["type"] == "message_stop"
+            end
+          end
+        end
+      end
+
+      def extract_sse_event(buffer)
+        event_end = buffer.index("\n\n")
+        return nil unless event_end
+
+        event_data = buffer[0...event_end]
+        buffer.replace(buffer[(event_end + 2)..] || "")
+
+        event = {}
+        event_data.split("\n").each do |line|
+          if line.start_with?("event: ")
+            event[:event] = line[7..]
+          elsif line.start_with?("data: ")
+            event[:data] = line[6..]
+          elsif line == "data:"
+            event[:data] = ""
+          end
+        end
+
+        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] = begin
+                  JSON.parse(tc[:arguments_json])
+                rescue
+                  {}
+                end
+                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
lib/net/llm/vertex_ai.rb
@@ -3,225 +3,28 @@
 module Net
   module Llm
     class VertexAI
-      attr_reader :project_id, :region, :model, :http
+      attr_reader :project_id, :region, :model
 
       def initialize(project_id:, region:, model: "claude-opus-4-5@20251101", http: Net::Llm.http)
         @project_id = project_id
         @region = region
         @model = model
-        @http = http
+        @claude = Claude.new(
+          endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project_id}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
+          headers: -> { { "Authorization" => "Bearer #{access_token}" } },
+          http: http,
+          anthropic_version: "vertex-2023-10-16"
+        )
       end
 
-      def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
-        payload = build_payload(messages, system, max_tokens, tools, block_given?)
-
-        if block_given?
-          stream_request(payload, &block)
-        else
-          post_request(payload)
-        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
+      def messages(...) = @claude.messages(...)
+      def fetch(...) = @claude.fetch(...)
 
       private
 
-      def build_payload(messages, system, max_tokens, tools, stream)
-        payload = {
-          anthropic_version: "vertex-2023-10-16",
-          max_tokens: max_tokens,
-          messages: messages,
-          stream: stream
-        }
-        payload[:system] = system if system
-        payload[:tools] = tools if tools
-        payload
-      end
-
-      def endpoint
-        "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project_id}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict"
-      end
-
-      def headers
-        { "Authorization" => "Bearer #{access_token}" }
-      end
-
       def access_token
         @access_token ||= `gcloud auth application-default print-access-token`.strip
       end
-
-      def post_request(payload)
-        handle_response(http.post(endpoint, headers: headers, body: payload))
-      end
-
-      def handle_response(response)
-        if response.is_a?(Net::HTTPSuccess)
-          JSON.parse(response.body)
-        else
-          { "code" => response.code, "body" => response.body }
-        end
-      end
-
-      def stream_request(payload, &block)
-        http.post(endpoint, headers: headers, body: payload) do |response|
-          raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
-          buffer = ""
-          response.read_body do |chunk|
-            buffer += chunk
-
-            while (event = extract_sse_event(buffer))
-              next if event[:data].nil? || event[:data].empty?
-              next if event[:data] == "[DONE]"
-
-              json = JSON.parse(event[:data])
-              block.call(json)
-
-              break if json["type"] == "message_stop"
-            end
-          end
-        end
-      end
-
-      def extract_sse_event(buffer)
-        event_end = buffer.index("\n\n")
-        return nil unless event_end
-
-        event_data = buffer[0...event_end]
-        buffer.replace(buffer[(event_end + 2)..] || "")
-
-        event = {}
-        event_data.split("\n").each do |line|
-          if line.start_with?("event: ")
-            event[:event] = line[7..]
-          elsif line.start_with?("data: ")
-            event[:data] = line[6..]
-          elsif line == "data:"
-            event[:data] = ""
-          end
-        end
-
-        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
lib/net/llm.rb
@@ -3,6 +3,7 @@
 require_relative "llm/version"
 require_relative "llm/openai"
 require_relative "llm/ollama"
+require_relative "llm/claude"
 require_relative "llm/anthropic"
 require_relative "llm/vertex_ai"
 require "net/hippie"