aicli 0.1.1 → 0.2.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38ddbde1455388bed7ee62cff84c0aaa5cbc7329b0210fb88aacdf9fe39a0e13
4
- data.tar.gz: 645ca62e82b191c88951293c8b04263428c7d5c3e4dfa7e6481db6ff59771a1c
3
+ metadata.gz: 49c27ca0316a3ca251344ea8a63589d603d67a6a89e77287b6615f9314650ed1
4
+ data.tar.gz: c44be18b27d259f0fd8f93f2133246775d042ab09576be64dc8138353d53d095
5
5
  SHA512:
6
- metadata.gz: a36e4429798ec0261b2d65b39d038e6276d7680ed87d042d8b8a20b5ede788b5ba5010303103d36c87f25b69ebdcd520ed651f4668110d0ad3d341fb6d0852bf
7
- data.tar.gz: 68d9f6c8c3789eca4c5b9ff4e65e31d3598ab5f917985293dab89048c3651ba20ba7761fa46c06f04b06f247b0316adb785c0d0cb611955ae1db032a42f6a85c
6
+ metadata.gz: becb34f20ed09c6d7de4f68c72b81aa6ae83cb72bf158e401c35bdabc291a2339d2fa16d845abf6934c826d1be0cbb50f48f45e9bec08a7c554a3e79bf8ba5c3
7
+ data.tar.gz: 9dc02259b8ea17da0d3a73aaade312cc88a0c234b9c6418014ac840025f8b440c9c735ac605165b898b00fe1e53e37f14365c60943652b6561e8ffa0b603f0ad
data/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.2.2
2
+
3
+ - Improve `ai update`: compare installed version with RubyGems, install latest explicitly, clearer status messages
4
+
5
+ ## 0.2.1
6
+
7
+ - Fix provider/model sync so Anthropic models are not sent to OpenAI
8
+ - Show active `provider / model` on each run
9
+ - Clearer error when a model is unavailable for the API key
10
+ - `config set MODEL=...` infers PROVIDER from the RubyLLM registry
11
+
12
+ ## 0.2.0
13
+
14
+ - Add Anthropic provider alongside OpenAI
15
+ - Route all LLM calls through RubyLLM
16
+ - Choose provider and model in `aicli config` (model list from RubyLLM registry)
17
+ - Config keys: `PROVIDER`, `ANTHROPIC_KEY` (plus existing `OPENAI_KEY` / `MODEL`)
18
+
19
+ ## 0.1.0
20
+
21
+ - Initial RubyGems release as `aicli`
22
+
1
23
  ## 1.0.12
2
24
 
3
25
  - Bug fixes
