Commit c33aa90

mo khan <mo@mokhan.ca>
2026-02-02 05:40:21
feat: add plugin api for registering new providers
1 parent f0009ed
lib/elelem/plugins/anthropic.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Elelem::Providers.register(:anthropic) do
+  Elelem::Net::Claude.anthropic(
+    model: ENV.fetch("ANTHROPIC_MODEL", "claude-opus-4-5-20250514"),
+    api_key: ENV.fetch("ANTHROPIC_API_KEY")
+  )
+end
lib/elelem/plugins/ollama.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Elelem::Providers.register(:ollama) do
+  Elelem::Net::Ollama.new(
+    model: ENV.fetch("OLLAMA_MODEL", "gpt-oss:latest"),
+    host: ENV.fetch("OLLAMA_HOST", "localhost:11434")
+  )
+end
lib/elelem/plugins/openai.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Elelem::Providers.register(:openai) do
+  Elelem::Net::OpenAI.new(
+    model: ENV.fetch("OPENAI_MODEL", "gpt-4o"),
+    api_key: ENV.fetch("OPENAI_API_KEY")
+  )
+end
lib/elelem/plugins/provider.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:provider) do |agent|
+  agent.commands.register("provider", description: "Switch provider", completions: -> { Elelem::Providers.names }) do |name|
+    if name.nil? || name.empty?
+      agent.terminal.say "  → available: #{Elelem::Providers.names.join(", ")}"
+    else
+      agent.client = Elelem::Providers.build(name)
+      agent.terminal.say "  → switched to #{name}"
+    end
+  end
+end
lib/elelem/plugins/vertex.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Elelem::Providers.register(:vertex) do
+  Elelem::Net::Claude.vertex(
+    model: ENV.fetch("VERTEX_MODEL", "claude-opus-4-5@20251101"),
+    project: ENV.fetch("GOOGLE_CLOUD_PROJECT"),
+    region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")
+  )
+end
lib/elelem/agent.rb
@@ -2,8 +2,8 @@
 
 module Elelem
   class Agent
-    attr_reader :conversation, :client, :toolbox, :terminal, :commands, :system_prompt
-    attr_writer :terminal, :toolbox, :commands
+    attr_reader :conversation, :system_prompt
+    attr_accessor :client, :toolbox, :terminal, :commands
 
     def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
       @client = client
lib/elelem/plugins.rb
@@ -9,17 +9,22 @@ module Elelem
     ].freeze
 
     def self.setup!(agent)
-      load_plugins
+      load!
+      run!(agent)
+    end
+
+    def self.run!(agent)
       registry.each_value { |plugin| plugin.call(agent) }
     end
 
     def self.reload!(agent)
+      Providers.registry.clear
       registry.clear
-      load_plugins
-      registry.each_value { |plugin| plugin.call(agent) }
+      load!
+      run!(agent)
     end
 
-    def self.load_plugins
+    def self.load!
       LOAD_PATHS.each do |path|
         dir = File.expand_path(path)
         next unless File.directory?(dir)
lib/elelem/providers.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Providers
+    def self.register(name, &factory)
+      registry[name.to_s] = factory
+    end
+
+    def self.build(name)
+      Plugins.load! if registry.empty?
+      registry.fetch(name.to_s).call
+    end
+
+    def self.names
+      registry.keys
+    end
+
+    def self.registry
+      @registry ||= {}
+    end
+  end
+end
lib/elelem.rb
@@ -26,6 +26,7 @@ require_relative "elelem/mcp"
 require_relative "elelem/net"
 require_relative "elelem/permissions"
 require_relative "elelem/plugins"
+require_relative "elelem/providers"
 require_relative "elelem/system_prompt"
 require_relative "elelem/terminal"
 require_relative "elelem/tool"
@@ -47,14 +48,16 @@ module Elelem
     end
   end
 
-  def self.start(client, toolbox: Toolbox.new)
+  def self.start(provider: "ollama", toolbox: Toolbox.new)
+    client = Providers.build(provider)
     agent = Agent.new(client, toolbox: toolbox)
     Plugins.setup!(agent)
     agent.terminal = Terminal.new(commands: agent.commands)
     agent.repl
   end
 
