rixie 0.1.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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/bin/rixie +7 -0
- data/lib/rixie/agent/compressor.rb +41 -0
- data/lib/rixie/agent/plan.rb +62 -0
- data/lib/rixie/agent/re_act.rb +53 -0
- data/lib/rixie/agent.rb +122 -0
- data/lib/rixie/cli/commands/base.rb +33 -0
- data/lib/rixie/cli/commands/compress.rb +49 -0
- data/lib/rixie/cli/commands/context.rb +18 -0
- data/lib/rixie/cli/commands/help.rb +21 -0
- data/lib/rixie/cli/commands/model.rb +25 -0
- data/lib/rixie/cli/commands/strategy.rb +50 -0
- data/lib/rixie/cli/commands.rb +8 -0
- data/lib/rixie/cli/markdown.rb +59 -0
- data/lib/rixie/cli/renderer.rb +171 -0
- data/lib/rixie/cli/spinner.rb +47 -0
- data/lib/rixie/cli/terminal.rb +28 -0
- data/lib/rixie/cli.rb +285 -0
- data/lib/rixie/configuration.rb +56 -0
- data/lib/rixie/context/history.rb +62 -0
- data/lib/rixie/context/plan.rb +31 -0
- data/lib/rixie/context/summary.rb +25 -0
- data/lib/rixie/error.rb +34 -0
- data/lib/rixie/event/compression_end.rb +7 -0
- data/lib/rixie/event/compression_start.rb +7 -0
- data/lib/rixie/event/envelope.rb +7 -0
- data/lib/rixie/event/finished.rb +7 -0
- data/lib/rixie/event/llm_call_start.rb +7 -0
- data/lib/rixie/event/run_end.rb +7 -0
- data/lib/rixie/event/run_start.rb +7 -0
- data/lib/rixie/event/task_end.rb +7 -0
- data/lib/rixie/event/task_start.rb +7 -0
- data/lib/rixie/event/thought_completed.rb +7 -0
- data/lib/rixie/event/token.rb +7 -0
- data/lib/rixie/event/tool_call_end.rb +7 -0
- data/lib/rixie/event/tool_call_start.rb +7 -0
- data/lib/rixie/event/tool_calls_completed.rb +7 -0
- data/lib/rixie/event.rb +16 -0
- data/lib/rixie/event_listener.rb +36 -0
- data/lib/rixie/http/client.rb +140 -0
- data/lib/rixie/llm/adapter/dummy.rb +38 -0
- data/lib/rixie/llm/adapter/openai.rb +147 -0
- data/lib/rixie/llm/client/resolver.rb +58 -0
- data/lib/rixie/llm/client.rb +33 -0
- data/lib/rixie/llm/response.rb +19 -0
- data/lib/rixie/llm/tool_call.rb +36 -0
- data/lib/rixie/mcp/http/client.rb +86 -0
- data/lib/rixie/mcp/http.rb +3 -0
- data/lib/rixie/mcp.rb +3 -0
- data/lib/rixie/message.rb +10 -0
- data/lib/rixie/prompt_builder.rb +13 -0
- data/lib/rixie/run.rb +60 -0
- data/lib/rixie/search/base.rb +13 -0
- data/lib/rixie/search/duck_duck_go.rb +66 -0
- data/lib/rixie/search/wikipedia.rb +59 -0
- data/lib/rixie/session.rb +153 -0
- data/lib/rixie/store/base.rb +37 -0
- data/lib/rixie/store/memory.rb +30 -0
- data/lib/rixie/store/null.rb +19 -0
- data/lib/rixie/strategy/plan_execute.rb +65 -0
- data/lib/rixie/strategy/re_act.rb +15 -0
- data/lib/rixie/strategy/simple.rb +14 -0
- data/lib/rixie/subscriber.rb +12 -0
- data/lib/rixie/subscribers/event_severity.rb +23 -0
- data/lib/rixie/subscribers/json_logger.rb +70 -0
- data/lib/rixie/subscribers/logger.rb +65 -0
- data/lib/rixie/task.rb +53 -0
- data/lib/rixie/token_counter.rb +10 -0
- data/lib/rixie/tool/calculator.rb +154 -0
- data/lib/rixie/tool/current_time.rb +30 -0
- data/lib/rixie/tool/fetch.rb +42 -0
- data/lib/rixie/tool/file_list.rb +39 -0
- data/lib/rixie/tool/file_read.rb +53 -0
- data/lib/rixie/tool/file_sandbox.rb +33 -0
- data/lib/rixie/tool/file_search.rb +72 -0
- data/lib/rixie/tool/human_input.rb +24 -0
- data/lib/rixie/tool/web_search.rb +34 -0
- data/lib/rixie/tool/wikipedia_search.rb +38 -0
- data/lib/rixie/tool.rb +23 -0
- data/lib/rixie/tool_executor.rb +34 -0
- data/lib/rixie/version.rb +5 -0
- data/lib/rixie.rb +74 -0
- data/sig/rixie.rbs +4 -0
- metadata +146 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Context
|
|
5
|
+
class Plan
|
|
6
|
+
def initialize(steps:, current_step:)
|
|
7
|
+
@steps = steps
|
|
8
|
+
@current_step = current_step
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_message
|
|
12
|
+
numbered = @steps.each_with_index.map { |s, i| "#{i + 1}. #{s[:title]}" }.join("\n")
|
|
13
|
+
content = <<~PROMPT
|
|
14
|
+
You are executing one step of a multi-step plan. Your output for this step will be combined with the outputs of other steps into a single response.
|
|
15
|
+
|
|
16
|
+
Full plan:
|
|
17
|
+
#{numbered}
|
|
18
|
+
|
|
19
|
+
Current step: #{@current_step[:title]}
|
|
20
|
+
#{@current_step[:description]}
|
|
21
|
+
|
|
22
|
+
Output instructions:
|
|
23
|
+
- Produce only the content for this step. Do not summarize other steps.
|
|
24
|
+
- Do not add closing remarks, transition sentences, or offers to elaborate (e.g. "Feel free to ask if you want more details").
|
|
25
|
+
- Write as if the reader will continue reading the next step's output immediately after.
|
|
26
|
+
PROMPT
|
|
27
|
+
[Message::System.new(content: content)]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module Context
|
|
5
|
+
class Summary
|
|
6
|
+
attr_reader :content
|
|
7
|
+
|
|
8
|
+
def initialize(content:)
|
|
9
|
+
@content = content
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.from_store(entry)
|
|
13
|
+
new(content: entry["content"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_message
|
|
17
|
+
[Message::System.new(content: "Previous conversation summary:\n#{@content}")]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_store
|
|
21
|
+
{"type" => "summary", "content" => @content}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/rixie/error.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class NoProviderError < ConfigurationError; end
|
|
8
|
+
class UnknownProviderError < ConfigurationError; end
|
|
9
|
+
|
|
10
|
+
class NotImplementedError < Error; end
|
|
11
|
+
|
|
12
|
+
class AgentError < Error; end
|
|
13
|
+
class MaxStepsExceededError < AgentError; end
|
|
14
|
+
class ToolNotFoundError < AgentError; end
|
|
15
|
+
|
|
16
|
+
module LLM
|
|
17
|
+
class Error < ::Rixie::Error; end
|
|
18
|
+
class ResponseTruncatedError < Error; end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Http
|
|
22
|
+
class Error < ::Rixie::Error; end
|
|
23
|
+
class TimeoutError < Error; end
|
|
24
|
+
class ConnectionError < Error; end
|
|
25
|
+
class SSRFError < Error; end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module MCP
|
|
29
|
+
class Error < ::Rixie::Error; end
|
|
30
|
+
class TimeoutError < Error; end
|
|
31
|
+
class ProtocolError < Error; end
|
|
32
|
+
class RequestError < Error; end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/rixie/event.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rixie/event/envelope"
|
|
4
|
+
require "rixie/event/token"
|
|
5
|
+
require "rixie/event/thought_completed"
|
|
6
|
+
require "rixie/event/finished"
|
|
7
|
+
require "rixie/event/tool_call_start"
|
|
8
|
+
require "rixie/event/tool_call_end"
|
|
9
|
+
require "rixie/event/tool_calls_completed"
|
|
10
|
+
require "rixie/event/llm_call_start"
|
|
11
|
+
require "rixie/event/run_start"
|
|
12
|
+
require "rixie/event/run_end"
|
|
13
|
+
require "rixie/event/task_start"
|
|
14
|
+
require "rixie/event/task_end"
|
|
15
|
+
require "rixie/event/compression_start"
|
|
16
|
+
require "rixie/event/compression_end"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Rixie
|
|
6
|
+
class EventListener
|
|
7
|
+
def initialize(session_id: nil, task_id: nil)
|
|
8
|
+
@session_id = session_id
|
|
9
|
+
@task_id = task_id
|
|
10
|
+
@run_id = nil
|
|
11
|
+
@sequence_number = 0
|
|
12
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_writer :run_id
|
|
16
|
+
|
|
17
|
+
def on(event_class, &block)
|
|
18
|
+
@listeners[event_class] << block
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit(event)
|
|
23
|
+
@sequence_number += 1
|
|
24
|
+
envelope = Event::Envelope.new(
|
|
25
|
+
event: event,
|
|
26
|
+
occurred_at: Time.now,
|
|
27
|
+
session_id: @session_id,
|
|
28
|
+
task_id: @task_id,
|
|
29
|
+
run_id: @run_id,
|
|
30
|
+
sequence_number: @sequence_number,
|
|
31
|
+
event_id: SecureRandom.uuid
|
|
32
|
+
)
|
|
33
|
+
@listeners[event.class].each { |block| block.call(envelope) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "socket"
|
|
9
|
+
|
|
10
|
+
module Rixie
|
|
11
|
+
module Http
|
|
12
|
+
class Client
|
|
13
|
+
DEFAULT_TIMEOUT = 30
|
|
14
|
+
DEFAULT_HEADERS = {"User-Agent" => "Rixie/#{Rixie::VERSION}"}.freeze
|
|
15
|
+
CONNECTION_ERRORS = [
|
|
16
|
+
Errno::EHOSTUNREACH, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
17
|
+
Errno::EADDRNOTAVAIL, Errno::ENETUNREACH,
|
|
18
|
+
SocketError, OpenSSL::SSL::SSLError
|
|
19
|
+
].freeze
|
|
20
|
+
REDIRECT_CODES = [301, 302, 303, 307, 308].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(timeout: DEFAULT_TIMEOUT, headers: {}, http_client: nil, allow_private: false)
|
|
23
|
+
@timeout = timeout
|
|
24
|
+
@default_headers = DEFAULT_HEADERS.merge(headers)
|
|
25
|
+
@http_client = http_client
|
|
26
|
+
@allow_private = allow_private
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(url)
|
|
30
|
+
validate_url!(url)
|
|
31
|
+
execute_request(url) { |uri| Net::HTTP::Get.new(uri) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def post(url, body: nil)
|
|
35
|
+
validate_url!(url)
|
|
36
|
+
execute_request(url) do |uri|
|
|
37
|
+
req = Net::HTTP::Post.new(uri)
|
|
38
|
+
req.body = body
|
|
39
|
+
req
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate_url!(url)
|
|
46
|
+
uri = URI.parse(url)
|
|
47
|
+
raise Rixie::Http::SSRFError, "Blocked scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
|
|
48
|
+
return if @allow_private
|
|
49
|
+
host = uri.host.to_s
|
|
50
|
+
raise Rixie::Http::SSRFError, "Blocked host: #{host}" if blocked_host?(host)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def execute_request(url, redirect_count: 0, &build_request)
|
|
54
|
+
uri = URI.parse(url)
|
|
55
|
+
http = http_client_for(uri)
|
|
56
|
+
request = build_request.call(uri)
|
|
57
|
+
@default_headers.each { |k, v| request[k] = v }
|
|
58
|
+
response = http.request(request)
|
|
59
|
+
|
|
60
|
+
return follow_redirect(url, response, redirect_count) if REDIRECT_CODES.include?(response.code.to_i)
|
|
61
|
+
|
|
62
|
+
{status: response.code.to_i, headers: normalize_headers(response.to_hash), body: decode_body(response)}
|
|
63
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
64
|
+
raise Rixie::Http::TimeoutError, e.message
|
|
65
|
+
rescue *CONNECTION_ERRORS => e
|
|
66
|
+
raise Rixie::Http::ConnectionError, e.message
|
|
67
|
+
rescue Rixie::Http::Error
|
|
68
|
+
raise
|
|
69
|
+
rescue => e
|
|
70
|
+
raise Rixie::Http::Error, e.message
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
MAX_REDIRECTS = 5
|
|
74
|
+
private_constant :MAX_REDIRECTS
|
|
75
|
+
def follow_redirect(original_url, response, redirect_count)
|
|
76
|
+
raise Rixie::Http::ConnectionError, "Too many redirects" if redirect_count >= MAX_REDIRECTS
|
|
77
|
+
location = response["location"]
|
|
78
|
+
raise Rixie::Http::ConnectionError, "Redirect missing Location header" unless location
|
|
79
|
+
location = URI.join(original_url, location).to_s
|
|
80
|
+
validate_url!(location)
|
|
81
|
+
execute_request(location, redirect_count: redirect_count + 1) { |u| Net::HTTP::Get.new(u) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def http_client_for(uri)
|
|
85
|
+
return @http_client if @http_client
|
|
86
|
+
|
|
87
|
+
build_http_client(uri)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_http_client(uri)
|
|
91
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
+
unless @allow_private
|
|
93
|
+
addresses = Socket.getaddrinfo(uri.host, nil, nil, :STREAM).map { |a| a[3] }
|
|
94
|
+
addresses.each do |ip|
|
|
95
|
+
raise Rixie::Http::SSRFError, "Blocked host: #{ip}" if blocked_host?(ip)
|
|
96
|
+
end
|
|
97
|
+
http.ipaddr = addresses.first
|
|
98
|
+
end
|
|
99
|
+
http.use_ssl = uri.scheme == "https"
|
|
100
|
+
http.open_timeout = @timeout
|
|
101
|
+
http.read_timeout = @timeout
|
|
102
|
+
http
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# NOTE: This regex is not exhaustive but covers common private IP ranges and localhost.
|
|
106
|
+
# The http client also performs DNS resolution and checks resolved IPs against this pattern to mitigate SSRF risks.
|
|
107
|
+
BLOCKED_HOSTS = /\A(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|localhost|0\.0\.0\.0)/
|
|
108
|
+
private_constant :BLOCKED_HOSTS
|
|
109
|
+
def blocked_host?(host)
|
|
110
|
+
return true if host.match?(BLOCKED_HOSTS)
|
|
111
|
+
lower = host.delete_prefix("[").delete_suffix("]").downcase
|
|
112
|
+
for_ipv6 = ->(host, lower) {
|
|
113
|
+
ipv6_host = host.include?(":")
|
|
114
|
+
loopback = lower == "::1"
|
|
115
|
+
private_host = lower.start_with?("fc", "fd", "fe80:", "::ffff:")
|
|
116
|
+
ipv6_host && (loopback || private_host)
|
|
117
|
+
}
|
|
118
|
+
for_ipv6.call(host, lower)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_headers(headers)
|
|
122
|
+
headers.transform_keys(&:downcase)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decode_body(response)
|
|
126
|
+
encoding = response["content-encoding"]
|
|
127
|
+
return response.body unless encoding
|
|
128
|
+
|
|
129
|
+
case encoding.downcase
|
|
130
|
+
when "gzip"
|
|
131
|
+
Zlib::GzipReader.new(StringIO.new(response.body)).read
|
|
132
|
+
when "deflate"
|
|
133
|
+
Zlib::Inflate.inflate(response.body)
|
|
134
|
+
else
|
|
135
|
+
response.body
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module LLM
|
|
5
|
+
module Adapter
|
|
6
|
+
class Dummy
|
|
7
|
+
DEFAULT_RESPONSE = {
|
|
8
|
+
"choices" => [{
|
|
9
|
+
"finish_reason" => "stop",
|
|
10
|
+
"message" => {"role" => "assistant", "content" => "[mock llm generated content]"}
|
|
11
|
+
}]
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(responses = nil, **_)
|
|
15
|
+
@responses = responses.is_a?(Array) ? responses.dup : nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def chat(messages, tools:)
|
|
19
|
+
if @responses.nil?
|
|
20
|
+
return Rixie::LLM::Response.from_openai_wire(DEFAULT_RESPONSE)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise "Rixie::LLM::Adapter::Dummy exhausted: no more responses enqueued" if @responses.empty?
|
|
24
|
+
|
|
25
|
+
Rixie::LLM::Response.from_openai_wire(@responses.shift)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stream(messages, tools:, &block)
|
|
29
|
+
response = chat(messages, tools: tools)
|
|
30
|
+
if (content = response.content)
|
|
31
|
+
block.call(Rixie::Event::Token.new(delta: content))
|
|
32
|
+
end
|
|
33
|
+
response
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "openai"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise Rixie::ConfigurationError, "openai gem is required. Add `gem 'openai'` to your Gemfile."
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Rixie
|
|
10
|
+
module LLM
|
|
11
|
+
module Adapter
|
|
12
|
+
class OpenAI
|
|
13
|
+
def initialize(model:, base_url:, api_key:, request_timeout: nil, max_tokens: nil, temperature: nil, parallel_tool_calls: true)
|
|
14
|
+
@model = model
|
|
15
|
+
@max_tokens = max_tokens
|
|
16
|
+
@temperature = temperature
|
|
17
|
+
@parallel_tool_calls = parallel_tool_calls
|
|
18
|
+
params = {api_key: api_key, base_url: base_url}
|
|
19
|
+
params[:timeout] = request_timeout if request_timeout
|
|
20
|
+
@client = ::OpenAI::Client.new(**params)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def chat(messages, tools:)
|
|
24
|
+
result = @client.chat.completions.create(**build_params(encode_messages(messages), tools))
|
|
25
|
+
Rixie::LLM::Response.from_openai_wire(normalize(result))
|
|
26
|
+
rescue ::OpenAI::Errors::Error => e
|
|
27
|
+
raise Rixie::LLM::Error, e.message
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stream(messages, tools:, &block)
|
|
31
|
+
params = build_params(encode_messages(messages), tools)
|
|
32
|
+
|
|
33
|
+
content = +""
|
|
34
|
+
accumulated_tool_calls = {}
|
|
35
|
+
finish_reason = nil
|
|
36
|
+
|
|
37
|
+
@client.chat.completions.stream_raw(**params).each do |chunk|
|
|
38
|
+
choice = chunk.choices&.first
|
|
39
|
+
next unless choice
|
|
40
|
+
|
|
41
|
+
finish_reason = choice.finish_reason if choice.finish_reason
|
|
42
|
+
delta = choice.delta
|
|
43
|
+
next unless delta
|
|
44
|
+
|
|
45
|
+
if (text = delta.content) && !text.empty?
|
|
46
|
+
block.call(Event::Token.new(delta: text))
|
|
47
|
+
content << text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
delta.tool_calls&.each do |tc|
|
|
51
|
+
i = tc.index
|
|
52
|
+
accumulated_tool_calls[i] ||= {
|
|
53
|
+
"id" => +"",
|
|
54
|
+
"function" => {"name" => +"", "arguments" => +""}
|
|
55
|
+
}
|
|
56
|
+
accumulated_tool_calls[i]["id"] << tc.id.to_s
|
|
57
|
+
accumulated_tool_calls[i]["function"]["name"] << tc.function&.name.to_s
|
|
58
|
+
accumulated_tool_calls[i]["function"]["arguments"] << tc.function&.arguments.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Rixie::LLM::Response.from_openai_wire(build_stream_raw(content, accumulated_tool_calls, finish_reason))
|
|
63
|
+
rescue ::OpenAI::Errors::Error => e
|
|
64
|
+
raise Rixie::LLM::Error, e.message
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def encode_messages(messages)
|
|
70
|
+
messages.map { |msg| encode_message(msg) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def encode_message(msg)
|
|
74
|
+
case msg
|
|
75
|
+
when Rixie::Message::System
|
|
76
|
+
{role: "system", content: msg.content}
|
|
77
|
+
when Rixie::Message::User
|
|
78
|
+
{role: "user", content: msg.content}
|
|
79
|
+
when Rixie::Message::Assistant
|
|
80
|
+
h = {role: "assistant", content: msg.content}
|
|
81
|
+
h[:tool_calls] = msg.tool_calls.map(&:to_openai_wire) unless msg.tool_calls.empty?
|
|
82
|
+
h
|
|
83
|
+
when Rixie::Message::Tool
|
|
84
|
+
{role: "tool", tool_call_id: msg.tool_call_id, content: msg.content}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_stream_raw(content, accumulated_tool_calls, finish_reason)
|
|
89
|
+
tool_calls_raw = accumulated_tool_calls.empty? ? nil : accumulated_tool_calls.values
|
|
90
|
+
{
|
|
91
|
+
"choices" => [{
|
|
92
|
+
"finish_reason" => finish_reason,
|
|
93
|
+
"message" => {
|
|
94
|
+
"content" => content.empty? ? nil : content,
|
|
95
|
+
"tool_calls" => tool_calls_raw
|
|
96
|
+
}
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def encode_tools(tools)
|
|
102
|
+
tools.map do |tool|
|
|
103
|
+
{
|
|
104
|
+
type: "function",
|
|
105
|
+
function: {
|
|
106
|
+
name: tool.name,
|
|
107
|
+
description: tool.description,
|
|
108
|
+
parameters: tool.input_schema
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_params(messages, tools)
|
|
115
|
+
params = {model: @model, messages: messages}
|
|
116
|
+
unless tools.empty?
|
|
117
|
+
params[:tools] = encode_tools(tools)
|
|
118
|
+
params[:parallel_tool_calls] = @parallel_tool_calls
|
|
119
|
+
end
|
|
120
|
+
params[:max_tokens] = @max_tokens if @max_tokens
|
|
121
|
+
params[:temperature] = @temperature unless @temperature.nil?
|
|
122
|
+
params
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize(result)
|
|
126
|
+
{
|
|
127
|
+
"choices" => (result.choices || []).map do |choice|
|
|
128
|
+
message = choice.message
|
|
129
|
+
{
|
|
130
|
+
"finish_reason" => choice.finish_reason,
|
|
131
|
+
"message" => {
|
|
132
|
+
"content" => message.content,
|
|
133
|
+
"tool_calls" => message.tool_calls&.map do |tc|
|
|
134
|
+
{
|
|
135
|
+
"id" => tc.id,
|
|
136
|
+
"function" => {"name" => tc.function.name, "arguments" => tc.function.arguments}
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rixie
|
|
4
|
+
module LLM
|
|
5
|
+
class Client
|
|
6
|
+
class Resolver
|
|
7
|
+
BUILTIN_PROVIDERS = {
|
|
8
|
+
"openai" => {
|
|
9
|
+
adapter: :openai,
|
|
10
|
+
base_url: "https://api.openai.com/v1",
|
|
11
|
+
api_key: -> { ENV["OPENAI_API_KEY"] }
|
|
12
|
+
},
|
|
13
|
+
"ollama" => {
|
|
14
|
+
adapter: :openai,
|
|
15
|
+
base_url: "http://localhost:11434/v1",
|
|
16
|
+
api_key: -> { "ollama" }
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.resolve(model: nil, provider: nil, request_timeout: nil, max_tokens: nil, temperature: nil, parallel_tool_calls: nil)
|
|
21
|
+
raise Rixie::NoProviderError, "No provider configured. Pass `provider:` or set Rixie.config.default_provider." if provider.nil?
|
|
22
|
+
|
|
23
|
+
all_providers = BUILTIN_PROVIDERS.merge(Rixie.config.custom_providers)
|
|
24
|
+
config = all_providers[provider.to_s]
|
|
25
|
+
raise Rixie::UnknownProviderError, "Unknown provider: #{provider.inspect}" if config.nil?
|
|
26
|
+
|
|
27
|
+
api_key = config[:api_key]
|
|
28
|
+
api_key = api_key.call if api_key.respond_to?(:call)
|
|
29
|
+
|
|
30
|
+
adapter_class = adapter_class_for(config[:adapter])
|
|
31
|
+
params = {
|
|
32
|
+
model: model,
|
|
33
|
+
base_url: config[:base_url],
|
|
34
|
+
api_key: api_key,
|
|
35
|
+
request_timeout: request_timeout,
|
|
36
|
+
max_tokens: max_tokens,
|
|
37
|
+
temperature: temperature
|
|
38
|
+
}
|
|
39
|
+
params[:parallel_tool_calls] = parallel_tool_calls unless parallel_tool_calls.nil? || !(adapter_class <= Adapter::OpenAI)
|
|
40
|
+
adapter_class.new(**params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.adapter_class_for(adapter)
|
|
44
|
+
case adapter
|
|
45
|
+
when :openai
|
|
46
|
+
require_relative "../adapter/openai"
|
|
47
|
+
Adapter::OpenAI
|
|
48
|
+
when Class
|
|
49
|
+
adapter
|
|
50
|
+
else
|
|
51
|
+
raise Rixie::ConfigurationError, "Unknown adapter: #{adapter.inspect}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
private_class_method :adapter_class_for
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|