data/README.md CHANGED
@@ -38,16 +38,25 @@ Ruby port by [Antonio Molinari](https://github.com/magnum), inspired by the orig
38
38
  gem build aicli.gemspec && gem install ./aicli-*.gem
39
39
  ```
40
40
 
41
- 2. Retrieve your API key from [OpenAI](https://platform.openai.com/account/api-keys)
41
+ 2. Choose a provider and set the matching API key.
42
42
 
43
- > Note: If you haven't already, you'll have to create an account and set up billing.
44
-
45
- 3. Set the key so aicli can use it:
43
+ **OpenAI** (default) key from [OpenAI](https://platform.openai.com/account/api-keys):
46
44
 
47
45
  ```sh
46
+ aicli config set PROVIDER=openai
48
47
  aicli config set OPENAI_KEY=<your token>
49
48
  ```
50
49
 
50
+ **Anthropic** — key from [Anthropic](https://console.anthropic.com/settings/keys):
51
+
52
+ ```sh
53
+ aicli config set PROVIDER=anthropic
54
+ aicli config set ANTHROPIC_KEY=<your token>
55
+ ```
56
+
57
+ Or use the interactive UI (`aicli config`) to pick provider, key, and model.
58
+ Model lists come from the [RubyLLM](https://rubyllm.com/models/) registry.
59
+
51
60
  This will create a `.aicli` file in your home directory.
52
61
 
53
62
  ## Usage
@@ -94,7 +103,20 @@ Or save the preference:
94
103
  aicli config set SILENT_MODE=true
95
104
  ```
96
105
 
97
- ### Custom API endpoint
106
+ ### Provider and model
107
+
108
+ ```sh
109
+ aicli config set PROVIDER=openai # or anthropic
110
+ aicli config set MODEL=gpt-4o-mini # provider-specific model id
111
+ ```
112
+
113
+ Defaults: `gpt-4o-mini` (OpenAI), `claude-sonnet-4-6` (Anthropic).
114
+
115
+ LLM calls go through [RubyLLM](https://rubyllm.com/), so chat streaming and model metadata stay provider-agnostic.
116
+
117
+ ### Custom OpenAI API endpoint
118
+
119
+ Useful for OpenAI-compatible proxies (ignored for Anthropic):
98
120
 
99
121
  ```sh
100
122
  aicli config set OPENAI_API_ENDPOINT=<your proxy endpoint>
@@ -148,20 +170,32 @@ aicli update
148
170
 
149
171
  ## Development
150
172
 
173
+ Run the local checkout (not the installed gem) with Bundler from this directory:
174
+
151
175
  ```sh
152
176
  bundle install
153
- bundle exec bin/aicli --help
154
- bundle exec bin/aicli config set OPENAI_KEY=<your token>
155
- bundle exec bin/aicli list all log files
177
+
178
+ bundle exec bin/ai --help
179
+ bundle exec bin/ai config
180
+ bundle exec bin/ai chat
181
+ bundle exec bin/ai list all log files
156
182
  ```
157
183
 
158
- ## Common Issues
184
+ `bin/aicli` is equivalent to `bin/ai`. `bundle exec` loads dependencies from the Gemfile and code from `lib/`.
159
185
 
160
- ### 429 error
186
+ Example config for local testing:
187
+
188
+ ```sh
189
+ bundle exec bin/ai config set PROVIDER=openai
190
+ bundle exec bin/ai config set OPENAI_KEY=<your token>
191
+ # or: PROVIDER=anthropic + ANTHROPIC_KEY=<your token>
192
+ ```
193
+
194
+ ## Common Issues
161
195
 
162
- This is due to incorrect billing setup or excessive quota usage. Please follow [this guide](https://help.openai.com/en/articles/6891831-error-code-429-you-exceeded-your-current-quota-please-check-your-plan-and-billing-details) to fix it.
196
+ ### Rate limit / quota errors
163
197
 
164
- You can activate billing at [this link](https://platform.openai.com/account/billing/overview).
198
+ Usually billing or quota on the configured provider (OpenAI or Anthropic). Check that provider’s console billing page and that the matching API key is set in `~/.aicli`.
165
199
 
166
200
  ## Motivation
167
201
 
data/lib/aicli/cli.rb CHANGED
@@ -86,9 +86,9 @@ module AiCli
86
86
  Commands::Update.run
87
87
  end
88
88
 
89
- map %w[--version -v] => :__print_version
89
+ map %w[--version -v] => :version
90
90
  desc 'version', 'Print version'
91
- def __print_version
91
+ def version
92
92
  puts AiCli::VERSION
93
93
  end
94
94
 
@@ -11,16 +11,14 @@ module AiCli
11
11
 
12
12
  def run
13
13
  config = Helpers::Config.get
14
- key = config['OPENAI_KEY']
15
- model = config['MODEL']
16
- api_endpoint = config['OPENAI_API_ENDPOINT']
17
- chat_history = []
14
+ chat = Helpers::Completion.start_chat(config)
18
15
 
19
16
  pastel = Pastel.new
20
17
  prompt = TTY::Prompt.new(interrupt: :exit)
21
18
 
22
19
  puts ''
23
20
  puts "┌ #{Helpers::I18n.t('Starting new conversation')}"
21
+ puts pastel.dim(" #{config['PROVIDER']} / #{config['MODEL']}")
24
22
 
25
23
  loop do
26
24
  user_prompt = prompt.ask(pastel.cyan("#{Helpers::I18n.t('You')}:")) do |q|
@@ -35,22 +33,25 @@ module AiCli
35
33
 
36
34
  spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('THINKING...')}", format: :dots)
37
35
  spinner.auto_spin
38
-
39
- chat_history << { 'role' => 'user', 'content' => user_prompt }
40
-
41
- stream_lines = Helpers::Completion.generate_completion(
42
- prompt: chat_history,
43
- key: key,
44
- model: model,
45
- api_endpoint: api_endpoint
36
+ started = false
37
+
38
+ Helpers::Completion.stream_chat_message(
39
+ chat,
40
+ user_prompt,
41
+ writer: lambda { |chunk|
42
+ unless started
43
+ spinner.success(pastel.green('aicli:'))
44
+ puts ''
45
+ started = true
46
+ end
47
+ print chunk
48
+ }
46
49
  )
47
- enumerator = stream_lines.each
48
- read_response = Helpers::Completion.read_data(enumerator)
49
50
 
50
- spinner.success(pastel.green('aicli:'))
51
- puts ''
52
- full_response = read_response.call(->(chunk) { print chunk })
53
- chat_history << { 'role' => 'assistant', 'content' => full_response }
51
+ unless started
52
+ spinner.success(pastel.green('aicli:'))
53
+ puts ''
54
+ end
54
55
  puts ''
55
56
  puts ''
56
57
  end
@@ -1,21 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
3
6
  require 'pastel'
4
7
 
5
8
  module AiCli
6
9
  module Commands
7
10
  module Update
11
+ RUBYGEMS_GEM_URI = 'https://rubygems.org/api/v1/gems/aicli.json'
12
+
8
13
  module_function
9
14
 
10
15
  def run
11
16
  pastel = Pastel.new
17
+ current = Gem::Version.new(AiCli::VERSION)
18
+
19
+ puts ''
20
+ puts pastel.dim("Current version: #{current}")
21
+
22
+ latest_str = fetch_latest_version
23
+ latest = Gem::Version.new(latest_str)
24
+ puts pastel.dim("Latest on RubyGems: #{latest}")
12
25
  puts ''
13
- command = 'gem update aicli'
14
- puts pastel.dim("#{Helpers::I18n.t('Running')}: #{command}")
26
+
27
+ if latest < current
28
+ puts pastel.yellow(
29
+ "Installed #{current} is newer than RubyGems #{latest}. Skipping update."
30
+ )
31
+ puts pastel.dim('Publish a new version, then run `ai update` again.')
32
+ return
33
+ end
34
+
35
+ if latest == current
36
+ puts pastel.green("Already up to date (#{current}).")
37
+ return
38
+ end
39
+
40
+ command = ['gem', 'install', 'aicli', '-v', latest_str]
41
+ puts pastel.dim("#{Helpers::I18n.t('Running')}: #{command.join(' ')}")
15
42
  puts ''
16
- system(command)
43
+
44
+ ok = system(*command)
17
45
  puts ''
46
+
47
+ unless ok
48
+ raise Helpers::KnownError,
49
+ 'Failed to update aicli. Try manually: gem install aicli'
50
+ end
51
+
52
+ puts pastel.green("Updated aicli to #{latest}.")
53
+ puts pastel.dim('Open a new shell if `ai version` still shows the old number.')
54
+ rescue Helpers::KnownError => e
55
+ puts "\n#{pastel.red('✖')} #{e.message}"
56
+ exit 1
57
+ rescue StandardError => e
58
+ puts "\n#{pastel.red('✖')} Could not check RubyGems for updates: #{e.message}"
59
+ Helpers::Error.handle_cli_error(e)
60
+ exit 1
61
+ end
62
+
63
+ def fetch_latest_version
64
+ uri = URI(RUBYGEMS_GEM_URI)
65
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
66
+ http.open_timeout = 10
67
+ http.read_timeout = 10
68
+ http.get(uri.request_uri)
69
+ end
70
+
71
+ unless response.is_a?(Net::HTTPSuccess)
72
+ raise "RubyGems returned HTTP #{response.code}"
73
+ end
74
+
75
+ version = JSON.parse(response.body)['version'].to_s
76
+ raise 'RubyGems response missing version' if version.empty?
77
+
78
+ version
18
79
  end
80
+ private_class_method :fetch_latest_version
19
81
  end
20
82
  end
21
83
  end
@@ -1,219 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'net/http'
5
- require 'uri'
6
- require 'thread'
7
-
8
3
  module AiCli
9
4
  module Helpers
10
5
  module Completion
11
6
  EXPLAIN_IN_SECOND_REQUEST = true
12
- SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n/i, /```[a-zA-Z]*/i, "\n"].freeze
7
+ SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n?/i, /```[a-zA-Z]*/i, "\n"].freeze
13
8
 
14
9
  module_function
15
10
 
16
- def get_script_and_info(prompt:, key:, api_endpoint:, model: nil)
17
- stream = generate_completion(
18
- prompt: full_prompt(prompt),
19
- key: key,
20
- model: model,
21
- api_endpoint: api_endpoint
22
- )
23
- enumerator = stream.each
24
-
11
+ def get_script_and_info(prompt:, config:)
25
12
  {
26
- read_script: read_data(enumerator, *SHELL_CODE_EXCLUSIONS),
27
- read_info: read_data(enumerator, *SHELL_CODE_EXCLUSIONS)
13
+ read_script: stream_prompt(full_prompt(prompt), config: config, exclusions: SHELL_CODE_EXCLUSIONS),
14
+ read_info: ->(_writer) { '' }
28
15
  }
29
16
  end
30
17
 
31
- def get_explanation(script:, key:, api_endpoint:, model: nil)
32
- stream = generate_completion(
33
- prompt: explanation_prompt(script),
34
- key: key,
35
- model: model,
36
- api_endpoint: api_endpoint
37
- )
38
- { read_explanation: read_data(stream.each) }
39
- end
40
-
41
- def get_revision(prompt:, code:, key:, api_endpoint:, model: nil)
42
- stream = generate_completion(
43
- prompt: revision_prompt(prompt, code),
44
- key: key,
45
- model: model,
46
- api_endpoint: api_endpoint
47
- )
48
- { read_script: read_data(stream.each, *SHELL_CODE_EXCLUSIONS) }
49
- end
50
-
51
- def get_models(key, api_endpoint)
52
- uri = URI.join(ensure_trailing_slash(api_endpoint), 'models')
53
- http = Net::HTTP.new(uri.host, uri.port)
54
- http.use_ssl = uri.scheme == 'https'
55
-
56
- request = Net::HTTP::Get.new(uri)
57
- request['Authorization'] = "Bearer #{key}"
58
- request['Content-Type'] = 'application/json'
59
-
60
- response = http.request(request)
61
- raise KnownError, "Failed to list models: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
-
63
- data = JSON.parse(response.body)
64
- data.fetch('data', []).select { |m| m['object'] == 'model' }
18
+ def get_explanation(script:, config:)
19
+ { read_explanation: stream_prompt(explanation_prompt(script), config: config) }
65
20
  end
66
21
 
67
- # Returns a live Enumerator of SSE "data: ..." lines
68
- def generate_completion(prompt:, key:, api_endpoint:, model: nil, number: 1)
69
- messages = if prompt.is_a?(Array)
70
- prompt
71
- else
72
- [{ 'role' => 'user', 'content' => prompt }]
73
- end
74
-
75
- uri = URI.join(ensure_trailing_slash(api_endpoint), 'chat/completions')
76
- body = {
77
- model: model || 'gpt-4o-mini',
78
- messages: messages,
79
- n: [number, 10].min,
80
- stream: true
22
+ def get_revision(prompt:, code:, config:)
23
+ {
24
+ read_script: stream_prompt(
25
+ revision_prompt(prompt, code),
26
+ config: config,
27
+ exclusions: SHELL_CODE_EXCLUSIONS
28
+ )
81
29
  }
30
+ end
82
31
 
83
- queue = Queue.new
84
- error_box = []
85
-
86
- Thread.new do
87
- http = Net::HTTP.new(uri.host, uri.port)
88
- http.use_ssl = uri.scheme == 'https'
89
- http.read_timeout = 120
90
-
91
- request = Net::HTTP::Post.new(uri)
92
- request['Authorization'] = "Bearer #{key}"
93
- request['Content-Type'] = 'application/json'
94
- request['Accept'] = 'text/event-stream'
95
- request.body = JSON.generate(body)
96
-
97
- http.request(request) do |response|
98
- unless response.is_a?(Net::HTTPSuccess)
99
- error_box << [:api, response.code.to_i, response.body]
100
- queue << :done
101
- next
102
- end
103
-
104
- buffer = +''
105
- response.read_body do |chunk|
106
- buffer << chunk
107
- while (eol = buffer.index("\n"))
108
- line = buffer.slice!(0..eol).strip
109
- queue << line if line.start_with?('data: ')
110
- end
111
- end
112
- end
113
- queue << :done
114
- rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
115
- error_box << [:network, uri.host, e.message]
116
- queue << :done
117
- rescue StandardError => e
118
- error_box << [:other, e]
119
- queue << :done
120
- end
121
-
122
- Enumerator.new do |yielder|
123
- loop do
124
- item = queue.pop
125
- break if item == :done
126
-
127
- yielder << item
128
- end
129
-
130
- if (err = error_box.first)
131
- case err[0]
132
- when :api
133
- handle_api_error(err[1], err[2])
134
- when :network
135
- raise KnownError,
136
- "Error connecting to #{err[1]}. Are you connected to the internet? (#{err[2]})"
137
- when :other
138
- raise err[1]
139
- end
140
- end
141
- end
32
+ def get_models(provider)
33
+ Llm.list_chat_models(provider)
142
34
  end
143
35
 
144
- def read_data(enumerator, *excluded)
36
+ # Streams a single-turn completion. Returns a callable that takes a writer.
37
+ def stream_prompt(prompt, config:, exclusions: [])
145
38
  lambda do |writer|
146
- data = +''
147
- data_start = false
148
- buffer = +''
149
- excluded_prefix = excluded.first
150
-
151
- loop do
152
- chunk = enumerator.next
153
- payloads = chunk.to_s.split("\n\n")
154
-
155
- payloads.each do |payload|
156
- return data if payload.include?('[DONE]')
157
-
158
- next unless payload.start_with?('data:')
159
-
160
- content = parse_content(payload)
161
-
162
- unless data_start
163
- buffer << content
164
- if excluded_prefix.nil? || buffer.match?(excluded_prefix)
165
- data_start = true
166
- buffer = +''
167
- break if excluded_prefix
168
- end
169
- end
170
-
171
- next unless data_start && !content.empty?
172
-
173
- cleaned = StripRegexPatterns.call(content, excluded)
174
- data << cleaned
175
- writer.call(cleaned)
176
- end
177
- rescue StopIteration
178
- break
179
- end
180
-
181
- data
39
+ stream_to_writer(prompt, config: config, exclusions: exclusions, writer: writer)
182
40
  end
183
41
  end
184
42
 
185
- def parse_content(payload)
186
- raw = payload.sub(/^data:\s*/, '')
187
- delta = JSON.parse(raw.strip)
188
- delta.dig('choices', 0, 'delta', 'content') || ''
189
- rescue JSON::ParserError => e
190
- "Error with JSON.parse and #{payload}.\n#{e}"
43
+ # Multi-turn chat helper used by `aicli chat`.
44
+ def start_chat(config)
45
+ Llm.build_chat(config)
191
46
  end
192
47
 
193
- def handle_api_error(status, body)
194
- message_string = begin
195
- JSON.pretty_generate(JSON.parse(body))
196
- rescue StandardError
197
- body.to_s
198
- end
199
-
200
- if status == 429
201
- raise KnownError, <<~MSG
202
- Request to OpenAI failed with status 429. This is due to incorrect billing setup or excessive quota usage. Please follow this guide to fix it: https://help.openai.com/en/articles/6891831-error-code-429-you-exceeded-your-current-quota-please-check-your-plan-and-billing-details
48
+ def stream_chat_message(chat, message, writer:)
49
+ data = +''
203
50
 
204
- You can activate billing here: https://platform.openai.com/account/billing/overview . Make sure to add a payment method if not under an active grant from OpenAI.
51
+ begin
52
+ chat.ask(message) do |chunk|
53
+ content = chunk.content.to_s
54
+ next if content.empty?
205
55
 
206
- Full message from OpenAI:
207
-
208
- #{message_string}
209
- MSG
56
+ data << content
57
+ writer.call(content)
58
+ end
59
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
60
+ handle_ruby_llm_error(e)
210
61
  end
211
62
 
212
- raise KnownError, <<~MSG
213
- Request to OpenAI failed with status #{status}:
214
-
215
- #{message_string}
216
- MSG
63
+ data
217
64
  end
218
65
 
219
66
  def explanation_prompt(script)
@@ -267,9 +114,84 @@ module AiCli
267
114
  PROMPT
268
115
  end
269
116
 
270
- def ensure_trailing_slash(endpoint)
271
- endpoint.end_with?('/') ? endpoint : "#{endpoint}/"
117
+ def stream_to_writer(prompt, config:, exclusions:, writer:)
118
+ chat = Llm.build_chat(config)
119
+ data = +''
120
+ data_start = exclusions.empty?
121
+ buffer = +''
122
+ excluded_prefix = exclusions.first
123
+
124
+ begin
125
+ chat.ask(prompt) do |chunk|
126
+ content = chunk.content.to_s
127
+ next if content.empty?
128
+
129
+ unless data_start
130
+ buffer << content
131
+ next unless excluded_prefix.nil? || buffer.match?(excluded_prefix)
132
+
133
+ data_start = true
134
+ remainder = StripRegexPatterns.call(buffer, exclusions)
135
+ buffer = +''
136
+ next if remainder.empty?
137
+
138
+ data << remainder
139
+ writer.call(remainder)
140
+ next
141
+ end
142
+
143
+ cleaned = StripRegexPatterns.call(content, exclusions)
144
+ next if cleaned.empty?
145
+
146
+ data << cleaned
147
+ writer.call(cleaned)
148
+ end
149
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
150
+ handle_ruby_llm_error(e)
151
+ end
152
+
153
+ data
154
+ end
155
+
156
+ def handle_ruby_llm_error(error)
157
+ message = error.message.to_s
158
+
159
+ if error.is_a?(RubyLLM::RateLimitError) || error.is_a?(RubyLLM::PaymentRequiredError) ||
160
+ message.match?(/rate.?limit|quota|billing/i)
161
+ raise KnownError, <<~MSG
162
+ Request to the LLM provider failed (rate limit / quota).
163
+
164
+ Check your plan and billing details for the configured provider, then try again.
165
+
166
+ Full message:
167
+
168
+ #{message}
169
+ MSG
170
+ end
171
+
172
+ if message.match?(/model_not_found|does not exist|do not have access/i)
173
+ raise KnownError, <<~MSG
174
+ The configured model is not available for this API key/provider.
175
+
176
+ Fix with:
177
+ #{Constants::COMMAND_NAME} config
178
+ or:
179
+ #{Constants::COMMAND_NAME} config set MODEL=#{Llm.default_model_for('anthropic')}
180
+ #{Constants::COMMAND_NAME} config set PROVIDER=anthropic
181
+
182
+ Full message:
183
+
184
+ #{message}
185
+ MSG
186
+ end
187
+
188
+ raise KnownError, <<~MSG
189
+ Request to the LLM provider failed:
190
+
191
+ #{message}
192
+ MSG
272
193
  end
194
+ private_class_method :stream_to_writer, :handle_ruby_llm_error
273
195
  end
274
196
  end
275
197
  end
@@ -11,15 +11,17 @@ module AiCli
11
11
  LEGACY_CONFIG_PATH = File.join(Dir.home, '.ai-shell')
12
12
 
13
13
  CONFIG_PARSERS = {
14
+ 'PROVIDER' => lambda { |provider|
15
+ Llm.normalize_provider(provider)
16
+ },
14
17
  'OPENAI_KEY' => lambda { |key|
15
- if key.nil? || key.empty?
16
- raise KnownError,
17
- "Please set your OpenAI API key via `#{Constants::COMMAND_NAME} config set OPENAI_KEY=<your token>`"
18
- end
19
- key
18
+ key.to_s
19
+ },
20
+ 'ANTHROPIC_KEY' => lambda { |key|
21
+ key.to_s
20
22
  },
