Commit 4e8a8d2
Changed files (5)
lib
elelem
anthropic
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