Commit fa60cbb

mo khan <mo@mokhan.ca>
2026-09-05 20:58:09
refactor: split features into separate gems
1 parent 70cb1a5
lib/elelem/ollama/client.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "uri"
+
+module Elelem
+  module Ollama
+    class Client
+      def initialize(
+        model:,
+        host: "localhost:11434",
+        think: false,
+        keep_alive: "5m",
+        options: {},
+        params: {},
+        read_timeout: 3600,
+        open_timeout: 10
+      )
+        @uri = normalize_uri(host)
+        @model = model
+        @think = think
+        @keep_alive = keep_alive
+        @options = options
+        @params = params
+        @read_timeout = read_timeout
+        @open_timeout = open_timeout
+      end
+
+      def fetch(messages, tools = [], &block)
+        tool_calls = []
+        body = build_request_body(messages, tools)
+
+        stream(body) do |event|
+          handle_event(event, tool_calls, &block)
+        end
+
+        tool_calls
+      end
+
+      private
+
+      def normalize_uri(host)
+        base = host.start_with?("http") ? host : "http://#{host}"
+        URI.join(base, "/api/chat")
+      end
+
+      def build_request_body(messages, tools)
+        {
+          model: @model,
+          messages: normalize(messages),
+          stream: true,
+          tools: presence(tools),
+          think: @think,
+          keep_alive: @keep_alive,
+          options: presence(@options)
+        }.merge(@params).compact
+      end
+
+      def presence(value)
+        value unless value.nil? || value.empty?
+      end
+
+      def normalize(messages)
+        pending = []
+
+        messages.map do |message|
+          case message[:role]
+          when "assistant" then normalize_calls(message, pending)
+          when "tool" then normalize_result(message, pending)
+          else message
+          end
+        end
+      end
+
+      def normalize_calls(message, pending)
+        calls = message[:tool_calls]
+        return message unless calls
+
+        pending.clear
+        message.merge(tool_calls: calls.map do |call|
+          pending << call[:name]
+          { function: { name: call[:name], arguments: call[:arguments] } }
+        end)
+      end
+
+      def normalize_result(message, pending)
+        name = pending.shift
+        return message unless name
+
+        message.except(:tool_call_id).merge(tool_name: name)
+      end
+
+      def handle_event(event, tool_calls, &block)
+        message = event["message"] || {}
+
+        unless event["done"]
+          block.call(type: "saying", text: message["content"]) if message["content"]
+          block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
+        end
+
+        if message["tool_calls"]
+          parsed = parse_tool_calls(message["tool_calls"])
+          parsed.each { |tc| block.call(tc.merge(type: "doing")) }
+          tool_calls.concat(parsed)
+        end
+      end
+
+      def stream(body)
+        request = Net::HTTP::Post.new(@uri)
+        request["content-type"] = "application/json"
+        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_ndjson_stream(response) { |event| yield event }
+          end
+        end
+      end
+
+      def read_ndjson_stream(response)
+        buffer = String.new
+
+        response.read_body do |chunk|
+          buffer << chunk
+
+          while (index = buffer.index("\n"))
+            line = buffer.slice!(0, index + 1)
+            next if line.strip.empty?
+
+            yield JSON.parse(line)
+          end
+        end
+      end
+
+      def parse_tool_calls(tool_calls)
+        tool_calls.map do |tool_call|
+          {
+            id: tool_call["id"],
+            name: tool_call.dig("function", "name"),
+            arguments: tool_call.dig("function", "arguments") || {}
+          }
+        end
+      end
+    end
+  end
+end
lib/elelem/ollama/plugin.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require_relative "../ollama"
+
+Elelem::Providers.register(:ollama) do
+  Elelem::Ollama::Client.new(
+    model: ENV.fetch("OLLAMA_MODEL", "gpt-oss:latest"),
+    host: ENV.fetch("OLLAMA_HOST", "localhost:11434")
+  )
+end
lib/elelem/ollama/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Ollama
+    VERSION = "0.1.0"
+  end
+end
lib/elelem/ollama.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+require_relative "ollama/version"
+require_relative "ollama/client"
elelem-ollama.gemspec
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/ollama/version"
+
+Gem::Specification.new do |spec|
+  spec.name = "elelem-ollama"
+  spec.version = Elelem::Ollama::VERSION
+  spec.authors = ["mo khan"]
+  spec.email = ["mo@mokhan.ca"]
+
+  spec.summary = "An Ollama provider plugin for elelem."
+  spec.description = "An Ollama 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/ollama.rb",
+    "lib/elelem/ollama/version.rb",
+    "lib/elelem/ollama/client.rb",
+    "lib/elelem/ollama/plugin.rb",
+  ]
+  spec.require_paths = ["lib"]
+
+  spec.add_dependency "elelem", "~> 0.10"
+  spec.add_dependency "json", "~> 2.0"
+end