commitgpt 0.2.0 → 0.3.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.
@@ -1,18 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "httparty"
4
+ require "net/http"
5
+ require "uri"
4
6
  require "json"
5
7
  require "io/console"
8
+ require "tty-prompt"
6
9
  require_relative "string"
10
+ require_relative "config_manager"
7
11
 
8
12
  # CommitGpt based on GPT-3
9
13
  module CommitGpt
10
14
  # Commit AI roboter based on GPT-3
11
15
  class CommitAi
12
- AICM_KEY = ENV.fetch("AICM_KEY", nil)
13
- AICM_LINK = ENV.fetch("AICM_LINK", "https://api.openai.com/v1")
14
- AICM_DIFF_LEN = ENV.fetch("AICM_DIFF_LEN", "32768").to_i
15
- AICM_MODEL = ENV.fetch("AICM_MODEL", "gpt-4o-mini")
16
+ attr_reader :api_key, :base_url, :model, :diff_len
17
+
18
+ def initialize
19
+ provider_config = ConfigManager.get_active_provider_config
20
+
21
+ if provider_config
22
+ @api_key = provider_config["api_key"]
23
+ @base_url = provider_config["base_url"]
24
+ @model = provider_config["model"]
25
+ @diff_len = provider_config["diff_len"] || 32768
26
+ else
27
+ @api_key = nil
28
+ @base_url = nil
29
+ @model = nil
30
+ @diff_len = 32768
31
+ end
32
+ end
16
33
 
17
34
  def aicm(verbose: false)
18
35
  exit(1) unless welcome
@@ -22,8 +39,55 @@ module CommitGpt
22
39
  puts diff
23
40
  puts "\n"
24
41
  end
25
- ai_commit_message = message(diff) || exit(1)
26
- puts `git commit -m "#{ai_commit_message}" && echo && echo && git log -1 && echo` if confirmed
42
+
43
+ loop do
44
+ ai_commit_message = message(diff) || exit(1)
45
+ action = confirm_commit(ai_commit_message)
46
+
47
+ case action
48
+ when :commit
49
+ commit_command = "git commit -m \"#{ai_commit_message}\""
50
+ puts "\n▲ Executing: #{commit_command}".orange
51
+ system(commit_command)
52
+ puts "\n\n"
53
+ puts `git log -1`
54
+ break
55
+ when :regenerate
56
+ puts "\n"
57
+ next
58
+ when :edit
59
+ prompt = TTY::Prompt.new
60
+ new_message = prompt.ask("Enter your commit message:")
61
+ if new_message && !new_message.strip.empty?
62
+ commit_command = "git commit -m \"#{new_message}\""
63
+ system(commit_command)
64
+ puts "\n"
65
+ puts `git log -1`
66
+ else
67
+ puts "▲ Commit aborted (empty message).".red
68
+ end
69
+ break
70
+ when :exit
71
+ puts "▲ Exit without commit.".yellow
72
+ break
73
+ end
74
+ end
75
+ end
76
+
77
+ def list_models
78
+ headers = {
79
+ "Content-Type" => "application/json",
80
+ "User-Agent" => "Ruby/#{RUBY_VERSION}"
81
+ }
82
+ headers["Authorization"] = "Bearer #{@api_key}" if @api_key
83
+
84
+ begin
85
+ response = HTTParty.get("#{@base_url}/models", headers: headers)
86
+ models = response["data"] || []
87
+ models.each { |m| puts m["id"] }
88
+ rescue StandardError => e
89
+ puts "▲ Failed to list models: #{e.message}".red
90
+ end
27
91
  end
28
92
 
29
93
  def list_models
@@ -44,47 +108,164 @@ module CommitGpt
44
108
 
45
109
  private
46
110
 
