Commit f445b6c

mo khan <mo@mokhan.ca>
2026-08-13 16:32:43
fix: stop dots from hanging indefinitely
1 parent e713eef
Changed files (4)
lib/elelem/net/ollama.rb
@@ -3,9 +3,21 @@
 module Elelem
   module Net
     class Ollama
-      def initialize(model:, host: "localhost:11434", http: Elelem::Net.http)
+      def initialize(
+        model:,
+        host: "localhost:11434",
+        think: "medium",
+        keep_alive: "5m",
+        options: {},
+        params: {},
+        http: Elelem::Net.http
+      )
         @url = normalize_url(host)
         @model = model
+        @think = think
+        @keep_alive = keep_alive
+        @options = options
+        @params = params
         @http = http
       end
 
@@ -27,8 +39,82 @@ module Elelem
         "#{base}/api/chat"
       end
 
+      # POST /api/chat request body. Anything left unset uses the server or default.
+=begin
+
+  | Field              | Type                  | Notes                                                 |
+  | ---                | ---                   | ---                                                   |
+  | model              | string                │ required                                              │
+  | messages           | array                 │ see below                                             │
+  | tools              | array                 │ JSON tool schemas                                     │
+  | stream             | bool                  │ NDJSON stream when true                               │
+  | think              | bool or string        │ thinking models; "low"/"medium"/"high"                │
+  | format             | "json" or JSON schema │ structured output                                     │
+  | options            | object                │ model params, see below                               │
+  | keep_alive         | duration              │ how long model stays resident, e.g. "5m", 0 to unload │
+  | truncate           | bool                  │ truncate prompt to fit context                        │
+  | shift              | bool                  │ shift context window instead of erroring when full    │
+  | logprobs           | bool                  │ return token logprobs                                 │
+  | top_logprobs       | int                   │ how many alternatives per token                       │
+  | _debug_render_only | bool                  │ return rendered prompt without inference              │
+
+  Message object
+
+  | Field | Description |
+  | ---- | --------- |
+  | role | (system|user|assistant|tool) |
+  | content | |
+  | thinking | |
+  | images | (base64 array, multimodal) |
+  | tool_calls | |
+  | tool_name | name of the tool that produced a tool message |
+
+  Options
+
+    Sampling:
+
+    | Field | Description |
+    | ---- | ---- |
+    | seed | |
+    | temperature | |
+    | top_k | |
+    | top_p | |
+    | min_p | |
+    | typical_p | |
+    | num_predict | |
+    | num_keep | |
+    | stop (array) | |
+    | repeat_last_n | |
+    | repeat_penalty | |
+    | presence_penalty | |
+    | frequency_penalty | |
+
+    Runner:
+
+    | Field | Description |
+    | ----- | ----------- |
+    | num_ctx | |
+    | num_batch | |
+    | num_gpu | |
+    | main_gpu | |
+    | use_mmap | |
+    | num_thread | |
+    | draft_num_predict | |
+=end
       def build_request_body(messages, tools)
-        { model: @model, messages:, tools:, stream: true }
+        {
+          model: @model,
+          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 handle_event(event, tool_calls, &block)
lib/elelem/terminal.rb
@@ -43,17 +43,21 @@ module Elelem
     end
 
     def print(text)
-      return if quiet? || blank?(text)
+      return if quiet?
 
       stop_dots
+      return if blank?(text)
+
       $stdout.print text
       @at_line_start = false
     end
 
     def say(text)
-      return if quiet? || blank?(text)
+      return if quiet?
 
       stop_dots
+      return if blank?(text)
+
       $stdout.puts text
       @at_line_start = true
     end
@@ -77,6 +81,7 @@ module Elelem
     def waiting
       return if quiet?
 
+      stop_dots
       @dots_thread = Thread.new do
         loop do
           $stdout.print "."
spec/elelem/net/ollama_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Net::Ollama 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, think: "medium", keep_alive: "5m")
+    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/elelem/terminal_spec.rb
@@ -91,6 +91,41 @@ RSpec.describe Elelem::Terminal do
     end
   end
 
+  describe "#waiting" do
+    around do |example|
+      original = $stdout
+      $stdout = StringIO.new
+      example.run
+    ensure
+      $stdout = original
+    end
+
+    def dots_thread
+      terminal.instance_variable_get(:@dots_thread)
+    end
+
+    it "stops dots when say receives blank text" do
+      terminal.waiting
+      thread = dots_thread
+      terminal.say(nil)
+      expect(thread.join(1)).to eq(thread)
+    end
+
+    it "stops dots when print receives blank text" do
+      terminal.waiting
+      thread = dots_thread
+      terminal.print(nil)
+      expect(thread.join(1)).to eq(thread)
+    end
+
+    it "kills the previous dots thread when called again" do
+      terminal.waiting
+      previous = dots_thread
+      terminal.waiting
+      expect(previous.join(1)).to eq(previous)
+    end
+  end
+
   describe "spacing consistency" do
     it "produces single blank line between sections regardless of method used" do
       expect {