ruby_coded 0.1.1 → 0.2.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +3 -1
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +15 -10
  5. data/lib/ruby_coded/auth/auth_manager.rb +20 -5
  6. data/lib/ruby_coded/auth/jwt_decoder.rb +29 -0
  7. data/lib/ruby_coded/auth/providers/openai.rb +19 -5
  8. data/lib/ruby_coded/chat/app/event_dispatch.rb +23 -3
  9. data/lib/ruby_coded/chat/app/login_handler.rb +79 -0
  10. data/lib/ruby_coded/chat/app/oauth_handler.rb +105 -0
  11. data/lib/ruby_coded/chat/app.rb +65 -6
  12. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +93 -0
  13. data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +104 -0
  14. data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +136 -0
  15. data/lib/ruby_coded/chat/codex_bridge/token_manager.rb +45 -0
  16. data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +51 -0
  17. data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +128 -0
  18. data/lib/ruby_coded/chat/codex_bridge.rb +126 -0
  19. data/lib/ruby_coded/chat/codex_models.rb +41 -0
  20. data/lib/ruby_coded/chat/command_handler/login_commands.rb +33 -0
  21. data/lib/ruby_coded/chat/command_handler/model_commands.rb +19 -6
  22. data/lib/ruby_coded/chat/command_handler.rb +6 -2
  23. data/lib/ruby_coded/chat/help.txt +2 -0
  24. data/lib/ruby_coded/chat/input_handler/login_inputs.rb +66 -0
  25. data/lib/ruby_coded/chat/input_handler.rb +3 -0
  26. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +1 -1
  27. data/lib/ruby_coded/chat/renderer/login_flow.rb +105 -0
  28. data/lib/ruby_coded/chat/renderer/login_flow_layout.rb +65 -0
  29. data/lib/ruby_coded/chat/renderer/status_bar.rb +2 -1
  30. data/lib/ruby_coded/chat/renderer.rb +12 -3
  31. data/lib/ruby_coded/chat/state/login_flow.rb +117 -0
  32. data/lib/ruby_coded/chat/state/login_flow_steps.rb +69 -0
  33. data/lib/ruby_coded/chat/state.rb +25 -2
  34. data/lib/ruby_coded/initializer.rb +14 -3
  35. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +1 -0
  36. data/lib/ruby_coded/strategies/oauth_strategy.rb +4 -3
  37. data/lib/ruby_coded/version.rb +1 -1
  38. metadata +33 -2
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CodexBridge
6
+ # Retry logic and error message formatting for the Codex API client.
7
+ module ErrorHandling
8
+ AGENT_SWITCH_PATTERN = /
9
+ \b(implement|go[ ]ahead|proceed|execut|ejecutar?|comenz|
10
+ comienz|hazlo|constru[iy]|adelante|dale|do[ ]it|build[ ]it)\b
11
+ /ix
12
+
13
+ private
14
+
15
+ def build_connection
16
+ Faraday.new(url: CODEX_BASE_URL) do |f|
17
+ f.options.timeout = 300
18
+ f.options.open_timeout = 30
19
+ end
20
+ end
21
+
22
+ def reset_call_counts
23
+ @tool_call_count = 0
24
+ @write_tool_call_count = 0
25
+ end
26
+
27
+ def prepare_send(input)
28
+ auto_switch_to_agent! if should_auto_switch_to_agent?(input)
29
+ reset_call_counts
30
+ @cancel_requested = false
31
+ @state.streaming = true
32
+ @state.add_message(:assistant, "")
33
+ end
34
+
35
+ def should_auto_switch_to_agent?(input)
36
+ @plan_mode && @state.respond_to?(:current_plan) && @state.current_plan &&
37
+ input.match?(AGENT_SWITCH_PATTERN)
38
+ end
39
+
40
+ def auto_switch_to_agent!
41
+ toggle_agentic_mode!(true)
42
+ @state.add_message(:system,
43
+ "Plan mode disabled — switching to agent mode to implement the plan.")
44
+ end
45
+
46
+ def attempt_with_retries(input, retries = 0)
47
+ perform_codex_request(input)
48
+ rescue Tools::AgentCancelledError, Tools::AgentIterationLimitError, Tools::ToolRejectedError => e
49
+ @state.add_message(:system, e.message)
50
+ rescue CodexAPIError => e
51
+ @state.fail_last_assistant(e, friendly_message: codex_error_message(e))
52
+ rescue Faraday::TooManyRequestsError => e
53
+ retry if (retries = handle_rate_limit_retry(e, retries))
54
+ @state.fail_last_assistant(e, friendly_message: rate_limit_message(e))
55
+ rescue StandardError => e
56
+ @state.fail_last_assistant(e, friendly_message: "Codex API error: #{e.message}")
57
+ end
58
+
59
+ def handle_rate_limit_retry(error, retries)
60
+ return unless retries < MAX_RATE_LIMIT_RETRIES && !@cancel_requested
61
+
62
+ retries += 1
63
+ delay = RATE_LIMIT_BASE_DELAY * (2**(retries - 1))
64
+ msg = "Rate limit reached. Retrying in #{delay}s... (#{retries}/#{MAX_RATE_LIMIT_RETRIES})"
65
+ @state.fail_last_assistant(error, friendly_message: msg)
66
+ sleep(delay)
67
+ @state.reset_last_assistant_content
68
+ retries
69
+ end
70
+
71
+ def codex_error_message(error)
72
+ case error.status
73
+ when 401
74
+ "Authentication failed. Your OAuth session may have expired. " \
75
+ "Try /login to re-authenticate. (#{error.message})"
76
+ when 403
77
+ "Access denied. Your ChatGPT subscription may not include Codex access. (#{error.message})"
78
+ when 404 then "Codex endpoint not found. The API may have changed. (#{error.message})"
79
+ when 429 then rate_limit_message(error)
80
+ else "Codex API error: #{error.message}"
81
+ end
82
+ end
83
+
84
+ def rate_limit_message(error)
85
+ <<~MSG.strip
86
+ ChatGPT usage limit reached. This may be a 5-hour or weekly limit on your Plus/Pro subscription.
87
+ Wait and try again later. Detail: #{error.message}
88
+ MSG
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CodexBridge
6
+ # Builds HTTP requests for the Codex Responses API.
7
+ module RequestBuilder
8
+ CODEX_HEADERS_BASE = {
9
+ "Content-Type" => "application/json",
10
+ "Accept" => "text/event-stream",
11
+ "originator" => "codex_cli_rs",
12
+ "OpenAI-Beta" => "responses=experimental"
13
+ }.freeze
14
+
15
+ DEFAULT_INSTRUCTIONS = "You are a helpful coding assistant. " \
16
+ "Answer concisely and provide code examples when relevant."
17
+
18
+ private
19
+
20
+ def codex_headers
21
+ credentials = current_credentials
22
+ account_id = Auth::JWTDecoder.extract_account_id(credentials["access_token"])
23
+
24
+ CODEX_HEADERS_BASE.merge(
25
+ "Authorization" => "Bearer #{credentials["access_token"]}",
26
+ "chatgpt-account-id" => account_id.to_s
27
+ )
28
+ end
29
+
30
+ def build_request_body
31
+ body = base_request_body
32
+ body[:tools] = build_tools_spec if @agentic_mode || @plan_mode
33
+ body
34
+ end
35
+
36
+ def base_request_body
37
+ {
38
+ model: @model, instructions: build_instructions,
39
+ input: build_input_array, store: false, stream: true,
40
+ reasoning: { effort: "medium", summary: "auto" },
41
+ text: { verbosity: "medium" },
42
+ include: ["reasoning.encrypted_content"]
43
+ }
44
+ end
45
+
46
+ def build_input_array
47
+ @conversation_history.map { |msg| format_history_message(msg) }
48
+ end
49
+
50
+ def format_history_message(msg)
51
+ case msg[:type]
52
+ when "function_call" then format_function_call(msg)
53
+ when "function_call_output" then format_function_output(msg)
54
+ else { role: msg[:role], content: msg[:content].to_s }
55
+ end
56
+ end
57
+
58
+ def format_function_call(msg)
59
+ {
60
+ type: "function_call", name: msg[:name],
61
+ arguments: msg[:arguments].is_a?(String) ? msg[:arguments] : msg[:arguments].to_json,
62
+ call_id: msg[:call_id]
63
+ }
64
+ end
65
+
66
+ def format_function_output(msg)
67
+ { type: "function_call_output", call_id: msg[:call_id], output: msg[:output].to_s }
68
+ end
69
+
70
+ def build_instructions
71
+ if @agentic_mode
72
+ Tools::SystemPrompt.build(
73
+ project_root: @project_root, max_write_rounds: MAX_WRITE_TOOL_ROUNDS,
74
+ max_total_rounds: MAX_TOTAL_TOOL_ROUNDS
75
+ )
76
+ elsif @plan_mode
77
+ Tools::PlanSystemPrompt.build(project_root: @project_root)
78
+ else
79
+ DEFAULT_INSTRUCTIONS
80
+ end
81
+ end
82
+
83
+ def build_tools_spec
84
+ tool_instances = if @agentic_mode
85
+ @tool_registry.build_tools
86
+ else
87
+ @tool_registry.build_readonly_tools
88
+ end
89
+
90
+ tool_instances.map { |tool| tool_to_responses_api(tool) }
91
+ end
92
+
93
+ def tool_to_responses_api(tool)
94
+ {
95
+ type: "function",
96
+ name: tool.name,
97
+ description: tool.description.to_s,
98
+ parameters: tool.params_schema
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CodexBridge
6
+ # Parses Server-Sent Events from the Codex streaming response and
7
+ # dispatches content deltas, tool calls, and completion signals.
8
+ module SSEParser
9
+ StreamContext = Struct.new(
10
+ :assistant_text, :pending_tool_calls, :buffer, :raw_body, :status, keyword_init: true
11
+ )
12
+
13
+ private
14
+
15
+ def perform_codex_request(input)
16
+ ensure_token_fresh!
17
+ ctx = StreamContext.new(assistant_text: +"", pending_tool_calls: [], buffer: +"", raw_body: +"", status: nil)
18
+ execute_streaming_post(ctx)
19
+ finalize_response(ctx.assistant_text)
20
+ process_pending_tool_calls(ctx.pending_tool_calls, input) if ctx.pending_tool_calls.any?
21
+ end
22
+
23
+ def execute_streaming_post(ctx)
24
+ response = post_streaming_request(ctx)
25
+ ctx.status ||= response.status
26
+ handle_http_error(ctx.status, ctx.raw_body) unless (200..299).cover?(ctx.status)
27
+ end
28
+
29
+ def post_streaming_request(ctx)
30
+ @conn.post(CODEX_RESPONSES_PATH) do |req|
31
+ req.headers = codex_headers
32
+ req.body = build_request_body.to_json
33
+ req.options.on_data = proc { |chunk, _size, env| handle_stream_chunk(ctx, chunk, env) }
34
+ end
35
+ end
36
+
37
+ def handle_stream_chunk(ctx, chunk, env)
38
+ ctx.status ||= env&.status
39
+ ctx.raw_body << chunk
40
+ return if @cancel_requested || (ctx.status && !(200..299).cover?(ctx.status))
41
+
42
+ ctx.buffer << chunk
43
+ process_sse_buffer(ctx.buffer, ctx.assistant_text, ctx.pending_tool_calls)
44
+ end
45
+
46
+ def process_sse_buffer(buffer, assistant_text, pending_tool_calls)
47
+ while (line_end = buffer.index("\n"))
48
+ line = buffer.slice!(0, line_end + 1).strip
49
+ next if line.empty?
50
+
51
+ process_sse_line(line, assistant_text, pending_tool_calls)
52
+ end
53
+ end
54
+
55
+ def process_sse_line(line, assistant_text, pending_tool_calls)
56
+ return unless line.start_with?("data: ")
57
+
58
+ data = line[6..]
59
+ return if data == "[DONE]"
60
+
61
+ event = parse_json(data)
62
+ return unless event
63
+
64
+ dispatch_sse_event(event, assistant_text, pending_tool_calls)
65
+ end
66
+
67
+ def dispatch_sse_event(event, assistant_text, pending_tool_calls)
68
+ case event["type"]
69
+ when "response.output_text.delta" then handle_text_delta(event, assistant_text)
70
+ when "response.function_call_arguments.delta" then handle_function_args_delta(event, pending_tool_calls)
71
+ when "response.function_call_arguments.done" then handle_function_call_done(event, pending_tool_calls)
72
+ when "response.output_item.added" then handle_output_item_added(event, pending_tool_calls)
73
+ end
74
+ end
75
+
76
+ def handle_text_delta(event, assistant_text)
77
+ delta = event["delta"]
78
+ return unless delta.is_a?(String) && !delta.empty?
79
+
80
+ assistant_text << delta
81
+ @state.streaming_append(delta)
82
+ end
83
+
84
+ def handle_output_item_added(event, pending_tool_calls)
85
+ item = event["item"]
86
+ return unless item && item["type"] == "function_call"
87
+
88
+ pending_tool_calls << {
89
+ call_id: item["call_id"] || item["id"],
90
+ name: item["name"],
91
+ arguments: +""
92
+ }
93
+ end
94
+
95
+ def handle_function_args_delta(event, pending_tool_calls)
96
+ delta = event["delta"]
97
+ return unless delta.is_a?(String)
98
+
99
+ pending_tool_calls.last&.tap { |c| c[:arguments] << delta }
100
+ end
101
+
102
+ def handle_function_call_done(event, pending_tool_calls)
103
+ call_id = event["call_id"] || event.dig("item", "call_id")
104
+ return unless call_id
105
+
106
+ tc = pending_tool_calls.find { |c| c[:call_id] == call_id }
107
+ tc[:arguments] = event["arguments"] || tc[:arguments] if tc
108
+ end
109
+
110
+ def finalize_response(assistant_text)
111
+ @conversation_history << { role: "assistant", content: assistant_text } unless assistant_text.empty?
112
+ end
113
+
114
+ def handle_http_error(status, body_text)
115
+ detail = extract_error_detail(body_text)
116
+ raise CodexAPIError.new(status, detail)
117
+ end
118
+
119
+ def extract_error_detail(body_text)
120
+ parsed = JSON.parse(body_text)
121
+ return body_text[0, 300] unless parsed.is_a?(Hash)
122
+
123
+ parsed.dig("error", "message") || parsed["detail"] || parsed["message"] || body_text[0, 300]
124
+ rescue StandardError
125
+ body_text[0, 300]
126
+ end
127
+
128
+ def parse_json(str)
129
+ JSON.parse(str)
130
+ rescue JSON::ParserError
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CodexBridge
6
+ # Manages OAuth token refresh for the Codex bridge.
7
+ # Checks token expiration before each request and refreshes
8
+ # via the existing OAuthStrategy/AuthManager infrastructure.
9
+ module TokenManager
10
+ TOKEN_REFRESH_BUFFER = 60
11
+
12
+ private
13
+
14
+ def current_credentials
15
+ @credentials_store.retrieve(:openai)
16
+ end
17
+
18
+ def ensure_token_fresh!
19
+ credentials = current_credentials
20
+ return unless credentials && credentials["auth_method"] == "oauth"
21
+ return unless token_expired?(credentials)
22
+
23
+ refresh_token!(credentials)
24
+ end
25
+
26
+ def token_expired?(credentials)
27
+ expires_at = credentials["expires_at"]
28
+ return false unless expires_at
29
+
30
+ Time.parse(expires_at) <= Time.now + TOKEN_REFRESH_BUFFER
31
+ end
32
+
33
+ def refresh_token!(credentials)
34
+ provider = Auth::AuthManager::PROVIDERS[:openai]
35
+ strategy = Strategies::OAuthStrategy.new(provider)
36
+ refreshed = strategy.refresh(credentials)
37
+ @credentials_store.store(:openai, refreshed)
38
+ @auth_manager&.configure_ruby_llm!
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CodexBridge
6
+ # Manages interactive approval flow for tool calls in agentic mode.
7
+ module ToolApproval
8
+ private
9
+
10
+ def request_approval(_tool_call, display_name, args, risk)
11
+ args_summary = args.map { |k, v| "#{k}: #{v}" }.join(", ")
12
+
13
+ if risk == Tools::BaseTool::SAFE_RISK || @state.auto_approve_tools?
14
+ @state.add_message(:tool_call, "[#{display_name}] #{args_summary}")
15
+ else
16
+ risk_label = risk == Tools::BaseTool::DANGEROUS_RISK ? "DANGEROUS" : "WRITE"
17
+ @state.request_tool_confirmation!(display_name, args, risk_label: risk_label)
18
+ decision = poll_tool_decision
19
+ apply_tool_decision(decision, display_name)
20
+ end
21
+ end
22
+
23
+ def poll_tool_decision
24
+ @state.mutex.synchronize do
25
+ loop do
26
+ return :cancelled if @cancel_requested
27
+
28
+ resp = @state.instance_variable_get(:@tool_confirmation_response)
29
+ return resp if %i[approved rejected].include?(resp)
30
+
31
+ @state.tool_cv.wait(@state.mutex, 0.1)
32
+ end
33
+ end
34
+ end
35
+
36
+ def apply_tool_decision(decision, display_name)
37
+ case decision
38
+ when :cancelled
39
+ @state.clear_tool_confirmation!
40
+ raise Tools::AgentCancelledError, "Operation cancelled by user"
41
+ when :approved
42
+ @state.resolve_tool_confirmation!(:approved)
43
+ when :rejected
44
+ @state.resolve_tool_confirmation!(:rejected)
45
+ raise Tools::ToolRejectedError, "User rejected #{display_name}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_approval"
4
+
5
+ module RubyCoded
6
+ module Chat
7
+ class CodexBridge
8
+ # Handles tool call execution, confirmation, and the multi-turn loop
9
+ # for agentic mode over the Codex Responses API.
10
+ module ToolHandling
11
+ include ToolApproval
12
+
13
+ private
14
+
15
+ def process_pending_tool_calls(pending_tool_calls, _original_input)
16
+ pending_tool_calls.each do |tool_call|
17
+ break if @cancel_requested
18
+
19
+ execute_tool_call(tool_call)
20
+ end
21
+
22
+ return if @cancel_requested
23
+
24
+ @state.add_message(:assistant, "")
25
+ continue_after_tools
26
+ end
27
+
28
+ def execute_tool_call(tool_call)
29
+ name = tool_call[:name]
30
+ args = parse_tool_arguments(tool_call[:arguments])
31
+ display_name = short_tool_name(name)
32
+ risk = @tool_registry.risk_level_for(name)
33
+
34
+ increment_call_counts(risk)
35
+ check_tool_limits!
36
+ warn_approaching_limit
37
+
38
+ request_approval(tool_call, display_name, args, risk)
39
+ result = run_tool(name, args)
40
+ record_tool_result(tool_call, result)
41
+ end
42
+
43
+ def parse_tool_arguments(args_str)
44
+ return args_str if args_str.is_a?(Hash)
45
+
46
+ JSON.parse(args_str)
47
+ rescue JSON::ParserError
48
+ {}
49
+ end
50
+
51
+ def run_tool(name, args)
52
+ tool_instances = @agentic_mode ? @tool_registry.build_tools : @tool_registry.build_readonly_tools
53
+ tool = tool_instances.find { |t| tool_name_match?(t, name) }
54
+
55
+ return { error: "Unknown tool: #{name}" } unless tool
56
+
57
+ symbolized = args.transform_keys(&:to_sym)
58
+ tool.execute(**symbolized)
59
+ rescue StandardError => e
60
+ { error: e.message }
61
+ end
62
+
63
+ def tool_name_match?(tool, name)
64
+ tool.name == name || tool.name.split("--").last == name.split("--").last
65
+ end
66
+
67
+ def record_tool_result(tool_call, result)
68
+ @state.add_message(:tool_result, truncate_result(result))
69
+ record_tool_call_history(tool_call, result)
70
+ end
71
+
72
+ def truncate_result(result)
73
+ text = result.to_s
74
+ return text if text.length <= MAX_TOOL_RESULT_CHARS
75
+
76
+ "#{text[0, MAX_TOOL_RESULT_CHARS]}\n... (truncated, #{text.length} total characters)"
77
+ end
78
+
79
+ def record_tool_call_history(tool_call, result)
80
+ @conversation_history << {
81
+ type: "function_call", call_id: tool_call[:call_id],
82
+ name: tool_call[:name], arguments: tool_call[:arguments]
83
+ }
84
+ @conversation_history << {
85
+ type: "function_call_output", call_id: tool_call[:call_id], output: result.to_s
86
+ }
87
+ end
88
+
89
+ def continue_after_tools
90
+ perform_codex_request(nil)
91
+ end
92
+
93
+ def increment_call_counts(risk)
94
+ @tool_call_count += 1
95
+ @write_tool_call_count += 1 unless risk == Tools::BaseTool::SAFE_RISK
96
+ end
97
+
98
+ def check_tool_limits!
99
+ if @write_tool_call_count >= MAX_WRITE_TOOL_ROUNDS
100
+ @write_tool_call_count = 0
101
+ @state.add_message(:system,
102
+ "Write tool call budget (#{MAX_WRITE_TOOL_ROUNDS}) reached — auto-resetting counter.")
103
+ end
104
+
105
+ return unless @tool_call_count > MAX_TOTAL_TOOL_ROUNDS
106
+
107
+ raise Tools::AgentIterationLimitError,
108
+ "Reached maximum of #{MAX_TOTAL_TOOL_ROUNDS} total tool calls. " \
109
+ "Send a new message to continue, or use /agent on to reset counters."
110
+ end
111
+
112
+ def warn_approaching_limit
113
+ threshold = (MAX_TOTAL_TOOL_ROUNDS * TOOL_ROUNDS_WARNING_THRESHOLD).to_i
114
+ return unless @tool_call_count == threshold
115
+
116
+ remaining = MAX_TOTAL_TOOL_ROUNDS - threshold
117
+ @state.add_message(:system,
118
+ "Approaching total tool call limit: #{remaining} calls remaining. " \
119
+ "Prioritize completing the most important work.")
120
+ end
121
+
122
+ def short_tool_name(name)
123
+ name.split("--").last
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "time"
6
+
7
+ require_relative "../tools/registry"
8
+ require_relative "../tools/system_prompt"
9
+ require_relative "../tools/plan_system_prompt"
10
+ require_relative "../tools/agent_cancelled_error"
11
+ require_relative "../tools/agent_iteration_limit_error"
12
+ require_relative "../auth/jwt_decoder"
13
+ require_relative "codex_bridge/request_builder"
14
+ require_relative "codex_bridge/sse_parser"
15
+ require_relative "codex_bridge/tool_handling"
16
+ require_relative "codex_bridge/token_manager"
17
+ require_relative "codex_bridge/error_handling"
18
+
19
+ module RubyCoded
20
+ module Chat
21
+ # Raised when the Codex HTTP API returns a non-2xx response.
22
+ class CodexAPIError < StandardError
23
+ attr_reader :status
24
+
25
+ def initialize(status, detail)
26
+ @status = status
27
+ super("HTTP #{status}: #{detail}")
28
+ end
29
+ end
30
+
31
+ # HTTP client for the ChatGPT Codex backend (Responses API).
32
+ # Implements the same public interface as LLMBridge so App can
33
+ # swap between them based on the active auth_method.
34
+ class CodexBridge
35
+ include RequestBuilder
36
+ include SSEParser
37
+ include ToolHandling
38
+ include TokenManager
39
+ include ErrorHandling
40
+
41
+ CODEX_BASE_URL = "https://chatgpt.com"
42
+ CODEX_RESPONSES_PATH = "/backend-api/codex/responses"
43
+ DEFAULT_MODEL = "gpt-5.4"
44
+
45
+ MAX_RATE_LIMIT_RETRIES = 2
46
+ RATE_LIMIT_BASE_DELAY = 2
47
+ MAX_WRITE_TOOL_ROUNDS = 50
48
+ MAX_TOTAL_TOOL_ROUNDS = 200
49
+ TOOL_ROUNDS_WARNING_THRESHOLD = 0.8
50
+ MAX_TOOL_RESULT_CHARS = 10_000
51
+
52
+ attr_reader :agentic_mode, :plan_mode, :project_root
53
+
54
+ def initialize(state, credentials_store:, auth_manager:, project_root: Dir.pwd)
55
+ @state = state
56
+ @credentials_store = credentials_store
57
+ @auth_manager = auth_manager
58
+ @project_root = project_root
59
+ @cancel_requested = @agentic_mode = @plan_mode = false
60
+ @model = state.model
61
+ @conversation_history = []
62
+ @tool_registry = Tools::Registry.new(project_root: @project_root)
63
+ reset_call_counts
64
+ @conn = build_connection
65
+ end
66
+
67
+ def send_async(input)
68
+ prepare_send(input)
69
+ @conversation_history << { role: "user", content: input }
70
+ Thread.new do
71
+ attempt_with_retries(input)
72
+ ensure
73
+ @state.streaming = false
74
+ end
75
+ end
76
+
77
+ def cancel!
78
+ @cancel_requested = true
79
+ @state.mutex.synchronize { @state.tool_cv.signal }
80
+ end
81
+
82
+ def reset_chat!(model_name)
83
+ @model = model_name
84
+ @conversation_history = []
85
+ end
86
+
87
+ def toggle_agentic_mode!(enabled)
88
+ @agentic_mode = enabled
89
+ @state.agentic_mode = enabled
90
+ if enabled && @plan_mode
91
+ @plan_mode = false
92
+ @state.deactivate_plan_mode!
93
+ end
94
+ @state.disable_auto_approve! unless enabled
95
+ end
96
+
97
+ def toggle_plan_mode!(enabled)
98
+ @plan_mode = enabled
99
+ return unless enabled && @agentic_mode
100
+
101
+ @agentic_mode = false
102
+ @state.agentic_mode = false
103
+ @state.disable_auto_approve!
104
+ end
105
+
106
+ def approve_tool!
107
+ @state.tool_confirmation_response = :approved
108
+ end
109
+
110
+ def approve_all_tools!
111
+ @state.enable_auto_approve!
112
+ @state.tool_confirmation_response = :approved
113
+ end
114
+
115
+ def reject_tool!
116
+ @state.tool_confirmation_response = :rejected
117
+ end
118
+
119
+ def reset_agent_session!
120
+ @tool_call_count = 0
121
+ @write_tool_call_count = 0
122
+ @conversation_history = []
123
+ end
124
+ end
125
+ end
126
+ end