rubyn-code 0.5.1 → 0.8.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 +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- 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/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- 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/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- 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 +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- 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/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- 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 +9 -4
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- 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/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
require 'securerandom'
|
|
6
5
|
require_relative '../message_builder'
|
|
@@ -8,6 +7,7 @@ require_relative '../message_builder'
|
|
|
8
7
|
module RubynCode
|
|
9
8
|
module LLM
|
|
10
9
|
module Adapters
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- auth helpers + streaming + finalize are all needed
|
|
11
11
|
class Anthropic < Base
|
|
12
12
|
include JsonParsing
|
|
13
13
|
include PromptCaching
|
|
@@ -17,12 +17,33 @@ module RubynCode
|
|
|
17
17
|
MAX_RETRIES = 3
|
|
18
18
|
RETRY_DELAYS = [2, 5, 10].freeze
|
|
19
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
|
+
|
|
20
31
|
AVAILABLE_MODELS = %w[
|
|
21
|
-
claude-
|
|
32
|
+
claude-fable-5
|
|
33
|
+
claude-opus-5
|
|
34
|
+
claude-opus-4-8
|
|
35
|
+
claude-opus-4-7
|
|
22
36
|
claude-opus-4-6
|
|
23
|
-
claude-
|
|
37
|
+
claude-sonnet-5
|
|
38
|
+
claude-sonnet-4-6
|
|
39
|
+
claude-haiku-4-5
|
|
24
40
|
].freeze
|
|
25
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
|
+
|
|
26
47
|
def provider_name
|
|
27
48
|
'anthropic'
|
|
28
49
|
end
|
|
@@ -31,14 +52,16 @@ module RubynCode
|
|
|
31
52
|
AVAILABLE_MODELS
|
|
32
53
|
end
|
|
33
54
|
|
|
34
|
-
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)
|
|
35
57
|
ensure_valid_token!
|
|
36
58
|
use_streaming = on_text && oauth_token?
|
|
37
59
|
|
|
38
60
|
body = build_request_body(
|
|
39
61
|
messages: messages, tools: tools, system: system,
|
|
40
62
|
model: model, max_tokens: max_tokens,
|
|
41
|
-
stream: use_streaming, task_budget: task_budget
|
|
63
|
+
stream: use_streaming, task_budget: task_budget,
|
|
64
|
+
thinking: thinking, effort: effort
|
|
42
65
|
)
|
|
43
66
|
|
|
44
67
|
return stream_request(body, on_text) if use_streaming
|
|
@@ -46,30 +69,73 @@ module RubynCode
|
|
|
46
69
|
execute_with_retries(body, on_text)
|
|
47
70
|
end
|
|
48
71
|
|
|
49
|
-
|
|
72
|
+
# -- Auth helpers ----------------------------------------------
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
# Validate the active auth path before sending the request.
|
|
75
|
+
# Raises Client::AuthExpiredError with a clear message instead
|
|
76
|
+
# of letting the HTTP request fail with a 401.
|
|
77
|
+
def ensure_valid_token!
|
|
78
|
+
return if token_valid? && access_token && !access_token.empty?
|
|
54
79
|
|
|
55
|
-
|
|
80
|
+
return if !oauth_token? && api_key && !api_key.empty?
|
|
56
81
|
|
|
57
|
-
|
|
58
|
-
access_token.include?('sk-ant-oat')
|
|
82
|
+
raise Client::AuthExpiredError, 'No valid authentication configured'
|
|
59
83
|
end
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
# @return [Boolean] true when using the OAuth token (vs API key).
|
|
86
|
+
# A token from the keychain (or marked source: :keychain /
|
|
87
|
+
# :oauth) is treated as OAuth; a token from :env or
|
|
88
|
+
# :tokens_file is treated as a long-lived API key.
|
|
89
|
+
def oauth_token?
|
|
90
|
+
token = raw_access_token
|
|
91
|
+
return false unless token.is_a?(Hash)
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
source = token[:source] || token['source']
|
|
94
|
+
[:keychain, 'keychain', :oauth].include?(source)
|
|
66
95
|
end
|
|
67
96
|
|
|
97
|
+
# @return [String, nil] the access token in use.
|
|
98
|
+
# Accepts either a raw String token or a Hash like
|
|
99
|
+
# { access_token: "...", expires_at: ..., source: :keychain }.
|
|
100
|
+
# Memoized per-instance so that repeated calls during one
|
|
101
|
+
# request don't re-hit the token store.
|
|
68
102
|
def access_token
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
return @access_token if defined?(@access_token)
|
|
104
|
+
|
|
105
|
+
token = raw_access_token
|
|
106
|
+
@access_token =
|
|
107
|
+
case token
|
|
108
|
+
when nil then nil
|
|
109
|
+
when String then token.empty? ? nil : token
|
|
110
|
+
when Hash then token[:access_token] || token['access_token']
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [String, nil] the API key (ANTHROPIC_API_KEY), or nil
|
|
115
|
+
def api_key
|
|
116
|
+
ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Boolean] true if the token store says the active
|
|
120
|
+
# credential is still valid
|
|
121
|
+
def token_valid?
|
|
122
|
+
return true unless defined?(Auth::TokenStore)
|
|
123
|
+
return true unless Auth::TokenStore.respond_to?(:valid?)
|
|
124
|
+
|
|
125
|
+
Auth::TokenStore.valid?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def raw_access_token
|
|
129
|
+
return @raw_access_token if defined?(@raw_access_token)
|
|
130
|
+
|
|
131
|
+
@raw_access_token =
|
|
132
|
+
(Auth::TokenStore.load if defined?(Auth::TokenStore) && Auth::TokenStore.respond_to?(:load))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
71
136
|
|
|
72
|
-
|
|
137
|
+
def api_url
|
|
138
|
+
API_URL
|
|
73
139
|
end
|
|
74
140
|
|
|
75
141
|
# -- Request execution --------------------------------------------
|
|
@@ -93,7 +159,7 @@ module RubynCode
|
|
|
93
159
|
|
|
94
160
|
def post_request(body)
|
|
95
161
|
connection.post(api_url) do |req|
|
|
96
|
-
apply_headers(req)
|
|
162
|
+
apply_headers(req, body)
|
|
97
163
|
req.body = JSON.generate(body)
|
|
98
164
|
end
|
|
99
165
|
end
|
|
@@ -118,7 +184,7 @@ module RubynCode
|
|
|
118
184
|
error_chunks = []
|
|
119
185
|
|
|
120
186
|
response = streaming_connection.post(api_url) do |req|
|
|
121
|
-
apply_headers(req)
|
|
187
|
+
apply_headers(req, body)
|
|
122
188
|
req.body = JSON.generate(body)
|
|
123
189
|
req.options.on_data = on_data_proc(streamer, error_chunks)
|
|
124
190
|
end
|
|
@@ -167,6 +233,7 @@ module RubynCode
|
|
|
167
233
|
end
|
|
168
234
|
|
|
169
235
|
def build_faraday_connection
|
|
236
|
+
require 'faraday'
|
|
170
237
|
Faraday.new do |f|
|
|
171
238
|
f.options.timeout = 300
|
|
172
239
|
f.options.open_timeout = 30
|
|
@@ -176,23 +243,35 @@ module RubynCode
|
|
|
176
243
|
|
|
177
244
|
# -- Headers ------------------------------------------------------
|
|
178
245
|
|
|
179
|
-
def apply_headers(req)
|
|
246
|
+
def apply_headers(req, body = {})
|
|
180
247
|
req.headers['Content-Type'] = 'application/json'
|
|
181
248
|
req.headers['anthropic-version'] = ANTHROPIC_VERSION
|
|
182
|
-
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)
|
|
183
250
|
end
|
|
184
251
|
|
|
185
|
-
def apply_oauth_headers(req)
|
|
252
|
+
def apply_oauth_headers(req, body)
|
|
186
253
|
req.headers['Authorization'] = "Bearer #{access_token}"
|
|
187
|
-
|
|
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(',')
|
|
188
259
|
req.headers['x-app'] = 'cli'
|
|
189
260
|
req.headers['User-Agent'] = 'claude-code/2.1.79'
|
|
190
261
|
req.headers['X-Claude-Code-Session-Id'] = session_id
|
|
191
262
|
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
|
192
263
|
end
|
|
193
264
|
|
|
194
|
-
def apply_api_key_headers(req)
|
|
265
|
+
def apply_api_key_headers(req, body)
|
|
195
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)
|
|
196
275
|
end
|
|
197
276
|
|
|
198
277
|
def session_id
|
|
@@ -201,15 +280,70 @@ module RubynCode
|
|
|
201
280
|
|
|
202
281
|
# -- Request body -------------------------------------------------
|
|
203
282
|
|
|
204
|
-
def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:,
|
|
205
|
-
|
|
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)
|
|
206
289
|
apply_system_blocks(body, system)
|
|
207
290
|
apply_tool_cache(body, tools)
|
|
291
|
+
apply_fallbacks(body, model)
|
|
208
292
|
body[:messages] = add_message_cache_breakpoint(messages)
|
|
209
293
|
body[:stream] = true if stream
|
|
210
294
|
body
|
|
211
295
|
end
|
|
212
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
|
+
|
|
213
347
|
# -- Response parsing ---------------------------------------------
|
|
214
348
|
|
|
215
349
|
def handle_api_response(response)
|
|
@@ -245,13 +379,17 @@ module RubynCode
|
|
|
245
379
|
def build_api_response(body)
|
|
246
380
|
content = parse_content_blocks(body['content'])
|
|
247
381
|
usage = parse_usage(body['usage'])
|
|
248
|
-
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
|
+
)
|
|
249
386
|
end
|
|
250
387
|
|
|
251
388
|
def parse_content_blocks(blocks)
|
|
252
389
|
(blocks || []).filter_map do |block|
|
|
253
390
|
case block['type']
|
|
254
391
|
when 'text' then TextBlock.new(text: block['text'])
|
|
392
|
+
when 'thinking' then ThinkingBlock.new(text: block['thinking'] || block['text'])
|
|
255
393
|
when 'tool_use'
|
|
256
394
|
ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
|
|
257
395
|
end
|
|
@@ -270,5 +408,6 @@ module RubynCode
|
|
|
270
408
|
end
|
|
271
409
|
end
|
|
272
410
|
end
|
|
411
|
+
# rubocop:enable Metrics/ClassLength
|
|
273
412
|
end
|
|
274
413
|
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
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'faraday'
|
|
4
3
|
require 'json'
|
|
5
4
|
require_relative '../message_builder'
|
|
6
5
|
|
|
@@ -31,7 +30,7 @@ module RubynCode
|
|
|
31
30
|
AVAILABLE_MODELS
|
|
32
31
|
end
|
|
33
32
|
|
|
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
|
|
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
|
|
35
34
|
body = build_request_body(
|
|
36
35
|
messages: messages, model: model, max_tokens: max_tokens,
|
|
37
36
|
tools: tools, system: system
|
|
@@ -141,6 +140,7 @@ module RubynCode
|
|
|
141
140
|
end
|
|
142
141
|
|
|
143
142
|
def build_faraday_connection
|
|
143
|
+
require 'faraday'
|
|
144
144
|
Faraday.new do |f|
|
|
145
145
|
f.options.timeout = 300
|
|
146
146
|
f.options.open_timeout = 30
|
|
@@ -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
|
|
@@ -36,7 +36,11 @@ module RubynCode
|
|
|
36
36
|
def add_message_cache_breakpoint(messages)
|
|
37
37
|
return messages if messages.nil? || messages.empty?
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# Only the last message gets the cache_control tag, so only it
|
|
40
|
+
# needs to be duped — copying the whole history every call is
|
|
41
|
+
# wasted work on long conversations.
|
|
42
|
+
tagged = messages.dup
|
|
43
|
+
tagged[-1] = tagged[-1].dup
|
|
40
44
|
tag_last_message_content(tagged.last)
|
|
41
45
|
tagged
|
|
42
46
|
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
|
|
@@ -6,6 +6,14 @@ module RubynCode
|
|
|
6
6
|
def type = 'text'
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
ThinkingBlock = Data.define(:text) do
|
|
10
|
+
def type = 'thinking'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ImageBlock = Data.define(:media_type, :data) do
|
|
14
|
+
def type = 'image'
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
ToolUseBlock = Data.define(:id, :name, :input) do
|
|
10
18
|
def type = 'tool_use'
|
|
11
19
|
end
|
|
@@ -24,7 +32,11 @@ module RubynCode
|
|
|
24
32
|
end
|
|
25
33
|
end
|
|
26
34
|
|
|
27
|
-
Response = Data.define(:id, :content, :stop_reason, :usage) do
|
|
35
|
+
Response = Data.define(:id, :content, :stop_reason, :stop_details, :usage) do
|
|
36
|
+
def initialize(id:, content:, stop_reason:, usage:, stop_details: nil)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
def text
|
|
29
41
|
content.select { |b| b.type == 'text' }.map(&:text).join
|
|
30
42
|
end
|
|
@@ -36,6 +48,10 @@ module RubynCode
|
|
|
36
48
|
def tool_use?
|
|
37
49
|
stop_reason == 'tool_use'
|
|
38
50
|
end
|
|
51
|
+
|
|
52
|
+
def refusal?
|
|
53
|
+
stop_reason == 'refusal'
|
|
54
|
+
end
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
class MessageBuilder
|
|
@@ -102,6 +118,10 @@ module RubynCode
|
|
|
102
118
|
case block
|
|
103
119
|
when TextBlock
|
|
104
120
|
{ type: 'text', text: block.text }
|
|
121
|
+
when ThinkingBlock
|
|
122
|
+
{ type: 'thinking', text: block.text }
|
|
123
|
+
when ImageBlock
|
|
124
|
+
{ type: 'image', source: { type: 'base64', media_type: block.media_type, data: block.data } }
|
|
105
125
|
when ToolUseBlock
|
|
106
126
|
{ type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
107
127
|
when ToolResultBlock
|
|
@@ -13,8 +13,8 @@ module RubynCode
|
|
|
13
13
|
# env_key: ANTHROPIC_API_KEY
|
|
14
14
|
# models:
|
|
15
15
|
# cheap: claude-haiku-4-5
|
|
16
|
-
# mid: claude-sonnet-
|
|
17
|
-
# top: claude-opus-
|
|
16
|
+
# mid: claude-sonnet-5
|
|
17
|
+
# top: claude-opus-5
|
|
18
18
|
# openai:
|
|
19
19
|
# env_key: OPENAI_API_KEY
|
|
20
20
|
# models:
|
|
@@ -46,11 +46,11 @@ module RubynCode
|
|
|
46
46
|
%w[openai gpt-5.4-nano]
|
|
47
47
|
].freeze,
|
|
48
48
|
mid: [
|
|
49
|
-
%w[anthropic claude-sonnet-
|
|
49
|
+
%w[anthropic claude-sonnet-5],
|
|
50
50
|
%w[openai gpt-5.4-mini]
|
|
51
51
|
].freeze,
|
|
52
52
|
top: [
|
|
53
|
-
%w[anthropic claude-opus-
|
|
53
|
+
%w[anthropic claude-opus-5],
|
|
54
54
|
%w[openai gpt-5.4]
|
|
55
55
|
].freeze
|
|
56
56
|
}.freeze
|