net-llm 0.3.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de6c591968c95eafb5af04842bc5fc8f4f0235369a6f7010843ab9adbe2199cd
4
- data.tar.gz: f8dbd38c119eacc38814a5eb173f1a2158564bce75dd5c6d111d3900e17f8136
3
+ metadata.gz: a7168d17a456b69a77ada9a8c5eb855ae6730a124cdd55dd9e72fee2bfa6fef1
4
+ data.tar.gz: 0fde66f7b0304486f3c5da851d7bbe97347f58fa0692dbd48c8462932ebe1bda
5
5
  SHA512:
6
- metadata.gz: dd8e9c389c9ed5f0614b386cf56a6359275d156197b4fe284671fc0f062d839181b938f91b898b472cf71f2d9090f5aedb9b4b725616ba97acf9f072f1a5a2a3
7
- data.tar.gz: 3ac65c38e85ff03fd29e1a9dd227d674455773a35290b6871ac18d05f844afc6142a0daebcb2c88a75ec858d57b0fc7c9eddb5b07a2e4a1c10917cb6156be0bd
6
+ metadata.gz: 1969cb1afc0e322f9899f97c95898c9ebf825fd826ce43af0fc0dc66fe86b298ca4538d3426519791661824265d6ff0ce51f3ecd2012b464639a969df4c1142f
7
+ data.tar.gz: 2d765715615d2d66f36fd00e8a64360061f740c8e19af1d1ef80c9d5d1b744964e5f3e741bc7af414d877acec7840adf855ba28159131bad31bcdebbe99dd5b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2025-01-07
4
+
5
+ ### Added
6
+ - VertexAI provider for Claude models via Google Cloud
7
+ - Uses Application Default Credentials (ADC) for authentication
8
+ - Supports streaming and non-streaming modes
9
+ - Model routing with NotImplementedError for unsupported models
10
+ - Unified `fetch(messages, tools = [], &block)` method across all providers
11
+ - Normalized response format with `:delta` and `:complete` types
12
+ - Consistent `tool_calls` structure: `{ id:, name:, arguments: }`
13
+ - Thinking content support in streaming responses
14
+ - Claude class for shared Anthropic protocol logic
15
+ - Automatic system message extraction from messages array
16
+ - Message normalization for tool results and tool_calls
17
+ - Environment variable support for provider configuration
18
+ - `OLLAMA_HOST` for Ollama (default: localhost:11434)
19
+ - `OPENAI_API_KEY` and `OPENAI_BASE_URL` for OpenAI
20
+ - `ANTHROPIC_API_KEY` for Anthropic
21
+ - `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_REGION` for VertexAI
22
+
23
+ ### Changed
24
+ - Refactored Anthropic provider to delegate to Claude class
25
+ - Refactored VertexAI provider to delegate to Claude class
26
+ - Updated default Anthropic model to claude-sonnet-4-20250514
27
+ - Updated default VertexAI model to claude-opus-4-5@20251101
28
+
29
+ ### Fixed
30
+ - Fixed streaming tool_calls accumulation in Ollama provider
31
+ - Fixed error responses to include response body for debugging
32
+ - Fixed VertexAI model name format (@ separator instead of -)
33
+
34
+ ## [0.4.0] - 2025-10-15
35
+ ### Added
36
+ - Added tool/function calling support to Ollama provider
37
+ - Ollama `chat` method now accepts optional `tools` parameter matching OpenAI signature
38
+ - Tools work in both streaming and non-streaming modes
39
+ - Added comprehensive test coverage for tool functionality
40
+
41
+ ### Changed
42
+ - Updated README with Ollama tools example
43
+ - Updated API coverage documentation
44
+
3
45
  ## [0.3.1] - 2025-10-08
4
46
  ### Fixed
5
47
  - Added missing net-hippie runtime dependency to gemspec
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Net::Llm
2
2
 