47
- def confirmed
48
- puts "▲ Do you want to commit this message? [y/n]".magenta
49
-
50
- use_commit_message = nil
51
- use_commit_message = $stdin.getch.downcase until use_commit_message =~ /\A[yn]\z/i
52
-
53
- puts "\n▲ Commit message has not been commited.\n".red if use_commit_message == "n"
54
-
55
- use_commit_message == "y"
111
+ def confirm_commit(message)
112
+ prompt = TTY::Prompt.new
113
+
114
+ begin
115
+ prompt.select("Action:") do |menu|
116
+ menu.choice "Commit", :commit
117
+ menu.choice "Regenerate", :regenerate
118
+ menu.choice "Edit", :edit
119
+ menu.choice "Exit without commit", :exit
120
+ end
121
+ rescue TTY::Reader::InputInterrupt, Interrupt
122
+ :exit
123
+ end
56
124
  end
57
125
 
58
126
  def message(diff = nil)
59
- puts "▲ Generating your AI commit message...\n".gray
60
- ai_commit_message = generate_commit(diff)
61
- return nil if ai_commit_message.nil?
62
-
63
- puts "#{"▲ Commit message: ".green}git commit -am \"#{ai_commit_message}\"\n\n"
64
- ai_commit_message
127
+ generate_commit(diff)
65
128
  end
66
129
 
67
130
  def git_diff
68
- diff = `git diff --cached . ":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"`.chomp
131
+ exclusions = '":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"'
132
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
133
+ diff_unstaged = `git diff . #{exclusions}`.chomp
69
134
 
70
- if diff.empty?
71
- puts "▲ No staged changes found. Make sure there are changes and run `git add .`".red
72
- return nil
135
+ if !diff_unstaged.empty?
136
+ if !diff_cached.empty?
137
+ # Scenario: Mixed state (some staged, some not)
138
+ puts "▲ You have both staged and unstaged changes:".yellow
139
+
140
+ staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
141
+ unstaged_files = `git diff --name-status . #{exclusions}`.chomp
142
+
143
+ puts "\n #{'Staged changes:'.green}"
144
+ puts staged_files.gsub(/^/, " ")
145
+
146
+ puts "\n #{'Unstaged changes:'.red}"
147
+ puts unstaged_files.gsub(/^/, " ")
148
+ puts ""
149
+
150
+ prompt = TTY::Prompt.new
151
+ choice = prompt.select("How to proceed?") do |menu|
152
+ menu.choice "Include unstaged changes (git add .)", :add_all
153
+ menu.choice "Use staged changes only", :staged_only
154
+ menu.choice "Exit", :exit
155
+ end
156
+
157
+ case choice
158
+ when :add_all
159
+ puts "▲ Running git add .".yellow
160
+ system("git add .")
161
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
162
+ when :exit
163
+ return nil
164
+ end
165
+ else
166
+ # Scenario: Only unstaged changes
167
+ choice = prompt_no_staged_changes
168
+ case choice
169
+ when :add_all
170
+ puts "▲ Running git add .".yellow
171
+ system("git add .")
172
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
173
+ if diff_cached.empty?
174
+ puts "▲ Still no changes to commit.".red
175
+ return nil
176
+ end
177
+ when :exit
178
+ return nil
179
+ end
180
+ end
181
+ elsif diff_cached.empty?
182
+ # Scenario: No changes at all (staged or unstaged)
183
+ # Check if there are ANY unstaged files (maybe untracked?)
184
+ # git status --porcelain includes untracked files
185
+ git_status = `git status --porcelain`.chomp
186
+ if git_status.empty?
187
+ puts "▲ No changes to commit. Working tree clean.".yellow
188
+ return nil
189
+ else
190
+ # Only untracked files? Or ignored files?
191
+ # If diff_unstaged is empty but git status is not, it usually means untracked files.
192
+ # Let's offer to add them too.
193
+ choice = prompt_no_staged_changes
194
+ case choice
195
+ when :add_all
196
+ puts "▲ Running git add .".yellow
197
+ system("git add .")
198
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
199
+ when :exit
200
+ return nil
201
+ end
202
+ end
73
203
  end
