Commit 4e8a8d2

mo khan <mo@mokhan.ca>
2026-09-05 20:58:09
refactor: split features into separate gems
1 parent bcdd0dd
lib/elelem/anthropic/client.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "uri"
+
+module Elelem
+  module Anthropic
+    class Client
+      def initialize(endpoint:, headers:, model:, version: nil, read_timeout: 3600, open_timeout: 10)
+        @uri = URI.parse(endpoint)
+        @headers_source = headers
+        @model = model
+        @version = version
+        @read_timeout = read_timeout
+        @open_timeout = open_timeout
+      end
+
+      def fetch(messages, tools = [], &block)
+        system_prompt, normalized_messages = extract_system(messages)
+        tool_calls = []
+
+        stream(normalized_messages, system_prompt, tools) do |event|
+          handle_event(event, tool_calls, &block)
+        end
+
+        finalize_tool_calls(tool_calls, &block)
+      end
+
+      private
+
+      def headers
+        @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
+      end
+
+      def handle_event(event, tool_calls, &block)
+        case event["type"]
+        when "content_block_start"
+          handle_content_block_start(event, tool_calls)
+        when "content_block_delta"
+          handle_content_block_delta(event, tool_calls, &block)
+        end
+      end
+
+      def handle_content_block_start(event, tool_calls)
+        content_block = event["content_block"]
+        return unless content_block["type"] == "tool_use"
+
+        tool_calls << {
+          id: content_block["id"],
+          name: content_block["name"],
+          args: String.new
+        }
+      end
+
+      def handle_content_block_delta(event, tool_calls, &block)
+        delta = event["delta"]
+
+        case delta["type"]
+        when "text_delta"
+          block.call(type: "saying", text: delta["text"])
+        when "thinking_delta"
+          block.call(type: "thinking", text: delta["thinking"])
+        when "input_json_delta"
+          tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
+        end
+      end
+
+      def finalize_tool_calls(tool_calls, &block)
+        tool_calls.each do |tool_call|
+          args = tool_call.delete(:args)
+          tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
+          block.call(type: "doing", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
+        end
+        tool_calls
+      end
+
+      def stream(messages, system_prompt, tools)
+        body = build_request_body(messages, system_prompt, tools)
+
+        request = Net::HTTP::Post.new(@uri)
+        request["content-type"] = "application/json"
+        headers.each { |k, v| request[k] = v }
+        request.body = JSON.generate(body)
+
+        http = Net::HTTP.new(@uri.host, @uri.port)
+        http.use_ssl = @uri.scheme == "https"
+        http.read_timeout = @read_timeout
+        http.open_timeout = @open_timeout
+
+        http.start do |conn|
+          conn.request(request) do |response|
+            raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+
+            read_sse_stream(response) { |event| yield event }
+          end
+        end
+      end
+
+      def build_request_body(messages, system_prompt, tools)
+        body = { max_tokens: 64000, messages:, stream: true }
+        body[:model] = @model unless @version
+        body[:anthropic_version] = @version if @version
+        body[:system] = system_prompt if system_prompt
+        body[:tools] = unwrap_tools(tools) unless tools.empty?
+        body
+      end
+
+      def read_sse_stream(response)
+        buffer = String.new
+
+        response.read_body do |chunk|
+          buffer << chunk
+
+          while (index = buffer.index("\n\n"))
+            raw_event = buffer.slice!(0, index + 2)
+            event = parse_sse(raw_event)
+            yield event if event
+          end
+        end
+      end
+
+      def parse_sse(raw)
+        line = raw.lines.find { |l| l.start_with?("data: ") }
+        return nil unless line
+
+        data = line.delete_prefix("data: ").strip
+        return nil if data == "[DONE]"
+
+        JSON.parse(data)
+      end
+
+      def extract_system(messages)
+        system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
+        system_content = system_messages.first&.dig(:content)
+        [system_content, normalize(other_messages)]
+      end
+
+      def normalize(messages)
+        messages.map { |message| normalize_message(message) }
+      end
+
+      def normalize_message(message)
+        case message[:role]
+        when "tool"
+          tool_result_message(message)
+        when "assistant"
+          message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
+        else
+          message
+        end
+      end
+
+      def tool_result_message(message)
+        {
+          role: "user",
+          content: [{
+            type: "tool_result",
+            tool_use_id: message[:tool_call_id],
+            content: message[:content]
+          }]
+        }
+      end
+
+      def assistant_with_tools_message(message)
+        text_content = build_text_content(message[:content])
+        tool_content = build_tool_content(message[:tool_calls])
+
+        { role: "assistant", content: text_content + tool_content }
+      end
+
+      def build_text_content(content)
+        return [] if content.to_s.empty?
+
+        [{ type: "text", text: content }]
+      end
+
+      def build_tool_content(tool_calls)
+        tool_calls.map do |tool_call|
+          {
+            type: "tool_use",
+            id: tool_call[:id],
+            name: tool_call[:name],
+            input: tool_call[:arguments]
+          }
+        end
+      end
+
+      def unwrap_tools(tools)
+        tools.map do |tool|
+          {
+            name: tool.dig(:function, :name),
+            description: tool.dig(:function, :description),
+            input_schema: tool.dig(:function, :parameters)
+          }
+        end
+      end
+    end
+  end
+end
lib/elelem/anthropic/plugin.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require_relative "../anthropic"
+
+Elelem::Providers.register(:anthropic) do
+  api_key = ENV.fetch("ANTHROPIC_API_KEY")
+  Elelem::Anthropic::Client.new(
+    endpoint: "https://api.anthropic.com/v1/messages",
+    headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
+    model: ENV.fetch("ANTHROPIC_MODEL", "claude-opus-4-5-20250514")
+  )
+end
lib/elelem/anthropic/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Anthropic
+    VERSION = "0.1.0"
+  end
+end
lib/elelem/anthropic.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+require_relative "anthropic/version"
+require_relative "anthropic/client"
elelem-anthropic.gemspec
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/anthropic/version"
+
+Gem::Specification.new do |spec|
+  spec.name = "elelem-anthropic"
+  spec.version = Elelem::Anthropic::VERSION
+  spec.authors = ["mo khan"]
+  spec.email = ["mo@mokhan.ca"]
+
+  spec.summary = "An Anthropic provider plugin for elelem."
+  spec.description = "An Anthropic provider plugin for elelem."
+  spec.homepage = "https://src.mokhan.ca/xlgmokha/elelem"
+  spec.license = "MIT"
+  spec.required_ruby_version = ">= 4.0.0"
+  spec.required_rubygems_version = ">= 4.0.0"
+  spec.metadata["allowed_push_host"] = "https://rubygems.org"
+  spec.metadata["homepage_uri"] = spec.homepage
+  spec.metadata["source_code_uri"] = "https://git.mokhan.ca/xlgmokha/elelem.git"
+
+  spec.files = [
+    "LICENSE.txt",
+    "lib/elelem/anthropic.rb",
+    "lib/elelem/anthropic/version.rb",
+    "lib/elelem/anthropic/client.rb",
+    "lib/elelem/anthropic/plugin.rb",
+  ]
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "elelem", "~> 0.10"
+  spec.add_dependency "json", "~> 2.0"
+end