Commit f445b6c
Changed files (4)
lib
elelem
net
spec
elelem
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 {