rubyn-code 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -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
- new_applied = instinct.times_applied + 1
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: new_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.execute(
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.execute(
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 do |row|
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