Commit 5904cd6
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