rubyn-code 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../message_builder'
4
+
5
+ module RubynCode
6
+ module LLM
7
+ module Adapters
8
+ # SSE streaming parser for the Anthropic Messages API.
9
+ #
10
+ # Handles Anthropic-specific event types: message_start, content_block_start,
11
+ # content_block_delta, content_block_stop, message_delta, message_stop, error.
12
+ # Accumulates content blocks and produces a normalized LLM::Response via #finalize.
13
+ class AnthropicStreaming
14
+ include JsonParsing
15
+
16
+ class ParseError < RubynCode::Error; end
17
+ class OverloadError < RubynCode::Error; end
18
+
19
+ Event = Data.define(:type, :data)
20
+
21
+ HANDLERS = {
22
+ 'message_start' => :handle_message_start,
23
+ 'content_block_start' => :handle_content_block_start,
24
+ 'content_block_delta' => :handle_content_block_delta,
25
+ 'content_block_stop' => :handle_content_block_stop,
26
+ 'message_delta' => :handle_message_delta,
27
+ 'message_stop' => :handle_message_stop,
28
+ 'error' => :handle_error
29
+ }.freeze
30
+
31
+ def initialize(&block)
32
+ @callback = block
33
+ @buffer = +''
34
+ @response_id = nil
35
+ @content_blocks = []
36
+ @current_block_index = nil
37
+ @current_text = +''
38
+ @current_tool_input_json = +''
39
+ @stop_reason = nil
40
+ @usage = nil
41
+ end
42
+
43
+ def feed(chunk)
44
+ @buffer << chunk
45
+ consume_events
46
+ end
47
+
48
+ def finalize
49
+ flush_pending_block
50
+ Response.new(
51
+ id: @response_id,
52
+ content: @content_blocks.compact,
53
+ stop_reason: @stop_reason,
54
+ usage: @usage
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ # -- SSE parsing --------------------------------------------------
61
+
62
+ def consume_events
63
+ while (idx = @buffer.index("\n\n"))
64
+ raw_event = @buffer.slice!(0..(idx + 1))
65
+ parse_sse(raw_event)
66
+ end
67
+ end
68
+
69
+ def parse_sse(raw)
70
+ event_type = nil
71
+ data_lines = []
72
+
73
+ raw.each_line do |line|
74
+ line = line.chomp
75
+ case line
76
+ when /\Aevent:\s*(.+)/ then event_type = ::Regexp.last_match(1).strip
77
+ when /\Adata:\s*(.*)/ then data_lines << ::Regexp.last_match(1)
78
+ end
79
+ end
80
+
81
+ return if data_lines.empty? && event_type.nil?
82
+
83
+ data_str = data_lines.join("\n")
84
+ dispatch(event_type, data_str.empty? ? {} : parse_json(data_str))
85
+ end
86
+
87
+ def dispatch(event_type, data)
88
+ handler = HANDLERS[event_type]
89
+ return unless handler
90
+
91
+ method(handler).arity.zero? ? send(handler) : send(handler, data)
92
+ end
93
+
94
+ # -- Event handlers -----------------------------------------------
95
+
96
+ def handle_message_start(data)
97
+ message = data['message'] || data
98
+ @response_id = message['id']
99
+ @usage = build_usage(message['usage']) if message['usage']
100
+ emit(:message_start, data)
101
+ end
102
+
103
+ def handle_content_block_start(data)
104
+ @current_block_index = data['index']
105
+ block = data['content_block'] || {}
106
+
107
+ case block['type']
108
+ when 'text'
109
+ @current_text = +(block['text'] || '')
110
+ when 'tool_use'
111
+ @current_tool_id = block['id']
112
+ @current_tool_name = block['name']
113
+ @current_tool_input_json = +''
114
+ end
115
+
116
+ emit(:content_block_start, data)
117
+ end
118
+
119
+ def handle_content_block_delta(data)
120
+ delta = data['delta'] || {}
121
+
122
+ case delta['type']
123
+ when 'text_delta'
124
+ text = delta['text'] || ''
125
+ @current_text << text
126
+ emit(:text_delta, { index: data['index'], text: text })
127
+ when 'input_json_delta'
128
+ json_chunk = delta['partial_json'] || ''
129
+ @current_tool_input_json << json_chunk
130
+ emit(:input_json_delta, { index: data['index'], partial_json: json_chunk })
131
+ end
132
+
133
+ emit(:content_block_delta, data)
134
+ end
135
+
136
+ def handle_content_block_stop(data)
137
+ store_current_block(data['index'].to_i)
138
+ emit(:content_block_stop, data)
139
+ end
140
+
141
+ def handle_message_delta(data)
142
+ delta = data['delta'] || {}
143
+ @stop_reason = delta['stop_reason'] if delta['stop_reason']
144
+ update_output_tokens(data['usage']) if data['usage']
145
+ emit(:message_delta, data)
146
+ end
147
+
148
+ def handle_message_stop
149
+ emit(:message_stop, {})
150
+ end
151
+
152
+ def handle_error(data)
153
+ error = data['error'] || data
154
+ error_type = error['type'] || 'unknown'
155
+ message = error['message'] || 'Unknown streaming error'
156
+
157
+ raise OverloadError, message if error_type == 'overloaded_error'
158
+
159
+ raise ParseError, "Streaming error (#{error_type}): #{message}"
160
+ end
161
+
162
+ # -- Block assembly (single code path) ----------------------------
163
+
164
+ def store_current_block(index)
165
+ if @current_tool_id
166
+ @content_blocks[index] = build_tool_block
167
+ elsif !@current_text.empty?
168
+ @content_blocks[index] = TextBlock.new(text: @current_text.dup)
169
+ @current_text = +''
170
+ end
171
+ end
172
+
173
+ def flush_pending_block
174
+ return unless @current_block_index
175
+
176
+ store_current_block(@current_block_index)
177
+ @current_block_index = nil
178
+ end
179
+
180
+ def build_tool_block
181
+ input = parse_json(@current_tool_input_json) || {}
182
+ block = ToolUseBlock.new(id: @current_tool_id, name: @current_tool_name, input: input)
183
+ @current_tool_id = nil
184
+ @current_tool_name = nil
185
+ @current_tool_input_json = +''
186
+ block
187
+ end
188
+
189
+ # -- Helpers ------------------------------------------------------
190
+
191
+ def build_usage(data)
192
+ Usage.new(
193
+ input_tokens: data['input_tokens'].to_i,
194
+ output_tokens: data['output_tokens'].to_i,
195
+ cache_creation_input_tokens: data['cache_creation_input_tokens'].to_i,
196
+ cache_read_input_tokens: data['cache_read_input_tokens'].to_i
197
+ )
198
+ end
199
+
200
+ def update_output_tokens(usage_data)
201
+ @usage = Usage.new(
202
+ input_tokens: @usage&.input_tokens || 0,
203
+ output_tokens: usage_data['output_tokens'].to_i,
204
+ cache_creation_input_tokens: @usage&.cache_creation_input_tokens || 0,
205
+ cache_read_input_tokens: @usage&.cache_read_input_tokens || 0
206
+ )
207
+ end
208
+
209
+ def emit(type, data)
210
+ @callback&.call(Event.new(type: type, data: data))
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Abstract base for all LLM provider adapters.
7
+ #
8
+ # Every adapter must implement #chat, #provider_name, and #models.
9
+ # The Client facade delegates to whichever adapter is active.
10
+ class Base
11
+ # @param messages [Array<Hash>] Conversation messages
12
+ # @param model [String] Model identifier
13
+ # @param max_tokens [Integer] Max output tokens
14
+ # @param tools [Array<Hash>, nil] Tool schemas
15
+ # @param system [String, nil] System prompt text
16
+ # @param on_text [Proc, nil] Streaming text callback
17
+ # @param task_budget [Hash, nil] Optional task budget context
18
+ # @return [LLM::Response]
19
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
20
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
21
+ end
22
+
23
+ # @return [String] Provider identifier (e.g. 'anthropic', 'openai')
24
+ def provider_name
25
+ raise NotImplementedError, "#{self.class}#provider_name must be implemented"
26
+ end
27
+
28
+ # @return [Array<String>] Available model identifiers
29
+ def models
30
+ raise NotImplementedError, "#{self.class}#models must be implemented"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Shared JSON parsing for adapters and streaming parsers.
7
+ # Swallows parse errors and returns nil — callers decide how to handle.
8
+ module JsonParsing
9
+ private
10
+
11
+ def parse_json(str)
12
+ return nil if str.nil? || (str.respond_to?(:strip) && str.strip.empty?)
13
+
14
+ JSON.parse(str)
15
+ rescue JSON::ParserError
16
+ nil
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require_relative '../message_builder'
6
+
7
+ module RubynCode
8
+ module LLM
9
+ module Adapters
10
+ class OpenAI < Base
11
+ include JsonParsing
12
+ include OpenAIMessageTranslator
13
+
14
+ API_URL = 'https://api.openai.com/v1/chat/completions'
15
+ MAX_RETRIES = 3
16
+ RETRY_DELAYS = [2, 5, 10].freeze
17
+
18
+ AVAILABLE_MODELS = %w[gpt-4o gpt-4o-mini gpt-4.1 gpt-4.1-mini gpt-4.1-nano o3 o4-mini].freeze
19
+
20
+ def initialize(api_key: nil, base_url: nil)
21
+ super()
22
+ @api_key = api_key
23
+ @base_url = base_url
24
+ end
25
+
26
+ def provider_name
27
+ 'openai'
28
+ end
29
+
30
+ def models
31
+ AVAILABLE_MODELS
32
+ end
33
+
34
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
35
+ body = build_request_body(
36
+ messages: messages, model: model, max_tokens: max_tokens,
37
+ tools: tools, system: system
38
+ )
39
+
40
+ return stream_request(body, on_text) if on_text
41
+
42
+ execute_with_retries(body, on_text)
43
+ end
44
+
45
+ private
46
+
47
+ # -- Auth ---------------------------------------------------------
48
+
49
+ def resolve_api_key
50
+ @api_key || ENV.fetch('OPENAI_API_KEY') { raise Client::AuthExpiredError, 'No OpenAI API key configured' }
51
+ end
52
+
53
+ # -- Execution ----------------------------------------------------
54
+
55
+ def execute_with_retries(body, on_text)
56
+ retries = 0
57
+ loop do
58
+ response = post_request(body)
59
+
60
+ if response.status == 429 && retries < MAX_RETRIES
61
+ RubynCode::Debug.llm("Rate limited (429), retry #{retries + 1}/#{MAX_RETRIES}")
62
+ sleep(RETRY_DELAYS[retries] || 10)
63
+ retries += 1
64
+ next
65
+ end
66
+
67
+ return finalize_response(response, on_text)
68
+ end
69
+ end
70
+
71
+ def post_request(body)
72
+ connection.post(api_url) do |req|
73
+ apply_headers(req)
74
+ req.body = JSON.generate(body)
75
+ end
76
+ end
77
+
78
+ def finalize_response(response, on_text)
79
+ resp = handle_api_response(response)
80
+ emit_full_text(resp, on_text)
81
+ resp
82
+ end
83
+
84
+ def emit_full_text(resp, on_text)
85
+ return unless on_text
86
+
87
+ text = resp.content.select { |b| b.respond_to?(:text) }.map(&:text).join
88
+ on_text.call(text) unless text.empty?
89
+ end
90
+
91
+ # -- Streaming ----------------------------------------------------
92
+
93
+ def stream_request(body, on_text)
94
+ streamer = build_streamer(on_text)
95
+ error_chunks = []
96
+
97
+ response = streaming_connection.post(api_url) do |req|
98
+ apply_headers(req)
99
+ req.body = JSON.generate(body.merge(stream: true))
100
+ req.options.on_data = on_data_proc(streamer, error_chunks)
101
+ end
102
+
103
+ handle_stream_errors(response, error_chunks)
104
+ streamer.finalize
105
+ end
106
+
107
+ def build_streamer(on_text)
108
+ OpenAIStreaming.new do |event|
109
+ on_text&.call(event.data[:text]) if event.type == :text_delta
110
+ end
111
+ end
112
+
113
+ def on_data_proc(streamer, error_chunks)
114
+ proc do |chunk, _bytes, env|
115
+ env.status == 200 ? streamer.feed(chunk) : error_chunks << chunk
116
+ end
117
+ end
118
+
119
+ def handle_stream_errors(response, error_chunks)
120
+ return if response.status == 200
121
+
122
+ body_text = error_chunks.join
123
+ error_msg = parse_json(body_text)&.dig('error', 'message') || body_text[0..500]
124
+ raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
125
+
126
+ raise Client::RequestError, "API streaming failed (#{response.status}): #{error_msg}"
127
+ end
128
+
129
+ # -- Connection ---------------------------------------------------
130
+
131
+ def api_url
132
+ @base_url ? "#{@base_url}/chat/completions" : API_URL
133
+ end
134
+
135
+ def connection
136
+ @connection ||= build_faraday_connection
137
+ end
138
+
139
+ def streaming_connection
140
+ @streaming_connection ||= build_faraday_connection
141
+ end
142
+
143
+ def build_faraday_connection
144
+ Faraday.new do |f|
145
+ f.options.timeout = 300
146
+ f.options.open_timeout = 30
147
+ f.adapter Faraday.default_adapter
148
+ end
149
+ end
150
+
151
+ # -- Headers ------------------------------------------------------
152
+
153
+ def apply_headers(req)
154
+ req.headers['Content-Type'] = 'application/json'
155
+ req.headers['Authorization'] = "Bearer #{resolve_api_key}"
156
+ end
157
+
158
+ # -- Request body -------------------------------------------------
159
+
160
+ def build_request_body(messages:, model:, max_tokens:, tools:, system:)
161
+ body = { model: model, max_tokens: max_tokens, messages: build_messages(messages, system) }
162
+ body[:tools] = format_tools(tools) if tools&.any?
163
+ body
164
+ end
165
+
166
+ def format_tools(tools)
167
+ tools.map do |tool|
168
+ {
169
+ type: 'function',
170
+ function: {
171
+ name: tool[:name] || tool['name'],
172
+ description: tool[:description] || tool['description'],
173
+ parameters: tool[:input_schema] || tool[:parameters] || tool['input_schema'] || tool['parameters']
174
+ }
175
+ }
176
+ end
177
+ end
178
+
179
+ # -- Response parsing ---------------------------------------------
180
+
181
+ def handle_api_response(response)
182
+ raise_on_error(response) unless response.success?
183
+
184
+ body = parse_json(response.body)
185
+ raise Client::RequestError, 'Invalid response from API' unless body
186
+
187
+ build_api_response(body)
188
+ end
189
+
190
+ def raise_on_error(response)
191
+ body = parse_json(response.body)
192
+ error_msg = body&.dig('error', 'message') || response.body[0..500]
193
+ log_api_error(response)
194
+
195
+ raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
196
+ raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
197
+
198
+ raise Client::RequestError, "API request failed (#{response.status}): #{error_msg}"
199
+ end
200
+
201
+ def log_api_error(response)
202
+ RubynCode::Debug.llm("API error #{response.status}: #{response.body[0..500]}")
203
+ end
204
+
205
+ def build_api_response(body)
206
+ message = body.dig('choices', 0, 'message') || {}
207
+ blocks = parse_response_content(message)
208
+ usage = parse_usage(body['usage'])
209
+ stop = normalize_stop_reason(body.dig('choices', 0, 'finish_reason'))
210
+
211
+ RubynCode::LLM::Response.new(id: body['id'], content: blocks, stop_reason: stop, usage: usage)
212
+ end
213
+
214
+ def parse_response_content(message)
215
+ blocks = []
216
+ blocks << RubynCode::LLM::TextBlock.new(text: message['content']) if message['content']
217
+ append_tool_call_blocks(blocks, message['tool_calls'])
218
+ blocks
219
+ end
220
+
221
+ def append_tool_call_blocks(blocks, tool_calls)
222
+ return unless tool_calls
223
+
224
+ tool_calls.each do |tc|
225
+ func = tc['function'] || {}
226
+ input = parse_json(func['arguments']) || {}
227
+ blocks << RubynCode::LLM::ToolUseBlock.new(id: tc['id'], name: func['name'], input: input)
228
+ end
229
+ end
230
+
231
+ def parse_usage(data)
232
+ return RubynCode::LLM::Usage.new(input_tokens: 0, output_tokens: 0) unless data
233
+
234
+ RubynCode::LLM::Usage.new(
235
+ input_tokens: data['prompt_tokens'].to_i,
236
+ output_tokens: data['completion_tokens'].to_i
237
+ )
238
+ end
239
+
240
+ def normalize_stop_reason(reason)
241
+ OpenAIStreaming::STOP_REASON_MAP[reason] || reason || 'end_turn'
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Adapter for OpenAI-compatible providers (Groq, Together, Ollama, etc.).
7
+ #
8
+ # Inherits all OpenAI logic but overrides the base URL, provider name,
9
+ # available models, and API key resolution.
10
+ class OpenAICompatible < OpenAI
11
+ def initialize(provider:, base_url:, api_key: nil, available_models: [])
12
+ super(api_key: api_key, base_url: base_url)
13
+ @provider = provider
14
+ @available_models = available_models.freeze
15
+ end
16
+
17
+ def provider_name
18
+ @provider
19
+ end
20
+
21
+ def models
22
+ @available_models
23
+ end
24
+
25
+ private
26
+
27
+ def resolve_api_key
28
+ return @api_key if @api_key
29
+
30
+ env_key = "#{@provider.upcase}_API_KEY"
31
+ ENV.fetch(env_key) do
32
+ return 'no-key-required' if local_provider?
33
+
34
+ raise Client::AuthExpiredError, "No #{@provider} API key configured. Set #{env_key}."
35
+ end
36
+ end
37
+
38
+ def local_provider?
39
+ return false unless @base_url
40
+
41
+ @base_url.match?(/localhost|127\.0\.0\.1|0\.0\.0\.0/)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Translates Anthropic-format messages to OpenAI Chat Completions format.
7
+ #
8
+ # Anthropic uses content blocks (tool_result, tool_use) inside message arrays,
9
+ # while OpenAI uses separate message roles and tool_calls arrays.
10
+ module OpenAIMessageTranslator
11
+ private
12
+
13
+ def build_messages(messages, system)
14
+ result = []
15
+ result << { role: 'system', content: system } if system
16
+ messages.each do |msg|
17
+ translated = translate_message(msg)
18
+ translated.is_a?(Array) ? result.concat(translated) : result.push(translated)
19
+ end
20
+ result
21
+ end
22
+
23
+ def translate_message(msg)
24
+ content = msg[:content] || msg['content']
25
+ role = msg[:role] || msg['role']
26
+
27
+ return translate_tool_results(content) if tool_results?(content)
28
+ return translate_assistant_tool_use(content) if role == 'assistant' && tool_use_blocks?(content)
29
+
30
+ { role: role, content: stringify_content(content) }
31
+ end
32
+
33
+ def tool_results?(content)
34
+ content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_result' }
35
+ end
36
+
37
+ def tool_use_blocks?(content)
38
+ content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_use' }
39
+ end
40
+
41
+ def translate_tool_results(content_blocks)
42
+ content_blocks.select { |b| block_type(b) == 'tool_result' }.map do |block|
43
+ tool_use_id = block[:tool_use_id] || block['tool_use_id']
44
+ text = stringify_content(block[:content] || block['content'])
45
+ { role: 'tool', tool_call_id: tool_use_id, content: text }
46
+ end
47
+ end
48
+
49
+ def translate_assistant_tool_use(content_blocks)
50
+ text_blocks, tool_blocks = partition_assistant_blocks(content_blocks)
51
+ msg = { role: 'assistant' }
52
+ msg[:content] = stringify_content(text_blocks) unless text_blocks.empty?
53
+ msg[:tool_calls] = tool_blocks.map { |b| build_tool_call_hash(b) } unless tool_blocks.empty?
54
+ msg
55
+ end
56
+
57
+ def partition_assistant_blocks(content_blocks)
58
+ texts, tools = content_blocks.partition { |b| block_type(b) != 'tool_use' }
59
+ [texts, tools]
60
+ end
61
+
62
+ def build_tool_call_hash(block)
63
+ input = block[:input] || block['input'] || {}
64
+ {
65
+ id: block[:id] || block['id'],
66
+ type: 'function',
67
+ function: {
68
+ name: block[:name] || block['name'],
69
+ arguments: input.is_a?(String) ? input : JSON.generate(input)
70
+ }
71
+ }
72
+ end
73
+
74
+ def stringify_content(content)
75
+ case content
76
+ when String then content
77
+ when Array
78
+ content.map { |b| b[:text] || b['text'] || b.to_s }.join
79
+ else
80
+ content.to_s
81
+ end
82
+ end
83
+
84
+ def block_type(block)
85
+ block[:type] || block['type']
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end