3
- A minimal Ruby gem providing interfaces to connect to OpenAI, Ollama, and Anthropic (Claude) LLM APIs.
3
+ A minimal Ruby gem providing interfaces to connect to OpenAI, Ollama, Anthropic (Claude), and VertexAI LLM APIs.
4
4
 
5
5
  ## Installation
6
6
 
@@ -87,6 +87,29 @@ response = client.chat(messages)
87
87
  puts response['message']['content']
88
88
  ```
89
89
 
90
+ #### With Tools
91
+
92
+ ```ruby
93
+ tools = [
94
+ {
95
+ type: 'function',
96
+ function: {
97
+ name: 'get_weather',
98
+ description: 'Get current weather',
99
+ parameters: {
100
+ type: 'object',
101
+ properties: {
102
+ location: { type: 'string' }
103
+ },
104
+ required: ['location']
105
+ }
106
+ }
107
+ }
108
+ ]
109
+
110
+ response = client.chat(messages, tools)
111
+ ```
112
+
90
113
  #### Streaming
91
114
 
92
115
  ```ruby
@@ -121,7 +144,7 @@ require 'net/llm'
121
144
 
122
145
  client = Net::Llm::Anthropic.new(
123
146
  api_key: ENV['ANTHROPIC_API_KEY'],
124
- model: 'claude-3-5-sonnet-20241022'
147
+ model: 'claude-sonnet-4-20250514'
125
148
  )
126
149
 