-  def self.ask(client, prompt, toolbox: Toolbox.new)
+  def self.ask(prompt, provider: "ollama", toolbox: Toolbox.new)
+    client = Providers.build(provider)
     agent = Agent.new(client, toolbox: toolbox, terminal: Terminal.new(quiet: true))
     Plugins.setup!(agent)
     agent.turn(prompt)
@@ -62,23 +65,8 @@ module Elelem
   end
 
   class CLI
-    MODELS = {
-      "ollama" => "gpt-oss:latest",
-      "anthropic" => "claude-opus-4-5-20250514",
-      "vertex" => "claude-opus-4-5@20251101",
-      "openai" => "gpt-4o"
-    }.freeze
-
-    PROVIDERS = {
-      "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
-      "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
-      "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
-      "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
-    }.freeze
-
     def initialize(args)
       @provider = "ollama"
-      @model = nil
       @args = parse(args)
     end
 
@@ -101,7 +89,6 @@ module Elelem
         o.separator "  help              Show this help"
         o.separator "\nOptions:"
         o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
-        o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
         o.on("-h", "--help") { puts o; exit }
       end
       @parser.parse!(args)
@@ -111,20 +98,15 @@ module Elelem
       puts @parser
     end
 
-    def client
-      model = @model || MODELS.fetch(@provider)
-      PROVIDERS.fetch(@provider).call(model)
-    end
-
     def chat
-      Elelem.start(client)
+      Elelem.start(provider: @provider)
     end
 
     def ask
       abort "Usage: elelem ask <prompt>" if @args.empty?
       prompt = @args.join(" ")
       prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
-      Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
+      Elelem::Terminal.new.markdown Elelem.ask(prompt, provider: @provider)
     end
 
     def files
spec/elelem/providers_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Providers do
+  before do
+    described_class.registry.clear
+  end
+
+  after do
+    described_class.registry.clear
+  end
+
+  describe ".register" do
+    it "registers a provider factory" do
+      client = double("client")
+      described_class.register(:test) { client }
+
+      expect(described_class.build("test")).to eq(client)
+    end
+
+    it "accepts symbol or string names" do
+      client = double("client")
+      described_class.register("string_provider") { client }
+
+      expect(described_class.build(:string_provider)).to eq(client)
+    end
+  end
+
+  describe ".build" do
+    it "calls the factory and returns the client" do
+      call_count = 0
+      described_class.register(:counter) { call_count += 1 }
+
+      described_class.build("counter")
+      described_class.build("counter")
+
+      expect(call_count).to eq(2)
+    end
+
+    it "raises KeyError for unknown provider" do
+      expect { described_class.build("unknown") }.to raise_error(KeyError)
+    end
+  end
+
+  describe ".names" do
+    it "returns registered provider names" do
+      described_class.register(:alpha) { }
+      described_class.register(:beta) { }
+
+      expect(described_class.names).to contain_exactly("alpha", "beta")
+    end
+  end
+end
Gemfile.lock
@@ -45,16 +45,20 @@ GEM
       regexp_parser (~> 2.0)
       simpleidn (~> 0.2)
     logger (1.7.0)
-    net-hippie (1.4.0)
+    monitor (0.2.0)
+    net-hippie (1.5.1)
       base64 (~> 0.1)
       json (~> 2.0)
       logger (~> 1.0)
-      net-http (~> 0.6)
-      openssl (~> 3.0)
+      monitor (~> 0.1)
+      net-http (~> 0.1)
+      openssl (~> 4.0)
+      resolv (~> 0.1)
+      timeout (~> 0.1)
     net-http (0.9.1)
       uri (>= 0.11.1)
     open3 (0.2.1)
-    openssl (3.3.2)
+    openssl (4.0.0)
     optparse (0.8.1)
     pathname (0.4.0)
     pp (0.6.3)
@@ -71,6 +75,7 @@ GEM
     regexp_parser (2.11.3)
     reline (0.6.3)
       io-console (~> 0.5)
+    resolv (0.7.1)
     rspec (3.13.2)
       rspec-core (~> 3.13.0)
       rspec-expectations (~> 3.13.0)
@@ -89,6 +94,7 @@ GEM
     simpleidn (0.2.3)
     stringio (3.2.0)
     tempfile (0.3.1)
+    timeout (0.6.0)
     tsort (0.2.0)
     uri (1.1.1)
     webrick (1.9.2)