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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/bin/rixie +7 -0
  6. data/lib/rixie/agent/compressor.rb +41 -0
  7. data/lib/rixie/agent/plan.rb +62 -0
  8. data/lib/rixie/agent/re_act.rb +53 -0
  9. data/lib/rixie/agent.rb +122 -0
  10. data/lib/rixie/cli/commands/base.rb +33 -0
  11. data/lib/rixie/cli/commands/compress.rb +49 -0
  12. data/lib/rixie/cli/commands/context.rb +18 -0
  13. data/lib/rixie/cli/commands/help.rb +21 -0
  14. data/lib/rixie/cli/commands/model.rb +25 -0
  15. data/lib/rixie/cli/commands/strategy.rb +50 -0
  16. data/lib/rixie/cli/commands.rb +8 -0
  17. data/lib/rixie/cli/markdown.rb +59 -0
  18. data/lib/rixie/cli/renderer.rb +171 -0
  19. data/lib/rixie/cli/spinner.rb +47 -0
  20. data/lib/rixie/cli/terminal.rb +28 -0
  21. data/lib/rixie/cli.rb +285 -0
  22. data/lib/rixie/configuration.rb +56 -0
  23. data/lib/rixie/context/history.rb +62 -0
  24. data/lib/rixie/context/plan.rb +31 -0
  25. data/lib/rixie/context/summary.rb +25 -0
  26. data/lib/rixie/error.rb +34 -0
  27. data/lib/rixie/event/compression_end.rb +7 -0
  28. data/lib/rixie/event/compression_start.rb +7 -0
  29. data/lib/rixie/event/envelope.rb +7 -0
  30. data/lib/rixie/event/finished.rb +7 -0
  31. data/lib/rixie/event/llm_call_start.rb +7 -0
  32. data/lib/rixie/event/run_end.rb +7 -0
  33. data/lib/rixie/event/run_start.rb +7 -0
  34. data/lib/rixie/event/task_end.rb +7 -0
  35. data/lib/rixie/event/task_start.rb +7 -0
  36. data/lib/rixie/event/thought_completed.rb +7 -0
  37. data/lib/rixie/event/token.rb +7 -0
  38. data/lib/rixie/event/tool_call_end.rb +7 -0
  39. data/lib/rixie/event/tool_call_start.rb +7 -0
  40. data/lib/rixie/event/tool_calls_completed.rb +7 -0
  41. data/lib/rixie/event.rb +16 -0
  42. data/lib/rixie/event_listener.rb +36 -0
  43. data/lib/rixie/http/client.rb +140 -0
  44. data/lib/rixie/llm/adapter/dummy.rb +38 -0
  45. data/lib/rixie/llm/adapter/openai.rb +147 -0
  46. data/lib/rixie/llm/client/resolver.rb +58 -0
  47. data/lib/rixie/llm/client.rb +33 -0
  48. data/lib/rixie/llm/response.rb +19 -0
  49. data/lib/rixie/llm/tool_call.rb +36 -0
  50. data/lib/rixie/mcp/http/client.rb +86 -0
  51. data/lib/rixie/mcp/http.rb +3 -0
  52. data/lib/rixie/mcp.rb +3 -0
  53. data/lib/rixie/message.rb +10 -0
  54. data/lib/rixie/prompt_builder.rb +13 -0
  55. data/lib/rixie/run.rb +60 -0
  56. data/lib/rixie/search/base.rb +13 -0
  57. data/lib/rixie/search/duck_duck_go.rb +66 -0
  58. data/lib/rixie/search/wikipedia.rb +59 -0
  59. data/lib/rixie/session.rb +153 -0
  60. data/lib/rixie/store/base.rb +37 -0
  61. data/lib/rixie/store/memory.rb +30 -0
  62. data/lib/rixie/store/null.rb +19 -0
  63. data/lib/rixie/strategy/plan_execute.rb +65 -0
  64. data/lib/rixie/strategy/re_act.rb +15 -0
  65. data/lib/rixie/strategy/simple.rb +14 -0
  66. data/lib/rixie/subscriber.rb +12 -0
  67. data/lib/rixie/subscribers/event_severity.rb +23 -0
  68. data/lib/rixie/subscribers/json_logger.rb +70 -0
  69. data/lib/rixie/subscribers/logger.rb +65 -0
  70. data/lib/rixie/task.rb +53 -0
  71. data/lib/rixie/token_counter.rb +10 -0
  72. data/lib/rixie/tool/calculator.rb +154 -0
  73. data/lib/rixie/tool/current_time.rb +30 -0
  74. data/lib/rixie/tool/fetch.rb +42 -0
  75. data/lib/rixie/tool/file_list.rb +39 -0
  76. data/lib/rixie/tool/file_read.rb +53 -0
  77. data/lib/rixie/tool/file_sandbox.rb +33 -0
  78. data/lib/rixie/tool/file_search.rb +72 -0
  79. data/lib/rixie/tool/human_input.rb +24 -0
  80. data/lib/rixie/tool/web_search.rb +34 -0
  81. data/lib/rixie/tool/wikipedia_search.rb +38 -0
  82. data/lib/rixie/tool.rb +23 -0
  83. data/lib/rixie/tool_executor.rb +34 -0
  84. data/lib/rixie/version.rb +5 -0
  85. data/lib/rixie.rb +74 -0
  86. data/sig/rixie.rbs +4 -0
  87. 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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ CompressionEnd = Data.define(:status, :entry_count)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ CompressionStart = Data.define(:entry_count, :keep_recent)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ Envelope = Data.define(:event, :occurred_at, :session_id, :task_id, :run_id, :sequence_number, :event_id)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ Finished = Data.define(:content)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ LlmCallStart = Data.define(:step_count)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ RunEnd = Data.define(:output, :status)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ RunStart = Data.define(:user_input)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ TaskEnd = Data.define(:output, :status)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ TaskStart = Data.define(:user_input, :strategy)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ ThoughtCompleted = Data.define(:thought)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ Token = Data.define(:delta)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ ToolCallEnd = Data.define(:tool_call, :result)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ ToolCallStart = Data.define(:tool_call)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Event
5
+ ToolCallsCompleted = Data.define(:tool_calls, :tool_results)
6
+ end
7
+ end
@@ -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