Commit 27c4249

mo khan <mo@mokhan.ca>
2025-10-08 14:47:45
feat: add client for ollama and anthropic
1 parent 6a98e2a
lib/net/llm/anthropic.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+module Net
+  module Llm
+    class Anthropic
+      attr_reader :api_key, :model
+
+      def initialize(api_key:, model: "claude-3-5-sonnet-20241022")
+        @api_key = api_key
+        @model = model
+      end
+
+      def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
+        uri = URI("https://api.anthropic.com/v1/messages")
+        payload = build_payload(messages, system, max_tokens, tools, block_given?)
+
+        if block_given?
+          stream_request(uri, payload, &block)
+        else
+          post_request(uri, payload)
+        end
+      end
+
+      private
+
+      def build_payload(messages, system, max_tokens, tools, stream)
+        payload = {
+          model: model,
+          max_tokens: max_tokens,
+          messages: messages,
+          stream: stream
+        }
+        payload[:system] = system if system
+        payload[:tools] = tools if tools
+        payload
+      end
+
+      def post_request(uri, payload)
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = true
+
+        request = Net::HTTP::Post.new(uri)
+        request["x-api-key"] = api_key
+        request["anthropic-version"] = "2023-06-01"
+        request["Content-Type"] = "application/json"
+        request.body = payload.to_json
+
+        response = http.start { |h| h.request(request) }
+        raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+        JSON.parse(response.body)
+      end
+
+      def stream_request(uri, payload, &block)
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = true
+        http.read_timeout = 3600
+
+        request = Net::HTTP::Post.new(uri)
+        request["x-api-key"] = api_key
+        request["anthropic-version"] = "2023-06-01"
+        request["Content-Type"] = "application/json"
+        request.body = payload.to_json
+
+        http.start do |h|
+          h.request(request) do |response|
+            unless response.is_a?(Net::HTTPSuccess)
+              raise "HTTP #{response.code}: #{response.body}"
+            end
+
+            buffer = ""
+            response.read_body do |chunk|
+              buffer += chunk
+
+              while (event = extract_sse_event(buffer))
+                next if event[:data].nil? || event[:data].empty?
+                next if event[:data] == "[DONE]"
+
+                json = JSON.parse(event[:data])
+                block.call(json)
+
+                break if json["type"] == "message_stop"
+              end
+            end
+          end
+        end
+      end
+
+      def extract_sse_event(buffer)
+        event_end = buffer.index("\n\n")
+        return nil unless event_end
+
+        event_data = buffer[0...event_end]
+        buffer.replace(buffer[(event_end + 2)..-1] || "")
+
+        event = {}
+        event_data.split("\n").each do |line|
+          if line.start_with?("event: ")
+            event[:event] = line[7..-1]
+          elsif line.start_with?("data: ")
+            event[:data] = line[6..-1]
+          elsif line == "data:"
+            event[:data] = ""
+          end
+        end
+
+        event
+      end
+    end
+  end
+end
lib/net/llm/ollama.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+module Net
+  module Llm
+    class Ollama
+      attr_reader :host, :model
+
+      def initialize(host: "localhost:11434", model: "llama2")
+        @host = host
+        @model = model
+      end
+
+      def chat(messages, &block)
+        uri = build_uri("/api/chat")
+        payload = { model: model, messages: messages, stream: block_given? }
+
+        if block_given?
+          stream_request(uri, payload, &block)
+        else
+          post_request(uri, payload)
+        end
+      end
+
+      def generate(prompt, &block)
+        uri = build_uri("/api/generate")
+        payload = { model: model, prompt: prompt, stream: block_given? }
+
+        if block_given?
+          stream_request(uri, payload, &block)
+        else
+          post_request(uri, payload)
+        end
+      end
+
+      def embeddings(input)
+        uri = build_uri("/api/embed")
+        payload = { model: model, input: input }
+        post_request(uri, payload)
+      end
+
+      def tags
+        uri = build_uri("/api/tags")
+        get_request(uri)
+      end
+
+      def show(name)
+        uri = build_uri("/api/show")
+        payload = { name: name }
+        post_request(uri, payload)
+      end
+
+      private
+
+      def build_uri(path)
+        base = host.start_with?("http://", "https://") ? host : "http://#{host}"
+        URI("#{base}#{path}")
+      end
+
+      def get_request(uri)
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = uri.scheme == "https"
+        request = Net::HTTP::Get.new(uri)
+        request["Accept"] = "application/json"
+
+        response = http.start { |h| h.request(request) }
+        raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+        JSON.parse(response.body)
+      end
+
+      def post_request(uri, payload)
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = uri.scheme == "https"
+        request = Net::HTTP::Post.new(uri)
+        request["Accept"] = "application/json"
+        request["Content-Type"] = "application/json"
+        request.body = payload.to_json
+
+        response = http.start { |h| h.request(request) }
+        raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+        JSON.parse(response.body)
+      end
+
+      def stream_request(uri, payload, &block)
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = uri.scheme == "https"
+        http.read_timeout = 3600
+
+        request = Net::HTTP::Post.new(uri)
+        request["Accept"] = "application/json"
+        request["Content-Type"] = "application/json"
+        request.body = payload.to_json
+
+        http.start do |h|
+          h.request(request) do |response|
+            unless response.is_a?(Net::HTTPSuccess)
+              raise "HTTP #{response.code}: #{response.body}"
+            end
+
+            buffer = ""
+            response.read_body do |chunk|
+              buffer += chunk
+
+              while (message = extract_message(buffer))
+                next if message.empty?
+
+                json = JSON.parse(message)
+                block.call(json)
+
+                break if json["done"]
+              end
+            end
+          end
+        end
+      end
+
+      def extract_message(buffer)
+        message_end = buffer.index("\n")
+        return nil unless message_end
+
+        message = buffer[0...message_end]
+        buffer.replace(buffer[(message_end + 1)..-1] || "")
+        message
+      end
+    end
+  end
+end
lib/net/llm.rb
@@ -1,6 +1,11 @@
 # frozen_string_literal: true
 
 require_relative "llm/version"
