rubyn-code 0.2.2 → 0.4.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -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,274 @@
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
+ def api_url
52
+ API_URL
53
+ end
54
+
55
+ # -- Auth ---------------------------------------------------------
56
+
57
+ def oauth_token?
58
+ access_token.include?('sk-ant-oat')
59
+ end
60
+
61
+ def ensure_valid_token!
62
+ return if Auth::TokenStore.valid?
63
+
64
+ raise Client::AuthExpiredError,
65
+ 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
66
+ end
67
+
68
+ def access_token
69
+ tokens = Auth::TokenStore.load
70
+ raise Client::AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
71
+
72
+ tokens[:access_token]
73
+ end
74
+
75
+ # -- Request execution --------------------------------------------
76
+
77
+ def execute_with_retries(body, on_text)
78
+ retries = 0
79
+ loop do
80
+ response = post_request(body)
81
+
82
+ if response.status == 429 && retries < MAX_RETRIES
83
+ delay = RETRY_DELAYS[retries] || 10
84
+ RubynCode::Debug.llm("Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})...")
85
+ sleep delay
86
+ retries += 1
87
+ next
88
+ end
89
+
90
+ return finalize_response(response, on_text)
91
+ end
92
+ end
93
+
94
+ def post_request(body)
95
+ connection.post(api_url) do |req|
96
+ apply_headers(req)
97
+ req.body = JSON.generate(body)
98
+ end
99
+ end
100
+
101
+ def finalize_response(response, on_text)
102
+ resp = handle_api_response(response)
103
+ emit_full_text(resp, on_text)
104
+ resp
105
+ end
106
+
107
+ def emit_full_text(resp, on_text)
108
+ return unless on_text
109
+
110
+ text = resp.content.select { |b| b.respond_to?(:text) }.map(&:text).join
111
+ on_text.call(text) unless text.empty?
112
+ end
113
+
114
+ # -- Streaming ----------------------------------------------------
115
+
116
+ def stream_request(body, on_text)
117
+ streamer = build_streamer(on_text)
118
+ error_chunks = []
119
+
120
+ response = streaming_connection.post(api_url) do |req|
121
+ apply_headers(req)
122
+ req.body = JSON.generate(body)
123
+ req.options.on_data = on_data_proc(streamer, error_chunks)
124
+ end
125
+
126
+ handle_stream_errors(response, error_chunks)
127
+ streamer.finalize
128
+ end
129
+
130
+ def build_streamer(on_text)
131
+ AnthropicStreaming.new do |event|
132
+ on_text&.call(event.data[:text]) if event.type == :text_delta
133
+ end
134
+ end
135
+
136
+ def on_data_proc(streamer, error_chunks)
137
+ proc do |chunk, _bytes, env|
138
+ env.status == 200 ? streamer.feed(chunk) : error_chunks << chunk
139
+ end
140
+ end
141
+
142
+ def handle_stream_errors(response, error_chunks)
143
+ return if response.status == 200
144
+
145
+ error_msg = extract_error_message(error_chunks.join)
146
+
147
+ raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
148
+ raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
149
+
150
+ raise Client::RequestError,
151
+ "API streaming request failed (#{response.status}): #{error_msg}"
152
+ end
153
+
154
+ def extract_error_message(body_text)
155
+ parsed = parse_json(body_text)
156
+ parsed&.dig('error', 'message') || body_text[0..500]
157
+ end
158
+
159
+ # -- HTTP ---------------------------------------------------------
160
+
161
+ def connection
162
+ @connection ||= build_faraday_connection
163
+ end
164
+
165
+ def streaming_connection
166
+ @streaming_connection ||= build_faraday_connection
167
+ end
168
+
169
+ def build_faraday_connection
170
+ Faraday.new do |f|
171
+ f.options.timeout = 300
172
+ f.options.open_timeout = 30
173
+ f.adapter Faraday.default_adapter
174
+ end
175
+ end
176
+
177
+ # -- Headers ------------------------------------------------------
178
+
179
+ def apply_headers(req)
180
+ req.headers['Content-Type'] = 'application/json'
181
+ req.headers['anthropic-version'] = ANTHROPIC_VERSION
182
+ oauth_token? ? apply_oauth_headers(req) : apply_api_key_headers(req)
183
+ end
184
+
185
+ def apply_oauth_headers(req)
186
+ req.headers['Authorization'] = "Bearer #{access_token}"
187
+ req.headers['anthropic-beta'] = 'oauth-2025-04-20'
188
+ req.headers['x-app'] = 'cli'
189
+ req.headers['User-Agent'] = 'claude-code/2.1.79'
190
+ req.headers['X-Claude-Code-Session-Id'] = session_id
191
+ req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
192
+ end
193
+
194
+ def apply_api_key_headers(req)
195
+ req.headers['x-api-key'] = access_token
196
+ end
197
+
198
+ def session_id
199
+ @session_id ||= SecureRandom.uuid
200
+ end
201
+
202
+ # -- Request body -------------------------------------------------
203
+
204
+ def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, **_opts) # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
205
+ body = { model: model, max_tokens: max_tokens }
206
+ apply_system_blocks(body, system)
207
+ apply_tool_cache(body, tools)
208
+ body[:messages] = add_message_cache_breakpoint(messages)
209
+ body[:stream] = true if stream
210
+ body
211
+ end
212
+
213
+ # -- Response parsing ---------------------------------------------
214
+
215
+ def handle_api_response(response)
216
+ raise_on_error(response) unless response.success?
217
+
218
+ body = parse_json(response.body)
219
+ raise Client::RequestError, 'Invalid response from API' unless body
220
+
221
+ build_api_response(body)
222
+ end
223
+
224
+ def raise_on_error(response)
225
+ body = parse_json(response.body)
226
+ error_msg = body&.dig('error', 'message') || response.body[0..500]
227
+ error_type = body&.dig('error', 'type') || 'api_error'
228
+
229
+ log_api_error(response)
230
+ raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
231
+ raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
232
+
233
+ raise Client::RequestError, "API request failed (#{response.status} #{error_type}): #{error_msg}"
234
+ end
235
+
236
+ def log_api_error(response)
237
+ RubynCode::Debug.llm("API error #{response.status}: #{response.body[0..500]}")
238
+ return unless RubynCode::Debug.enabled?
239
+
240
+ response.headers.each do |k, v|
241
+ RubynCode::Debug.llm(" #{k}: #{v}") if k.match?(/rate|retry|limit|anthropic/i)
242
+ end
243
+ end
244
+
245
+ def build_api_response(body)
246
+ content = parse_content_blocks(body['content'])
247
+ usage = parse_usage(body['usage'])
248
+ Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
249
+ end
250
+
251
+ def parse_content_blocks(blocks)
252
+ (blocks || []).filter_map do |block|
253
+ case block['type']
254
+ when 'text' then TextBlock.new(text: block['text'])
255
+ when 'tool_use'
256
+ ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
257
+ end
258
+ end
259
+ end
260
+
261
+ def parse_usage(data)
262
+ return Usage.new(input_tokens: 0, output_tokens: 0) unless data
263
+
264
+ Usage.new(
265
+ input_tokens: data['input_tokens'].to_i,
266
+ output_tokens: data['output_tokens'].to_i,
267
+ cache_creation_input_tokens: data['cache_creation_input_tokens'].to_i,
268
+ cache_read_input_tokens: data['cache_read_input_tokens'].to_i
269
+ )
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Adapter for Anthropic-compatible providers that use the Messages API format.
7
+ #
8
+ # Inherits all Anthropic logic but overrides the base URL, provider name,
9
+ # available models, and API key resolution.
10
+ class AnthropicCompatible < Anthropic
11
+ def initialize(provider:, base_url:, api_key: nil, available_models: [])
12
+ super()
13
+ @provider = provider
14
+ @base_url = base_url
15
+ @api_key = api_key
16
+ @available_models = available_models.freeze
17
+ end
18
+
19
+ def provider_name
20
+ @provider
21
+ end
22
+
23
+ def models
24
+ @available_models
25
+ end
26
+
27
+ private
28
+
29
+ def api_url
30
+ "#{@base_url}/messages"
31
+ end
32
+
33
+ def ensure_valid_token!
34
+ resolve_api_key # raises if missing
35
+ end
36
+
37
+ def oauth_token?
38
+ false
39
+ end
40
+
41
+ def access_token
42
+ resolve_api_key
43
+ end
44
+
45
+ def resolve_api_key
46
+ return @api_key if @api_key
47
+
48
+ stored = Auth::TokenStore.load_provider_key(@provider)
49
+ return stored if stored
50
+
51
+ env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
52
+ ENV.fetch(env_key) do
53
+ raise Client::AuthExpiredError,
54
+ "No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end