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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|