Commit 27c4249
Changed files (12)
lib
net
spec
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