rubyn-code 0.7.0 → 0.9.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 +45 -17
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +3 -3
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +6 -1
- data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +14 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +11 -7
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -14,6 +14,9 @@ module RubynCode
|
|
|
14
14
|
INDEX_DIR = '.rubyn-code'
|
|
15
15
|
INDEX_FILE = 'codebase_index.json'
|
|
16
16
|
CHARS_PER_TOKEN = 4
|
|
17
|
+
# Bump when the stored shape changes so stale indexes rebuild instead
|
|
18
|
+
# of silently serving degraded data. 2 = Prism spans + call edges.
|
|
19
|
+
FORMAT_VERSION = 2
|
|
17
20
|
|
|
18
21
|
attr_reader :nodes, :edges, :index_path
|
|
19
22
|
|
|
@@ -33,6 +36,7 @@ module RubynCode
|
|
|
33
36
|
|
|
34
37
|
ruby_files.each { |file| index_file(file) }
|
|
35
38
|
extract_rails_edges
|
|
39
|
+
prune_call_edges!
|
|
36
40
|
save!
|
|
37
41
|
self
|
|
38
42
|
end
|
|
@@ -42,6 +46,8 @@ module RubynCode
|
|
|
42
46
|
return nil unless File.exist?(@index_path)
|
|
43
47
|
|
|
44
48
|
data = JSON.parse(File.read(@index_path))
|
|
49
|
+
return nil unless data['format_version'] == FORMAT_VERSION
|
|
50
|
+
|
|
45
51
|
@nodes = data['nodes'] || []
|
|
46
52
|
# uniq drops duplicate edges accumulated by older versions, which
|
|
47
53
|
# appended tests edges on every update! without dedup.
|
|
@@ -68,6 +74,7 @@ module RubynCode
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
extract_rails_edges
|
|
77
|
+
prune_call_edges!
|
|
71
78
|
save!
|
|
72
79
|
self
|
|
73
80
|
end
|
|
@@ -81,6 +88,7 @@ module RubynCode
|
|
|
81
88
|
remove_nodes_for(absolute)
|
|
82
89
|
index_file(absolute) if File.exist?(absolute)
|
|
83
90
|
extract_rails_edges
|
|
91
|
+
prune_call_edges!
|
|
84
92
|
save!
|
|
85
93
|
self
|
|
86
94
|
end
|
|
@@ -237,14 +245,66 @@ module RubynCode
|
|
|
237
245
|
content = File.read(file)
|
|
238
246
|
@file_mtimes[relative] = File.mtime(file).to_i
|
|
239
247
|
|
|
240
|
-
|
|
241
|
-
extract_methods(content, relative)
|
|
248
|
+
extract_symbols(content, relative)
|
|
242
249
|
extract_associations(content, relative)
|
|
243
250
|
extract_rails_patterns(content, relative)
|
|
244
251
|
rescue StandardError => e
|
|
245
252
|
RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
|
|
246
253
|
end
|
|
247
254
|
|
|
255
|
+
# Prism gives real line spans (needed for verbatim source in code_graph)
|
|
256
|
+
# and call edges. Files that don't parse fall back to the regex pass,
|
|
257
|
+
# which produces the same node shapes minus end_line/owner/calls.
|
|
258
|
+
def extract_symbols(content, file)
|
|
259
|
+
extracted = PrismExtractor.extract(content)
|
|
260
|
+
unless extracted
|
|
261
|
+
extract_classes(content, file)
|
|
262
|
+
extract_methods(content, file)
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
add_class_nodes(extracted.classes, file)
|
|
267
|
+
add_method_nodes(extracted.defs, file)
|
|
268
|
+
add_call_edges(extracted.calls, file)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def add_class_nodes(classes, file)
|
|
272
|
+
classes.each do |c|
|
|
273
|
+
@nodes << {
|
|
274
|
+
'type' => classify_node(file, c[:kind]), 'name' => c[:name],
|
|
275
|
+
'file' => file, 'line' => c[:line], 'end_line' => c[:end_line]
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def add_method_nodes(methods, file)
|
|
281
|
+
methods.each do |m|
|
|
282
|
+
@nodes << {
|
|
283
|
+
'type' => 'method', 'name' => m[:name], 'file' => file,
|
|
284
|
+
'line' => m[:line], 'end_line' => m[:end_line],
|
|
285
|
+
'owner' => m[:owner], 'params' => m[:params], 'visibility' => 'public'
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# `from` stays the file path so remove_nodes_for's from-based cleanup
|
|
291
|
+
# applies to call edges too; the calling method rides in from_method.
|
|
292
|
+
def add_call_edges(calls, file)
|
|
293
|
+
calls.each do |c|
|
|
294
|
+
@edges << {
|
|
295
|
+
'from' => file, 'from_method' => c[:from], 'to' => c[:to],
|
|
296
|
+
'relationship' => 'calls', 'line' => c[:line]
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Drop call edges whose target isn't defined in the project — filters
|
|
302
|
+
# out stdlib/gem calls (puts, map, ...) that would swamp the graph.
|
|
303
|
+
def prune_call_edges!
|
|
304
|
+
defined_methods = @nodes.filter_map { |n| n['name'] if n['type'] == 'method' }.to_set
|
|
305
|
+
@edges.reject! { |e| e['relationship'] == 'calls' && !defined_methods.include?(e['to']) }
|
|
306
|
+
end
|
|
307
|
+
|
|
248
308
|
def extract_classes(content, file)
|
|
249
309
|
content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
|
|
250
310
|
node_type = classify_node(file, type)
|
|
@@ -342,7 +402,8 @@ module RubynCode
|
|
|
342
402
|
|
|
343
403
|
def save!
|
|
344
404
|
FileUtils.mkdir_p(File.dirname(@index_path))
|
|
345
|
-
data = { '
|
|
405
|
+
data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
|
|
406
|
+
'file_mtimes' => @file_mtimes }
|
|
346
407
|
File.write(@index_path, JSON.generate(data))
|
|
347
408
|
end
|
|
348
409
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Index
|
|
7
|
+
# Prism-based symbol and call extractor for a single Ruby file.
|
|
8
|
+
# Returns classes/modules and methods with real line spans, plus
|
|
9
|
+
# method-to-method call sites the regex pass can't see. Used by
|
|
10
|
+
# CodebaseIndex, with the regex extractors as a parse-error fallback.
|
|
11
|
+
class PrismExtractor < Prism::Visitor
|
|
12
|
+
Result = Struct.new(:classes, :defs, :calls)
|
|
13
|
+
|
|
14
|
+
# @return [Result, nil] nil when the file doesn't parse
|
|
15
|
+
def self.extract(content)
|
|
16
|
+
parsed = Prism.parse(content)
|
|
17
|
+
return nil unless parsed.success?
|
|
18
|
+
|
|
19
|
+
visitor = new
|
|
20
|
+
visitor.visit(parsed.value)
|
|
21
|
+
Result.new(visitor.classes, visitor.defs, visitor.calls)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :classes, :defs, :calls
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
super
|
|
30
|
+
@namespace = []
|
|
31
|
+
@current_def = nil
|
|
32
|
+
@classes = [] # { name:, kind:, line:, end_line: }
|
|
33
|
+
@defs = [] # { name:, owner:, line:, end_line:, params: }
|
|
34
|
+
@calls = [] # { from:, to:, line: }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_class_node(node)
|
|
38
|
+
record_namespace(node, 'class') { super }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visit_module_node(node)
|
|
42
|
+
record_namespace(node, 'module') { super }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def visit_def_node(node)
|
|
46
|
+
@defs << {
|
|
47
|
+
name: node.name.to_s,
|
|
48
|
+
owner: @namespace.join('::'),
|
|
49
|
+
line: node.location.start_line,
|
|
50
|
+
end_line: node.location.end_line,
|
|
51
|
+
params: node.parameters ? "(#{node.parameters.slice})" : nil
|
|
52
|
+
}
|
|
53
|
+
previous = @current_def
|
|
54
|
+
@current_def = node.name.to_s
|
|
55
|
+
super
|
|
56
|
+
@current_def = previous
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ponytail: resolution is by method name, not receiver type — a call to
|
|
60
|
+
# `user.save` links to any `save` defined in the project. Real receiver
|
|
61
|
+
# inference needs type analysis; name matching covers the common case.
|
|
62
|
+
def visit_call_node(node)
|
|
63
|
+
@calls << { from: @current_def, to: node.name.to_s, line: node.location.start_line } if @current_def
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The block continues the walk into child nodes (the caller's `super`).
|
|
70
|
+
def record_namespace(node, kind)
|
|
71
|
+
name = node.constant_path.slice
|
|
72
|
+
@classes << {
|
|
73
|
+
name: name, kind: kind,
|
|
74
|
+
line: node.location.start_line, end_line: node.location.end_line
|
|
75
|
+
}
|
|
76
|
+
@namespace.push(name)
|
|
77
|
+
yield
|
|
78
|
+
@namespace.pop
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -112,8 +112,7 @@ module RubynCode
|
|
|
112
112
|
tag_set = tags.to_set(&:downcase)
|
|
113
113
|
|
|
114
114
|
instincts.select do |inst|
|
|
115
|
-
|
|
116
|
-
inst_tags.any? { |t| tag_set.include?(t) }
|
|
115
|
+
inst.context_tags.any? { |t| tag_set.include?(t.downcase) }
|
|
117
116
|
end
|
|
118
117
|
end
|
|
119
118
|
|
|
@@ -11,20 +11,39 @@ module RubynCode
|
|
|
11
11
|
class Anthropic < Base
|
|
12
12
|
include JsonParsing
|
|
13
13
|
include PromptCaching
|
|
14
|
-
include TokenCaching
|
|
15
14
|
|
|
16
15
|
API_URL = 'https://api.anthropic.com/v1/messages'
|
|
17
16
|
ANTHROPIC_VERSION = '2023-06-01'
|
|
18
17
|
MAX_RETRIES = 3
|
|
19
18
|
RETRY_DELAYS = [2, 5, 10].freeze
|
|
20
19
|
|
|
20
|
+
# Fable/Mythos/Opus 5 models opt into a server-side Opus 4.8 fallback
|
|
21
|
+
# by default: a declined request (safety-classifier refusal) is
|
|
22
|
+
# transparently re-served by the fallback model in the same call.
|
|
23
|
+
# No config toggle — hardcoded for these model families only.
|
|
24
|
+
FALLBACK_ELIGIBLE_MODELS = /\Aclaude-(fable|mythos|opus-5)/
|
|
25
|
+
FALLBACK_BETA = 'server-side-fallback-2026-06-01'
|
|
26
|
+
|
|
27
|
+
TASK_BUDGET_BETA = 'task-budgets-2026-03-13'
|
|
28
|
+
TASK_BUDGET_MIN_TOKENS = 20_000 # API minimum; below this, task_budget is omitted entirely
|
|
29
|
+
TASK_BUDGET_MODELS = /\Aclaude-(fable|mythos|sonnet-5|opus-5|opus-4-[78])/
|
|
30
|
+
|
|
21
31
|
AVAILABLE_MODELS = %w[
|
|
22
32
|
claude-fable-5
|
|
33
|
+
claude-opus-5
|
|
23
34
|
claude-opus-4-8
|
|
24
|
-
claude-
|
|
25
|
-
claude-
|
|
35
|
+
claude-opus-4-7
|
|
36
|
+
claude-opus-4-6
|
|
37
|
+
claude-sonnet-5
|
|
38
|
+
claude-sonnet-4-6
|
|
39
|
+
claude-haiku-4-5
|
|
26
40
|
].freeze
|
|
27
41
|
|
|
42
|
+
# Models on the adaptive-thinking API surface (Claude 4.6+).
|
|
43
|
+
# budget_tokens is removed there and returns a 400; older models
|
|
44
|
+
# (Haiku 4.5, Sonnet/Opus 4.5 and earlier) still take enabled + budget.
|
|
45
|
+
ADAPTIVE_THINKING_MODELS = /\Aclaude-(fable|mythos|opus-5|opus-4-[678]|sonnet-5|sonnet-4-6)/
|
|
46
|
+
|
|
28
47
|
def provider_name
|
|
29
48
|
'anthropic'
|
|
30
49
|
end
|
|
@@ -33,14 +52,16 @@ module RubynCode
|
|
|
33
52
|
AVAILABLE_MODELS
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil,
|
|
55
|
+
def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, # rubocop:disable Metrics/ParameterLists -- mirrors LLM adapter interface
|
|
56
|
+
task_budget: nil, thinking: nil, effort: nil)
|
|
37
57
|
ensure_valid_token!
|
|
38
58
|
use_streaming = on_text && oauth_token?
|
|
39
59
|
|
|
40
60
|
body = build_request_body(
|
|
41
61
|
messages: messages, tools: tools, system: system,
|
|
42
62
|
model: model, max_tokens: max_tokens,
|
|
43
|
-
stream: use_streaming, task_budget: task_budget
|
|
63
|
+
stream: use_streaming, task_budget: task_budget,
|
|
64
|
+
thinking: thinking, effort: effort
|
|
44
65
|
)
|
|
45
66
|
|
|
46
67
|
return stream_request(body, on_text) if use_streaming
|
|
@@ -138,7 +159,7 @@ module RubynCode
|
|
|
138
159
|
|
|
139
160
|
def post_request(body)
|
|
140
161
|
connection.post(api_url) do |req|
|
|
141
|
-
apply_headers(req)
|
|
162
|
+
apply_headers(req, body)
|
|
142
163
|
req.body = JSON.generate(body)
|
|
143
164
|
end
|
|
144
165
|
end
|
|
@@ -163,7 +184,7 @@ module RubynCode
|
|
|
163
184
|
error_chunks = []
|
|
164
185
|
|
|
165
186
|
response = streaming_connection.post(api_url) do |req|
|
|
166
|
-
apply_headers(req)
|
|
187
|
+
apply_headers(req, body)
|
|
167
188
|
req.body = JSON.generate(body)
|
|
168
189
|
req.options.on_data = on_data_proc(streamer, error_chunks)
|
|
169
190
|
end
|
|
@@ -189,7 +210,6 @@ module RubynCode
|
|
|
189
210
|
|
|
190
211
|
error_msg = extract_error_message(error_chunks.join)
|
|
191
212
|
|
|
192
|
-
invalidate_token_cache! if response.status == 401
|
|
193
213
|
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
194
214
|
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
195
215
|
|
|
@@ -223,23 +243,35 @@ module RubynCode
|
|
|
223
243
|
|
|
224
244
|
# -- Headers ------------------------------------------------------
|
|
225
245
|
|
|
226
|
-
def apply_headers(req)
|
|
246
|
+
def apply_headers(req, body = {})
|
|
227
247
|
req.headers['Content-Type'] = 'application/json'
|
|
228
248
|
req.headers['anthropic-version'] = ANTHROPIC_VERSION
|
|
229
|
-
oauth_token? ? apply_oauth_headers(req) : apply_api_key_headers(req)
|
|
249
|
+
oauth_token? ? apply_oauth_headers(req, body) : apply_api_key_headers(req, body)
|
|
230
250
|
end
|
|
231
251
|
|
|
232
|
-
def apply_oauth_headers(req)
|
|
252
|
+
def apply_oauth_headers(req, body)
|
|
233
253
|
req.headers['Authorization'] = "Bearer #{access_token}"
|
|
234
|
-
|
|
254
|
+
# The subscription-auth beta must stay present, so feature betas append rather than replace.
|
|
255
|
+
betas = ['oauth-2025-04-20']
|
|
256
|
+
betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
|
|
257
|
+
betas << FALLBACK_BETA if body[:fallbacks]
|
|
258
|
+
req.headers['anthropic-beta'] = betas.join(',')
|
|
235
259
|
req.headers['x-app'] = 'cli'
|
|
236
260
|
req.headers['User-Agent'] = 'claude-code/2.1.79'
|
|
237
261
|
req.headers['X-Claude-Code-Session-Id'] = session_id
|
|
238
262
|
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
|
239
263
|
end
|
|
240
264
|
|
|
241
|
-
def apply_api_key_headers(req)
|
|
265
|
+
def apply_api_key_headers(req, body)
|
|
242
266
|
req.headers['x-api-key'] = access_token
|
|
267
|
+
betas = []
|
|
268
|
+
betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
|
|
269
|
+
betas << FALLBACK_BETA if body[:fallbacks]
|
|
270
|
+
req.headers['anthropic-beta'] = betas.join(',') unless betas.empty?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def task_budget_on_wire?(body)
|
|
274
|
+
!!body&.dig(:output_config, :task_budget)
|
|
243
275
|
end
|
|
244
276
|
|
|
245
277
|
def session_id
|
|
@@ -248,15 +280,70 @@ module RubynCode
|
|
|
248
280
|
|
|
249
281
|
# -- Request body -------------------------------------------------
|
|
250
282
|
|
|
251
|
-
def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:,
|
|
252
|
-
|
|
283
|
+
def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
|
|
284
|
+
thinking: nil, effort: nil, task_budget: nil, **_opts)
|
|
285
|
+
body = { model: model, max_tokens: ensure_max_tokens_for_thinking(max_tokens, thinking) }
|
|
286
|
+
apply_thinking(body, thinking)
|
|
287
|
+
apply_effort(body, effort)
|
|
288
|
+
apply_task_budget(body, task_budget, model)
|
|
253
289
|
apply_system_blocks(body, system)
|
|
254
290
|
apply_tool_cache(body, tools)
|
|
291
|
+
apply_fallbacks(body, model)
|
|
255
292
|
body[:messages] = add_message_cache_breakpoint(messages)
|
|
256
293
|
body[:stream] = true if stream
|
|
257
294
|
body
|
|
258
295
|
end
|
|
259
296
|
|
|
297
|
+
def apply_fallbacks(body, model)
|
|
298
|
+
return unless model.to_s.match?(FALLBACK_ELIGIBLE_MODELS)
|
|
299
|
+
|
|
300
|
+
body[:fallbacks] = [{ model: 'claude-opus-4-8' }]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Advisory pacing signal only — Claude sees a running countdown and paces
|
|
304
|
+
# itself, but max_tokens remains the enforced hard cap per response.
|
|
305
|
+
def apply_task_budget(body, task_budget, model)
|
|
306
|
+
return unless task_budget.is_a?(Hash)
|
|
307
|
+
return unless model.to_s.match?(TASK_BUDGET_MODELS)
|
|
308
|
+
|
|
309
|
+
# `remaining` is what's left for the task, matching what the model should pace against.
|
|
310
|
+
total = task_budget[:remaining].to_i
|
|
311
|
+
return if total < TASK_BUDGET_MIN_TOKENS
|
|
312
|
+
|
|
313
|
+
(body[:output_config] ||= {})[:task_budget] = { type: 'tokens', total: total }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def apply_thinking(body, thinking)
|
|
317
|
+
return unless thinking.is_a?(Hash) && thinking[:budget_tokens].to_i.positive?
|
|
318
|
+
|
|
319
|
+
body[:thinking] =
|
|
320
|
+
if body[:model].to_s.match?(ADAPTIVE_THINKING_MODELS)
|
|
321
|
+
{ type: 'adaptive' }
|
|
322
|
+
else
|
|
323
|
+
{ type: 'enabled', budget_tokens: thinking[:budget_tokens].to_i }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Merges into `output_config` (rather than assigning a whole hash) so
|
|
328
|
+
# this composes with other output_config keys, e.g. task_budget.
|
|
329
|
+
def apply_effort(body, effort)
|
|
330
|
+
return unless effort
|
|
331
|
+
|
|
332
|
+
(body[:output_config] ||= {})[:effort] = effort
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Anthropic requires max_tokens > budget_tokens. When thinking is on,
|
|
336
|
+
# raise max_tokens to budget + 1024 if not already large enough.
|
|
337
|
+
def ensure_max_tokens_for_thinking(max_tokens, thinking)
|
|
338
|
+
return max_tokens unless thinking.is_a?(Hash)
|
|
339
|
+
|
|
340
|
+
budget = thinking[:budget_tokens].to_i
|
|
341
|
+
return max_tokens if budget.zero?
|
|
342
|
+
return max_tokens if max_tokens >= budget + 1024
|
|
343
|
+
|
|
344
|
+
budget + 1024
|
|
345
|
+
end
|
|
346
|
+
|
|
260
347
|
# -- Response parsing ---------------------------------------------
|
|
261
348
|
|
|
262
349
|
def handle_api_response(response)
|
|
@@ -274,7 +361,6 @@ module RubynCode
|
|
|
274
361
|
error_type = body&.dig('error', 'type') || 'api_error'
|
|
275
362
|
|
|
276
363
|
log_api_error(response)
|
|
277
|
-
invalidate_token_cache! if response.status == 401
|
|
278
364
|
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
279
365
|
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
280
366
|
|
|
@@ -293,13 +379,17 @@ module RubynCode
|
|
|
293
379
|
def build_api_response(body)
|
|
294
380
|
content = parse_content_blocks(body['content'])
|
|
295
381
|
usage = parse_usage(body['usage'])
|
|
296
|
-
Response.new(
|
|
382
|
+
Response.new(
|
|
383
|
+
id: body['id'], content: content, stop_reason: body['stop_reason'],
|
|
384
|
+
stop_details: body['stop_details'], usage: usage
|
|
385
|
+
)
|
|
297
386
|
end
|
|
298
387
|
|
|
299
388
|
def parse_content_blocks(blocks)
|
|
300
389
|
(blocks || []).filter_map do |block|
|
|
301
390
|
case block['type']
|
|
302
391
|
when 'text' then TextBlock.new(text: block['text'])
|
|
392
|
+
when 'thinking' then ThinkingBlock.new(text: block['thinking'] || block['text'])
|
|
303
393
|
when 'tool_use'
|
|
304
394
|
ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
|
|
305
395
|
end
|
|
@@ -35,8 +35,10 @@ module RubynCode
|
|
|
35
35
|
@content_blocks = []
|
|
36
36
|
@current_block_index = nil
|
|
37
37
|
@current_text = +''
|
|
38
|
+
@current_thinking_text = +''
|
|
38
39
|
@current_tool_input_json = +''
|
|
39
40
|
@stop_reason = nil
|
|
41
|
+
@stop_details = nil
|
|
40
42
|
@usage = nil
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -51,6 +53,7 @@ module RubynCode
|
|
|
51
53
|
id: @response_id,
|
|
52
54
|
content: @content_blocks.compact,
|
|
53
55
|
stop_reason: @stop_reason,
|
|
56
|
+
stop_details: @stop_details,
|
|
54
57
|
usage: @usage
|
|
55
58
|
)
|
|
56
59
|
end
|
|
@@ -107,6 +110,8 @@ module RubynCode
|
|
|
107
110
|
case block['type']
|
|
108
111
|
when 'text'
|
|
109
112
|
@current_text = +(block['text'] || '')
|
|
113
|
+
when 'thinking'
|
|
114
|
+
@current_thinking_text = +(block['thinking'] || '')
|
|
110
115
|
when 'tool_use'
|
|
111
116
|
@current_tool_id = block['id']
|
|
112
117
|
@current_tool_name = block['name']
|
|
@@ -124,6 +129,10 @@ module RubynCode
|
|
|
124
129
|
text = delta['text'] || ''
|
|
125
130
|
@current_text << text
|
|
126
131
|
emit(:text_delta, { index: data['index'], text: text })
|
|
132
|
+
when 'thinking_delta'
|
|
133
|
+
text = delta['thinking'] || ''
|
|
134
|
+
@current_thinking_text << text
|
|
135
|
+
emit(:thinking_delta, { index: data['index'], text: text })
|
|
127
136
|
when 'input_json_delta'
|
|
128
137
|
json_chunk = delta['partial_json'] || ''
|
|
129
138
|
@current_tool_input_json << json_chunk
|
|
@@ -141,6 +150,7 @@ module RubynCode
|
|
|
141
150
|
def handle_message_delta(data)
|
|
142
151
|
delta = data['delta'] || {}
|
|
143
152
|
@stop_reason = delta['stop_reason'] if delta['stop_reason']
|
|
153
|
+
@stop_details = delta['stop_details'] if delta['stop_details']
|
|
144
154
|
update_output_tokens(data['usage']) if data['usage']
|
|
145
155
|
emit(:message_delta, data)
|
|
146
156
|
end
|
|
@@ -167,6 +177,9 @@ module RubynCode
|
|
|
167
177
|
elsif !@current_text.empty?
|
|
168
178
|
@content_blocks[index] = TextBlock.new(text: @current_text.dup)
|
|
169
179
|
@current_text = +''
|
|
180
|
+
elsif !@current_thinking_text.empty?
|
|
181
|
+
@content_blocks[index] = ThinkingBlock.new(text: @current_thinking_text.dup)
|
|
182
|
+
@current_thinking_text = +''
|
|
170
183
|
end
|
|
171
184
|
end
|
|
172
185
|
|
|
@@ -15,8 +15,9 @@ module RubynCode
|
|
|
15
15
|
# @param system [String, nil] System prompt text
|
|
16
16
|
# @param on_text [Proc, nil] Streaming text callback
|
|
17
17
|
# @param task_budget [Hash, nil] Optional task budget context
|
|
18
|
+
# @param thinking [Hash, nil] Optional {budget_tokens: Integer} for extended reasoning
|
|
18
19
|
# @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
|
+
def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
|
|
20
21
|
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -30,7 +30,7 @@ module RubynCode
|
|
|
30
30
|
AVAILABLE_MODELS
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
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
|
|
33
|
+
def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
|
|
34
34
|
body = build_request_body(
|
|
35
35
|
messages: messages, model: model, max_tokens: max_tokens,
|
|
36
36
|
tools: tools, system: system
|
|
@@ -27,6 +27,10 @@ module RubynCode
|
|
|
27
27
|
return translate_tool_results(content) if tool_results?(content)
|
|
28
28
|
return translate_assistant_tool_use(content) if role == 'assistant' && tool_use_blocks?(content)
|
|
29
29
|
|
|
30
|
+
if content.is_a?(Array) && content.any? { |b| block_type(b) == 'image' }
|
|
31
|
+
return { role: role, content: translate_user_content_with_images(content) }
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
{ role: role, content: stringify_content(content) }
|
|
31
35
|
end
|
|
32
36
|
|
|
@@ -34,6 +38,23 @@ module RubynCode
|
|
|
34
38
|
content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_result' }
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Emit OpenAI multipart content: text blocks stay text, image blocks
|
|
42
|
+
# become {type: 'image_url', image_url: {url: 'data:<media>;base64,...'}}.
|
|
43
|
+
def translate_user_content_with_images(content)
|
|
44
|
+
content.map do |block|
|
|
45
|
+
case block_type(block)
|
|
46
|
+
when 'image'
|
|
47
|
+
source = block[:source] || block['source']
|
|
48
|
+
media = source[:media_type] || source['media_type']
|
|
49
|
+
data = source[:data] || source['data']
|
|
50
|
+
{ type: 'image_url', image_url: { url: "data:#{media};base64,#{data}" } }
|
|
51
|
+
else
|
|
52
|
+
text = block[:text] || block['text'] || ''
|
|
53
|
+
{ type: 'text', text: text }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
37
58
|
def tool_use_blocks?(content)
|
|
38
59
|
content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_use' }
|
|
39
60
|
end
|
|
@@ -15,20 +15,22 @@ module RubynCode
|
|
|
15
15
|
class PromptTooLongError < RequestError; end
|
|
16
16
|
|
|
17
17
|
attr_reader :adapter
|
|
18
|
-
attr_accessor :model
|
|
18
|
+
attr_accessor :model, :thinking_budget_tokens, :effort
|
|
19
19
|
|
|
20
20
|
def initialize(model: nil, provider: nil, adapter: nil)
|
|
21
21
|
settings = Config::Settings.new
|
|
22
22
|
@model = model || settings.model
|
|
23
23
|
@provider = provider || settings.provider
|
|
24
24
|
@adapter = adapter || resolve_adapter(@provider)
|
|
25
|
+
@thinking_budget_tokens = 0
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def chat(messages:, tools: nil, system: nil, model: nil, **opts)
|
|
28
29
|
effective_model = model || @model
|
|
29
30
|
max_tokens = opts[:max_tokens] || Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS
|
|
31
|
+
effective_thinking = opts[:thinking] || current_thinking_hash
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
kwargs = {
|
|
32
34
|
messages: messages,
|
|
33
35
|
tools: tools,
|
|
34
36
|
system: system,
|
|
@@ -36,7 +38,18 @@ module RubynCode
|
|
|
36
38
|
max_tokens: max_tokens,
|
|
37
39
|
on_text: opts[:on_text],
|
|
38
40
|
task_budget: opts[:task_budget]
|
|
39
|
-
|
|
41
|
+
}
|
|
42
|
+
kwargs[:thinking] = effective_thinking if effective_thinking
|
|
43
|
+
kwargs[:effort] = @effort if @effort
|
|
44
|
+
|
|
45
|
+
@adapter.chat(**kwargs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def current_thinking_hash
|
|
49
|
+
budget = @thinking_budget_tokens.to_i
|
|
50
|
+
return nil unless budget.positive?
|
|
51
|
+
|
|
52
|
+
{ budget_tokens: budget }
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
def stream(messages:, tools: nil, system: nil, model: nil,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
# Pulls in ImageBlock so the constant is loaded before ImageReader resolves it.
|
|
7
|
+
module LLM
|
|
8
|
+
autoload :ImageBlock, 'rubyn_code/llm/message_builder'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module LLM
|
|
12
|
+
# Reads image files from disk and returns image content blocks suitable for
|
|
13
|
+
# sending to the LLM as part of a user turn. Supports common raster formats
|
|
14
|
+
# accepted by both Anthropic and OpenAI vision APIs.
|
|
15
|
+
module ImageReader
|
|
16
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
MEDIA_TYPES = {
|
|
19
|
+
'.png' => 'image/png',
|
|
20
|
+
'.jpg' => 'image/jpeg',
|
|
21
|
+
'.jpeg' => 'image/jpeg',
|
|
22
|
+
'.gif' => 'image/gif',
|
|
23
|
+
'.webp' => 'image/webp'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
EXTENSIONS_REGEX = /\.(png|jpe?g|gif|webp)\z/i
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Build a base64 data URI of the form:
|
|
31
|
+
# "data:image/png;base64,iVBORw0KG..."
|
|
32
|
+
# Returns nil for non-image paths or unreadable/oversized files.
|
|
33
|
+
def data_uri(path)
|
|
34
|
+
block = for_path(path)
|
|
35
|
+
return nil unless block
|
|
36
|
+
|
|
37
|
+
"data:#{block.media_type};base64,#{block.data}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [LLM::ImageBlock, nil] nil for non-image / unreadable paths
|
|
41
|
+
def for_path(path)
|
|
42
|
+
ext = File.extname(path)
|
|
43
|
+
media = MEDIA_TYPES[ext.downcase] || MEDIA_TYPES[".#{ext.sub(/^\./, '').downcase}"]
|
|
44
|
+
return nil unless media
|
|
45
|
+
return nil unless File.file?(path)
|
|
46
|
+
|
|
47
|
+
bytes = File.binread(path)
|
|
48
|
+
return nil if bytes.bytesize > MAX_BYTES
|
|
49
|
+
|
|
50
|
+
ImageBlock.new(media_type: media, data: Base64.strict_encode64(bytes))
|
|
51
|
+
rescue Errno::ENOENT, Errno::EACCES, ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def image_extension?(path)
|
|
56
|
+
path.to_s.match?(EXTENSIONS_REGEX)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|