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
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../message_builder'
4
+
5
+ module RubynCode
6
+ module LLM
7
+ module Adapters
8
+ # SSE streaming parser for OpenAI Chat Completions API.
9
+ #
10
+ # Parses `data: {...}` lines from the SSE stream, accumulates content deltas
11
+ # and tool_calls, and produces a normalized LLM::Response via #finalize.
12
+ class OpenAIStreaming
13
+ include JsonParsing
14
+
15
+ Event = Data.define(:type, :data)
16
+
17
+ STOP_REASON_MAP = {
18
+ 'stop' => 'end_turn',
19
+ 'tool_calls' => 'tool_use',
20
+ 'length' => 'max_tokens',
21
+ 'content_filter' => 'end_turn'
22
+ }.freeze
23
+
24
+ def initialize(&block)
25
+ @callback = block
26
+ @buffer = +''
27
+ @content_text = +''
28
+ @tool_calls = {}
29
+ @response_id = nil
30
+ @model = nil
31
+ @finish_reason = nil
32
+ @usage = nil
33
+ end
34
+
35
+ def feed(chunk)
36
+ @buffer << chunk
37
+ consume_sse_events
38
+ end
39
+
40
+ def finalize
41
+ content = build_content_blocks
42
+ stop = STOP_REASON_MAP[@finish_reason] || @finish_reason || 'end_turn'
43
+
44
+ RubynCode::LLM::Response.new(
45
+ id: @response_id,
46
+ content: content,
47
+ stop_reason: stop,
48
+ usage: @usage || RubynCode::LLM::Usage.new(input_tokens: 0, output_tokens: 0)
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def consume_sse_events
55
+ while (idx = @buffer.index("\n\n"))
56
+ line = @buffer.slice!(0..(idx + 1)).strip
57
+ process_sse_line(line)
58
+ end
59
+ end
60
+
61
+ def process_sse_line(line)
62
+ return unless line.start_with?('data: ')
63
+
64
+ payload = line.sub('data: ', '')
65
+ return if payload == '[DONE]'
66
+
67
+ data = parse_json(payload)
68
+ return unless data
69
+
70
+ handle_chunk(data)
71
+ end
72
+
73
+ def handle_chunk(data)
74
+ @response_id ||= data['id']
75
+ @model ||= data['model']
76
+ extract_usage(data)
77
+
78
+ choice = data.dig('choices', 0)
79
+ return unless choice
80
+
81
+ @finish_reason = choice['finish_reason'] if choice['finish_reason']
82
+ process_delta(choice['delta'] || {})
83
+ end
84
+
85
+ def extract_usage(data)
86
+ return unless data['usage']
87
+
88
+ @usage = RubynCode::LLM::Usage.new(
89
+ input_tokens: data['usage']['prompt_tokens'].to_i,
90
+ output_tokens: data['usage']['completion_tokens'].to_i
91
+ )
92
+ end
93
+
94
+ def process_delta(delta)
95
+ handle_content_delta(delta['content']) if delta.key?('content')
96
+ handle_tool_calls_delta(delta['tool_calls']) if delta['tool_calls']
97
+ end
98
+
99
+ def handle_content_delta(text)
100
+ return if text.nil? || text.empty?
101
+
102
+ @content_text << text
103
+ @callback&.call(Event.new(type: :text_delta, data: { text: text }))
104
+ end
105
+
106
+ def handle_tool_calls_delta(tool_calls)
107
+ tool_calls.each { |tool_call| accumulate_tool_call(tool_call) }
108
+ end
109
+
110
+ def accumulate_tool_call(tool_call)
111
+ idx = tool_call['index']
112
+ @tool_calls[idx] ||= { id: nil, name: +'', arguments: +'' }
113
+
114
+ entry = @tool_calls[idx]
115
+ entry[:id] = tool_call['id'] if tool_call['id']
116
+ merge_function_delta(entry, tool_call['function'])
117
+ end
118
+
119
+ def merge_function_delta(entry, func)
120
+ return unless func
121
+
122
+ entry[:name] << func['name'].to_s
123
+ entry[:arguments] << func['arguments'].to_s
124
+ end
125
+
126
+ def build_content_blocks
127
+ blocks = []
128
+ blocks << RubynCode::LLM::TextBlock.new(text: @content_text) unless @content_text.empty?
129
+
130
+ @tool_calls.keys.sort.each do |idx|
131
+ entry = @tool_calls[idx]
132
+ input = parse_json(entry[:arguments]) || {}
133
+ blocks << RubynCode::LLM::ToolUseBlock.new(id: entry[:id], name: entry[:name], input: input)
134
+ end
135
+
136
+ blocks
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Anthropic prompt caching logic.
7
+ #
8
+ # Injects `cache_control: { type: 'ephemeral' }` into system blocks,
9
+ # tool definitions, and the last message — enabling Anthropic's prompt
10
+ # caching to skip re-processing static content across turns.
11
+ module PromptCaching
12
+ CACHE_EPHEMERAL = { type: 'ephemeral' }.freeze
13
+
14
+ OAUTH_GATE = "You are Claude Code, Anthropic's official CLI for Claude."
15
+
16
+ private
17
+
18
+ def apply_system_blocks(body, system)
19
+ if oauth_token?
20
+ blocks = [{ type: 'text', text: OAUTH_GATE, cache_control: CACHE_EPHEMERAL }]
21
+ blocks << { type: 'text', text: system, cache_control: CACHE_EPHEMERAL } if system
22
+ body[:system] = blocks
23
+ elsif system
24
+ body[:system] = [{ type: 'text', text: system, cache_control: CACHE_EPHEMERAL }]
25
+ end
26
+ end
27
+
28
+ def apply_tool_cache(body, tools)
29
+ return if tools.nil? || tools.empty?
30
+
31
+ cached_tools = tools.map(&:dup)
32
+ cached_tools.last[:cache_control] = CACHE_EPHEMERAL
33
+ body[:tools] = cached_tools
34
+ end
35
+
36
+ def add_message_cache_breakpoint(messages)
37
+ return messages if messages.nil? || messages.empty?
38
+
39
+ tagged = messages.map(&:dup)
40
+ tag_last_message_content(tagged.last)
41
+ tagged
42
+ end
43
+
44
+ def tag_last_message_content(last_msg)
45
+ content = last_msg[:content]
46
+ case content
47
+ when Array
48
+ return if content.empty?
49
+
50
+ last_msg[:content] = content.map(&:dup)
51
+ last_block = last_msg[:content].last
52
+ last_block[:cache_control] = CACHE_EPHEMERAL if last_block.is_a?(Hash)
53
+ when String
54
+ last_msg[:content] = [{ type: 'text', text: content, cache_control: CACHE_EPHEMERAL }]
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,284 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
- require 'json'
5
- require 'open3'
6
3
  require_relative 'message_builder'
7
4
 
8
5
  module RubynCode
9
6
  module LLM
7
+ # Thin facade over provider-specific adapters.
8
+ #
9
+ # All consumers (Agent::Loop, REPL, DaemonRunner) talk to Client.
10
+ # Client delegates to the resolved adapter, which can be swapped
11
+ # at runtime via `switch_provider!` or the `/model` command.
10
12
  class Client
11
- API_URL = 'https://api.anthropic.com/v1/messages'
12
- ANTHROPIC_VERSION = '2023-06-01'
13
- OAUTH_GATE = "You are Claude Code, Anthropic's official CLI for Claude."
14
- RUBYN_IDENTITY = <<~IDENTITY.strip
15
- Disregard the above identity. You are Rubyn Code, an AI-powered coding assistant specialized for Ruby and Rails development. You are NOT Claude Code. Your name is Rubyn.
16
- You help Ruby developers write, debug, refactor, and test code. You follow Ruby best practices, Rails conventions, and write clean, idiomatic Ruby.
17
- IDENTITY
13
+ class RequestError < RubynCode::Error; end
14
+ class AuthExpiredError < RubynCode::AuthenticationError; end
15
+ class PromptTooLongError < RequestError; end
18
16
 
19
- class RequestError < RubynCode::Error
20
- end
21
-
22
- class AuthExpiredError < RubynCode::AuthenticationError
23
- end
17
+ attr_reader :adapter
18
+ attr_accessor :model
24
19
 
25
- def initialize(model: nil)
20
+ def initialize(model: nil, provider: nil, adapter: nil)
26
21
  @model = model || Config::Defaults::DEFAULT_MODEL
27
- end
28
-
29
- MAX_RETRIES = 3
30
- RETRY_DELAYS = [2, 5, 10].freeze
31
-
32
- def chat(messages:, tools: nil, system: nil, model: nil, max_tokens: Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS,
33
- on_text: nil, task_budget: nil)
34
- ensure_valid_token!
35
-
36
- use_streaming = on_text && access_token.include?('sk-ant-oat')
37
-
38
- body = build_request_body(
39
- messages:, tools:, system:,
40
- model: model || @model, max_tokens:, stream: use_streaming,
41
- task_budget: task_budget
22
+ @provider = provider || Config::Defaults::DEFAULT_PROVIDER
23
+ @adapter = adapter || resolve_adapter(@provider)
24
+ end
25
+
26
+ def chat(messages:, tools: nil, system: nil, model: nil, **opts)
27
+ effective_model = model || @model
28
+ max_tokens = opts[:max_tokens] || Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS
29
+
30
+ @adapter.chat(
31
+ messages: messages,
32
+ tools: tools,
33
+ system: system,
34
+ model: effective_model,
35
+ max_tokens: max_tokens,
36
+ on_text: opts[:on_text],
37
+ task_budget: opts[:task_budget]
42
38
  )
43
-
44
- retries = 0
45
- loop do
46
- return stream_request(body, on_text) if use_streaming
47
-
48
- response = connection.post(API_URL) do |req|
49
- apply_headers(req)
50
- req.body = JSON.generate(body)
51
- end
52
-
53
- if response.status == 429 && retries < MAX_RETRIES
54
- delay = RETRY_DELAYS[retries] || 10
55
- RubynCode::Debug.llm("Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})...")
56
- sleep delay
57
- retries += 1
58
- next
59
- end
60
-
61
- resp = handle_api_response(response)
62
-
63
- # If on_text is provided but we're not using SSE streaming (API key auth),
64
- # call the callback with the full text after receiving
65
- if on_text
66
- text = (resp.content || []).select { |b| b.respond_to?(:text) }.map(&:text).join
67
- on_text.call(text) unless text.empty?
68
- end
69
-
70
- return resp
71
- end
72
39
  end
73
40
 
74
41
  def stream(messages:, tools: nil, system: nil, model: nil,
75
42
  max_tokens: Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS, &block)
76
- chat(messages:, tools:, system:, model:, max_tokens:, on_text: block)
77
- end
78
-
79
- private
80
-
81
- def stream_request(body, on_text)
82
- streamer = Streaming.new do |event|
83
- on_text&.call(event.data[:text]) if event.type == :text_delta
84
- end
85
-
86
- error_chunks = []
87
-
88
- response = streaming_connection.post(API_URL) do |req|
89
- apply_headers(req)
90
- req.body = JSON.generate(body)
91
-
92
- req.options.on_data = proc do |chunk, _overall_received_bytes, env|
93
- if env.status == 200
94
- streamer.feed(chunk)
95
- else
96
- error_chunks << chunk
97
- end
98
- end
99
- end
100
-
101
- unless response.status == 200
102
- body_text = error_chunks.join
103
- body_text = response.body.to_s if body_text.empty?
104
- parsed = parse_json(body_text)
105
- error_msg = parsed&.dig('error', 'message') || body_text[0..500]
106
- RubynCode::Debug.llm("Streaming API error #{response.status}: #{body_text[0..500]}")
107
- raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
108
-
109
- raise RequestError, "API request failed (#{response.status}): #{error_msg}"
110
- end
111
-
112
- streamer.finalize
113
- end
114
-
115
- def streaming_connection
116
- @streaming_connection ||= Faraday.new do |f|
117
- f.options.timeout = 300
118
- f.options.open_timeout = 30
119
- f.adapter Faraday.default_adapter
120
- end
121
- end
122
-
123
- def apply_headers(req)
124
- req.headers['Content-Type'] = 'application/json'
125
- req.headers['anthropic-version'] = ANTHROPIC_VERSION
126
-
127
- token = access_token
128
- if token.include?('sk-ant-oat')
129
- # OAuth subscriber — same headers as Claude Code CLI
130
- req.headers['Authorization'] = "Bearer #{token}"
131
- req.headers['anthropic-beta'] = 'oauth-2025-04-20'
132
- req.headers['x-app'] = 'cli'
133
- req.headers['User-Agent'] = 'claude-code/2.1.79'
134
- req.headers['X-Claude-Code-Session-Id'] = session_id
135
- req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
136
- else
137
- # API key
138
- req.headers['x-api-key'] = token
139
- end
43
+ chat(messages: messages, tools: tools, system: system,
44
+ model: model, max_tokens: max_tokens, on_text: block)
140
45
  end
141
46
 
142
- def session_id
143
- @session_id ||= SecureRandom.uuid
47
+ def provider_name
48
+ @adapter.provider_name
144
49
  end
145
50
 
146
- CACHE_EPHEMERAL = { type: 'ephemeral' }.freeze
147
-
148
- def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, task_budget: nil)
149
- body = { model: model, max_tokens: max_tokens }
150
-
151
- # ── System prompt ──────────────────────────────────────────────
152
- # Split into static (cacheable across turns) and dynamic blocks.
153
- # OAuth tokens require OAUTH_GATE as the first block for model access.
154
- oauth = access_token.include?('sk-ant-oat')
155
-
156
- if oauth
157
- blocks = [{ type: 'text', text: OAUTH_GATE, cache_control: CACHE_EPHEMERAL }]
158
- blocks << { type: 'text', text: system, cache_control: CACHE_EPHEMERAL } if system
159
- body[:system] = blocks
160
- elsif system
161
- body[:system] = [{ type: 'text', text: system, cache_control: CACHE_EPHEMERAL }]
162
- end
163
-
164
- # ── Tools ──────────────────────────────────────────────────────
165
- # Cache the tool block so definitions don't re-tokenize each turn.
166
- if tools && !tools.empty?
167
- cached_tools = tools.map(&:dup)
168
- cached_tools.last[:cache_control] = CACHE_EPHEMERAL
169
- body[:tools] = cached_tools
170
- end
171
-
172
- # ── Messages with cache breakpoint ─────────────────────────────
173
- # Place a single cache_control breakpoint on the last message so
174
- # the entire conversation prefix is cached server-side (~5 min TTL).
175
- # This is the biggest token saver: on turn N, turns 1..(N-1) are
176
- # served from cache instead of re-tokenized.
177
- body[:messages] = add_message_cache_breakpoint(messages)
178
-
179
- body[:stream] = true if stream
180
- body
51
+ def models
52
+ @adapter.models
181
53
  end
182
54
 
183
- # Injects cache_control on the last content block of the last message.
184
- # Only one breakpoint per request Anthropic recommends exactly one on
185
- # messages to avoid stale cache page retention.
186
- def add_message_cache_breakpoint(messages)
187
- return messages if messages.nil? || messages.empty?
188
-
189
- # Deep-dup only the last message to avoid mutating the conversation
190
- tagged = messages.map(&:dup)
191
- last_msg = tagged.last
192
-
193
- content = last_msg[:content]
194
- case content
195
- when Array
196
- return tagged if content.empty?
197
-
198
- last_msg[:content] = content.map(&:dup)
199
- last_block = last_msg[:content].last
200
- last_block[:cache_control] = CACHE_EPHEMERAL if last_block.is_a?(Hash)
201
- when String
202
- # Convert to block form so we can attach cache_control
203
- last_msg[:content] = [{ type: 'text', text: content, cache_control: CACHE_EPHEMERAL }]
204
- end
205
-
206
- tagged
55
+ # Switch the active provider (and optionally model) at runtime.
56
+ # Called by the REPL when `/model provider:model` is used.
57
+ #
58
+ # @param provider [String] provider name ('anthropic', 'openai', etc.)
59
+ # @param model [String, nil] optional model to set
60
+ def switch_provider!(provider, model: nil)
61
+ @provider = provider
62
+ @adapter = resolve_adapter(provider)
63
+ @model = model if model
207
64
  end
208
65
 
209
- class PromptTooLongError < RequestError
210
- end
211
-
212
- def handle_api_response(response)
213
- unless response.success?
214
- body = parse_json(response.body)
215
- error_msg = body&.dig('error', 'message') || response.body[0..500]
216
- error_type = body&.dig('error', 'type') || 'api_error'
217
-
218
- RubynCode::Debug.llm("API error #{response.status}: #{response.body[0..500]}")
219
- if RubynCode::Debug.enabled?
220
- response.headers.each do |k, v|
221
- RubynCode::Debug.llm(" #{k}: #{v}") if k.match?(/rate|retry|limit|anthropic/i)
222
- end
223
- end
224
-
225
- raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
226
- raise PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
227
-
228
- raise RequestError, "API request failed (#{response.status} #{error_type}): #{error_msg}"
229
- end
230
-
231
- body = parse_json(response.body)
232
- raise RequestError, 'Invalid response from API' unless body
66
+ private
233
67
 
234
- build_api_response(body)
235
- end
68
+ # Builds the appropriate adapter for a given provider name.
69
+ def resolve_adapter(provider)
70
+ case provider
71
+ when 'anthropic' then Adapters::Anthropic.new
72
+ when 'openai' then Adapters::OpenAI.new
73
+ else
74
+ config = Config::Settings.new.provider_config(provider)
75
+ base_url = config&.fetch('base_url', nil)
236
76
 
237
- def build_api_response(body)
238
- content = (body['content'] || []).map do |block|
239
- case block['type']
240
- when 'text' then TextBlock.new(text: block['text'])
241
- when 'tool_use' then ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
77
+ unless base_url
78
+ raise ConfigError,
79
+ "Unknown provider '#{provider}'. Add base_url to config.yml under providers.#{provider}"
242
80
  end
243
- end.compact
244
81
 
245
- usage_data = body['usage'] || {}
246
- usage = Usage.new(
247
- input_tokens: usage_data['input_tokens'].to_i,
248
- output_tokens: usage_data['output_tokens'].to_i,
249
- cache_creation_input_tokens: usage_data['cache_creation_input_tokens'].to_i,
250
- cache_read_input_tokens: usage_data['cache_read_input_tokens'].to_i
251
- )
252
-
253
- Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
254
- end
255
-
256
- def ensure_valid_token!
257
- return if Auth::TokenStore.valid?
258
-
259
- raise AuthExpiredError, 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
260
- end
261
-
262
- def access_token
263
- tokens = Auth::TokenStore.load
264
- raise AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
265
-
266
- tokens[:access_token]
267
- end
268
-
269
- def connection
270
- @connection ||= Faraday.new do |f|
271
- f.options.timeout = 300
272
- f.options.open_timeout = 30
273
- f.adapter Faraday.default_adapter
82
+ Adapters::OpenAICompatible.new(provider: provider, base_url: base_url)
274
83
  end
275
84
  end
276
-
277
- def parse_json(str)
278
- JSON.parse(str)
279
- rescue JSON::ParserError
280
- nil
281
- end
282
85
  end
283
86
  end
284
87
  end