74
204
 
75
- if diff.length > AICM_DIFF_LEN
76
- puts "▲ The diff is too large (#{diff.length} chars, max #{AICM_DIFF_LEN}). Set AICM_DIFF_LEN to increase limit.".red
77
- return nil
205
+ diff = diff_cached
206
+
207
+ if diff.length > @diff_len
208
+ choice = prompt_diff_handling(diff.length, @diff_len)
209
+ case choice
210
+ when :truncate
211
+ puts "▲ Truncating diff to #{@diff_len} chars...".yellow
212
+ diff = diff[0...@diff_len]
213
+ when :unlimited
214
+ puts "▲ Using full diff (#{diff.length} chars)...".yellow
215
+ when :exit
216
+ return nil
217
+ end
78
218
  end
79
219
 
80
220
  diff
81
221
  end
82
222
 
223
+ def prompt_no_staged_changes
224
+ puts "▲ No staged changes found (but unstaged/untracked files exist).".yellow
225
+ prompt = TTY::Prompt.new
226
+ begin
227
+ prompt.select("Choose an option:") do |menu|
228
+ menu.choice "Run 'git add .' to stage all changes", :add_all
229
+ menu.choice "Exit (stage files manually)", :exit
230
+ end
231
+ rescue TTY::Reader::InputInterrupt, Interrupt
232
+ :exit
233
+ end
234
+ end
235
+
236
+ def prompt_diff_handling(current_len, max_len)
237
+ puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
238
+ prompt = TTY::Prompt.new
239
+ begin
240
+ prompt.select("Choose an option:") do |menu|
241
+ menu.choice "Use first #{max_len} characters to generate commit message", :truncate
242
+ menu.choice "Use unlimited characters (may fail or be slow)", :unlimited
243
+ menu.choice "Exit", :exit
244
+ end
245
+ rescue TTY::Reader::InputInterrupt, Interrupt
246
+ :exit
247
+ end
248
+ end
249
+
83
250
  def welcome
84
251
  puts "\n▲ Welcome to AI Commits!".green
85
252
 
86
- if AICM_KEY.nil? && AICM_LINK == "https://api.openai.com/v1"
87
- puts "▲ Please save your API key as an env variable by doing 'export AICM_KEY=YOUR_API_KEY'".red
253
+ # Check if config exists
254
+ unless ConfigManager.config_exists?
255
+ puts "▲ Configuration not found. Generating default config...".yellow
256
+ ConfigManager.generate_default_configs
257
+ puts "▲ Please run 'aicm setup' to configure your provider.".red
258
+ return false
259
+ end
260
+
261
+ # Check if active provider is configured
262
+ if @api_key.nil? || @api_key.empty?
263
+ puts "▲ No active provider configured. Please run 'aicm setup'.".red
264
+ return false
265
+ end
266
+
267
+ if @model.nil? || @model.empty?
268
+ puts "▲ No model selected. Please run 'aicm setup'.".red
88
269
  return false
89
270
  end
90
271
 
@@ -116,45 +297,199 @@ module CommitGpt
116
297
  }
117
298
  ]
118
299
 
300
+ # Check config for disable_reasoning support (default true if not set)
301
+ provider_config = ConfigManager.get_active_provider_config
302
+ can_disable_reasoning = provider_config.key?("can_disable_reasoning") ? provider_config["can_disable_reasoning"] : true
303
+ # Get configured max_tokens or default to 2000
304
+ configured_max_tokens = provider_config["max_tokens"] || 2000
305
+
119
306
  payload = {
120
- model: AICM_MODEL,
307
+ model: @model,
121
308
  messages: messages,
122
- temperature: 0.7,
123
- max_tokens: 300,
124
- disable_reasoning: true
309
+ temperature: 0.5,
310
+ stream: true
125
311
  }
126
312
 