+require_relative "llm/ollama"
+require_relative "llm/anthropic"
+require "net/http"
+require "json"
+require "uri"
 
 module Net
   module Llm
@@ -33,6 +38,39 @@ module Net
         raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
         JSON.parse(response.body)
       end
+
+      def models(timeout: DEFAULT_TIMEOUT)
+        uri = URI("#{base_url}/models")
+        request = Net::HTTP::Get.new(uri)
+        request["Authorization"] = "Bearer #{api_key}"
+
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = true
+        http.open_timeout = timeout
+        http.read_timeout = timeout
+
+        response = http.start { |h| h.request(request) }
+        raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+        JSON.parse(response.body)
+      end
+
+      def embeddings(input, model: "text-embedding-ada-002", timeout: DEFAULT_TIMEOUT)
+        uri = URI("#{base_url}/embeddings")
+        request = Net::HTTP::Post.new(uri)
+        request["Authorization"] = "Bearer #{api_key}"
+        request["Content-Type"] = "application/json"
+        request.body = { model: model, input: input }.to_json
+
+        http = Net::HTTP.new(uri.hostname, uri.port)
+        http.use_ssl = true
+        http.open_timeout = timeout
+        http.read_timeout = timeout
+        http.write_timeout = timeout if http.respond_to?(:write_timeout=)
+
+        response = http.start { |h| h.request(request) }
+        raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+        JSON.parse(response.body)
+      end
     end
   end
 end
