Commit 5904cd6

mo khan <mo@mokhan.ca>
2026-01-20 22:05:08
refactor: simplify fetch interface
1 parent e22a21a
exe/elelem
@@ -6,10 +6,10 @@ require "elelem"
 Signal.trap("INT") { exit 1 }
 
 PROVIDERS = {
-  "ollama" => -> (model) { Elelem::Net::Ollama.new(model: model || "gpt-oss:latest") },
-  "anthropic" => -> (model) { Elelem::Net::Claude.anthropic(model: model || "claude-opus-4-5-20250514") },
-  "vertex" => -> (model) { Elelem::Net::Claude.vertex(model: model || "claude-opus-4-5@20251101") },
-  "openai" => -> (model) { Elelem::Net::OpenAI.new(model: model || "gpt-4o") }
+  "ollama" => -> (model) { Elelem::Net::Ollama.new(model: model || "gpt-oss:latest", host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
+  "anthropic" => -> (model) { Elelem::Net::Claude.anthropic(model: model || "claude-opus-4-5-20250514", api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
+  "vertex" => -> (model) { Elelem::Net::Claude.vertex(model: model || "claude-opus-4-5@20251101", project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
+  "openai" => -> (model) { Elelem::Net::OpenAI.new(model: model || "gpt-4o", api_key: ENV.fetch("OPENAI_API_KEY")) }
 }.freeze
 
 def parse_args(args)
lib/elelem/net/claude.rb
@@ -3,25 +3,31 @@
 module Elelem
   module Net
     class Claude
-      def initialize(endpoint:, headers:, model: nil, version: nil, http: Elelem::Net.http)
-        @endpoint, @headers_src, @model, @version, @http = endpoint, headers, model, version, http
+      def self.anthropic(model:, api_key:, http: Elelem::Net.http)
+        new(
+          endpoint: "https://api.anthropic.com/v1/messages",
+          headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
+          model:,
+          http:
+        )
       end
 
-      def self.anthropic(model:, api_key: ENV.fetch("ANTHROPIC_API_KEY"), http: Elelem::Net.http)
-        new(endpoint: "https://api.anthropic.com/v1/messages",
-            headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
-            model:, http:)
+      def self.vertex(model:, project:, region: "us-east5", http: Elelem::Net.http)
+        new(
+          endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
+          headers: -> { { "Authorization" => "Bearer #{`gcloud auth application-default print-access-token`.strip}" } },
+          version: "vertex-2023-10-16",
+          http:
+        )
       end
 
-      def self.vertex(model:, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5"), http: Elelem::Net.http)
-        new(endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
-            headers: -> { { "Authorization" => "Bearer #{`gcloud auth application-default print-access-token`.strip}" } },
-            version: "vertex-2023-10-16", http:)
+      def initialize(endpoint:, headers:, model:, version: nil, http: Elelem::Net.http)
+        @endpoint, @headers_src, @model, @version, @http = endpoint, headers, model, version, http
       end
 
       def fetch(messages, tools = [], &block)
         system, msgs = extract_system(messages)
-        content, thinking, tool_calls = "", "", []
+        tool_calls = []
 
         stream(msgs, system, tools) do |event|
           case event["type"]
@@ -32,23 +38,17 @@ module Elelem
           when "content_block_delta"
             case event.dig("delta", "type")
             when "text_delta"
-              text = event.dig("delta", "text")
-              content += text
-              block.call(type: :delta, content: text, thinking: nil, tool_calls: nil)
+              block.call(content: event.dig("delta", "text"), thinking: nil)
             when "thinking_delta"
-              text = event.dig("delta", "thinking")
-              thinking += text.to_s
-              block.call(type: :delta, content: nil, thinking: text, tool_calls: nil)
+              block.call(content: nil, thinking: event.dig("delta", "thinking"))
             when "input_json_delta"
               tool_calls.last[:args] += event.dig("delta", "partial_json").to_s if tool_calls.any?
             end
           when "message_stop"
-            tool_calls.each do |tc|
-              tc[:arguments] = begin; JSON.parse(tc.delete(:args)); rescue; {}; end
-            end
-            block.call(type: :complete, content:, thinking: (thinking unless thinking.empty?), tool_calls:)
+            tool_calls.each { |tool_call| tool_call[:arguments] = begin; JSON.parse(tool_call.delete(:args)); rescue; {}; end }
           end
         end
+        tool_calls
       end
 
       private
@@ -60,7 +60,7 @@ module Elelem
         body[:model] = @model if @model
         body[:anthropic_version] = @version if @version
         body[:system] = system if system
-        body[:tools] = tools.map { |t| t[:function] ? { name: t[:function][:name], description: t[:function][:description], input_schema: t[:function][:parameters] } : t } unless tools.empty?
+        body[:tools] = unwrap_tools(tools) unless tools.empty?
 
         @http.post(@endpoint, headers:, body:) do |res|
           raise "HTTP #{res.code}: #{res.body}" unless res.is_a?(::Net::HTTPSuccess)
@@ -86,18 +86,18 @@ module Elelem
 
       def normalize(messages)
         messages.map do |m|
-          role, tcs = m[:role] || m["role"], m[:tool_calls] || m["tool_calls"]
+          role, tool_calls = m[:role] || m["role"], m[:tool_calls] || m["tool_calls"]
 
           if role == "tool"
             { role: "user", content: [{ type: "tool_result", tool_use_id: m[:tool_call_id] || m["tool_call_id"], content: m[:content] || m["content"] }] }
-          elsif role == "assistant" && tcs&.any?
+          elsif role == "assistant" && tool_calls&.any?
             content = []
             text = m[:content] || m["content"]
             content << { type: "text", text: } if text && !text.empty?
-            tcs.each do |tc|
-              fn = tc[:function] || tc["function"] || {}
+            tool_calls.each do |tool_call|
+              fn = tool_call[:function] || tool_call["function"] || {}
               args = fn[:arguments] || fn["arguments"]
-              content << { type: "tool_use", id: tc[:id] || tc["id"], name: fn[:name] || fn["name"] || tc[:name] || tc["name"], input: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {}) }
+              content << { type: "tool_use", id: tool_call[:id] || tool_call["id"], name: fn[:name] || fn["name"] || tool_call[:name] || tool_call["name"], input: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {}) }
             end
             { role: "assistant", content: }
           else
@@ -105,6 +105,16 @@ module Elelem
           end
         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/net/ollama.rb
@@ -3,24 +3,21 @@
 module Elelem
   module Net
     class Ollama
-      def initialize(model:, host: ENV.fetch("OLLAMA_HOST", "localhost:11434"), http: Elelem::Net.http)
+      def initialize(model:, host: "localhost:11434", http: Elelem::Net.http)
         @url = "#{host.start_with?('http') ? host : "http://#{host}"}/api/chat"
         @model, @http = model, http
       end
 
       def fetch(messages, tools = [], &block)
-        content, thinking, tool_calls = "", "", []
+        tool_calls = []
 
         stream({ model: @model, messages:, tools:, stream: true }) do |json|
           msg = json["message"] || {}
-          content += msg["content"].to_s
-          thinking += msg["thinking"].to_s
+          block.call(content: msg["content"], thinking: msg["thinking"]) unless json["done"]
           tool_calls.concat(parse_tools(msg["tool_calls"])) if msg["tool_calls"]
-
-          block.call(json["done"] ?
-            { type: :complete, content:, thinking: nilify(thinking), tool_calls: } :
-            { type: :delta, content: msg["content"], thinking: msg["thinking"], tool_calls: nil })
         end
+
+        tool_calls
       end
 
       private
@@ -38,11 +35,15 @@ module Elelem
         end
       end
 
-      def parse_tools(tcs)
-        tcs.map { |tc| { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments") || {} } }
+      def parse_tools(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
-
-      def nilify(s) = s.empty? ? nil : s
     end
   end
 end
lib/elelem/net/openai.rb
@@ -3,36 +3,29 @@
 module Elelem
   module Net
     class OpenAI
-      def initialize(model:, api_key: ENV.fetch("OPENAI_API_KEY"), base_url: ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1"), http: Elelem::Net.http)
+      def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", http: Elelem::Net.http)
         @url = "#{base_url}/chat/completions"
         @model, @api_key, @http = model, api_key, http
       end
 
       def fetch(messages, tools = [], &block)
-        content, tool_calls, stop = "", {}, :end_turn
-        body = { model: @model, messages:, stream: true }
-        body.merge!(tools:, tool_choice: "auto") unless tools.empty?
+        tool_calls = {}
+        body = { model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
 
         stream(body) do |json|
           delta = json.dig("choices", 0, "delta") || {}
+          block.call(content: delta["content"], thinking: nil) if delta["content"]
 
-          if (text = delta["content"])
-            content += text
-            block.call(type: :delta, content: text, thinking: nil, tool_calls: nil)
-          end
-
-          delta["tool_calls"]&.each do |tc|
-            idx = tc["index"]
+          delta["tool_calls"]&.each do |tool_call|
+            idx = tool_call["index"]
             tool_calls[idx] ||= { id: nil, name: nil, args: "" }
-            tool_calls[idx][:id] ||= tc["id"]
-            tool_calls[idx][:name] ||= tc.dig("function", "name")
-            tool_calls[idx][:args] += tc.dig("function", "arguments").to_s
+            tool_calls[idx][:id] ||= tool_call["id"]
+            tool_calls[idx][:name] ||= tool_call.dig("function", "name")
+            tool_calls[idx][:args] += tool_call.dig("function", "arguments").to_s
           end
-
-          stop = json.dig("choices", 0, "finish_reason")&.to_sym || stop
         end
 
-        block.call(type: :complete, content:, thinking: nil, tool_calls: finalize_tools(tool_calls))
+        finalize_tools(tool_calls)
       end
 
       private
@@ -40,6 +33,7 @@ module Elelem
       def stream(body, &block)
         @http.post(@url, headers: { "Authorization" => "Bearer #{@api_key}" }, body:) do |res|
           raise "HTTP #{res.code}: #{res.body}" unless res.is_a?(::Net::HTTPSuccess)
+
           buf = ""
           res.read_body do |chunk|
             buf += chunk
@@ -52,10 +46,13 @@ module Elelem
         end
       end
 
-      def finalize_tools(tcs)
-        tcs.values.map do |tc|
-          args = begin; JSON.parse(tc[:args]); rescue; {}; end
-          { id: tc[:id], name: tc[:name], arguments: args }
+      def finalize_tools(tool_calls)
+        tool_calls.values.map do |tool_call|
+          {
+            id: tool_call[:id],
+            name: tool_call[:name],
+            arguments: JSON.parse(tool_call[:args])
+          }
         end
       end
     end
lib/elelem/agent.rb
@@ -98,14 +98,10 @@ module Elelem
     end
 
     def fetch_response(ctx)
-      content, tool_calls = "", []
-      client.fetch(history + ctx, toolbox.to_h) do |chunk|
-        terminal.print(terminal.think(chunk[:thinking])) if chunk[:thinking]
-
-        case chunk[:type]
-        when :delta then content += chunk[:content].to_s
-        when :complete then content, tool_calls = chunk[:content].to_s, chunk[:tool_calls] || []
-        end
+      content = ""
+      tool_calls = client.fetch(history + ctx, toolbox.to_h) do |delta|
+        content += delta[:content].to_s
+        terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
       end
       [content, tool_calls]
     rescue => e
lib/elelem/toolbox.rb
@@ -4,7 +4,7 @@ module Elelem
   class Toolbox
     TOOLS = {
       "read" => {
-        desc: "Read file",
+        description: "Read file",
         params: { path: { type: "string" } },
         required: ["path"],
         fn: lambda do |a|
@@ -13,7 +13,7 @@ module Elelem
         end
       },
       "write" => {
-        desc: "Write file",
+        description: "Write file",
         params: { path: { type: "string" }, content: { type: "string" } },
         required: ["path", "content"],
         fn: lambda do |a|
@@ -23,7 +23,7 @@ module Elelem
         end
       },
       "execute" => {
-        desc: "Run shell command (supports pipes and redirections)",
+        description: "Run shell command (supports pipes and redirections)",
         params: { command: { type: "string" } },
         required: ["command"],
         fn: ->(a) { Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) } }
@@ -78,17 +78,13 @@ module Elelem
     end
 
     def to_h
-      tools.map do |name, t|
+      tools.map do |name, tool|
         {
           type: "function",
           function: {
             name: name,
-            description: t[:desc],
-            parameters: {
-              type: "object",
-              properties: t[:params],
-              required: t[:required]
-            }
+            description: tool[:description],
+            parameters: { type: "object", properties: tool[:params], required: tool[:required] }
           }
         }
       end