Module: AIGit::AIClient

Defined in:
lib/ai_git/ai_client.rb

Constant Summary collapse

READ_TIMEOUT_SECONDS =
120
OPEN_TIMEOUT_SECONDS =
10
MAX_ATTEMPTS =

Transient failures worth retrying with backoff.

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
OUTPUT_NOISE_PREFIXES =
/^(Here|Output|Generated|Based on|The changes)/i.freeze
OUTPUT_NOISE_HEADERS =
/^(Here is|The (commit message|review) is|```|json|markdown)/i.freeze

Class Method Summary collapse

Class Method Details

.complete(prompt:, model_name:, temperature:) ⇒ Object

Sends prompt to the local llama.cpp server and returns the cleaned text body.



29
30
31
# File 'lib/ai_git/ai_client.rb', line 29

def complete(prompt:, model_name:, temperature:)
  sanitize(openai_complete(prompt, model_name, temperature))
end

.connection_error_message(error) ⇒ Object



105
106
107
108
109
110
111
# File 'lib/ai_git/ai_client.rb', line 105

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



94
95
96
97
98
99
100
101
102
103
# File 'lib/ai_git/ai_client.rb', line 94

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



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/ai_git/ai_client.rb', line 33

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



80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/ai_git/ai_client.rb', line 80

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



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/ai_git/ai_client.rb', line 53

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

.retry_delay(attempt) ⇒ Object



49
50
51
# File 'lib/ai_git/ai_client.rb', line 49

def retry_delay(attempt)
  RETRY_BASE_DELAY * (2**(attempt - 1))
end

.sanitize(text) ⇒ Object



113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/ai_git/ai_client.rb', line 113

def sanitize(text)
  cleaned = text.to_s
                .gsub(OUTPUT_NOISE_HEADERS, "")
                .gsub(/^>\s*/, "")
                .gsub(/\\n/, "\n")
                .strip

  cleaned.lines
         .map(&:strip)
         .reject { |line| line.match?(OUTPUT_NOISE_PREFIXES) }
         .join("\n")
         .strip
end

.transient_status?(code) ⇒ Boolean

Returns:

  • (Boolean)


45
46
47
# File 'lib/ai_git/ai_client.rb', line 45

def transient_status?(code)
  TRANSIENT_STATUSES.include?(code.to_i)
end