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

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 connection_error_message(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.message}. #{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 http_error_message(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 connection_error_message(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 http_error_message(uri, response)
  end
end

.preamble?(line) ⇒ Boolean

Returns:

  • (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

Returns:

  • (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