127
150
  messages = [
@@ -171,6 +194,54 @@ tools = [
171
194
  response = client.messages(messages, tools: tools)
172
195
  ```
173
196
 
197
+ ### VertexAI
198
+
199
+ ```ruby
200
+ require 'net/llm'
201
+
202
+ client = Net::Llm::VertexAI.new(
203
+ project_id: ENV['GOOGLE_CLOUD_PROJECT'],
204
+ region: ENV.fetch('GOOGLE_CLOUD_REGION', 'us-east5'),
205
+ model: 'claude-opus-4-5@20251101'
206
+ )
207
+
208
+ messages = [
209
+ { role: 'user', content: 'Hello!' }
210
+ ]
211
+
212
+ response = client.messages(messages)
213
+ puts response.dig('content', 0, 'text')
214
+ ```
215
+
216
+ Uses Application Default Credentials (ADC) for authentication. Run `gcloud auth application-default login` to configure.
217
+
218
+ ### Unified Fetch Interface
219
+
220
+ All providers support a unified `fetch` method with a normalized response format:
221
+
222
+ ```ruby
223
+ result = client.fetch(messages, tools)
224
+
225
+ result[:type] # :complete
226
+ result[:content] # "Response text"
227
+ result[:thinking] # Extended thinking (Claude only)
228
+ result[:tool_calls] # [{ id:, name:, arguments: }]
229
+ result[:stop_reason] # :end_turn, :tool_use, :max_tokens
230
+ ```
231
+
232
+ #### Streaming
233
+
234
+ ```ruby
235
+ client.fetch(messages, tools) do |chunk|
236
+ case chunk[:type]
237
+ when :delta
238
+ print chunk[:content]
239
+ when :complete
240
+ puts "\nDone: #{chunk[:stop_reason]}"
241
+ end
242
+ end
243
+ ```
244
+
174
245
  ## Error Handling
175
246
 
176
247
  All non-streaming API methods return error information as a hash when requests fail:
@@ -195,7 +266,7 @@ Streaming methods still raise exceptions on HTTP errors.
195
266
  - `/v1/embeddings`
196
267
 
197
268
  ### Ollama
198
- - `/api/chat` (with streaming)
269
+ - `/api/chat` (with streaming and tools)
199
270
  - `/api/generate` (with streaming)
200
271
  - `/api/embed`
201
272
  - `/api/tags`
@@ -204,6 +275,9 @@ Streaming methods still raise exceptions on HTTP errors.
204
275
  ### Anthropic (Claude)
205
276
  - `/v1/messages` (with streaming and tools)
206
277
 
278
+ ### VertexAI
279
+ - Claude models via Google Cloud AI Platform (with streaming and tools)
280
+
207
281
  ## Development
208
282
 
209
283
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -212,7 +286,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
212
286
 
213
287
  ## Contributing
214
288
 
215
- Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/net-llm.
289
+ Send me an email. For instructions see https://git-send-email.io/.
216
290
 
217
291
  ## License
218
292
 
@@ -3,99 +3,21 @@
3
3
  module Net
4
4
  module Llm
5
5
  class Anthropic
6
- attr_reader :api_key, :model, :http
6
+ attr_reader :api_key, :model
7
7
 
8
- def initialize(api_key:, model: "claude-3-5-sonnet-20241022", http: Net::Llm.http)
8
+ def initialize(api_key: ENV.fetch("ANTHROPIC_API_KEY"), model: "claude-sonnet-4-20250514", http: Net::Llm.http)
9
9
  @api_key = api_key
10
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 = {
11
+ @claude = Claude.new(
12
+ endpoint: "https://api.anthropic.com/v1/messages",
13
+ headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
29
14
  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
15
+ http: http
16
+ )
56
17
  end
57
18
 
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
19
+ def messages(...) = @claude.messages(...)
20
+ def fetch(...) = @claude.fetch(...)
99
21
  end
100
22
  end
101
23
  end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Llm
5
+ class Claude
6
+ attr_reader :endpoint, :headers, :model, :http, :anthropic_version
7
+
8
+ def initialize(endpoint:, headers:, http:, model: nil, anthropic_version: nil)
9
+ @endpoint = endpoint
10
+ @headers_source = headers
11
+ @model = model
12
+ @http = http
13
+ @anthropic_version = anthropic_version
14
+ end
15
+
16
+ def headers
17
+ @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
18
+ end
19
+
20
+ def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
21
+ payload = build_payload(messages, system, max_tokens, tools, block_given?)
22
+
23
+ if block_given?
24
+ stream_request(payload, &block)
25
+ else
26
+ post_request(payload)
27
+ end
28
+ end
29
+
30
+ def fetch(messages, tools = [], &block)
31
+ system_message, user_messages = extract_system_message(messages)
32
+ anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
33
+
34
+ if block_given?
35
+ fetch_streaming(user_messages, anthropic_tools, system: system_message, &block)
36
+ else
37
+ fetch_non_streaming(user_messages, anthropic_tools, system: system_message)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def build_payload(messages, system, max_tokens, tools, stream)
44
+ payload = { max_tokens: max_tokens, messages: messages, stream: stream }
45
+ payload[:model] = model if model
46
+ payload[:anthropic_version] = anthropic_version if anthropic_version
47
+ payload[:system] = system if system
48
+ payload[:tools] = tools if tools
49
+ payload
50
+ end
51
+
52
+ def post_request(payload)
53
+ handle_response(http.post(endpoint, headers: headers, body: payload))
54
+ end
55
+
56
+ def handle_response(response)
57
+ if response.is_a?(Net::HTTPSuccess)
58
+ JSON.parse(response.body)
59
+ else
60
+ { "code" => response.code, "body" => response.body }
61
+ end
62
+ end
63
+
64
+ def stream_request(payload, &block)
65
+ http.post(endpoint, headers: headers, body: payload) do |response|
66
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
67
+
68
+ buffer = ""
69
+ response.read_body do |chunk|
70
+ buffer += chunk
71
+
72
+ while (event = extract_sse_event(buffer))
73
+ next if event[:data].nil? || event[:data].empty?
74
+ next if event[:data] == "[DONE]"
75
+
76
+ json = JSON.parse(event[:data])
77
+ block.call(json)
78
+
79
+ break if json["type"] == "message_stop"
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def extract_sse_event(buffer)
86
+ event_end = buffer.index("\n\n")
87
+ return nil unless event_end
88
+
89
+ event_data = buffer[0...event_end]
90
+ buffer.replace(buffer[(event_end + 2)..] || "")
91
+
92
+ event = {}
93
+ event_data.split("\n").each do |line|
94
+ if line.start_with?("event: ")
95
+ event[:event] = line[7..]
96
+ elsif line.start_with?("data: ")
97
+ event[:data] = line[6..]
98
+ elsif line == "data:"
99
+ event[:data] = ""
100
+ end
101
+ end
102
+
103
+ event
104
+ end
105
+
106
+ def extract_system_message(messages)
107
+ system_msg = messages.find { |m| m[:role] == "system" || m["role"] == "system" }
108
+ system_content = system_msg ? (system_msg[:content] || system_msg["content"]) : nil
109
+ other_messages = messages.reject { |m| m[:role] == "system" || m["role"] == "system" }
110
+ normalized_messages = normalize_messages_for_claude(other_messages)
111
+ [system_content, normalized_messages]
112
+ end
113
+
114
+ def normalize_messages_for_claude(messages)
115
+ messages.map do |msg|
116
+ role = msg[:role] || msg["role"]
117
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
118
+
119
+ if role == "tool"
120
+ {
121
+ role: "user",
122
+ content: [{
123
+ type: "tool_result",
124
+ tool_use_id: msg[:tool_call_id] || msg["tool_call_id"],
125
+ content: msg[:content] || msg["content"]
126
+ }]
127
+ }
128
+ elsif role == "assistant" && tool_calls&.any?
129
+ content = []
130
+ text = msg[:content] || msg["content"]
131
+ content << { type: "text", text: text } if text && !text.empty?
132
+ tool_calls.each do |tc|
133
+ func = tc[:function] || tc["function"] || {}
134
+ args = func[:arguments] || func["arguments"]
135
+ input = args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
136
+ content << {
137
+ type: "tool_use",
138
+ id: tc[:id] || tc["id"],
139
+ name: func[:name] || func["name"] || tc[:name] || tc["name"],
140
+ input: input
141
+ }
142
+ end
143
+ { role: "assistant", content: content }
144
+ else
145
+ msg
146
+ end
147
+ end
148
+ end
149
+
150
+ def fetch_non_streaming(messages, tools, system: nil)
151
+ result = self.messages(messages, system: system, tools: tools)
152
+ return result if result["code"]
153
+
154
+ {
155
+ type: :complete,
156
+ content: extract_text_content(result["content"]),
157
+ thinking: extract_thinking_content(result["content"]),
158
+ tool_calls: extract_tool_calls(result["content"]),
159
+ stop_reason: map_stop_reason(result["stop_reason"])
160
+ }
161
+ end
162
+
163
+ def fetch_streaming(messages, tools, system: nil, &block)
164
+ content = ""
165
+ thinking = ""
166
+ tool_calls = []
167
+ stop_reason = :end_turn
168
+
169
+ self.messages(messages, system: system, tools: tools) do |event|
170
+ case event["type"]
171
+ when "content_block_start"
172
+ if event.dig("content_block", "type") == "tool_use"
173
+ tool_calls << {
174
+ id: event.dig("content_block", "id"),
175
+ name: event.dig("content_block", "name"),
176
+ arguments: {}
177
+ }
178
+ end
179
+ when "content_block_delta"
180
+ delta = event["delta"]
181
+ case delta["type"]
182
+ when "text_delta"
183
+ text = delta["text"]
184
+ content += text
185
+ block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
186
+ when "thinking_delta"
187
+ text = delta["thinking"]
188
+ thinking += text if text
189
+ block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
190
+ when "input_json_delta"
191
+ if tool_calls.any?
192
+ tool_calls.last[:arguments_json] ||= ""
193
+ tool_calls.last[:arguments_json] += delta["partial_json"] || ""
194
+ end
195
+ end
196
+ when "message_delta"
197
+ stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
198
+ when "message_stop"
199
+ tool_calls.each do |tc|
200
+ if tc[:arguments_json]
201
+ tc[:arguments] = begin
202
+ JSON.parse(tc[:arguments_json])
203
+ rescue
204
+ {}
205
+ end
206
+ tc.delete(:arguments_json)
207
+ end
208
+ end
209
+ block.call({
210
+ type: :complete,
211
+ content: content,
212
+ thinking: thinking.empty? ? nil : thinking,
213
+ tool_calls: tool_calls,
214
+ stop_reason: stop_reason
215
+ })
216
+ end
217
+ end
218
+ end
219
+
220
+ def extract_text_content(content_blocks)
221
+ return nil unless content_blocks
222
+
223
+ content_blocks
224
+ .select { |b| b["type"] == "text" }
225
+ .map { |b| b["text"] }
226
+ .join
227
+ end
228
+
229
+ def extract_thinking_content(content_blocks)
230
+ return nil unless content_blocks
231
+
232
+ thinking = content_blocks
233
+ .select { |b| b["type"] == "thinking" }
234
+ .map { |b| b["thinking"] }
235
+ .join
236
+
237
+ thinking.empty? ? nil : thinking
238
+ end
239
+
240
+ def extract_tool_calls(content_blocks)
241
+ return [] unless content_blocks
242
+
243
+ content_blocks
244
+ .select { |b| b["type"] == "tool_use" }
245
+ .map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {} } }
246
+ end
247
+
248
+ def normalize_tool_for_anthropic(tool)
249
+ if tool[:function]
250
+ { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
251
+ else
252
+ tool
253
+ end
254
+ end
255
+
256
+ def map_stop_reason(reason)
257
+ case reason
258
+ when "end_turn" then :end_turn
259
+ when "tool_use" then :tool_use
260
+ when "max_tokens" then :max_tokens
261
+ else :end_turn
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -5,75 +5,121 @@ module Net
5
5
  class Ollama
6
6
  attr_reader :host, :model, :http
7
7
 
8
- def initialize(host: "localhost:11434", model: "llama2", http: Net::Llm.http)
8
+ def initialize(host: ENV.fetch("OLLAMA_HOST", "localhost:11434"), model: "gpt-oss", http: Net::Llm.http)
9
9
  @host = host
10
10
  @model = model
11
11
  @http = http
12
12
  end
13
13
 
14
- def chat(messages, &block)
15
- url = build_url("/api/chat")
14
+ def chat(messages, tools = [], &block)
16
15
  payload = { model: model, messages: messages, stream: block_given? }
16
+ payload[:tools] = tools unless tools.empty?
17
17
 
18
- if block_given?
19
- stream_request(url, payload, &block)
20
- else
21
- post_request(url, payload)
22
- end
18
+ execute(build_url("/api/chat"), payload, &block)
23
19
  end
24
20
 
25
- def generate(prompt, &block)
26
- url = build_url("/api/generate")
27
- payload = { model: model, prompt: prompt, stream: block_given? }
21
+ def fetch(messages, tools = [], &block)
22
+ content = ""
23
+ thinking = ""
24
+ tool_calls = []
28
25
 
29
26
  if block_given?
30
- stream_request(url, payload, &block)
27
+ chat(messages, tools) do |chunk|
28
+ msg = chunk["message"] || {}
29
+ delta_content = msg["content"]
30
+ delta_thinking = msg["thinking"]
31
+
32
+ content += delta_content if delta_content
33
+ thinking += delta_thinking if delta_thinking
34
+ tool_calls += normalize_tool_calls(msg["tool_calls"]) if msg["tool_calls"]
35
+
36
+ if chunk["done"]
37
+ block.call({
38
+ type: :complete,
39
+ content: content,
40
+ thinking: thinking.empty? ? nil : thinking,
41
+ tool_calls: tool_calls,
42
+ stop_reason: map_stop_reason(chunk["done_reason"])
43
+ })
44
+ else
45
+ block.call({
46
+ type: :delta,
47
+ content: delta_content,
48
+ thinking: delta_thinking,
49
+ tool_calls: nil
50
+ })
51
+ end
52
+ end
31
53
  else
32
- post_request(url, payload)
54
+ result = chat(messages, tools)
55
+ msg = result["message"] || {}
56
+ {
57
+ type: :complete,
58
+ content: msg["content"],
59
+ thinking: msg["thinking"],
60
+ tool_calls: normalize_tool_calls(msg["tool_calls"]),
61
+ stop_reason: map_stop_reason(result["done_reason"])
62
+ }
33
63
  end
34
64
  end
35
65
 
66
+ def generate(prompt, &block)
67
+ execute(build_url("/api/generate"), {
68
+ model: model,
69
+ prompt: prompt,
70
+ stream: block_given?
71
+ }, &block)
72
+ end
73
+
36
74
  def embeddings(input)
37
- url = build_url("/api/embed")
38
- payload = { model: model, input: input }
39
- post_request(url, payload)
75
+ post_request(build_url("/api/embed"), { model: model, input: input })
40
76
  end
41
77
 
42
78
  def tags
43
- url = build_url("/api/tags")
44
- response = http.get(url)
45
- handle_response(response)
79
+ get_request(build_url("/api/tags"))
46
80
  end
47
81
 
48
82
  def show(name)
49
- url = build_url("/api/show")
50
- payload = { name: name }
51
- post_request(url, payload)
83
+ post_request(build_url("/api/show"), { name: name })
52
84
  end
53
85
 
54
86
  private
55
87
 
88
+ def execute(url, payload, &block)
89
+ if block_given?
90
+ stream_request(url, payload, &block)
91
+ else
92
+ post_request(url, payload)
93
+ end
94
+ end
95
+
56
96
  def build_url(path)
57
97
  base = host.start_with?("http://", "https://") ? host : "http://#{host}"
58
98
  "#{base}#{path}"
59
99
  end
60
100
 
101
+ def get_request(url)
102
+ handle_response(http.get(url))
103
+ end
104
+
61
105
  def post_request(url, payload)
62
- response = http.post(url, body: payload)
63
- handle_response(response)
106
+ handle_response(http.post(url, body: payload))
64
107
  end
65
108
 
66
109
  def handle_response(response)
67
110
  if response.is_a?(Net::HTTPSuccess)
68
111
  JSON.parse(response.body)
69
112
  else
70
- { "code" => response.code, "body" => response.body }
113
+ {
114
+ "code" => response.code,
115
+ "body" => response.body
116
+ }
71
117
  end
72
118
  end
73
119
 
74
120
  def stream_request(url, payload, &block)
75
121
  http.post(url, body: payload) do |response|
76
- raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
122
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
77
123
 
78
124
  buffer = ""
79
125
  response.read_body do |chunk|
@@ -99,6 +145,27 @@ module Net
99
145
  buffer.replace(buffer[(message_end + 1)..-1] || "")
100
146
  message
101
147
  end
148
+
149
+ def normalize_tool_calls(tool_calls)
150
+ return [] if tool_calls.nil? || tool_calls.empty?
151
+
152
+ tool_calls.map do |tc|
153
+ {
154
+ id: tc["id"] || tc.dig("function", "id"),
155
+ name: tc.dig("function", "name"),
156
+ arguments: tc.dig("function", "arguments") || {}
157
+ }
158
+ end
159
+ end
160
+
161
+ def map_stop_reason(reason)
162
+ case reason
163
+ when "stop" then :end_turn
164
+ when "tool_calls", "tool_use" then :tool_use
165
+ when "length" then :max_tokens
166
+ else :end_turn
167
+ end
168
+ end
102
169
  end
103
170
  end
104
171
  end
@@ -5,7 +5,7 @@ module Net
5
5
  class OpenAI
6
6
  attr_reader :api_key, :base_url, :model, :http
7
7
 
8
- def initialize(api_key:, base_url: "https://api.openai.com/v1", model: "gpt-4o-mini", http: Net::Llm.http)
8
+ def initialize(api_key: ENV.fetch("OPENAI_API_KEY"), base_url: ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1"), model: "gpt-4o-mini", http: Net::Llm.http)
9
9
  @api_key = api_key
10
10
  @base_url = base_url
11
11
  @model = model
@@ -20,6 +20,14 @@ module Net
20
20
  ))
21
21
  end
22
22
 
23
+ def fetch(messages, tools = [], &block)
24
+ if block_given?
25
+ fetch_streaming(messages, tools, &block)
26
+ else
27
+ fetch_non_streaming(messages, tools)
28
+ end
29
+ end
30
+
23
31
  def models
24
32
  handle_response(http.get("#{base_url}/models", headers: headers))
25
33
  end
@@ -45,6 +53,120 @@ module Net
45
53
  { "code" => response.code, "body" => response.body }
46
54
  end
47
55
  end
56
+
57
+ def fetch_non_streaming(messages, tools)
58
+ body = { model: model, messages: messages }
59
+ body[:tools] = tools unless tools.empty?
60
+ body[:tool_choice] = "auto" unless tools.empty?
61
+
62
+ result = handle_response(http.post("#{base_url}/chat/completions", headers: headers, body: body))
63
+ return result if result["code"]
64
+
65
+ msg = result.dig("choices", 0, "message") || {}
66
+ {
67
+ type: :complete,
68
+ content: msg["content"],
69
+ thinking: nil,
70
+ tool_calls: normalize_tool_calls(msg["tool_calls"]),
71
+ stop_reason: map_stop_reason(result.dig("choices", 0, "finish_reason"))
72
+ }
73
+ end
74
+
75
+ def fetch_streaming(messages, tools, &block)
76
+ body = { model: model, messages: messages, stream: true }
77
+ body[:tools] = tools unless tools.empty?
78
+ body[:tool_choice] = "auto" unless tools.empty?
79
+
80
+ content = ""
81
+ tool_calls = {}
82
+ stop_reason = :end_turn
83
+
84
+ http.post("#{base_url}/chat/completions", headers: headers, body: body) do |response|
85
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
86
+
87
+ buffer = ""
88
+ response.read_body do |chunk|
89
+ buffer += chunk
90
+
91
+ while (line = extract_line(buffer))
92
+ next if line.empty? || !line.start_with?("data: ")
93
+
94
+ data = line[6..]
95
+ break if data == "[DONE]"
96
+
97
+ json = JSON.parse(data)
98
+ delta = json.dig("choices", 0, "delta") || {}
99
+
100
+ if delta["content"]
101
+ content += delta["content"]
102
+ block.call({ type: :delta, content: delta["content"], thinking: nil, tool_calls: nil })
103
+ end
104
+
105
+ if delta["tool_calls"]
106
+ delta["tool_calls"].each do |tc|
107
+ idx = tc["index"]
108
+ tool_calls[idx] ||= { id: nil, name: nil, arguments_json: "" }
109
+ tool_calls[idx][:id] = tc["id"] if tc["id"]
110
+ tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
111
+ tool_calls[idx][:arguments_json] += tc.dig("function", "arguments") || ""
112
+ end
113
+ end
114
+
115
+ if json.dig("choices", 0, "finish_reason")
116
+ stop_reason = map_stop_reason(json.dig("choices", 0, "finish_reason"))
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ final_tool_calls = tool_calls.values.map do |tc|
123
+ args = begin
124
+ JSON.parse(tc[:arguments_json])
125
+ rescue
126
+ {}
127
+ end
128
+ { id: tc[:id], name: tc[:name], arguments: args }
129
+ end
130
+
131
+ block.call({
132
+ type: :complete,
133
+ content: content,
134
+ thinking: nil,
135
+ tool_calls: final_tool_calls,
136
+ stop_reason: stop_reason
137
+ })
138
+ end
139
+
140
+ def extract_line(buffer)
141
+ line_end = buffer.index("\n")
142
+ return nil unless line_end
143
+
144
+ line = buffer[0...line_end]
145
+ buffer.replace(buffer[(line_end + 1)..] || "")
146
+ line
147
+ end
148
+
149
+ def normalize_tool_calls(tool_calls)
150
+ return [] if tool_calls.nil? || tool_calls.empty?
151
+
152
+ tool_calls.map do |tc|
153
+ args = tc.dig("function", "arguments")
154
+ {
155
+ id: tc["id"],
156
+ name: tc.dig("function", "name"),
157
+ arguments: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
158
+ }
159
+ end
160
+ end
161
+
162
+ def map_stop_reason(reason)
163
+ case reason
164
+ when "stop" then :end_turn
165
+ when "tool_calls" then :tool_use
166
+ when "length" then :max_tokens
167
+ else :end_turn
168
+ end
169
+ end
48
170
  end
49
171
  end
50
172
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Llm
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Llm
5
+ class VertexAI
6
+ attr_reader :project_id, :region, :model
7
+
8
+ def initialize(project_id: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5"), model: "claude-opus-4-5@20251101", http: Net::Llm.http)
9
+ @project_id = project_id
10
+ @region = region
11
+ @model = model
12
+ @handler = build_handler(http)
13
+ end
14
+
15
+ def messages(...) = @handler.messages(...)
16
+ def fetch(...) = @handler.fetch(...)
17
+
18
+ private
19
+
20
+ def build_handler(http)
21
+ if model.start_with?("claude-")
22
+ Claude.new(
23
+ endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project_id}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
24
+ headers: -> { { "Authorization" => "Bearer #{access_token}" } },
25
+ http: http,
26
+ anthropic_version: "vertex-2023-10-16"
27
+ )
28
+ else
29
+ raise NotImplementedError, "Model '#{model}' is not yet supported. Only Claude models (claude-*) are currently implemented."
30
+ end
31
+ end
32
+
33
+ def access_token
34
+ @access_token ||= `gcloud auth application-default print-access-token`.strip
35
+ end
36
+ end
37
+ end
38
+ end
data/lib/net/llm.rb CHANGED
@@ -3,7 +3,9 @@
3
3
  require_relative "llm/version"
4
4
  require_relative "llm/openai"
5
5
  require_relative "llm/ollama"
6
+ require_relative "llm/claude"
6
7
  require_relative "llm/anthropic"
8
+ require_relative "llm/vertex_ai"
7
9
  require "net/hippie"
8
10
  require "json"
9
11
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -51,6 +51,62 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: vcr
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '6.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '6.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: webmock
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.0'
54
110
  description: A minimal Ruby gem providing interfaces to connect to OpenAI, Ollama,
55
111
  and Anthropic (Claude) LLM APIs
56
112
  email:
@@ -65,17 +121,19 @@ files:
65
121
  - Rakefile
66
122
  - lib/net/llm.rb
67
123
  - lib/net/llm/anthropic.rb
124
+ - lib/net/llm/claude.rb
68
125
  - lib/net/llm/ollama.rb
69
126
  - lib/net/llm/openai.rb
70
127
  - lib/net/llm/version.rb
128
+ - lib/net/llm/vertex_ai.rb
71
129
  - sig/net/llm.rbs
72
- homepage: https://github.com/xlgmokha/net-llm
130
+ homepage: https://src.mokhan.ca/xlgmokha/net-llm/
73
131
  licenses:
74
132
  - MIT
75
133
  metadata:
76
- homepage_uri: https://github.com/xlgmokha/net-llm
77
- source_code_uri: https://github.com/xlgmokha/net-llm
78
- changelog_uri: https://github.com/xlgmokha/net-llm/blob/main/CHANGELOG.md
134
+ homepage_uri: https://src.mokhan.ca/xlgmokha/net-llm/
135
+ source_code_uri: https://src.mokhan.ca/xlgmokha/net-llm/
136
+ changelog_uri: https://src.mokhan.ca/xlgmokha/net-llm/blob/main/CHANGELOG.md.html
79
137
  rdoc_options: []
80
138
  require_paths:
81
139
  - lib