Commit f62a82d

mo khan <mo@mokhan.ca>
2026-09-07 03:27:45
test: restore rspec suite and fix think/keep_alive opt-in regression
Moved spec/elelem/net/ollama_spec.rb from elelem core (renamed to spec/elelem/ollama/client_spec.rb; class is Elelem::Ollama::Client, not Elelem::Net::Ollama). Client had no http: injection seam, so adds one via a private HttpAdapter wrapping the existing Net::HTTP calls, matching the block-based post(url, body:) shape the spec mocks. Also fixes a regression: think and keep_alive defaulted to false/"5m" and were always sent, which is exactly what core's 1ba3b9c ("fix: make ollama think and keep_alive opt-in") fixed upstream before this gem was split out — always sending them broke non-thinking models and silently overrode a server's OLLAMA_KEEP_ALIVE. Restores nil defaults (omitted via the existing #compact) and OLLAMA_THINK/OLLAMA_KEEP_ALIVE env var passthrough in the provider registration. Claude-Session: https://claude.ai/code/session_01DUuj4amvRrPxDnHPHBkvz5
1 parent 38ad5a0
lib/elelem/ollama/client.rb
@@ -6,12 +6,13 @@ module Elelem
       def initialize(
         model:,
         host: "localhost:11434",
-        think: false,
-        keep_alive: "5m",
+        think: nil,
+        keep_alive: nil,
         options: {},
         params: {},
         read_timeout: 3600,
-        open_timeout: 10
+        open_timeout: 10,
+        http: nil
       )
         @uri = normalize_uri(host)
         @model = model
@@ -21,6 +22,7 @@ module Elelem
         @params = params
         @read_timeout = read_timeout
         @open_timeout = open_timeout
+        @http = http
       end
 
       def fetch(messages, tools = [], &block)
@@ -103,24 +105,17 @@ module Elelem
       end
 
       def stream(body)
-        request = Net::HTTP::Post.new(@uri)
-        request["content-type"] = "application/json"
-        request.body = JSON.generate(body)
+        http.post(@uri, body: body) do |response|
+          raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
 
-        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
+          read_ndjson_stream(response) { |event| yield event }
         end
       end
 
+      def http
+        @http ||= HttpAdapter.new(read_timeout: @read_timeout, open_timeout: @open_timeout)
+      end
+
       def read_ndjson_stream(response)
         buffer = String.new
 
@@ -145,6 +140,28 @@ module Elelem
           }
         end
       end
+
+      class HttpAdapter
+        def initialize(read_timeout:, open_timeout:)
+          @read_timeout = read_timeout
+          @open_timeout = open_timeout
+        end
+
+        def post(uri, 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) { |response| yield response }
+          end
+        end
+      end
     end
   end
 end
lib/elelem/ollama.rb
@@ -11,6 +11,8 @@ require_relative "ollama/client"
 Elelem::Providers.register(:ollama) do
   Elelem::Ollama::Client.new(
     model: ENV.fetch("OLLAMA_MODEL", "gpt-oss:latest"),
-    host: ENV.fetch("OLLAMA_HOST", "localhost:11434")
+    host: ENV.fetch("OLLAMA_HOST", "localhost:11434"),
+    think: ENV["OLLAMA_THINK"],
+    keep_alive: ENV["OLLAMA_KEEP_ALIVE"]
   )
 end
spec/elelem/ollama/client_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Ollama::Client do
+  subject(:client) { described_class.new(model: "gpt-oss:latest", http:, **params) }
+
+  let(:params) { {} }
+  let(:messages) { [{ role: "user", content: "hi" }] }
+  let(:body) { http.body }
+
+  let(:response) do
+    ::Net::HTTPOK.new("1.1", "200", "OK").tap do |it|
+      allow(it).to receive(:read_body).and_yield(%({"done":true,"message":{}}\n))
+    end
+  end
+
+  let(:http) do
+    Class.new do
+      attr_reader :body
+
+      def initialize(response)
+        @response = response
+      end
+
+      def post(_url, body:)
+        @body = body
+        yield @response
+      end
+    end.new(response)
+  end
+
+  describe "#fetch" do
+    it "sends only model, messages and stream by default" do
+      client.fetch(messages) { }
+
+      expect(body).to eq(model: "gpt-oss:latest", messages:, stream: true)
+    end
+
+    it "sends tools when present" do
+      tools = [{ type: "function", function: { name: "read" } }]
+
+      client.fetch(messages, tools) { }
+
+      expect(body[:tools]).to eq(tools)
+    end
+
+    context "with tuning keywords" do
+      let(:params) { { think: "high", keep_alive: "30m", options: { num_ctx: 32_768 } } }
+
+      it "sends them in the request body" do
+        client.fetch(messages) { }
+
+        expect(body).to include(think: "high", keep_alive: "30m", options: { num_ctx: 32_768 })
+      end
+    end
+
+    context "with an empty options hash" do
+      let(:params) { { options: {} } }
+
+      it "omits options" do
+        client.fetch(messages) { }
+
+        expect(body).not_to have_key(:options)
+      end
+    end
+
+    context "with passthrough params" do
+      let(:params) { { params: { format: "json", truncate: false, top_logprobs: 3 } } }
+
+      it "merges them into the request body" do
+        client.fetch(messages) { }
+
+        expect(body).to include(format: "json", truncate: false, top_logprobs: 3)
+      end
+    end
+
+    context "with a passthrough param that collides with a keyword" do
+      let(:params) { { think: "low", params: { think: "high" } } }
+
+      it "prefers the passthrough value" do
+        client.fetch(messages) { }
+
+        expect(body[:think]).to eq("high")
+      end
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/ollama"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
Gemfile
@@ -6,3 +6,4 @@ gemspec name: "elelem-ollama"
 
 gem "irb"
 gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Gemfile.lock
@@ -13,6 +13,7 @@ GEM
     base64 (0.3.0)
     bigdecimal (4.1.2)
     date (3.5.1)
+    diff-lcs (1.6.2)
     digest (3.2.1)
     elelem (0.10.0)
       base64 (~> 0.1)
@@ -84,6 +85,19 @@ GEM
     reline (0.7.0)
       io-console (~> 0.5)
     resolv (0.7.2)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.6)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
     securerandom (0.4.1)
     shellwords (0.2.2)
     simpleidn (0.3.0)
@@ -102,12 +116,14 @@ DEPENDENCIES
   elelem-ollama!
   irb
   rake (~> 13.0)
+  rspec (~> 3.0)
 
 CHECKSUMS
   base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
   bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
   bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
   date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
   digest (3.2.1) sha256=ab3312b4e272d7d5dc41c564c86a25861a1f34ac5153374199a0b74861395947
   elelem (0.10.0) sha256=de67f3a28351640da471e6e80e7a1d79e83e293cbc502a9e98c3c609cad2e237
   elelem-ollama (0.1.0)
@@ -135,6 +151,11 @@ CHECKSUMS
   regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
   reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
   resolv (0.7.2) sha256=626d044d975ab2daac759bf898416f1b51e2cb8dcd6727c2b5b5b28b97ead2e1
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
   securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
   shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
   simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
Rakefile
@@ -1,4 +1,8 @@
 # frozen_string_literal: true
 
 require "bundler/gem_tasks"
-task default: %i[]
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: %i[spec]