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
|
@@ -38,7 +38,7 @@ module RubynCode
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
module InstinctMethods
|
|
41
|
+
module InstinctMethods # rubocop:disable Metrics/ModuleLength -- instinct CRUD + decay logic with DB operations
|
|
42
42
|
# The minimum confidence threshold below which instincts are considered stale.
|
|
43
43
|
MIN_CONFIDENCE = 0.05
|
|
44
44
|
|
|
@@ -75,26 +75,26 @@ module RubynCode
|
|
|
75
75
|
# @param helpful [Boolean] whether the instinct was helpful this time
|
|
76
76
|
# @return [Instinct] a new instinct with updated confidence and counters
|
|
77
77
|
def reinforce(instinct, helpful: true)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if helpful
|
|
81
|
-
new_helpful = instinct.times_helpful + 1
|
|
82
|
-
boost = 0.1 * (1.0 - instinct.confidence) # Diminishing returns
|
|
83
|
-
new_confidence = (instinct.confidence + boost).clamp(0.0, 1.0)
|
|
84
|
-
else
|
|
85
|
-
new_helpful = instinct.times_helpful
|
|
86
|
-
penalty = 0.15 * instinct.confidence # Proportional penalty
|
|
87
|
-
new_confidence = (instinct.confidence - penalty).clamp(MIN_CONFIDENCE, 1.0)
|
|
88
|
-
end
|
|
78
|
+
new_confidence, new_helpful = compute_reinforcement(instinct, helpful)
|
|
89
79
|
|
|
90
80
|
instinct.with(
|
|
91
81
|
confidence: new_confidence,
|
|
92
|
-
times_applied:
|
|
82
|
+
times_applied: instinct.times_applied + 1,
|
|
93
83
|
times_helpful: new_helpful,
|
|
94
84
|
updated_at: Time.now
|
|
95
85
|
)
|
|
96
86
|
end
|
|
97
87
|
|
|
88
|
+
def compute_reinforcement(instinct, helpful)
|
|
89
|
+
if helpful
|
|
90
|
+
boost = 0.1 * (1.0 - instinct.confidence)
|
|
91
|
+
[(instinct.confidence + boost).clamp(0.0, 1.0), instinct.times_helpful + 1]
|
|
92
|
+
else
|
|
93
|
+
penalty = 0.15 * instinct.confidence
|
|
94
|
+
[(instinct.confidence - penalty).clamp(MIN_CONFIDENCE, 1.0), instinct.times_helpful]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
98
|
# Returns a human-readable label for a confidence score.
|
|
99
99
|
#
|
|
100
100
|
# @param score [Float] the confidence score (0.0 to 1.0)
|
|
@@ -119,20 +119,41 @@ module RubynCode
|
|
|
119
119
|
now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
120
120
|
|
|
121
121
|
if helpful
|
|
122
|
-
db
|
|
123
|
-
'UPDATE instincts SET confidence = MIN(1.0, confidence + 0.1 * (1.0 - confidence)), times_applied = times_applied + 1, times_helpful = times_helpful + 1, updated_at = ? WHERE id = ?',
|
|
124
|
-
[now, instinct_id]
|
|
125
|
-
)
|
|
122
|
+
reinforce_positive(db, instinct_id, now)
|
|
126
123
|
else
|
|
127
|
-
db
|
|
128
|
-
"UPDATE instincts SET confidence = MAX(#{MIN_CONFIDENCE}, confidence - 0.15 * confidence), times_applied = times_applied + 1, updated_at = ? WHERE id = ?",
|
|
129
|
-
[now, instinct_id]
|
|
130
|
-
)
|
|
124
|
+
reinforce_negative(db, instinct_id, now)
|
|
131
125
|
end
|
|
132
126
|
rescue StandardError => e
|
|
133
127
|
warn "[Learning::InstinctMethods] Failed to reinforce instinct #{instinct_id}: #{e.message}"
|
|
134
128
|
end
|
|
135
129
|
|
|
130
|
+
def reinforce_positive(db, instinct_id, now)
|
|
131
|
+
db.execute(
|
|
132
|
+
<<~SQL.tr("\n", ' ').strip,
|
|
133
|
+
UPDATE instincts
|
|
134
|
+
SET confidence = MIN(1.0, confidence + 0.1 * (1.0 - confidence)),
|
|
135
|
+
times_applied = times_applied + 1,
|
|
136
|
+
times_helpful = times_helpful + 1,
|
|
137
|
+
updated_at = ?
|
|
138
|
+
WHERE id = ?
|
|
139
|
+
SQL
|
|
140
|
+
[now, instinct_id]
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reinforce_negative(db, instinct_id, now)
|
|
145
|
+
db.execute(
|
|
146
|
+
<<~SQL.tr("\n", ' ').strip,
|
|
147
|
+
UPDATE instincts
|
|
148
|
+
SET confidence = MAX(#{MIN_CONFIDENCE}, confidence - 0.15 * confidence),
|
|
149
|
+
times_applied = times_applied + 1,
|
|
150
|
+
updated_at = ?
|
|
151
|
+
WHERE id = ?
|
|
152
|
+
SQL
|
|
153
|
+
[now, instinct_id]
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
136
157
|
# Applies time-based decay to all instincts in the database for a given
|
|
137
158
|
# project, removing any that fall below minimum confidence.
|
|
138
159
|
#
|
|
@@ -146,30 +167,38 @@ module RubynCode
|
|
|
146
167
|
).to_a
|
|
147
168
|
|
|
148
169
|
now = Time.now
|
|
149
|
-
rows.each
|
|
150
|
-
updated_at = begin
|
|
151
|
-
Time.parse(row['updated_at'].to_s)
|
|
152
|
-
rescue StandardError
|
|
153
|
-
Time.now
|
|
154
|
-
end
|
|
155
|
-
elapsed_days = (now - updated_at).to_f / 86_400
|
|
156
|
-
next if elapsed_days <= 0
|
|
157
|
-
|
|
158
|
-
decay_factor = Math.exp(-row['decay_rate'].to_f * elapsed_days)
|
|
159
|
-
new_confidence = (row['confidence'].to_f * decay_factor).clamp(MIN_CONFIDENCE, 1.0)
|
|
160
|
-
|
|
161
|
-
if new_confidence <= MIN_CONFIDENCE
|
|
162
|
-
db.execute('DELETE FROM instincts WHERE id = ?', [row['id']])
|
|
163
|
-
else
|
|
164
|
-
db.execute(
|
|
165
|
-
'UPDATE instincts SET confidence = ? WHERE id = ?',
|
|
166
|
-
[new_confidence, row['id']]
|
|
167
|
-
)
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
+
rows.each { |row| decay_single_row(db, row, now) }
|
|
170
171
|
rescue StandardError => e
|
|
171
172
|
warn "[Learning::InstinctMethods] Failed to decay instincts: #{e.message}"
|
|
172
173
|
end
|
|
174
|
+
|
|
175
|
+
def decay_single_row(db, row, now)
|
|
176
|
+
elapsed_days = compute_elapsed_days(row, now)
|
|
177
|
+
return if elapsed_days <= 0
|
|
178
|
+
|
|
179
|
+
new_confidence = compute_decayed_confidence(row, elapsed_days)
|
|
180
|
+
apply_decay_to_db(db, row['id'], new_confidence)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def compute_elapsed_days(row, now)
|
|
184
|
+
updated_at = Time.parse(row['updated_at'].to_s)
|
|
185
|
+
(now - updated_at).to_f / 86_400
|
|
186
|
+
rescue StandardError
|
|
187
|
+
0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def compute_decayed_confidence(row, elapsed_days)
|
|
191
|
+
decay_factor = Math.exp(-row['decay_rate'].to_f * elapsed_days)
|
|
192
|
+
(row['confidence'].to_f * decay_factor).clamp(MIN_CONFIDENCE, 1.0)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply_decay_to_db(db, instinct_id, new_confidence)
|
|
196
|
+
if new_confidence <= MIN_CONFIDENCE
|
|
197
|
+
db.execute('DELETE FROM instincts WHERE id = ?', [instinct_id])
|
|
198
|
+
else
|
|
199
|
+
db.execute('UPDATE instincts SET confidence = ? WHERE id = ?', [new_confidence, instinct_id])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
173
202
|
end
|
|
174
203
|
end
|
|
175
204
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Learning
|
|
5
|
+
# Uses learned instincts to skip redundant discovery steps. For example,
|
|
6
|
+
# if we know the project uses FactoryBot, skip checking test_helper.rb
|
|
7
|
+
# and looking for factories/ — just generate FactoryBot-style specs.
|
|
8
|
+
class Shortcut
|
|
9
|
+
SHORTCUT_RULES = {
|
|
10
|
+
'uses_factory_bot' => {
|
|
11
|
+
skip: %w[test_helper factories_check],
|
|
12
|
+
apply: { spec_template: :factory_bot_rspec }
|
|
13
|
+
},
|
|
14
|
+
'uses_rspec' => {
|
|
15
|
+
skip: %w[framework_detection],
|
|
16
|
+
apply: { test_framework: :rspec }
|
|
17
|
+
},
|
|
18
|
+
'uses_minitest' => {
|
|
19
|
+
skip: %w[framework_detection],
|
|
20
|
+
apply: { test_framework: :minitest }
|
|
21
|
+
},
|
|
22
|
+
'uses_service_objects' => {
|
|
23
|
+
skip: %w[pattern_detection],
|
|
24
|
+
apply: { service_pattern: 'app/services/**/*_service.rb' }
|
|
25
|
+
},
|
|
26
|
+
'uses_devise' => {
|
|
27
|
+
skip: %w[auth_detection],
|
|
28
|
+
apply: { auth_framework: :devise }
|
|
29
|
+
},
|
|
30
|
+
'uses_grape' => {
|
|
31
|
+
skip: %w[api_detection],
|
|
32
|
+
apply: { api_framework: :grape }
|
|
33
|
+
},
|
|
34
|
+
'uses_sidekiq' => {
|
|
35
|
+
skip: %w[job_detection],
|
|
36
|
+
apply: { job_framework: :sidekiq }
|
|
37
|
+
}
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :applied_shortcuts, :tokens_saved_estimate
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@applied_shortcuts = []
|
|
44
|
+
@tokens_saved_estimate = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Apply shortcuts based on instinct patterns.
|
|
48
|
+
#
|
|
49
|
+
# @param instinct_patterns [Array<String>] patterns from the instincts table
|
|
50
|
+
# @return [Hash] aggregated settings from applied shortcuts
|
|
51
|
+
def apply(instinct_patterns)
|
|
52
|
+
settings = {}
|
|
53
|
+
|
|
54
|
+
instinct_patterns.each do |pattern|
|
|
55
|
+
rule_key = match_rule(pattern)
|
|
56
|
+
next unless rule_key
|
|
57
|
+
|
|
58
|
+
rule = SHORTCUT_RULES[rule_key]
|
|
59
|
+
settings.merge!(rule[:apply])
|
|
60
|
+
@applied_shortcuts << { rule: rule_key, skipped: rule[:skip] }
|
|
61
|
+
@tokens_saved_estimate += rule[:skip].size * 500
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
settings
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if a discovery step should be skipped.
|
|
68
|
+
#
|
|
69
|
+
# @param step_name [String] the discovery step name
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def skip?(step_name)
|
|
72
|
+
@applied_shortcuts.any? { |s| s[:skipped].include?(step_name.to_s) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns stats about shortcuts applied this session.
|
|
76
|
+
def stats
|
|
77
|
+
{
|
|
78
|
+
shortcuts_applied: @applied_shortcuts.size,
|
|
79
|
+
steps_skipped: @applied_shortcuts.sum { |s| s[:skipped].size },
|
|
80
|
+
tokens_saved_estimate: @tokens_saved_estimate
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def match_rule(pattern)
|
|
87
|
+
normalized = pattern.to_s.downcase
|
|
88
|
+
SHORTCUT_RULES.each_key do |key|
|
|
89
|
+
return key if normalized.include?(key.tr('_', ' ')) || normalized.include?(key)
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../message_builder'
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module LLM
|
|
10
|
+
module Adapters
|
|
11
|
+
class Anthropic < Base
|
|
12
|
+
include JsonParsing
|
|
13
|
+
include PromptCaching
|
|
14
|
+
|
|
15
|
+
API_URL = 'https://api.anthropic.com/v1/messages'
|
|
16
|
+
ANTHROPIC_VERSION = '2023-06-01'
|
|
17
|
+
MAX_RETRIES = 3
|
|
18
|
+
RETRY_DELAYS = [2, 5, 10].freeze
|
|
19
|
+
|
|
20
|
+
AVAILABLE_MODELS = %w[
|
|
21
|
+
claude-sonnet-4-20250514
|
|
22
|
+
claude-opus-4-6
|
|
23
|
+
claude-haiku-4-20250506
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
def provider_name
|
|
27
|
+
'anthropic'
|
|
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 -- mirrors LLM adapter interface
|
|
35
|
+
ensure_valid_token!
|
|
36
|
+
use_streaming = on_text && oauth_token?
|
|
37
|
+
|
|
38
|
+
body = build_request_body(
|
|
39
|
+
messages: messages, tools: tools, system: system,
|
|
40
|
+
model: model, max_tokens: max_tokens,
|
|
41
|
+
stream: use_streaming, task_budget: task_budget
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return stream_request(body, on_text) if use_streaming
|
|
45
|
+
|
|
46
|
+
execute_with_retries(body, on_text)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# -- Auth ---------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def oauth_token?
|
|
54
|
+
access_token.include?('sk-ant-oat')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ensure_valid_token!
|
|
58
|
+
return if Auth::TokenStore.valid?
|
|
59
|
+
|
|
60
|
+
raise Client::AuthExpiredError,
|
|
61
|
+
'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def access_token
|
|
65
|
+
tokens = Auth::TokenStore.load
|
|
66
|
+
raise Client::AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
|
|
67
|
+
|
|
68
|
+
tokens[:access_token]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# -- Request execution --------------------------------------------
|
|
72
|
+
|
|
73
|
+
def execute_with_retries(body, on_text)
|
|
74
|
+
retries = 0
|
|
75
|
+
loop do
|
|
76
|
+
response = post_request(body)
|
|
77
|
+
|
|
78
|
+
if response.status == 429 && retries < MAX_RETRIES
|
|
79
|
+
delay = RETRY_DELAYS[retries] || 10
|
|
80
|
+
RubynCode::Debug.llm("Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})...")
|
|
81
|
+
sleep delay
|
|
82
|
+
retries += 1
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return finalize_response(response, on_text)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def post_request(body)
|
|
91
|
+
connection.post(API_URL) do |req|
|
|
92
|
+
apply_headers(req)
|
|
93
|
+
req.body = JSON.generate(body)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def finalize_response(response, on_text)
|
|
98
|
+
resp = handle_api_response(response)
|
|
99
|
+
emit_full_text(resp, on_text)
|
|
100
|
+
resp
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def emit_full_text(resp, on_text)
|
|
104
|
+
return unless on_text
|
|
105
|
+
|
|
106
|
+
text = resp.content.select { |b| b.respond_to?(:text) }.map(&:text).join
|
|
107
|
+
on_text.call(text) unless text.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -- Streaming ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def stream_request(body, on_text)
|
|
113
|
+
streamer = build_streamer(on_text)
|
|
114
|
+
error_chunks = []
|
|
115
|
+
|
|
116
|
+
response = streaming_connection.post(API_URL) do |req|
|
|
117
|
+
apply_headers(req)
|
|
118
|
+
req.body = JSON.generate(body)
|
|
119
|
+
req.options.on_data = on_data_proc(streamer, error_chunks)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
handle_stream_errors(response, error_chunks)
|
|
123
|
+
streamer.finalize
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_streamer(on_text)
|
|
127
|
+
AnthropicStreaming.new do |event|
|
|
128
|
+
on_text&.call(event.data[:text]) if event.type == :text_delta
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def on_data_proc(streamer, error_chunks)
|
|
133
|
+
proc do |chunk, _bytes, env|
|
|
134
|
+
env.status == 200 ? streamer.feed(chunk) : error_chunks << chunk
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_stream_errors(response, error_chunks)
|
|
139
|
+
return if response.status == 200
|
|
140
|
+
|
|
141
|
+
error_msg = extract_error_message(error_chunks.join)
|
|
142
|
+
|
|
143
|
+
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
144
|
+
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
145
|
+
|
|
146
|
+
raise Client::RequestError,
|
|
147
|
+
"API streaming request failed (#{response.status}): #{error_msg}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_error_message(body_text)
|
|
151
|
+
parsed = parse_json(body_text)
|
|
152
|
+
parsed&.dig('error', 'message') || body_text[0..500]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# -- HTTP ---------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def connection
|
|
158
|
+
@connection ||= build_faraday_connection
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def streaming_connection
|
|
162
|
+
@streaming_connection ||= build_faraday_connection
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def build_faraday_connection
|
|
166
|
+
Faraday.new do |f|
|
|
167
|
+
f.options.timeout = 300
|
|
168
|
+
f.options.open_timeout = 30
|
|
169
|
+
f.adapter Faraday.default_adapter
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# -- Headers ------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def apply_headers(req)
|
|
176
|
+
req.headers['Content-Type'] = 'application/json'
|
|
177
|
+
req.headers['anthropic-version'] = ANTHROPIC_VERSION
|
|
178
|
+
oauth_token? ? apply_oauth_headers(req) : apply_api_key_headers(req)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def apply_oauth_headers(req)
|
|
182
|
+
req.headers['Authorization'] = "Bearer #{access_token}"
|
|
183
|
+
req.headers['anthropic-beta'] = 'oauth-2025-04-20'
|
|
184
|
+
req.headers['x-app'] = 'cli'
|
|
185
|
+
req.headers['User-Agent'] = 'claude-code/2.1.79'
|
|
186
|
+
req.headers['X-Claude-Code-Session-Id'] = session_id
|
|
187
|
+
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def apply_api_key_headers(req)
|
|
191
|
+
req.headers['x-api-key'] = access_token
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def session_id
|
|
195
|
+
@session_id ||= SecureRandom.uuid
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# -- Request body -------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, **_opts) # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
|
|
201
|
+
body = { model: model, max_tokens: max_tokens }
|
|
202
|
+
apply_system_blocks(body, system)
|
|
203
|
+
apply_tool_cache(body, tools)
|
|
204
|
+
body[:messages] = add_message_cache_breakpoint(messages)
|
|
205
|
+
body[:stream] = true if stream
|
|
206
|
+
body
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# -- Response parsing ---------------------------------------------
|
|
210
|
+
|
|
211
|
+
def handle_api_response(response)
|
|
212
|
+
raise_on_error(response) unless response.success?
|
|
213
|
+
|
|
214
|
+
body = parse_json(response.body)
|
|
215
|
+
raise Client::RequestError, 'Invalid response from API' unless body
|
|
216
|
+
|
|
217
|
+
build_api_response(body)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raise_on_error(response)
|
|
221
|
+
body = parse_json(response.body)
|
|
222
|
+
error_msg = body&.dig('error', 'message') || response.body[0..500]
|
|
223
|
+
error_type = body&.dig('error', 'type') || 'api_error'
|
|
224
|
+
|
|
225
|
+
log_api_error(response)
|
|
226
|
+
raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
227
|
+
raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
|
|
228
|
+
|
|
229
|
+
raise Client::RequestError, "API request failed (#{response.status} #{error_type}): #{error_msg}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def log_api_error(response)
|
|
233
|
+
RubynCode::Debug.llm("API error #{response.status}: #{response.body[0..500]}")
|
|
234
|
+
return unless RubynCode::Debug.enabled?
|
|
235
|
+
|
|
236
|
+
response.headers.each do |k, v|
|
|
237
|
+
RubynCode::Debug.llm(" #{k}: #{v}") if k.match?(/rate|retry|limit|anthropic/i)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_api_response(body)
|
|
242
|
+
content = parse_content_blocks(body['content'])
|
|
243
|
+
usage = parse_usage(body['usage'])
|
|
244
|
+
Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def parse_content_blocks(blocks)
|
|
248
|
+
(blocks || []).filter_map do |block|
|
|
249
|
+
case block['type']
|
|
250
|
+
when 'text' then TextBlock.new(text: block['text'])
|
|
251
|
+
when 'tool_use'
|
|
252
|
+
ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def parse_usage(data)
|
|
258
|
+
return Usage.new(input_tokens: 0, output_tokens: 0) unless data
|
|
259
|
+
|
|
260
|
+
Usage.new(
|
|
261
|
+
input_tokens: data['input_tokens'].to_i,
|
|
262
|
+
output_tokens: data['output_tokens'].to_i,
|
|
263
|
+
cache_creation_input_tokens: data['cache_creation_input_tokens'].to_i,
|
|
264
|
+
cache_read_input_tokens: data['cache_read_input_tokens'].to_i
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|