net-llm 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +126 -2
- data/lib/net/llm/anthropic.rb +101 -0
- data/lib/net/llm/ollama.rb +104 -0
- data/lib/net/llm/openai.rb +50 -0
- data/lib/net/llm/version.rb +1 -1
- data/lib/net/llm.rb +10 -27
- metadata +47 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5fc7c8401eba4ab7b254b1247280c97f61db7fd7ed9b562c90bae0bc4066fbbc
|
4
|
+
data.tar.gz: a74469b7086118a424b20f688a1ed899a28cb8652bbc5683c65d5bfba8d748ea
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fcbb8722b6d6c2d9414ce49ded2a4abff7da0051e924483f6900676d6602ae64f5c1437f152b025b4a84a7c1700a2c9874254521f9b39206ab757955e18e042f
|
7
|
+
data.tar.gz: bea9a981fead23269fdc12f0012030187dfbd7da1bc444a8d23eee75fe4eba1dae68a9f1570e54352a8fe3c84bd3abd1d6563b88612342d48ffd425fbb6b95d4
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,30 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.3.0] - 2025-10-08
|
4
|
+
### Changed
|
5
|
+
- Refactored all providers to use net-hippie HTTP client
|
6
|
+
- Reduced code duplication across providers
|
7
|
+
- Improved error handling consistency
|
8
|
+
|
9
|
+
### Added
|
10
|
+
- Added net-hippie dependency for cleaner HTTP interactions
|
11
|
+
|
12
|
+
## [0.2.0] - 2025-10-08
|
13
|
+
|
14
|
+
- Add Ollama provider with streaming support
|
15
|
+
- `/api/chat` endpoint with streaming
|
16
|
+
- `/api/generate` endpoint with streaming
|
17
|
+
- `/api/embed` endpoint for embeddings
|
18
|
+
- `/api/tags` endpoint to list models
|
19
|
+
- `/api/show` endpoint for model info
|
20
|
+
- Add Anthropic (Claude) provider with streaming support
|
21
|
+
- `/v1/messages` endpoint with streaming
|
22
|
+
- Support for system prompts
|
23
|
+
- Support for tools/function calling
|
24
|
+
- Extend OpenAI provider
|
25
|
+
- Add `/v1/models` endpoint
|
26
|
+
- Add `/v1/embeddings` endpoint
|
27
|
+
|
3
28
|
## [0.1.0] - 2025-10-07
|
4
29
|
|
5
30
|
- Initial release
|
data/README.md
CHANGED
@@ -69,16 +69,140 @@ tools = [
|
|
69
69
|
response = client.chat(messages, tools)
|
70
70
|
```
|
71
71
|
|
72
|
+
### Ollama
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
require 'net/llm'
|
76
|
+
|
77
|
+
client = Net::Llm::Ollama.new(
|
78
|
+
host: 'localhost:11434',
|
79
|
+
model: 'gpt-oss:latest'
|
80
|
+
)
|
81
|
+
|
82
|
+
messages = [
|
83
|
+
{ role: 'user', content: 'Hello!' }
|
84
|
+
]
|
85
|
+
|
86
|
+
response = client.chat(messages)
|
87
|
+
puts response['message']['content']
|
88
|
+
```
|
89
|
+
|
90
|
+
#### Streaming
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
client.chat(messages) do |chunk|
|
94
|
+
print chunk.dig('message', 'content')
|
95
|
+
end
|
96
|
+
```
|
97
|
+
|
98
|
+
#### Generate
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
response = client.generate('Write a haiku')
|
102
|
+
puts response['response']
|
103
|
+
|
104
|
+
client.generate('Write a haiku') do |chunk|
|
105
|
+
print chunk['response']
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
#### Other Endpoints
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
client.embeddings('Hello world')
|
113
|
+
client.tags
|
114
|
+
client.show('llama2')
|
115
|
+
```
|
116
|
+
|
117
|
+
### Anthropic (Claude)
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
require 'net/llm'
|
121
|
+
|
122
|
+
client = Net::Llm::Anthropic.new(
|
123
|
+
api_key: ENV['ANTHROPIC_API_KEY'],
|
124
|
+
model: 'claude-3-5-sonnet-20241022'
|
125
|
+
)
|
126
|
+
|
127
|
+
messages = [
|
128
|
+
{ role: 'user', content: 'Hello!' }
|
129
|
+
]
|
130
|
+
|
131
|
+
response = client.messages(messages)
|
132
|
+
puts response.dig('content', 0, 'text')
|
133
|
+
```
|
134
|
+
|
135
|
+
#### With System Prompt
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
response = client.messages(
|
139
|
+
messages,
|
140
|
+
system: 'You are a helpful assistant'
|
141
|
+
)
|
142
|
+
```
|
143
|
+
|
144
|
+
#### Streaming
|
145
|
+
|
146
|
+
```ruby
|
147
|
+
client.messages(messages) do |event|
|
148
|
+
if event['type'] == 'content_block_delta'
|
149
|
+
print event.dig('delta', 'text')
|
150
|
+
end
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
#### With Tools
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
tools = [
|
158
|
+
{
|
159
|
+
name: 'get_weather',
|
160
|
+
description: 'Get current weather',
|
161
|
+
input_schema: {
|
162
|
+
type: 'object',
|
163
|
+
properties: {
|
164
|
+
location: { type: 'string' }
|
165
|
+
},
|
166
|
+
required: ['location']
|
167
|
+
}
|
168
|
+
}
|
169
|
+
]
|
170
|
+
|
171
|
+
response = client.messages(messages, tools: tools)
|
172
|
+
```
|
173
|
+
|
174
|
+
## Error Handling
|
175
|
+
|
176
|
+
All non-streaming API methods return error information as a hash when requests fail:
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
response = client.chat(messages, tools)
|
180
|
+
|
181
|
+
if response["code"]
|
182
|
+
puts "Error #{response["code"]}: #{response["body"]}"
|
183
|
+
else
|
184
|
+
puts response.dig('choices', 0, 'message', 'content')
|
185
|
+
end
|
186
|
+
```
|
187
|
+
|
188
|
+
Streaming methods still raise exceptions on HTTP errors.
|
189
|
+
|
72
190
|
## API Coverage
|
73
191
|
|
74
192
|
### OpenAI
|
75
193
|
- `/v1/chat/completions` (with tools support)
|
194
|
+
- `/v1/models`
|
195
|
+
- `/v1/embeddings`
|
76
196
|
|
77
197
|
### Ollama
|
78
|
-
|
198
|
+
- `/api/chat` (with streaming)
|
199
|
+
- `/api/generate` (with streaming)
|
200
|
+
- `/api/embed`
|
201
|
+
- `/api/tags`
|
202
|
+
- `/api/show`
|
79
203
|
|
80
204
|
### Anthropic (Claude)
|
81
|
-
|
205
|
+
- `/v1/messages` (with streaming and tools)
|
82
206
|
|
83
207
|
## Development
|
84
208
|
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Net
|
4
|
+
module Llm
|
5
|
+
class Anthropic
|
6
|
+
attr_reader :api_key, :model, :http
|
7
|
+
|
8
|
+
def initialize(api_key:, model: "claude-3-5-sonnet-20241022", http: Net::Llm.http)
|
9
|
+
@api_key = api_key
|
10
|
+
@model = model
|
11
|
+
@http = http
|
12
|
+
end
|
13
|
+
|
14
|
+
def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
|
15
|
+
url = "https://api.anthropic.com/v1/messages"
|
16
|
+
payload = build_payload(messages, system, max_tokens, tools, block_given?)
|
17
|
+
|
18
|
+
if block_given?
|
19
|
+
stream_request(url, payload, &block)
|
20
|
+
else
|
21
|
+
post_request(url, payload)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def build_payload(messages, system, max_tokens, tools, stream)
|
28
|
+
payload = {
|
29
|
+
model: model,
|
30
|
+
max_tokens: max_tokens,
|
31
|
+
messages: messages,
|
32
|
+
stream: stream
|
33
|
+
}
|
34
|
+
payload[:system] = system if system
|
35
|
+
payload[:tools] = tools if tools
|
36
|
+
payload
|
37
|
+
end
|
38
|
+
|
39
|
+
def headers
|
40
|
+
{
|
41
|
+
"x-api-key" => api_key,
|
42
|
+
"anthropic-version" => "2023-06-01"
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def post_request(url, payload)
|
47
|
+
handle_response(http.post(url, headers: headers, body: payload))
|
48
|
+
end
|
49
|
+
|
50
|
+
def handle_response(response)
|
51
|
+
if response.is_a?(Net::HTTPSuccess)
|
52
|
+
JSON.parse(response.body)
|
53
|
+
else
|
54
|
+
{ "code" => response.code, "body" => response.body }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def stream_request(url, payload, &block)
|
59
|
+
http.post(url, headers: headers, body: payload) do |response|
|
60
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
61
|
+
|
62
|
+
buffer = ""
|
63
|
+
response.read_body do |chunk|
|
64
|
+
buffer += chunk
|
65
|
+
|
66
|
+
while (event = extract_sse_event(buffer))
|
67
|
+
next if event[:data].nil? || event[:data].empty?
|
68
|
+
next if event[:data] == "[DONE]"
|
69
|
+
|
70
|
+
json = JSON.parse(event[:data])
|
71
|
+
block.call(json)
|
72
|
+
|
73
|
+
break if json["type"] == "message_stop"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def extract_sse_event(buffer)
|
80
|
+
event_end = buffer.index("\n\n")
|
81
|
+
return nil unless event_end
|
82
|
+
|
83
|
+
event_data = buffer[0...event_end]
|
84
|
+
buffer.replace(buffer[(event_end + 2)..-1] || "")
|
85
|
+
|
86
|
+
event = {}
|
87
|
+
event_data.split("\n").each do |line|
|
88
|
+
if line.start_with?("event: ")
|
89
|
+
event[:event] = line[7..-1]
|
90
|
+
elsif line.start_with?("data: ")
|
91
|
+
event[:data] = line[6..-1]
|
92
|
+
elsif line == "data:"
|
93
|
+
event[:data] = ""
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
event
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Net
|
4
|
+
module Llm
|
5
|
+
class Ollama
|
6
|
+
attr_reader :host, :model, :http
|
7
|
+
|
8
|
+
def initialize(host: "localhost:11434", model: "llama2", http: Net::Llm.http)
|
9
|
+
@host = host
|
10
|
+
@model = model
|
11
|
+
@http = http
|
12
|
+
end
|
13
|
+
|
14
|
+
def chat(messages, &block)
|
15
|
+
url = build_url("/api/chat")
|
16
|
+
payload = { model: model, messages: messages, stream: block_given? }
|
17
|
+
|
18
|
+
if block_given?
|
19
|
+
stream_request(url, payload, &block)
|
20
|
+
else
|
21
|
+
post_request(url, payload)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def generate(prompt, &block)
|
26
|
+
url = build_url("/api/generate")
|
27
|
+
payload = { model: model, prompt: prompt, stream: block_given? }
|
28
|
+
|
29
|
+
if block_given?
|
30
|
+
stream_request(url, payload, &block)
|
31
|
+
else
|
32
|
+
post_request(url, payload)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def embeddings(input)
|
37
|
+
url = build_url("/api/embed")
|
38
|
+
payload = { model: model, input: input }
|
39
|
+
post_request(url, payload)
|
40
|
+
end
|
41
|
+
|
42
|
+
def tags
|
43
|
+
url = build_url("/api/tags")
|
44
|
+
response = http.get(url)
|
45
|
+
handle_response(response)
|
46
|
+
end
|
47
|
+
|
48
|
+
def show(name)
|
49
|
+
url = build_url("/api/show")
|
50
|
+
payload = { name: name }
|
51
|
+
post_request(url, payload)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def build_url(path)
|
57
|
+
base = host.start_with?("http://", "https://") ? host : "http://#{host}"
|
58
|
+
"#{base}#{path}"
|
59
|
+
end
|
60
|
+
|
61
|
+
def post_request(url, payload)
|
62
|
+
response = http.post(url, body: payload)
|
63
|
+
handle_response(response)
|
64
|
+
end
|
65
|
+
|
66
|
+
def handle_response(response)
|
67
|
+
if response.is_a?(Net::HTTPSuccess)
|
68
|
+
JSON.parse(response.body)
|
69
|
+
else
|
70
|
+
{ "code" => response.code, "body" => response.body }
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def stream_request(url, payload, &block)
|
75
|
+
http.post(url, body: payload) do |response|
|
76
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
77
|
+
|
78
|
+
buffer = ""
|
79
|
+
response.read_body do |chunk|
|
80
|
+
buffer += chunk
|
81
|
+
|
82
|
+
while (message = extract_message(buffer))
|
83
|
+
next if message.empty?
|
84
|
+
|
85
|
+
json = JSON.parse(message)
|
86
|
+
block.call(json)
|
87
|
+
|
88
|
+
break if json["done"]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def extract_message(buffer)
|
95
|
+
message_end = buffer.index("\n")
|
96
|
+
return nil unless message_end
|
97
|
+
|
98
|
+
message = buffer[0...message_end]
|
99
|
+
buffer.replace(buffer[(message_end + 1)..-1] || "")
|
100
|
+
message
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Net
|
4
|
+
module Llm
|
5
|
+
class OpenAI
|
6
|
+
attr_reader :api_key, :base_url, :model, :http
|
7
|
+
|
8
|
+
def initialize(api_key:, base_url: "https://api.openai.com/v1", model: "gpt-4o-mini", http: Net::Llm.http)
|
9
|
+
@api_key = api_key
|
10
|
+
@base_url = base_url
|
11
|
+
@model = model
|
12
|
+
@http = http
|
13
|
+
end
|
14
|
+
|
15
|
+
def chat(messages, tools)
|
16
|
+
handle_response(http.post(
|
17
|
+
"#{base_url}/chat/completions",
|
18
|
+
headers: headers,
|
19
|
+
body: { model: model, messages: messages, tools: tools, tool_choice: "auto" }
|
20
|
+
))
|
21
|
+
end
|
22
|
+
|
23
|
+
def models
|
24
|
+
handle_response(http.get("#{base_url}/models", headers: headers))
|
25
|
+
end
|
26
|
+
|
27
|
+
def embeddings(input, model: "text-embedding-ada-002")
|
28
|
+
handle_response(http.post(
|
29
|
+
"#{base_url}/embeddings",
|
30
|
+
headers: headers,
|
31
|
+
body: { model: model, input: input },
|
32
|
+
))
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def headers
|
38
|
+
{ "Authorization" => Net::Hippie.bearer_auth(api_key) }
|
39
|
+
end
|
40
|
+
|
41
|
+
def handle_response(response)
|
42
|
+
if response.is_a?(Net::HTTPSuccess)
|
43
|
+
JSON.parse(response.body)
|
44
|
+
else
|
45
|
+
{ "code" => response.code, "body" => response.body }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/net/llm/version.rb
CHANGED
data/lib/net/llm.rb
CHANGED
@@ -1,38 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "llm/version"
|
4
|
+
require_relative "llm/openai"
|
5
|
+
require_relative "llm/ollama"
|
6
|
+
require_relative "llm/anthropic"
|
7
|
+
require "net/hippie"
|
8
|
+
require "json"
|
4
9
|
|
5
10
|
module Net
|
6
11
|
module Llm
|
7
12
|
class Error < StandardError; end
|
8
|
-
DEFAULT_TIMEOUT = 60 * 2
|
9
13
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
@base_url = base_url
|
16
|
-
@model = model
|
17
|
-
end
|
18
|
-
|
19
|
-
def chat(messages, tools, timeout: DEFAULT_TIMEOUT)
|
20
|
-
uri = URI("#{base_url}/chat/completions")
|
21
|
-
request = Net::HTTP::Post.new(uri)
|
22
|
-
request["Authorization"] = "Bearer #{api_key}"
|
23
|
-
request["Content-Type"] = "application/json"
|
24
|
-
request.body = { model: model, messages: messages, tools: tools, tool_choice: "auto" }.to_json
|
25
|
-
|
26
|
-
http = Net::HTTP.new(uri.hostname, uri.port)
|
27
|
-
http.use_ssl = true
|
28
|
-
http.open_timeout = timeout
|
29
|
-
http.read_timeout = timeout
|
30
|
-
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
31
|
-
|
32
|
-
response = http.start { |h| h.request(request) }
|
33
|
-
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
34
|
-
JSON.parse(response.body)
|
35
|
-
end
|
14
|
+
def self.http
|
15
|
+
@http ||= Net::Hippie::Client.new(
|
16
|
+
read_timeout: 3600,
|
17
|
+
open_timeout: 10
|
18
|
+
)
|
36
19
|
end
|
37
20
|
end
|
38
21
|
end
|
metadata
CHANGED
@@ -1,14 +1,56 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: net-llm
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mo khan
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
-
dependencies:
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: json
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '2.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '2.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: uri
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '1.0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.0'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: net-http
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0.6'
|
47
|
+
type: :runtime
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0.6'
|
12
54
|
description: A minimal Ruby gem providing interfaces to connect to OpenAI, Ollama,
|
13
55
|
and Anthropic (Claude) LLM APIs
|
14
56
|
email:
|
@@ -22,6 +64,9 @@ files:
|
|
22
64
|
- README.md
|
23
65
|
- Rakefile
|
24
66
|
- lib/net/llm.rb
|
67
|
+
- lib/net/llm/anthropic.rb
|
68
|
+
- lib/net/llm/ollama.rb
|
69
|
+
- lib/net/llm/openai.rb
|
25
70
|
- lib/net/llm/version.rb
|
26
71
|
- sig/net/llm.rbs
|
27
72
|
homepage: https://github.com/xlgmokha/net-llm
|