313
+ if can_disable_reasoning
314
+ payload[:disable_reasoning] = true
315
+ payload[:max_tokens] = 300
316
+ else
317
+ payload[:max_tokens] = configured_max_tokens
318
+ end
319
+
320
+ # Initial UI feedback (only on first try)
321
+ puts "....... Generating your AI commit message ......".gray unless defined?(@is_retrying) && @is_retrying
322
+
323
+ full_content = ""
324
+ full_reasoning = ""
325
+ printed_reasoning = false
326
+ printed_content_prefix = false
327
+ stop_stream = false
328
+
329
+ uri = URI("#{@base_url}/chat/completions")
330
+ http = Net::HTTP.new(uri.host, uri.port)
331
+ http.use_ssl = (uri.scheme == "https")
332
+ http.read_timeout = 120
333
+
334
+ request = Net::HTTP::Post.new(uri)
335
+ request["Content-Type"] = "application/json"
336
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
337
+ request.body = payload.to_json
338
+
127
339
  begin
128
- headers = {
129
- "Content-Type" => "application/json",
130
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
131
- }
132
- headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
340
+ http.request(request) do |response|
341
+ if response.code != "200"
342
+ # Parse error body
343
+ error_body = response.read_body
344
+ result = JSON.parse(error_body) rescue nil
345
+
346
+ error_msg = if result
347
+ result.dig("error", "message") || result["error"] || result["message"]
348
+ else
349
+ error_body
350
+ end
133
351
 
134
- response = HTTParty.post("#{AICM_LINK}/chat/completions",
135
- headers: headers,
136
- body: payload.to_json)
352
+ if error_msg.nil? || error_msg.to_s.strip.empty?
353
+ error_msg = "HTTP #{response.code}"
354
+ error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
355
+ end
356
+
357
+ if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == "400")
358
+ puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
359
+ ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
360
+ @is_retrying = true
361
+ return generate_commit(diff)
362
+ else
363
+ puts "▲ API Error: #{error_msg}".red
364
+ return nil
365
+ end
366
+ end
367
+
368
+ # Process Streaming Response
369
+ buffer = ""
370
+ response.read_body do |chunk|
371
+ break if stop_stream
137
372
 
138
- # Check for API error response
139
- if response["error"]
140
- puts "▲ API Error: #{response['error']['message']}".red
141
- return nil
142
- end
373
+ buffer += chunk
374
+ while (line_end = buffer.index("\n"))
375
+ line = buffer.slice!(0, line_end + 1).strip
376
+ next if line.empty?
377
+ next unless line.start_with?("data: ")
143
378
 
144
- message = response.dig("choices", 0, "message")
145
- # Some models (like zai-glm) use 'reasoning' instead of 'content'
146
- ai_commit = message&.dig("content") || message&.dig("reasoning")
147
- if ai_commit.nil?
148
- puts "▲ Unexpected API response format:".red
149
- puts response.inspect
150
- return nil
379
+ data_str = line[6..-1]
380
+ next if data_str == "[DONE]"
381
+
382
+ begin
383
+ data = JSON.parse(data_str)
384
+ delta = data.dig("choices", 0, "delta")
385
+ next unless delta
386
+
387
+ # Handle Reasoning
388
+ reasoning_chunk = delta["reasoning_content"] || delta["reasoning"]
389
+ if reasoning_chunk && !reasoning_chunk.empty?
390
+ unless printed_reasoning
391
+ puts "\nThinking...".gray
392
+ end
393
+ print reasoning_chunk.gray
394
+ full_reasoning += reasoning_chunk
395
+ printed_reasoning = true
396
+ $stdout.flush
397
+ end
398
+
399
+ # Handle Content
400
+ content_chunk = delta["content"]
401
+ if content_chunk && !content_chunk.empty?
402
+ if printed_reasoning && !printed_content_prefix
403
+ puts "" # Newline after reasoning block
404
+ end
405
+
406
+ unless printed_content_prefix
407
+ print "\n▲ Commit message: git commit -am \"".green
408
+ printed_content_prefix = true
409
+ end
410
+
411
+ # Prevent infinite loops/repetitive garbage
412
+ if full_content.length + content_chunk.length > 300
413
+ stop_stream = true
414
+ break
415
+ end
416
+
417
+ print content_chunk.green
418
+ full_content += content_chunk
419
+ $stdout.flush
420
+ end
421
+
422
+ # Handle Usage (some providers send usage at the end)
423
+ if data["usage"]
424
+ @last_usage = data["usage"]
425
+ end
426
+
427
+ rescue JSON::ParserError
428
+ # Partial JSON, wait for more data
429
+ end
430
+ end
431
+ end
151
432
  end