spec/net/llm/anthropic_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+RSpec.describe Net::Llm::Anthropic do
+  let(:api_key) { "test-key" }
+  let(:client) { described_class.new(api_key: api_key) }
+
+  describe "#initialize" do
+    it "sets default model" do
+      expect(client.model).to eq("claude-3-5-sonnet-20241022")
+    end
+
+    it "allows custom model" do
+      custom_client = described_class.new(api_key: api_key, model: "claude-3-opus-20240229")
+      expect(custom_client.model).to eq("claude-3-opus-20240229")
+    end
+  end
+
+  describe "#messages" do
+    let(:messages) { [{ role: "user", content: "Hello" }] }
+
+    context "without streaming" do
+      let(:response_body) do
+        {
+          id: "msg_123",
+          type: "message",
+          role: "assistant",
+          content: [{ type: "text", text: "Hi there!" }],
+          model: "claude-3-5-sonnet-20241022",
+          stop_reason: "end_turn"
+        }.to_json
+      end
+
+      it "makes a POST request to /v1/messages" do
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .with(
+            headers: {
+              "x-api-key" => api_key,
+              "anthropic-version" => "2023-06-01",
+              "Content-Type" => "application/json"
+            },
+            body: hash_including(
+              model: "claude-3-5-sonnet-20241022",
+              max_tokens: 1024,
+              messages: messages,
+              stream: false
+            )
+          )
+          .to_return(status: 200, body: response_body)
+
+        result = client.messages(messages)
+        expect(result["content"][0]["text"]).to eq("Hi there!")
+      end
+
+      it "includes system prompt when provided" do
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .with(body: hash_including(system: "You are helpful"))
+          .to_return(status: 200, body: response_body)
+
+        client.messages(messages, system: "You are helpful")
+      end
+
+      it "includes tools when provided" do
+        tools = [{ name: "get_weather", description: "Get weather" }]
+
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .with(body: hash_including(tools: tools))
+          .to_return(status: 200, body: response_body)
+
+        client.messages(messages, tools: tools)
+      end
+
+      it "allows custom max_tokens" do
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .with(body: hash_including(max_tokens: 2048))
+          .to_return(status: 200, body: response_body)
+
+        client.messages(messages, max_tokens: 2048)
+      end
+
+      it "raises on HTTP error" do
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .to_return(status: 401, body: "Unauthorized")
+
+        expect { client.messages(messages) }.to raise_error(/HTTP 401/)
+      end
+    end
+
+    context "with streaming" do
+      it "yields SSE events to the block" do
+        sse_events = [
+          "event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
+          "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"H\"}}\n\n",
+          "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"i\"}}\n\n",
+          "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+        ]
+        response_body = sse_events.join
+
+        stub_request(:post, "https://api.anthropic.com/v1/messages")
+          .with(body: hash_including(stream: true))
+          .to_return(status: 200, body: response_body)
+
+        results = []
+        client.messages(messages) { |event| results << event }
+
+        expect(results.size).to eq(4)
+        expect(results[0]["type"]).to eq("message_start")
+        expect(results[1]["delta"]["text"]).to eq("H")
+        expect(results[2]["delta"]["text"]).to eq("i")
+        expect(results[3]["type"]).to eq("message_stop")
+      end
+    end
+  end
+end
spec/net/llm/ollama_spec.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+RSpec.describe Net::Llm::Ollama do
+  let(:host) { "localhost:11434" }
+  let(:model) { "llama2" }
+  let(:client) { described_class.new(host: host, model: model) }
+
+  describe "#initialize" do
+    it "sets default host" do
+      default_client = described_class.new
+      expect(default_client.host).to eq("localhost:11434")
+    end
+
+    it "sets default model" do
+      default_client = described_class.new
+      expect(default_client.model).to eq("llama2")
+    end
+
+    it "allows custom host" do
+      custom_client = described_class.new(host: "ollama.example.com:11434")
+      expect(custom_client.host).to eq("ollama.example.com:11434")
+    end
+
+    it "allows custom model" do
+      custom_client = described_class.new(model: "codellama")
+      expect(custom_client.model).to eq("codellama")
+    end
+  end
+
+  describe "#chat" do
+    let(:messages) { [{ role: "user", content: "Hello" }] }
+
+    context "without streaming" do
+      let(:response_body) { { message: { content: "Hi" }, done: true }.to_json }
+
+      it "makes a POST request to /api/chat" do
+        stub_request(:post, "http://localhost:11434/api/chat")
+          .with(
+            headers: {
+              "Accept" => "application/json",
+              "Content-Type" => "application/json"
+            },
+            body: hash_including(
+              model: "llama2",
+              messages: messages,
+              stream: false
+            )
+          )
+          .to_return(status: 200, body: response_body)
+
+        result = client.chat(messages)
+        expect(result["message"]["content"]).to eq("Hi")
+        expect(result["done"]).to eq(true)
+      end
+
+      it "raises on HTTP error" do
+        stub_request(:post, "http://localhost:11434/api/chat")
+          .to_return(status: 500, body: "Server error")
+
+        expect { client.chat(messages) }.to raise_error(/HTTP 500/)
+      end
+    end
+
+    context "with streaming" do
+      it "yields each chunk to the block" do
+        chunks = [
+          { message: { content: "H" }, done: false }.to_json,
+          { message: { content: "i" }, done: false }.to_json,
+          { message: { content: "" }, done: true }.to_json
+        ]
+        response_body = chunks.join("\n") + "\n"
+
+        stub_request(:post, "http://localhost:11434/api/chat")
+          .with(body: hash_including(stream: true))
+          .to_return(status: 200, body: response_body)
+
+        results = []
+        client.chat(messages) { |chunk| results << chunk }
+
+        expect(results.size).to eq(3)
+        expect(results[0]["message"]["content"]).to eq("H")
+        expect(results[1]["message"]["content"]).to eq("i")
+        expect(results[2]["done"]).to eq(true)
+      end
+    end
+  end
+
+  describe "#generate" do
+    let(:prompt) { "Write a poem" }
+
+    context "without streaming" do
+      let(:response_body) { { response: "Roses are red", done: true }.to_json }
+
+      it "makes a POST request to /api/generate" do
+        stub_request(:post, "http://localhost:11434/api/generate")
+          .with(
+            headers: {
+              "Accept" => "application/json",
+              "Content-Type" => "application/json"
+            },
+            body: hash_including(
+              model: "llama2",
+              prompt: prompt,
+              stream: false
+            )
+          )
+          .to_return(status: 200, body: response_body)
+
+        result = client.generate(prompt)
+        expect(result["response"]).to eq("Roses are red")
+      end
+    end
+
+    context "with streaming" do
+      it "yields each chunk to the block" do
+        chunks = [
+          { response: "R" }.to_json,
+          { response: "oses", done: true }.to_json
+        ]
+        response_body = chunks.join("\n") + "\n"
+
+        stub_request(:post, "http://localhost:11434/api/generate")
+          .with(body: hash_including(stream: true))
+          .to_return(status: 200, body: response_body)
+
+        results = []
+        client.generate(prompt) { |chunk| results << chunk }
+
+        expect(results.size).to eq(2)
+        expect(results[0]["response"]).to eq("R")
+        expect(results[1]["done"]).to eq(true)
+      end
+    end
+  end
+
+  describe "#embeddings" do
+    let(:input) { "Hello world" }
+    let(:response_body) { { embeddings: [[0.1, 0.2, 0.3]] }.to_json }
+
+    it "makes a POST request to /api/embed" do
+      stub_request(:post, "http://localhost:11434/api/embed")
+        .with(
+          headers: {
+            "Accept" => "application/json",
+            "Content-Type" => "application/json"
+          },
+          body: hash_including(
+            model: "llama2",
+            input: input
+          )
+        )
+        .to_return(status: 200, body: response_body)
+
+      result = client.embeddings(input)
+      expect(result["embeddings"]).to eq([[0.1, 0.2, 0.3]])
+    end
+  end
+
+  describe "#tags" do
+    let(:response_body) { { models: [{ name: "llama2" }] }.to_json }
+
+    it "makes a GET request to /api/tags" do
+      stub_request(:get, "http://localhost:11434/api/tags")
+        .with(headers: { "Accept" => "application/json" })
+        .to_return(status: 200, body: response_body)
+
+      result = client.tags
+      expect(result["models"]).to be_an(Array)
+      expect(result["models"][0]["name"]).to eq("llama2")
+    end
+  end
+
+  describe "#show" do
+    let(:model_name) { "llama2" }
+    let(:response_body) { { modelfile: "FROM llama2" }.to_json }
+
+    it "makes a POST request to /api/show" do
+      stub_request(:post, "http://localhost:11434/api/show")
+        .with(
+          headers: {
+            "Accept" => "application/json",
+            "Content-Type" => "application/json"
+          },
+          body: hash_including(name: model_name)
+        )
+        .to_return(status: 200, body: response_body)
+
+      result = client.show(model_name)
+      expect(result["modelfile"]).to eq("FROM llama2")
+    end
+  end
+end
spec/net/llm_spec.rb
@@ -4,8 +4,121 @@ RSpec.describe Net::Llm do
   it "has a version number" do
     expect(Net::Llm::VERSION).not_to be nil
   end
+end
+
+RSpec.describe Net::Llm::OpenAI do
+  let(:api_key) { "test-key" }
+  let(:client) { described_class.new(api_key: api_key) }
+
+  describe "#initialize" do
+    it "sets default base_url" do
+      expect(client.base_url).to eq("https://api.openai.com/v1")
+    end
+
+    it "sets default model" do
+      expect(client.model).to eq("gpt-4o-mini")
+    end
+
+    it "allows custom base_url" do
+      custom_client = described_class.new(api_key: api_key, base_url: "https://custom.com/v1")
+      expect(custom_client.base_url).to eq("https://custom.com/v1")
+    end
+
+    it "allows custom model" do
+      custom_client = described_class.new(api_key: api_key, model: "gpt-4")
+      expect(custom_client.model).to eq("gpt-4")
+    end
+  end
+
+  describe "#chat" do
+    let(:messages) { [{ role: "user", content: "Hello" }] }
+    let(:tools) { [] }
+    let(:response_body) { { choices: [{ message: { content: "Hi" } }] }.to_json }
+
+    it "makes a POST request to chat/completions" do
+      stub_request(:post, "https://api.openai.com/v1/chat/completions")
+        .with(
+          headers: {
+            "Authorization" => "Bearer #{api_key}",
+            "Content-Type" => "application/json"
+          },
+          body: hash_including(
+            model: "gpt-4o-mini",
+            messages: messages,
+            tools: tools,
+            tool_choice: "auto"
+          )
+        )
+        .to_return(status: 200, body: response_body)
+
+      result = client.chat(messages, tools)
+      expect(result["choices"][0]["message"]["content"]).to eq("Hi")
+    end
+
+    it "raises on HTTP error" do
+      stub_request(:post, "https://api.openai.com/v1/chat/completions")
+        .to_return(status: 401, body: "Unauthorized")
+
+      expect { client.chat(messages, tools) }.to raise_error(/HTTP 401/)
+    end
+  end
+
+  describe "#models" do
+    let(:response_body) { { data: [{ id: "gpt-4o-mini" }] }.to_json }
+
+    it "makes a GET request to models" do
+      stub_request(:get, "https://api.openai.com/v1/models")
+        .with(headers: { "Authorization" => "Bearer #{api_key}" })
+        .to_return(status: 200, body: response_body)
+
+      result = client.models
+      expect(result["data"]).to be_an(Array)
+      expect(result["data"][0]["id"]).to eq("gpt-4o-mini")
+    end
+
+    it "raises on HTTP error" do
+      stub_request(:get, "https://api.openai.com/v1/models")
+        .to_return(status: 500, body: "Server error")
+
+      expect { client.models }.to raise_error(/HTTP 500/)
+    end
+  end
+
+  describe "#embeddings" do
+    let(:input) { "Hello world" }
+    let(:response_body) { { data: [{ embedding: [0.1, 0.2, 0.3] }] }.to_json }
+
+    it "makes a POST request to embeddings" do
+      stub_request(:post, "https://api.openai.com/v1/embeddings")
+        .with(
+          headers: {
+            "Authorization" => "Bearer #{api_key}",
+            "Content-Type" => "application/json"
+          },
+          body: hash_including(
+            model: "text-embedding-ada-002",
+            input: input
+          )
+        )
+        .to_return(status: 200, body: response_body)
+
+      result = client.embeddings(input)
+      expect(result["data"][0]["embedding"]).to eq([0.1, 0.2, 0.3])
+    end
+
+    it "allows custom model" do
+      stub_request(:post, "https://api.openai.com/v1/embeddings")
+        .with(body: hash_including(model: "text-embedding-3-small"))
+        .to_return(status: 200, body: response_body)
+
+      client.embeddings(input, model: "text-embedding-3-small")
+    end
+
+    it "raises on HTTP error" do
+      stub_request(:post, "https://api.openai.com/v1/embeddings")
+        .to_return(status: 400, body: "Bad request")
 
-  it "does something useful" do
-    expect(false).to eq(true)
+      expect { client.embeddings(input) }.to raise_error(/HTTP 400/)
+    end
   end
 end
spec/spec_helper.rb
@@ -1,12 +1,10 @@
 # frozen_string_literal: true
 
 require "net/llm"
+require "webmock/rspec"
 
 RSpec.configure do |config|
-  # Enable flags like --only-failures and --next-failure
   config.example_status_persistence_file_path = ".rspec_status"
-
-  # Disable RSpec exposing methods globally on `Module` and `main`
   config.disable_monkey_patching!
 
   config.expect_with :rspec do |c|
CHANGELOG.md
@@ -1,5 +1,21 @@
 ## [Unreleased]
 
+## [0.2.0] - TBD
+
+- Add Ollama provider with streaming support
+  - `/api/chat` endpoint with streaming
+  - `/api/generate` endpoint with streaming
+  - `/api/embed` endpoint for embeddings
+  - `/api/tags` endpoint to list models
+  - `/api/show` endpoint for model info
+- Add Anthropic (Claude) provider with streaming support
+  - `/v1/messages` endpoint with streaming
+  - Support for system prompts
+  - Support for tools/function calling
+- Extend OpenAI provider
+  - Add `/v1/models` endpoint
+  - Add `/v1/embeddings` endpoint
+
 ## [0.1.0] - 2025-10-07
 
 - Initial release
Gemfile
@@ -9,3 +9,5 @@ gem "irb"
 gem "rake", "~> 13.0"
 
 gem "rspec", "~> 3.0"
+
+gem "webmock", "~> 3.25", group: :development
Gemfile.lock
@@ -6,11 +6,18 @@ PATH
 GEM
   remote: https://rubygems.org/
   specs:
+    addressable (2.8.7)
+      public_suffix (>= 2.0.2, < 7.0)
+    bigdecimal (3.3.0)
     cgi (0.4.2)
+    crack (1.0.0)
+      bigdecimal
+      rexml
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (4.0.4)
       cgi (>= 0.3.3)
+    hashdiff (1.2.1)
     io-console (0.8.1)
     irb (1.14.3)
       rdoc (>= 4.0.0)
@@ -18,12 +25,14 @@ GEM
     psych (5.2.2)
       date
       stringio
+    public_suffix (6.0.2)
     rake (13.3.0)
     rdoc (6.14.0)
       erb
       psych (>= 4.0.0)
     reline (0.6.0)
       io-console (~> 0.5)
+    rexml (3.4.4)
     rspec (3.13.1)
       rspec-core (~> 3.13.0)
       rspec-expectations (~> 3.13.0)
@@ -38,6 +47,10 @@ GEM
       rspec-support (~> 3.13.0)
     rspec-support (3.13.6)
     stringio (3.1.2)
+    webmock (3.25.1)
+      addressable (>= 2.8.0)
+      crack (>= 0.3.2)
+      hashdiff (>= 0.4.0, < 2.0.0)
 
 PLATFORMS
   ruby
@@ -48,6 +61,7 @@ DEPENDENCIES
   net-llm!
   rake (~> 13.0)
   rspec (~> 3.0)
+  webmock (~> 3.25)
 
 BUNDLED WITH
    2.7.2
net-llm.gemspec
@@ -30,9 +30,7 @@ Gem::Specification.new do |spec|
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
-  # Uncomment to register a new dependency of your gem
-  # spec.add_dependency "example-gem", "~> 1.0"
-
-  # For more information and examples about making a new gem, check out our
-  # guide at: https://bundler.io/guides/creating_gem.html
+  spec.add_dependency "json", "~> 2.0"
+  spec.add_dependency "uri", "~> 1.0"
+  spec.add_dependency "net-http", "~> 0.6"
 end
README.md
@@ -69,16 +69,124 @@ tools = [
 response = client.chat(messages, tools)
 ```
 
+### Ollama
+
+```ruby
+require 'net/llm'
+
+client = Net::Llm::Ollama.new(
+  host: 'localhost:11434',
+  model: 'gpt-oss:latest'
+)
+
+messages = [
+  { role: 'user', content: 'Hello!' }
+]
+
+response = client.chat(messages)
+puts response['message']['content']
+```
+
+#### Streaming
+
+```ruby
+client.chat(messages) do |chunk|
+  print chunk.dig('message', 'content')
+end
+```
+
+#### Generate
+
+```ruby
+response = client.generate('Write a haiku')
+puts response['response']
+
+client.generate('Write a haiku') do |chunk|
+  print chunk['response']
+end
+```
+
+#### Other Endpoints
+
+```ruby
+client.embeddings('Hello world')
+client.tags
+client.show('llama2')
+```
+
+### Anthropic (Claude)
+
+```ruby
+require 'net/llm'
+
+client = Net::Llm::Anthropic.new(
+  api_key: ENV['ANTHROPIC_API_KEY'],
+  model: 'claude-3-5-sonnet-20241022'
+)
+
+messages = [
+  { role: 'user', content: 'Hello!' }
+]
+
+response = client.messages(messages)
+puts response.dig('content', 0, 'text')
+```
+
+#### With System Prompt
+
+```ruby
+response = client.messages(
+  messages,
+  system: 'You are a helpful assistant'
+)
+```
+
+#### Streaming
+
+```ruby
+client.messages(messages) do |event|
+  if event['type'] == 'content_block_delta'
+    print event.dig('delta', 'text')
+  end
+end
+```
+
+#### With Tools
+
+```ruby
+tools = [
+  {
+    name: 'get_weather',
+    description: 'Get current weather',
+    input_schema: {
+      type: 'object',
+      properties: {
+        location: { type: 'string' }
+      },
+      required: ['location']
+    }
+  }
+]
+
+response = client.messages(messages, tools: tools)
+```
+
 ## API Coverage
 
 ### OpenAI
 - `/v1/chat/completions` (with tools support)
+- `/v1/models`
+- `/v1/embeddings`
 
 ### Ollama
-Coming soon
+- `/api/chat` (with streaming)
+- `/api/generate` (with streaming)
+- `/api/embed`
+- `/api/tags`
+- `/api/show`
 
 ### Anthropic (Claude)
-Coming soon
+- `/v1/messages` (with streaming and tools)
 
 ## Development