Module: AIGit::AIClient
- Defined in:
- lib/ai_git/ai_client.rb
Constant Summary collapse
- READ_TIMEOUT_SECONDS =
120- OPEN_TIMEOUT_SECONDS =
10- MAX_ATTEMPTS =
3- RETRY_BASE_DELAY =
0.5- TRANSIENT_STATUSES =
[408, 425, 429, 500, 502, 503, 504].freeze
- RETRYABLE_ERRORS =
[ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, Net::OpenTimeout, Net::ReadTimeout, SocketError, EOFError ].freeze
- PREAMBLE_PREFIXES =
/\A(here|output|generated|based\son|the\schanges| the\s(commit\smessage|review)\sis|json|markdown)\b/ix.freeze
- CODE_FENCE =
/\A`{3,}/.freeze
- ESCAPED_MESSAGE =
/\A[^\n]*\\n\\n[^\n]*\z/.freeze
Class Method Summary collapse
- .complete(prompt:, model_name:, temperature:) ⇒ Object
- .connection_error_message(error) ⇒ Object
- .http_error_message(uri, response) ⇒ Object
- .openai_complete(prompt, model_name, temperature) ⇒ Object
- .perform_request(uri, body) ⇒ Object
- .post_json(body) ⇒ Object
- .preamble?(line) ⇒ Boolean
- .retry_delay(attempt) ⇒ Object
- .sanitize(text) ⇒ Object
- .strip_code_fences(lines) ⇒ Object
- .strip_preamble(lines) ⇒ Object
- .transient_status?(code) ⇒ Boolean
- .unescape_newlines(text) ⇒ Object
Class Method Details
.complete(prompt:, model_name:, temperature:) ⇒ Object
47 48 49 |
# File 'lib/ai_git/ai_client.rb', line 47 def complete(prompt:, model_name:, temperature:) sanitize(openai_complete(prompt, model_name, temperature)) end |
.connection_error_message(error) ⇒ Object
123 124 125 126 127 128 129 |
# File 'lib/ai_git/ai_client.rb', line 123 def (error) provider = AIGit::Config.provider base_url = AIGit::Config.base_url hint = "Is the local server running? See `ai_git config`." "Cannot reach #{provider} at #{base_url} after #{MAX_ATTEMPTS} attempts: #{error.}. #{hint}" end |
.http_error_message(uri, response) ⇒ Object
112 113 114 115 116 117 118 119 120 121 |
# File 'lib/ai_git/ai_client.rb', line 112 def (uri, response) provider = AIGit::Config.provider body = response.body.to_s.strip body = "#{body[0, 500]}…" if body.length > 500 hint = response.code.to_i == 404 ? " Check the model name and base URL (see `ai_git config`)." : "" "#{provider} returned HTTP #{response.code} at #{uri}.#{hint}" \ "#{body.empty? ? '' : "\n#{body}"}" end |
.openai_complete(prompt, model_name, temperature) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/ai_git/ai_client.rb', line 51 def openai_complete(prompt, model_name, temperature) body = { model: model_name, messages: [{ role: "user", content: prompt }], stream: false, temperature: temperature } data = post_json(body) data.dig("choices", 0, "message", "content").to_s end |
.perform_request(uri, body) ⇒ Object
98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/ai_git/ai_client.rb', line 98 def perform_request(uri, body) request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = body.to_json Net::HTTP.start( uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT_SECONDS, read_timeout: READ_TIMEOUT_SECONDS ) { |http| http.request(request) } end |
.post_json(body) ⇒ Object
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/ai_git/ai_client.rb', line 71 def post_json(body) uri = URI("#{AIGit::Config.base_url}#{AIGit::Config.endpoint}") attempt = 0 loop do attempt += 1 begin response = perform_request(uri, body) rescue *RETRYABLE_ERRORS => e raise (e) if attempt >= MAX_ATTEMPTS sleep retry_delay(attempt) next end return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess) if transient_status?(response.code) && attempt < MAX_ATTEMPTS sleep retry_delay(attempt) next end raise (uri, response) end end |
.preamble?(line) ⇒ Boolean
161 162 163 |
# File 'lib/ai_git/ai_client.rb', line 161 def preamble?(line) line.strip.match?(PREAMBLE_PREFIXES) end |
.retry_delay(attempt) ⇒ Object
67 68 69 |
# File 'lib/ai_git/ai_client.rb', line 67 def retry_delay(attempt) RETRY_BASE_DELAY * (2**(attempt - 1)) end |
.sanitize(text) ⇒ Object
131 132 133 134 135 136 137 |
# File 'lib/ai_git/ai_client.rb', line 131 def sanitize(text) lines = unescape_newlines(text.to_s) .lines .map { |line| line.rstrip.sub(/\A>\s*/, "") } strip_preamble(strip_code_fences(lines)).join("\n").strip end |
.strip_code_fences(lines) ⇒ Object
145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/ai_git/ai_client.rb', line 145 def strip_code_fences(lines) lines = lines.drop_while { |line| line.strip.empty? } if lines.first&.match?(CODE_FENCE) unfenced = lines.first.sub(CODE_FENCE, "").strip unfenced.empty? ? lines.shift : lines[0] = unfenced end lines.pop while lines.last && (lines.last.strip.empty? || lines.last.strip.match?(CODE_FENCE)) lines end |
.strip_preamble(lines) ⇒ Object
157 158 159 |
# File 'lib/ai_git/ai_client.rb', line 157 def strip_preamble(lines) lines.drop_while { |line| line.strip.empty? || preamble?(line) } end |
.transient_status?(code) ⇒ Boolean
63 64 65 |
# File 'lib/ai_git/ai_client.rb', line 63 def transient_status?(code) TRANSIENT_STATUSES.include?(code.to_i) end |
.unescape_newlines(text) ⇒ Object
139 140 141 142 143 |
# File 'lib/ai_git/ai_client.rb', line 139 def unescape_newlines(text) return text unless text.match?(ESCAPED_MESSAGE) text.gsub(/\\n/, "\n") end |