152
433
  rescue StandardError => e
153
434
  puts "▲ Error: #{e.message}".red
154
435
  return nil
155
436
  end
437
+
438
+ # Close the quote
439
+ puts "\"".green if printed_content_prefix
440
+
441
+ # Post-processing Logic (Retry if empty content)
442
+ if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
443
+ if can_disable_reasoning
444
+ puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
445
+ ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
446
+ @is_retrying = true
447
+ return generate_commit(diff)
448
+ else
449
+ puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
450
+ prompt = TTY::Prompt.new
451
+ choice = prompt.select("Choose an action:") do |menu|
452
+ menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
453
+ menu.choice "Set custom max_tokens...", :custom
454
+ menu.choice "Abort", :abort
455
+ end
456
+
457
+ new_max = case choice
458
+ when :double
459
+ configured_max_tokens * 2
460
+ when :custom
461
+ prompt.ask("Enter new max_tokens:", convert: :int)
462
+ when :abort
463
+ return nil
464
+ end
465
+
466
+ if new_max
467
+ puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
468
+ ConfigManager.update_provider(provider_config["name"], { "max_tokens" => new_max })
469
+ @is_retrying = true
470
+ return generate_commit(diff)
471
+ end
472
+ return nil
473
+ end
474
+ end
475
+
476
+ if full_content.empty? && full_reasoning.empty?
477
+ puts "▲ No response from AI.".red
478
+ return nil
479
+ end
480
+
481
+ # Print usage info if available (saved from stream or approximated)
482
+ if defined?(@last_usage) && @last_usage
483
+ puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
484
+ @last_usage = nil
485
+ end
486
+
487
+ # Reset retrying flag
488
+ @is_retrying = false
156
489
 