21
23
  'MODEL' => lambda { |model|
22
- model.nil? || model.empty? ? 'gpt-4o-mini' : model
24
+ model.to_s
23
25
  },
24
26
  'SILENT_MODE' => lambda { |mode|
25
27
  mode.to_s.downcase == 'true'
@@ -43,21 +45,48 @@ module AiCli
43
45
  parsed[key] = parser.call(value)
44
46
  end
45
47
 
48
+ raw_model = cli_config&.dig('MODEL') || config['MODEL']
49
+ if raw_model.nil? || raw_model.to_s.empty?
50
+ parsed['MODEL'] = Llm.default_model_for(parsed['PROVIDER'])
51
+ end
52
+
53
+ provider, model = Llm.resolve_provider_and_model(parsed)
54
+ parsed['PROVIDER'] = provider
55
+ parsed['MODEL'] = model
46
56
  parsed
47
57
  end
48
58
 
49
59
  def set(key_values)
50
60
  config = read_config_file
61
+ updates = {}
51
62
 
52
63
  key_values.each do |key, value|
53
64
  unless CONFIG_PARSERS.key?(key)
54
65
  raise KnownError, "#{I18n.t('Invalid config property')}: #{key}"
55
66
  end
56
67
 
57
- parsed = CONFIG_PARSERS[key].call(value)
58
- config[key] = parsed.to_s
68
+ updates[key] = CONFIG_PARSERS[key].call(value).to_s
69
+ end
70
+
71
+ # Keep PROVIDER and MODEL aligned with the RubyLLM registry.
72
+ if updates.key?('MODEL') && !updates['MODEL'].empty?
73
+ model_provider = Llm.provider_for_model(updates['MODEL'])
74
+ if model_provider
75
+ updates['PROVIDER'] = model_provider unless updates.key?('PROVIDER')
76
+ end
77
+ end
78
+
79
+ if updates.key?('PROVIDER')
80
+ provider = Llm.normalize_provider(updates['PROVIDER'])
81
+ updates['PROVIDER'] = provider
82
+ next_model = updates['MODEL'] || config['MODEL']
83
+ known_ids = Completion.get_models(provider).map { |m| m['id'] }
84
+ if next_model.to_s.empty? || !known_ids.include?(next_model)
85
+ updates['MODEL'] = Llm.default_model_for(provider) unless updates.key?('MODEL') && known_ids.include?(updates['MODEL'])
86
+ end
59
87
  end
60
88
 
89
+ updates.each { |key, value| config[key] = value }
61
90
  File.write(CONFIG_PATH, stringify_ini(config))
62
91
  end
63
92
 
@@ -72,8 +101,12 @@ module AiCli
72
101
  loop do
73
102
  config = get
74
103
  choice = prompt.select("#{I18n.t('Set config')}:") do |menu|
104
+ menu.choice "#{I18n.t('Provider')} (#{display_hint(config, 'PROVIDER')})",
105
+ 'PROVIDER'
75
106
  menu.choice "#{I18n.t('OpenAI Key')} (#{display_hint(config, 'OPENAI_KEY') { |v| "sk-...#{v[-3..]}" }})",
76
107
  'OPENAI_KEY'
108
+ menu.choice "#{I18n.t('Anthropic Key')} (#{display_hint(config, 'ANTHROPIC_KEY') { |v| "...#{v[-3..]}" }})",
109
+ 'ANTHROPIC_KEY'
77
110
  menu.choice "#{I18n.t('OpenAI API Endpoint')} (#{display_hint(config, 'OPENAI_API_ENDPOINT')})",
78
111
  'OPENAI_API_ENDPOINT'
79
112
  menu.choice "#{I18n.t('Silent Mode')} (#{display_hint(config, 'SILENT_MODE')})",
@@ -86,11 +119,27 @@ module AiCli
86
119
  end
87
120
 
88
121
  case choice
122
+ when 'PROVIDER'
123
+ provider = prompt.select(I18n.t('Pick a provider')) do |menu|
124
+ Llm::PROVIDERS.each { |p| menu.choice p, p }
125
+ end
126
+ updates = [['PROVIDER', provider]]
127
+ current_model = get['MODEL']
128
+ known_ids = Completion.get_models(provider).map { |m| m['id'] }
129
+ unless known_ids.include?(current_model)
130
+ updates << ['MODEL', Llm.default_model_for(provider)]
131
+ end
132
+ set(updates)
89
133
  when 'OPENAI_KEY'
90
134
  key = prompt.ask(I18n.t('Enter your OpenAI API key')) do |q|
91
135
  q.required true
92
136
  end
93
137
  set([['OPENAI_KEY', key]])
138
+ when 'ANTHROPIC_KEY'
139
+ key = prompt.ask(I18n.t('Enter your Anthropic API key')) do |q|
140
+ q.required true
141
+ end
142
+ set([['ANTHROPIC_KEY', key]])
94
143
  when 'OPENAI_API_ENDPOINT'
95
144
  api_endpoint = prompt.ask(I18n.t('Enter your OpenAI API Endpoint'))
96
145
  set([['OPENAI_API_ENDPOINT', api_endpoint]]) if api_endpoint
@@ -99,9 +148,17 @@ module AiCli
99
148
  set([['SILENT_MODE', silent ? 'true' : 'false']])
100
149
  when 'MODEL'
101
150
  cfg = get
102
- models = Completion.get_models(cfg['OPENAI_KEY'], cfg['OPENAI_API_ENDPOINT'])
103
- model = prompt.select('Pick a model.') do |menu|
104
- models.each { |m| menu.choice m['id'], m['id'] }
151
+ models = Completion.get_models(cfg['PROVIDER'])
152
+ if models.empty?
153
+ puts pastel.yellow(I18n.t('No models found for this provider.'))
154
+ next
155
+ end
156
+ model = prompt.select(I18n.t('Pick a model.')) do |menu|
157
+ menu.default cfg['MODEL'] if models.any? { |m| m['id'] == cfg['MODEL'] }
158
+ models.each do |m|
159
+ label = m['name'].empty? || m['name'] == m['id'] ? m['id'] : "#{m['name']} (#{m['id']})"
160
+ menu.choice label, m['id']
161
+ end
105
162
  end
106
163
  set([['MODEL', model]])
107
164
  when 'LANGUAGE'
@@ -149,7 +206,7 @@ module AiCli
149
206
  end
150
207
 
151
208
  def display_hint(config, key)
152
- if config.key?(key) && !config[key].nil?
209
+ if config.key?(key) && !config[key].nil? && !(config[key].respond_to?(:empty?) && config[key].empty?)
153
210
  value = config[key]
154
211
  block_given? ? yield(value) : value.to_s
155
212
  else
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module AiCli
6
+ module Helpers
7
+ module Llm
8
+ PROVIDERS = %w[openai anthropic].freeze
9
+
10
+ DEFAULT_MODELS = {
11
+ 'openai' => 'gpt-4o-mini',
12
+ 'anthropic' => 'claude-sonnet-4-6'
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def configure!(config)
18
+ provider = normalize_provider(config['PROVIDER'])
19
+ ensure_credentials!(config, provider)
20
+
21
+ RubyLLM.configure do |c|
22
+ openai_key = config['OPENAI_KEY'].to_s
23
+ anthropic_key = config['ANTHROPIC_KEY'].to_s
24
+ endpoint = config['OPENAI_API_ENDPOINT'].to_s
25
+
26
+ c.openai_api_key = openai_key unless openai_key.empty?
27
+ c.anthropic_api_key = anthropic_key unless anthropic_key.empty?
28
+
29
+ if !endpoint.empty? && endpoint != 'https://api.openai.com/v1'
30
+ c.openai_api_base = endpoint.sub(%r{/+\z}, '')
31
+ end
32
+ end
33
+
34
+ provider
35
+ end
36
+
37
+ def build_chat(config)
38
+ provider, model = resolve_provider_and_model(config)
39
+ ensure_credentials!(config, provider)
40
+
41
+ configure!(config.merge('PROVIDER' => provider, 'MODEL' => model))
42
+
43
+ opts = { model: model, provider: provider.to_sym }
44
+ opts[:assume_model_exists] = true unless model_known?(model, provider)
45
+ RubyLLM.chat(**opts)
46
+ end
47
+
48
+ # Returns [provider, model], fixing mismatches against the RubyLLM registry.
49
+ def resolve_provider_and_model(config)
50
+ provider = normalize_provider(config['PROVIDER'])
51
+ model = config['MODEL'].to_s
52
+ model = default_model_for(provider) if model.empty?
53
+
54
+ model_provider = provider_for_model(model)
55
+ if model_provider && model_provider != provider
56
+ # Prefer the provider that actually owns the selected model.
57
+ provider = model_provider
58
+ elsif model_provider.nil? && !model_known?(model, provider)
59
+ # Unknown id for this provider: fall back to a safe default.
60
+ model = default_model_for(provider)
61
+ end
62
+
63
+ [provider, model]
64
+ end
65
+
66
+ def provider_for_model(model_id)
67
+ return nil if model_id.to_s.empty?
68
+
69
+ info = RubyLLM.models.find(model_id)
70
+ normalize_provider(info.provider)
71
+ rescue StandardError
72
+ # Try each known provider explicitly (aliases can be provider-specific).
73
+ PROVIDERS.each do |provider|
74
+ return provider if model_known?(model_id, provider)
75
+ end
76
+ nil
77
+ end
78
+
79
+ def list_chat_models(provider)
80
+ provider = normalize_provider(provider)
81
+
82
+ RubyLLM.models.chat_models
83
+ .by_provider(provider.to_sym)
84
+ .select { |m| text_chat_model?(m) }
85
+ .sort_by { |m| [m.name.to_s.downcase, m.id] }
86
+ .map { |m| { 'id' => m.id, 'name' => m.name.to_s } }
87
+ end
88
+
89
+ def default_model_for(provider)
90
+ DEFAULT_MODELS.fetch(normalize_provider(provider), DEFAULT_MODELS['openai'])
91
+ end
92
+
93
+ def normalize_provider(provider)
94
+ value = provider.to_s.downcase
95
+ PROVIDERS.include?(value) ? value : 'openai'
96
+ end
97
+
98
+ def ensure_credentials!(config, provider = nil)
99
+ provider = normalize_provider(provider || config['PROVIDER'])
100
+
101
+ case provider
102
+ when 'anthropic'
103
+ if config['ANTHROPIC_KEY'].to_s.empty?
104
+ raise KnownError,
105
+ "Please set your Anthropic API key via `#{Constants::COMMAND_NAME} config set ANTHROPIC_KEY=<your token>`"
106
+ end
107
+ else
108
+ if config['OPENAI_KEY'].to_s.empty?
109
+ raise KnownError,
110
+ "Please set your OpenAI API key via `#{Constants::COMMAND_NAME} config set OPENAI_KEY=<your token>`"
111
+ end
112
+ end
113
+ end
114
+
115
+ def model_known?(model, provider)
116
+ RubyLLM.models.find(model, provider.to_sym)
117
+ true
118
+ rescue StandardError
119
+ false
120
+ end
121
+
122
+ def text_chat_model?(model)
123
+ outputs = Array(model.modalities&.output).map(&:to_s)
124
+ return false unless outputs.include?('text')
125
+
126
+ !model.id.match?(/tts|transcribe|realtime|whisper|embedding|moderation|dall-e|imagen|search-preview|babbage|davinci|ada-|curie|instruct/i)
127
+ end
128
+ end
129
+ end
130
+ end
data/lib/aicli/prompt.rb CHANGED
@@ -14,14 +14,12 @@ module AiCli
14
14
  init_i18n
15
15
 
16
16
  config = Helpers::Config.get
17
- key = config['OPENAI_KEY']
18
- model = config['MODEL']
19
- api_endpoint = config['OPENAI_API_ENDPOINT']
20
17
  skip_explanation = silent_mode || config['SILENT_MODE']
21
18
 
22
19
  pastel = Pastel.new
23
20
  puts ''
24
21
  puts "┌ #{pastel.cyan(Helpers::Constants::PROJECT_NAME)}"
22
+ puts pastel.dim(" #{config['PROVIDER']} / #{config['MODEL']}")
25
23
 
26
24
  the_prompt = use_prompt.nil? || use_prompt.strip.empty? ? ask_prompt : use_prompt
27
25
 
@@ -30,9 +28,7 @@ module AiCli
30
28
 
31
29
  result = Helpers::Completion.get_script_and_info(
32
30
  prompt: the_prompt,
33
- key: key,
34
- model: model,
35
- api_endpoint: api_endpoint
31
+ config: config
36
32
  )
37
33
 
38
34
  spinner.success(Helpers::I18n.t('Your script') + ':')
@@ -50,9 +46,7 @@ module AiCli
50
46
  if info.nil? || info.empty?
51
47
  explanation = Helpers::Completion.get_explanation(
52
48
  script: script,
53
- key: key,
54
- model: model,
55
- api_endpoint: api_endpoint
49
+ config: config
56
50
  )
57
51
  spinner.success(Helpers::I18n.t('Explanation') + ':')
58
52
  puts ''
@@ -65,7 +59,7 @@ module AiCli
65
59
  end
66
60
  end
67
61
 
68
- run_or_revise_flow(script, key, model, api_endpoint, silent_mode)
62
+ run_or_revise_flow(script, config, silent_mode)
69
63
  end
70
64
 
71
65
  def init_i18n
@@ -102,14 +96,13 @@ module AiCli
102
96
  end
103
97
 
104
98
  def run_script(script)
105
- pastel = Pastel.new
106
99
  puts "└ #{Helpers::I18n.t('Running')}: #{script}"
107
100
  puts ''
108
101
  system(ENV['SHELL'] || 'bash', '-c', script)
109
102
  Helpers::ShellHistory.append(script)
110
103
  end
111
104
 
112
- def run_or_revise_flow(script, key, model, api_endpoint, silent_mode)
105
+ def run_or_revise_flow(script, config, silent_mode)
113
106
  prompt = TTY::Prompt.new(interrupt: :exit)
114
107
  empty_script = script.strip.empty?
115
108
 
@@ -132,7 +125,7 @@ module AiCli
132
125
  new_script = prompt.ask(Helpers::I18n.t('you can edit script here'), default: script)
133
126
  run_script(new_script) if new_script && !new_script.empty?
134
127
  when :revise
135
- revision_flow(script, key, model, api_endpoint, silent_mode)
128
+ revision_flow(script, config, silent_mode)
136
129
  when :copy
137
130
  Clipboard.copy(script)
138
131
  puts "└ #{Helpers::I18n.t('Copied to clipboard!')}"
@@ -142,7 +135,7 @@ module AiCli
142
135
  end
143
136
  end
144
137
 
145
- def revision_flow(current_script, key, model, api_endpoint, silent_mode)
138
+ def revision_flow(current_script, config, silent_mode)
146
139
  pastel = Pastel.new
147
140
  revision = ask_revision
148
141
 
@@ -152,9 +145,7 @@ module AiCli
152
145
  result = Helpers::Completion.get_revision(
153
146
  prompt: revision,
154
147
  code: current_script,
155
- key: key,
156
- model: model,
157
- api_endpoint: api_endpoint
148
+ config: config
158
149
  )
159
150
 
160
151
  spinner.success(Helpers::I18n.t('Your new script') + ':')
@@ -170,9 +161,7 @@ module AiCli
170
161
 
171
162
  explanation = Helpers::Completion.get_explanation(
172
163
  script: script,
173
- key: key,
174
- model: model,
175
- api_endpoint: api_endpoint
164
+ config: config
176
165
  )
177
166
 
178
167
  info_spinner.success(Helpers::I18n.t('Explanation') + ':')
@@ -183,7 +172,7 @@ module AiCli
183
172
  puts pastel.dim('•')
184
173
  end
185
174
 
186
- run_or_revise_flow(script, key, model, api_endpoint, silent_mode)
175
+ run_or_revise_flow(script, config, silent_mode)
187
176
  end
188
177
 
189
178
  private_class_method :init_i18n, :examples, :ask_prompt, :ask_revision,
data/lib/aicli/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AiCli
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.2'
5
5
  end
data/lib/aicli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'aicli/helpers/i18n'
7
7
  require_relative 'aicli/helpers/os_detect'
8
8
  require_relative 'aicli/helpers/strip_regex_patterns'
9
9
  require_relative 'aicli/helpers/shell_history'
10
+ require_relative 'aicli/helpers/llm'
10
11
  require_relative 'aicli/helpers/completion'
11
12
  require_relative 'aicli/helpers/config'
12
13
  require_relative 'aicli/prompt'
data/locales/en.yml CHANGED
@@ -8,14 +8,20 @@ Please set your OpenAI API key via `ai config set OPENAI_KEY=<your token>`: Plea
8
8
  set your OpenAI API key via `ai config set OPENAI_KEY=<your token>`
9
9
  Set config: Set config
10
10
  Enter your OpenAI API key: Enter your OpenAI API key
11
+ Enter your Anthropic API key: Enter your Anthropic API key
11
12
  "(not set)": "(not set)"
13
+ Provider: Provider
14
+ Pick a provider: Pick a provider
12
15
  OpenAI Key: OpenAI Key
16
+ Anthropic Key: Anthropic Key
13
17
  Please enter a key: Please enter a key
14
18
  OpenAI API Endpoint: OpenAI API Endpoint
15
19
  Enter your OpenAI API Endpoint: Enter your OpenAI API Endpoint
16
20
  Silent Mode: Silent Mode
17
21
  Enable silent mode?: Enable silent mode?
18
22
  Model: Model
23
+ Pick a model.: Pick a model.
24
+ No models found for this provider.: No models found for this provider.
19
25
  Enter the model you want to use: Enter the model you want to use
20
26
  Language: Language
21
27
  Enter the language you want to use: Enter the language you want to use
data/locales/it.yml CHANGED
@@ -8,14 +8,20 @@ Please set your OpenAI API key via `ai config set OPENAI_KEY=<your token>`: Per
8
8
  imposta la tua chiave API OpenAI tramite `ai config set OPENAI_KEY=<your token>`
9
9
  Set config: Imposta la configurazione
10
10
  Enter your OpenAI API key: Inserisci la tua chiave API OpenAI
11
+ Enter your Anthropic API key: Inserisci la tua chiave API Anthropic
11
12
  "(not set)": "(non impostato)"
13
+ Provider: Provider
14
+ Pick a provider: Scegli un provider
12
15
  OpenAI Key: Chiave OpenAI
16
+ Anthropic Key: Chiave Anthropic
13
17
  Please enter a key: Per favore inserisci una chiave
14
18
  OpenAI API Endpoint: Endpoint API OpenAI
15
19
  Enter your OpenAI API Endpoint: Inserisci il tuo endpoint API OpenAI
16
20
  Silent Mode: Modalità silenziosa
17
21
  Enable silent mode?: Abilitare la modalità silenziosa?
18
22
  Model: Modello
23
+ Pick a model.: Scegli un modello.
24
+ No models found for this provider.: Nessun modello trovato per questo provider.
19
25
  Enter the model you want to use: Inserisci il modello che vuoi utilizzare
20
26
  Language: Lingua
21
27
  Enter the language you want to use: Inserisci la lingua che vuoi utilizzare
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aicli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antonio Molinari
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ruby_llm
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.16'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: thor
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -104,6 +118,7 @@ files:
104
118
  - lib/aicli/helpers/constants.rb
105
119
  - lib/aicli/helpers/error.rb
106
120
  - lib/aicli/helpers/i18n.rb
121
+ - lib/aicli/helpers/llm.rb
107
122
  - lib/aicli/helpers/os_detect.rb
108
123
  - lib/aicli/helpers/shell_history.rb
109
124
  - lib/aicli/helpers/strip_regex_patterns.rb