Commit 94db6df
Changed files (4)
lib
net
lib/net/llm/anthropic.rb
@@ -3,224 +3,21 @@
module Net
module Llm
class Anthropic
- attr_reader :api_key, :model, :http
+ attr_reader :api_key, :model
def initialize(api_key:, model: "claude-sonnet-4-20250514", http: Net::Llm.http)
@api_key = api_key
@model = model
- @http = http
- end
-
- def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
- url = "https://api.anthropic.com/v1/messages"
- payload = build_payload(messages, system, max_tokens, tools, block_given?)
-
- if block_given?
- stream_request(url, payload, &block)
- else
- post_request(url, payload)
- end
- end
-
- def fetch(messages, tools = [], &block)
- anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
-
- if block_given?
- fetch_streaming(messages, anthropic_tools, &block)
- else
- fetch_non_streaming(messages, anthropic_tools)
- end
- end
-
- private
-
- def build_payload(messages, system, max_tokens, tools, stream)
- payload = {
+ @claude = Claude.new(
+ endpoint: "https://api.anthropic.com/v1/messages",
+ headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
model: model,
- max_tokens: max_tokens,
- messages: messages,
- stream: stream
- }
- payload[:system] = system if system
- payload[:tools] = tools if tools
- payload
+ http: http
+ )
end
- def headers
- {
- "x-api-key" => api_key,
- "anthropic-version" => "2023-06-01"
- }
- end
-
- def post_request(url, payload)
- handle_response(http.post(url, headers: headers, body: payload))
- end
-
- def handle_response(response)
- if response.is_a?(Net::HTTPSuccess)
- JSON.parse(response.body)
- else
- { "code" => response.code, "body" => response.body }
- end
- end
-
- def stream_request(url, payload, &block)
- http.post(url, headers: headers, body: payload) do |response|
- raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
- 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
-
- 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
-
- def fetch_non_streaming(messages, tools)
- result = self.messages(messages, tools: tools)
- return result if result["code"]
-
- {
- type: :complete,
- content: extract_text_content(result["content"]),
- thinking: extract_thinking_content(result["content"]),
- tool_calls: extract_tool_calls(result["content"]),
- stop_reason: map_stop_reason(result["stop_reason"])
- }
- end
-
- def fetch_streaming(messages, tools, &block)
- content = ""
- thinking = ""
- tool_calls = []
- stop_reason = :end_turn
- current_block_type = nil
-
- self.messages(messages, tools: tools) do |event|
- case event["type"]
- when "content_block_start"
- current_block_type = event.dig("content_block", "type")
- if current_block_type == "tool_use"
- tool_calls << {
- id: event.dig("content_block", "id"),
- name: event.dig("content_block", "name"),
- arguments: {}
- }
- end
- when "content_block_delta"
- delta = event["delta"]
- case delta["type"]
- when "text_delta"
- text = delta["text"]
- content += text
- block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
- when "thinking_delta"
- text = delta["thinking"]
- thinking += text if text
- block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
- when "input_json_delta"
- if tool_calls.any?
- tool_calls.last[:arguments_json] ||= ""
- tool_calls.last[:arguments_json] += delta["partial_json"] || ""
- end
- end
- when "message_delta"
- stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
- when "message_stop"
- tool_calls.each do |tc|
- if tc[:arguments_json]
- tc[:arguments] = JSON.parse(tc[:arguments_json]) rescue {}
- tc.delete(:arguments_json)
- end
- end
- block.call({
- type: :complete,
- content: content,
- thinking: thinking.empty? ? nil : thinking,
- tool_calls: tool_calls,
- stop_reason: stop_reason
- })
- end
- end
- end
-
- def extract_text_content(content_blocks)
- return nil unless content_blocks
-
- content_blocks
- .select { |b| b["type"] == "text" }
- .map { |b| b["text"] }
- .join
- end
-
- def extract_thinking_content(content_blocks)
- return nil unless content_blocks
-
- thinking = content_blocks
- .select { |b| b["type"] == "thinking" }
- .map { |b| b["thinking"] }
- .join
-
- thinking.empty? ? nil : thinking
- end
-
- def extract_tool_calls(content_blocks)
- return [] unless content_blocks
-
- content_blocks
- .select { |b| b["type"] == "tool_use" }
- .map do |b|
- { id: b["id"], name: b["name"], arguments: b["input"] || {} }
- end
- end
-
- def normalize_tool_for_anthropic(tool)
- if tool[:function]
- { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
- else
- tool
- end
- end
-
- def map_stop_reason(reason)
- case reason
- when "end_turn" then :end_turn
- when "tool_use" then :tool_use
- when "max_tokens" then :max_tokens
- else :end_turn
- end
- end
+ def messages(...) = @claude.messages(...)
+ def fetch(...) = @claude.fetch(...)
end
end
end
lib/net/llm/claude.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+module Net
+ module Llm
+ class Claude
+ attr_reader :endpoint, :headers, :model, :http, :anthropic_version
+
+ def initialize(endpoint:, headers:, http:, model: nil, anthropic_version: nil)
+ @endpoint = endpoint
+ @headers_source = headers
+ @model = model
+ @http = http
+ @anthropic_version = anthropic_version
+ end
+
+ def headers
+ @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
+ end
+
+ def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
+ payload = build_payload(messages, system, max_tokens, tools, block_given?)
+
+ if block_given?
+ stream_request(payload, &block)
+ else
+ post_request(payload)
+ end
+ end
+
+ def fetch(messages, tools = [], &block)
+ anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
+
+ if block_given?
+ fetch_streaming(messages, anthropic_tools, &block)
+ else
+ fetch_non_streaming(messages, anthropic_tools)
+ end
+ end
+
+ private
+
+ def build_payload(messages, system, max_tokens, tools, stream)
+ payload = { max_tokens: max_tokens, messages: messages, stream: stream }
+ payload[:model] = model if model
+ payload[:anthropic_version] = anthropic_version if anthropic_version
+ payload[:system] = system if system
+ payload[:tools] = tools if tools
+ payload
+ end
+
+ def post_request(payload)
+ handle_response(http.post(endpoint, headers: headers, body: payload))
+ end
+
+ def handle_response(response)
+ if response.is_a?(Net::HTTPSuccess)
+ JSON.parse(response.body)
+ else
+ { "code" => response.code, "body" => response.body }
+ end
+ end
+
+ def stream_request(payload, &block)
+ http.post(endpoint, headers: headers, body: payload) do |response|
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+ 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
+
+ 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)..] || "")
+
+ event = {}
+ event_data.split("\n").each do |line|
+ if line.start_with?("event: ")
+ event[:event] = line[7..]
+ elsif line.start_with?("data: ")
+ event[:data] = line[6..]
+ elsif line == "data:"
+ event[:data] = ""
+ end
+ end
+
+ event
+ end
+
+ def fetch_non_streaming(messages, tools)
+ result = self.messages(messages, tools: tools)
+ return result if result["code"]
+
+ {
+ type: :complete,
+ content: extract_text_content(result["content"]),
+ thinking: extract_thinking_content(result["content"]),
+ tool_calls: extract_tool_calls(result["content"]),
+ stop_reason: map_stop_reason(result["stop_reason"])
+ }
+ end
+
+ def fetch_streaming(messages, tools, &block)
+ content = ""
+ thinking = ""
+ tool_calls = []
+ stop_reason = :end_turn
+
+ self.messages(messages, tools: tools) do |event|
+ case event["type"]
+ when "content_block_start"
+ if event.dig("content_block", "type") == "tool_use"
+ tool_calls << {
+ id: event.dig("content_block", "id"),
+ name: event.dig("content_block", "name"),
+ arguments: {}
+ }
+ end
+ when "content_block_delta"
+ delta = event["delta"]
+ case delta["type"]
+ when "text_delta"
+ text = delta["text"]
+ content += text
+ block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
+ when "thinking_delta"
+ text = delta["thinking"]
+ thinking += text if text
+ block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
+ when "input_json_delta"
+ if tool_calls.any?
+ tool_calls.last[:arguments_json] ||= ""
+ tool_calls.last[:arguments_json] += delta["partial_json"] || ""
+ end
+ end
+ when "message_delta"
+ stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
+ when "message_stop"
+ tool_calls.each do |tc|
+ if tc[:arguments_json]
+ tc[:arguments] = begin
+ JSON.parse(tc[:arguments_json])
+ rescue
+ {}
+ end
+ tc.delete(:arguments_json)
+ end
+ end
+ block.call({
+ type: :complete,
+ content: content,
+ thinking: thinking.empty? ? nil : thinking,
+ tool_calls: tool_calls,
+ stop_reason: stop_reason
+ })
+ end
+ end
+ end
+
+ def extract_text_content(content_blocks)
+ return nil unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "text" }
+ .map { |b| b["text"] }
+ .join
+ end
+
+ def extract_thinking_content(content_blocks)
+ return nil unless content_blocks
+
+ thinking = content_blocks
+ .select { |b| b["type"] == "thinking" }
+ .map { |b| b["thinking"] }
+ .join
+
+ thinking.empty? ? nil : thinking
+ end
+
+ def extract_tool_calls(content_blocks)
+ return [] unless content_blocks
+
+ content_blocks
+ .select { |b| b["type"] == "tool_use" }
+ .map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {} } }
+ end
+
+ def normalize_tool_for_anthropic(tool)
+ if tool[:function]
+ { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
+ else
+ tool
+ end
+ end
+
+ def map_stop_reason(reason)
+ case reason
+ when "end_turn" then :end_turn
+ when "tool_use" then :tool_use
+ when "max_tokens" then :max_tokens
+ else :end_turn
+ end
+ end
+ end
+ end
+end
lib/net/llm/vertex_ai.rb
@@ -3,225 +3,28 @@
module Net
module Llm
class VertexAI
- attr_reader :project_id, :region, :model, :http
+ attr_reader :project_id, :region, :model
def initialize(project_id:, region:, model: "claude-opus-4-5@20251101", http: Net::Llm.http)
@project_id = project_id
@region = region
@model = model
- @http = http
+ @claude = Claude.new(
+ endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project_id}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
+ headers: -> { { "Authorization" => "Bearer #{access_token}" } },
+ http: http,
+ anthropic_version: "vertex-2023-10-16"
+ )
end
- def messages(messages, system: nil, max_tokens: 1024, tools: nil, &block)
- payload = build_payload(messages, system, max_tokens, tools, block_given?)
-
- if block_given?
- stream_request(payload, &block)
- else
- post_request(payload)
- end
- end
-
- def fetch(messages, tools = [], &block)
- anthropic_tools = tools.empty? ? nil : tools.map { |t| normalize_tool_for_anthropic(t) }
-
- if block_given?
- fetch_streaming(messages, anthropic_tools, &block)
- else
- fetch_non_streaming(messages, anthropic_tools)
- end
- end
+ def messages(...) = @claude.messages(...)
+ def fetch(...) = @claude.fetch(...)
private
- def build_payload(messages, system, max_tokens, tools, stream)
- payload = {
- anthropic_version: "vertex-2023-10-16",
- max_tokens: max_tokens,
- messages: messages,
- stream: stream
- }
- payload[:system] = system if system
- payload[:tools] = tools if tools
- payload
- end
-
- def endpoint
- "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project_id}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict"
- end
-
- def headers
- { "Authorization" => "Bearer #{access_token}" }
- end
-
def access_token
@access_token ||= `gcloud auth application-default print-access-token`.strip
end
-
- def post_request(payload)
- handle_response(http.post(endpoint, headers: headers, body: payload))
- end
-
- def handle_response(response)
- if response.is_a?(Net::HTTPSuccess)
- JSON.parse(response.body)
- else
- { "code" => response.code, "body" => response.body }
- end
- end
-
- def stream_request(payload, &block)
- http.post(endpoint, headers: headers, body: payload) do |response|
- raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
- 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
-
- 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)..] || "")
-
- event = {}
- event_data.split("\n").each do |line|
- if line.start_with?("event: ")
- event[:event] = line[7..]
- elsif line.start_with?("data: ")
- event[:data] = line[6..]
- elsif line == "data:"
- event[:data] = ""
- end
- end
-
- event
- end
-
- def fetch_non_streaming(messages, tools)
- result = self.messages(messages, tools: tools)
- return result if result["code"]
-
- {
- type: :complete,
- content: extract_text_content(result["content"]),
- thinking: extract_thinking_content(result["content"]),
- tool_calls: extract_tool_calls(result["content"]),
- stop_reason: map_stop_reason(result["stop_reason"])
- }
- end
-
- def fetch_streaming(messages, tools, &block)
- content = ""
- thinking = ""
- tool_calls = []
- stop_reason = :end_turn
-
- self.messages(messages, tools: tools) do |event|
- case event["type"]
- when "content_block_start"
- if event.dig("content_block", "type") == "tool_use"
- tool_calls << {
- id: event.dig("content_block", "id"),
- name: event.dig("content_block", "name"),
- arguments: {}
- }
- end
- when "content_block_delta"
- delta = event["delta"]
- case delta["type"]
- when "text_delta"
- text = delta["text"]
- content += text
- block.call({ type: :delta, content: text, thinking: nil, tool_calls: nil })
- when "thinking_delta"
- text = delta["thinking"]
- thinking += text if text
- block.call({ type: :delta, content: nil, thinking: text, tool_calls: nil })
- when "input_json_delta"
- if tool_calls.any?
- tool_calls.last[:arguments_json] ||= ""
- tool_calls.last[:arguments_json] += delta["partial_json"] || ""
- end
- end
- when "message_delta"
- stop_reason = map_stop_reason(event.dig("delta", "stop_reason"))
- when "message_stop"
- tool_calls.each do |tc|
- if tc[:arguments_json]
- tc[:arguments] = JSON.parse(tc[:arguments_json]) rescue {}
- tc.delete(:arguments_json)
- end
- end
- block.call({
- type: :complete,
- content: content,
- thinking: thinking.empty? ? nil : thinking,
- tool_calls: tool_calls,
- stop_reason: stop_reason
- })
- end
- end
- end
-
- def extract_text_content(content_blocks)
- return nil unless content_blocks
-
- content_blocks
- .select { |b| b["type"] == "text" }
- .map { |b| b["text"] }
- .join
- end
-
- def extract_thinking_content(content_blocks)
- return nil unless content_blocks
-
- thinking = content_blocks
- .select { |b| b["type"] == "thinking" }
- .map { |b| b["thinking"] }
- .join
-
- thinking.empty? ? nil : thinking
- end
-
- def extract_tool_calls(content_blocks)
- return [] unless content_blocks
-
- content_blocks
- .select { |b| b["type"] == "tool_use" }
- .map { |b| { id: b["id"], name: b["name"], arguments: b["input"] || {} } }
- end
-
- def normalize_tool_for_anthropic(tool)
- if tool[:function]
- { name: tool[:function][:name], description: tool[:function][:description], input_schema: tool[:function][:parameters] }
- else
- tool
- end
- end
-
- def map_stop_reason(reason)
- case reason
- when "end_turn" then :end_turn
- when "tool_use" then :tool_use
- when "max_tokens" then :max_tokens
- else :end_turn
- end
- end
end
end
end
lib/net/llm.rb
@@ -3,6 +3,7 @@
require_relative "llm/version"
require_relative "llm/openai"
require_relative "llm/ollama"
+require_relative "llm/claude"
require_relative "llm/anthropic"
require_relative "llm/vertex_ai"
require "net/hippie"