157
- ai_commit.gsub(/(\r\n|\n|\r)/, "").gsub(/\A["']|["']\z/, "")
490
+ # Take only the first non-empty line to avoid repetition or multi-line garbage
491
+ first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
492
+ first_line&.gsub(/\A["']|["']\z/, "") || ""
158
493
  end
159
494
  end
160
495
  end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "provider_presets"
6
+ require_relative "string"
7
+
8
+ module CommitGpt
9
+ # Manages configuration files for CommitGPT
10
+ class ConfigManager
11
+ class << self
12
+ # Get the config directory path
13
+ def config_dir
14
+ File.expand_path("~/.config/commitgpt")
15
+ end
16
+
17
+ # Get main config file path
18
+ def main_config_path
19
+ File.join(config_dir, "config.yml")
20
+ end
21
+
22
+ # Get local config file path
23
+ def local_config_path
24
+ File.join(config_dir, "config.local.yml")
25
+ end
26
+
27
+ # Ensure config directory exists
28
+ def ensure_config_dir
29
+ FileUtils.mkdir_p(config_dir) unless Dir.exist?(config_dir)
30
+ end
31
+
32
+ # Check if config files exist
33
+ def config_exists?
34
+ File.exist?(main_config_path)
35
+ end
36
+
37
+ # Load and merge configuration files
38
+ def load_config
39
+ return nil unless config_exists?
40
+
41
+ main_config = YAML.load_file(main_config_path) || {}
42
+ local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : {}
43
+
44
+ merge_configs(main_config, local_config)
45
+ end
46
+
47
+ # Get active provider configuration
48
+ def get_active_provider_config
49
+ config = load_config
50
+ return nil if config.nil? || config["active_provider"].nil?
51
+
52
+ active_provider = config["active_provider"]
53
+ providers = config["providers"] || []
54
+
55
+ providers.find { |p| p["name"] == active_provider }
56
+ end
57
+
58
+ # Save main config
59
+ def save_main_config(config)
60
+ ensure_config_dir
61
+ File.write(main_config_path, config.to_yaml)
62
+ end
63
+
64
+ # Save local config
65
+ def save_local_config(config)
66
+ ensure_config_dir
67
+ File.write(local_config_path, config.to_yaml)
68
+ end
69
+
70
+ # Generate default configuration files
71
+ def generate_default_configs
72
+ ensure_config_dir
73
+
74
+ # Generate main config with all providers but empty models
75
+ providers = PROVIDER_PRESETS.map do |preset|
76
+ {
77
+ "name" => preset[:value],
78
+ "model" => "",
79
+ "diff_len" => 32768,
80
+ "base_url" => preset[:base_url]
81
+ }
82
+ end
83
+
84
+ main_config = {
85
+ "providers" => providers,
86
+ "active_provider" => ""
87
+ }
88
+
89
+ # Generate local config with empty API keys
90
+ local_providers = PROVIDER_PRESETS.map do |preset|
91
+ {
92
+ "name" => preset[:value],
93
+ "api_key" => ""
94
+ }
95
+ end
96
+
97
+ local_config = {
98
+ "providers" => local_providers
99
+ }
100
+
101
+ save_main_config(main_config)
102
+ save_local_config(local_config)
103
+
104
+ # Remind user to add config.local.yml to .gitignore
105
+ puts "▲ Generated default configuration files.".green
106
+ puts "▲ Remember to add ~/.config/commitgpt/config.local.yml to your .gitignore".yellow
107
+ end
108
+
109
+ # Get list of configured providers (with API keys)
110
+ def configured_providers
111
+ config = load_config
112
+ return [] if config.nil?
113
+
114
+ providers = config["providers"] || []
115
+ providers.select { |p| p["api_key"] && !p["api_key"].empty? }
116
+ end
117
+
118
+ # Update provider configuration
119
+ def update_provider(provider_name, main_attrs = {}, local_attrs = {})
120
+ # Update main config
121
+ main_config = YAML.load_file(main_config_path)
122
+ provider = main_config["providers"].find { |p| p["name"] == provider_name }
123
+ provider&.merge!(main_attrs)
124
+ save_main_config(main_config)
125
+
126
+ # Update local config
127
+ local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : { "providers" => [] }
128
+ local_provider = local_config["providers"].find { |p| p["name"] == provider_name }
129
+ if local_provider
130
+ local_provider.merge!(local_attrs)
131
+ else
132
+ local_config["providers"] << { "name" => provider_name }.merge(local_attrs)
133
+ end
134
+ save_local_config(local_config)
135
+ end
136
+
137
+ # Set active provider
138
+ def set_active_provider(provider_name)
139
+ main_config = YAML.load_file(main_config_path)
140
+ main_config["active_provider"] = provider_name
141
+ save_main_config(main_config)
142
+ end
143
+
144
+ private
145
+
146
+ # Merge main config with local config (local overrides main)
147
+ def merge_configs(main_config, local_config)
148
+ result = main_config.dup
149
+
150
+ # Merge provider-specific settings
151
+ main_providers = main_config["providers"] || []
152
+ local_providers = local_config["providers"] || []
153
+
154
+ merged_providers = main_providers.map do |main_provider|
155
+ local_provider = local_providers.find { |lp| lp["name"] == main_provider["name"] }
156
+ local_provider ? main_provider.merge(local_provider) : main_provider
157
+ end
158
+
159
+ result["providers"] = merged_providers
160
+ result
161
+ end
162
+ end
163
+ end
164
+ end