ace-llm 0.30.0

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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/llm/config.yml +31 -0
  3. data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
  4. data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
  5. data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
  6. data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
  7. data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
  8. data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
  9. data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
  10. data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
  11. data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
  12. data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
  13. data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
  14. data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
  15. data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
  16. data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
  17. data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
  18. data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
  19. data/.ace-defaults/llm/providers/anthropic.yml +34 -0
  20. data/.ace-defaults/llm/providers/google.yml +36 -0
  21. data/.ace-defaults/llm/providers/groq.yml +29 -0
  22. data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
  23. data/.ace-defaults/llm/providers/mistral.yml +33 -0
  24. data/.ace-defaults/llm/providers/openai.yml +33 -0
  25. data/.ace-defaults/llm/providers/openrouter.yml +45 -0
  26. data/.ace-defaults/llm/providers/togetherai.yml +26 -0
  27. data/.ace-defaults/llm/providers/xai.yml +30 -0
  28. data/.ace-defaults/llm/providers/zai.yml +18 -0
  29. data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
  30. data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
  31. data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
  32. data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
  33. data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
  34. data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
  35. data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
  36. data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
  37. data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
  38. data/CHANGELOG.md +641 -0
  39. data/LICENSE +21 -0
  40. data/README.md +42 -0
  41. data/Rakefile +14 -0
  42. data/exe/ace-llm +25 -0
  43. data/handbook/guides/llm-query-tool-reference.g.md +683 -0
  44. data/handbook/templates/agent/plan-mode.template.md +48 -0
  45. data/lib/ace/llm/atoms/env_reader.rb +155 -0
  46. data/lib/ace/llm/atoms/error_classifier.rb +200 -0
  47. data/lib/ace/llm/atoms/http_client.rb +162 -0
  48. data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
  49. data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
  50. data/lib/ace/llm/cli/commands/query.rb +280 -0
  51. data/lib/ace/llm/cli.rb +24 -0
  52. data/lib/ace/llm/configuration.rb +180 -0
  53. data/lib/ace/llm/models/fallback_config.rb +216 -0
  54. data/lib/ace/llm/molecules/client_registry.rb +336 -0
  55. data/lib/ace/llm/molecules/config_loader.rb +39 -0
  56. data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
  57. data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
  58. data/lib/ace/llm/molecules/format_handlers.rb +183 -0
  59. data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
  60. data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
  61. data/lib/ace/llm/molecules/preset_loader.rb +99 -0
  62. data/lib/ace/llm/molecules/provider_loader.rb +198 -0
  63. data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
  64. data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
  65. data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
  66. data/lib/ace/llm/organisms/base_client.rb +264 -0
  67. data/lib/ace/llm/organisms/google_client.rb +187 -0
  68. data/lib/ace/llm/organisms/groq_client.rb +197 -0
  69. data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
  70. data/lib/ace/llm/organisms/mistral_client.rb +180 -0
  71. data/lib/ace/llm/organisms/openai_client.rb +195 -0
  72. data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
  73. data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
  74. data/lib/ace/llm/organisms/xai_client.rb +213 -0
  75. data/lib/ace/llm/organisms/zai_client.rb +149 -0
  76. data/lib/ace/llm/query_interface.rb +455 -0
  77. data/lib/ace/llm/version.rb +7 -0
  78. data/lib/ace/llm.rb +61 -0
  79. metadata +318 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_client"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Organisms
8
+ # LMStudioClient handles interactions with local LM Studio server
9
+ # LM Studio provides an OpenAI-compatible API for local models
10
+ class LMStudioClient < BaseClient
11
+ API_BASE_URL = "http://localhost:1234"
12
+ DEFAULT_MODEL = "local-model"
13
+ DEFAULT_GENERATION_CONFIG = {
14
+ temperature: 0.7,
15
+ max_tokens: nil,
16
+ top_p: nil,
17
+ top_k: nil
18
+ }.freeze
19
+
20
+ # Get the provider name
21
+ # @return [String] Provider name
22
+ def self.provider_name
23
+ "lmstudio"
24
+ end
25
+
26
+ # Check if this client needs API credentials
27
+ # @return [Boolean] False - LM Studio doesn't need API keys
28
+ def needs_credentials?
29
+ false
30
+ end
31
+
32
+ # Generate a response from LM Studio
33
+ # @param messages [Array<Hash>, String] Messages or prompt
34
+ # @param options [Hash] Generation options
35
+ # @return [Hash] Response with text and metadata
36
+ def generate(messages, **options)
37
+ messages_array = build_messages(messages)
38
+ generation_params = extract_generation_options(options)
39
+
40
+ request_body = build_request_body(messages_array, generation_params)
41
+ response = make_api_request(request_body)
42
+
43
+ parse_response(response)
44
+ rescue => e
45
+ handle_api_error(e)
46
+ end
47
+
48
+ private
49
+
50
+ # Build request body for LM Studio API (OpenAI-compatible)
51
+ # @param messages [Array<Hash>] Messages
52
+ # @param generation_params [Hash] Generation parameters
53
+ # @return [Hash] Request body
54
+ def build_request_body(messages, generation_params)
55
+ request = {
56
+ messages: messages,
57
+ stream: false
58
+ }
59
+
60
+ # Add generation parameters
61
+ request[:temperature] = generation_params[:temperature] if generation_params[:temperature]
62
+ request[:max_tokens] = generation_params[:max_tokens] if generation_params[:max_tokens]
63
+ request[:top_p] = generation_params[:top_p] if generation_params[:top_p]
64
+
65
+ # LM Studio may not support all OpenAI parameters, but we'll include them
66
+ request
67
+ end
68
+
69
+ # Make API request to LM Studio
70
+ # @param body [Hash] Request body
71
+ # @return [Hash] API response
72
+ def make_api_request(body)
73
+ url = "#{@base_url}/v1/chat/completions"
74
+
75
+ response = @http_client.post(
76
+ url,
77
+ body,
78
+ headers: {
79
+ "Content-Type" => "application/json"
80
+ }
81
+ )
82
+
83
+ unless response.success?
84
+ # LM Studio might not be running
85
+ if response.status == 0 || response.body.nil?
86
+ raise Ace::LLM::ProviderError,
87
+ "Cannot connect to LM Studio at #{@base_url}. " \
88
+ "Please ensure LM Studio is running with the local server enabled."
89
+ end
90
+
91
+ error_body = begin
92
+ response.body
93
+ rescue
94
+ {}
95
+ end
96
+ error_message = error_body["error"] || "Unknown error"
97
+
98
+ raise Ace::LLM::ProviderError, "LM Studio API error (#{response.status}): #{error_message}"
99
+ end
100
+
101
+ response.body
102
+ rescue Faraday::ConnectionFailed => e
103
+ raise Ace::LLM::ProviderError,
104
+ "Cannot connect to LM Studio at #{@base_url}. " \
105
+ "Please ensure LM Studio is running with the local server enabled. " \
106
+ "Error: #{e.message}"
107
+ end
108
+
109
+ # Parse API response
110
+ # @param response [Hash] Raw API response
111
+ # @return [Hash] Parsed response with text and metadata
112
+ def parse_response(response)
113
+ # LM Studio uses OpenAI-compatible format
114
+ choice = response.dig("choices", 0)
115
+ text = choice.dig("message", "content") if choice
116
+
117
+ unless text
118
+ raise Ace::LLM::ProviderError, "No text in response from LM Studio"
119
+ end
120
+
121
+ # Extract metadata
122
+ metadata = {
123
+ finish_reason: choice["finish_reason"],
124
+ model_used: response["model"] || "local-model"
125
+ }
126
+
127
+ # Add token usage if available
128
+ usage = response["usage"]
129
+ if usage
130
+ metadata[:input_tokens] = usage["prompt_tokens"]
131
+ metadata[:output_tokens] = usage["completion_tokens"]
132
+ metadata[:total_tokens] = usage["total_tokens"]
133
+ end
134
+
135
+ create_response(text, metadata)
136
+ end
137
+
138
+ # Fetch API key from environment (overridden to return nil)
139
+ # @return [nil] LM Studio doesn't need API keys
140
+ def fetch_api_key_from_env
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_client"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Organisms
8
+ # MistralClient handles interactions with Mistral AI's API
9
+ class MistralClient < BaseClient
10
+ API_BASE_URL = "https://api.mistral.ai"
11
+ DEFAULT_MODEL = "mistral-large-latest"
12
+
13
+ # Generation parameters to include in API request
14
+ GENERATION_KEYS = %i[temperature max_tokens top_p random_seed].freeze
15
+
16
+ DEFAULT_GENERATION_CONFIG = {
17
+ temperature: 0.7,
18
+ max_tokens: nil,
19
+ top_p: nil,
20
+ random_seed: nil
21
+ }.freeze
22
+
23
+ # Get the provider name
24
+ # @return [String] Provider name
25
+ def self.provider_name
26
+ "mistral"
27
+ end
28
+
29
+ # Generate a response from Mistral
30
+ # @param messages [Array<Hash>, String] Messages or prompt
31
+ # @param options [Hash] Generation options
32
+ # @return [Hash] Response with text and metadata
33
+ def generate(messages, **options)
34
+ messages_array = build_messages(messages)
35
+ generation_params = extract_generation_options(options)
36
+
37
+ request_body = build_request_body(messages_array, generation_params)
38
+ response = make_api_request(request_body)
39
+
40
+ parse_response(response)
41
+ rescue => e
42
+ handle_api_error(e)
43
+ end
44
+
45
+ private
46
+
47
+ # Build request body for Mistral API
48
+ # @param messages [Array<Hash>] Messages
49
+ # @param generation_params [Hash] Generation parameters
50
+ # @return [Hash] Request body
51
+ def build_request_body(messages, generation_params)
52
+ request = {
53
+ model: @model,
54
+ messages: messages
55
+ }
56
+
57
+ # Add generation parameters (use nil? to preserve zero values like temperature: 0)
58
+ GENERATION_KEYS.each do |key|
59
+ request[key] = generation_params[key] unless generation_params[key].nil?
60
+ end
61
+
62
+ request
63
+ end
64
+
65
+ # Make API request to Mistral
66
+ # @param body [Hash] Request body
67
+ # @return [Hash] API response
68
+ def make_api_request(body)
69
+ url = "#{@base_url}/v1/chat/completions"
70
+
71
+ response = @http_client.post(
72
+ url,
73
+ body,
74
+ headers: {
75
+ "Content-Type" => "application/json",
76
+ "Authorization" => "Bearer #{@api_key}"
77
+ }
78
+ )
79
+
80
+ unless response.success?
81
+ _error_type, error_message, status = parse_error_response(response)
82
+ raise Ace::LLM::ProviderError, "Mistral API error (#{status}): #{error_message}"
83
+ end
84
+
85
+ response.body
86
+ end
87
+
88
+ # Parse error response from API
89
+ # Extracts error details from the response body, handling both JSON (Hash)
90
+ # and non-JSON (String) responses gracefully.
91
+ #
92
+ # Mistral API can return errors in different formats:
93
+ # - {"error": {"message": "...", "type": "..."}} (OpenAI-style)
94
+ # - {"message": "..."} (Mistral-style)
95
+ # - {"error": "simple message"} (flat format)
96
+ #
97
+ # @param response [Faraday::Response] Failed API response
98
+ # @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
99
+ def parse_error_response(response)
100
+ raw_body = response.body
101
+ status = response.status
102
+
103
+ case raw_body
104
+ in Hash => error_body
105
+ # Check for Mistral-style top-level message first
106
+ if error_body["message"].is_a?(String)
107
+ return ["unknown", error_body["message"], status]
108
+ end
109
+
110
+ error_obj = error_body["error"]
111
+ case error_obj
112
+ when Hash
113
+ error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
114
+ error_type = error_obj["type"] || "unknown"
115
+ when String
116
+ error_message = error_obj
117
+ error_type = "unknown"
118
+ else
119
+ error_message = build_fallback_error_message(raw_body, status)
120
+ error_type = "unknown"
121
+ end
122
+ else
123
+ error_message = build_fallback_error_message(raw_body, status)
124
+ error_type = "unknown"
125
+ end
126
+
127
+ [error_type, error_message, status]
128
+ end
129
+
130
+ # Build fallback error message for non-JSON responses
131
+ #
132
+ # @param raw_body [Object] Raw response body
133
+ # @param status [Integer] HTTP status code
134
+ # @return [String] Human-readable error message
135
+ def build_fallback_error_message(raw_body, status)
136
+ if raw_body.is_a?(String) && !raw_body.empty?
137
+ snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
138
+ snippet += "..." if raw_body.bytesize > 100
139
+ "Non-JSON response: #{snippet}"
140
+ else
141
+ "Unknown error: #{status}"
142
+ end
143
+ end
144
+
145
+ # Parse API response
146
+ # @param response [Hash] Raw API response
147
+ # @return [Hash] Parsed response with text and metadata
148
+ def parse_response(response)
149
+ # Extract text from response
150
+ choice = response.dig("choices", 0)
151
+ text = choice.dig("message", "content") if choice
152
+
153
+ unless text
154
+ raise Ace::LLM::ProviderError, "No text in response from Mistral"
155
+ end
156
+
157
+ # Extract metadata
158
+ metadata = {
159
+ finish_reason: choice["finish_reason"],
160
+ id: response["id"],
161
+ created: response["created"]
162
+ }
163
+
164
+ # Add token usage if available
165
+ usage = response["usage"]
166
+ if usage
167
+ metadata[:input_tokens] = usage["prompt_tokens"]
168
+ metadata[:output_tokens] = usage["completion_tokens"]
169
+ metadata[:total_tokens] = usage["total_tokens"]
170
+ end
171
+
172
+ # Add model info
173
+ metadata[:model_used] = response["model"] if response["model"]
174
+
175
+ create_response(text, metadata)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_client"
4
+ require_relative "../molecules/openai_compatible_params"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Organisms
9
+ # OpenAIClient handles interactions with OpenAI's API
10
+ class OpenAIClient < BaseClient
11
+ include Molecules::OpenAICompatibleParams
12
+
13
+ API_BASE_URL = "https://api.openai.com"
14
+ DEFAULT_MODEL = "gpt-4o"
15
+
16
+ # Generation parameters to include in API request
17
+ GENERATION_KEYS = %i[temperature max_tokens top_p frequency_penalty presence_penalty].freeze
18
+
19
+ DEFAULT_GENERATION_CONFIG = {
20
+ temperature: 0.7,
21
+ max_tokens: nil,
22
+ top_p: nil,
23
+ frequency_penalty: nil,
24
+ presence_penalty: nil
25
+ }.freeze
26
+
27
+ # Get the provider name
28
+ # @return [String] Provider name
29
+ def self.provider_name
30
+ "openai"
31
+ end
32
+
33
+ # Generate a response from OpenAI
34
+ # @param messages [Array<Hash>, String] Messages or prompt
35
+ # @param options [Hash] Generation options
36
+ # @return [Hash] Response with text and metadata
37
+ def generate(messages, **options)
38
+ messages_array = build_messages(messages)
39
+ generation_params = extract_generation_options(options)
40
+
41
+ request_body = build_request_body(messages_array, generation_params)
42
+ response = make_api_request(request_body)
43
+
44
+ parse_response(response)
45
+ rescue => e
46
+ handle_api_error(e)
47
+ end
48
+
49
+ private
50
+
51
+ # Build request body for OpenAI API
52
+ # @param messages [Array<Hash>] Messages
53
+ # @param generation_params [Hash] Generation parameters
54
+ # @return [Hash] Request body
55
+ def build_request_body(messages, generation_params)
56
+ # Handle system_append - use shared helper for deep copy and concatenation
57
+ processed_messages = process_messages_with_system_append(
58
+ messages,
59
+ generation_params[:system_append]
60
+ )
61
+
62
+ request = {
63
+ model: @model,
64
+ messages: processed_messages
65
+ }
66
+
67
+ # Add generation parameters (use nil? to preserve zero values like temperature: 0)
68
+ GENERATION_KEYS.each do |key|
69
+ request[key] = generation_params[key] unless generation_params[key].nil?
70
+ end
71
+
72
+ # Add streaming flag (always false for now)
73
+ request[:stream] = false
74
+
75
+ request
76
+ end
77
+
78
+ # Extract generation options including OpenAI-specific ones
79
+ # @param options [Hash] Raw options
80
+ # @return [Hash] Generation parameters
81
+ def extract_generation_options(options)
82
+ gen_opts = super
83
+
84
+ # Add OpenAI-compatible options
85
+ extract_openai_compatible_options(options, gen_opts)
86
+
87
+ gen_opts.compact
88
+ end
89
+
90
+ # Make API request to OpenAI
91
+ # @param body [Hash] Request body
92
+ # @return [Hash] API response
93
+ def make_api_request(body)
94
+ url = "#{@base_url}/v1/chat/completions"
95
+
96
+ response = @http_client.post(
97
+ url,
98
+ body,
99
+ headers: {
100
+ "Content-Type" => "application/json",
101
+ "Authorization" => "Bearer #{@api_key}"
102
+ }
103
+ )
104
+
105
+ unless response.success?
106
+ error_type, error_message, status = parse_error_response(response)
107
+ raise Ace::LLM::ProviderError, "OpenAI API error (#{status}): #{error_type} - #{error_message}"
108
+ end
109
+
110
+ response.body
111
+ end
112
+
113
+ # Parse error response from API
114
+ # Extracts error details from the response body, handling both JSON (Hash)
115
+ # and non-JSON (String) responses gracefully.
116
+ #
117
+ # @param response [Faraday::Response] Failed API response
118
+ # @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
119
+ def parse_error_response(response)
120
+ raw_body = response.body
121
+ status = response.status
122
+
123
+ case raw_body
124
+ in Hash => error_body
125
+ error_obj = error_body["error"]
126
+ case error_obj
127
+ when Hash
128
+ error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
129
+ error_type = error_obj["type"] || "unknown"
130
+ when String
131
+ error_message = error_obj
132
+ error_type = "unknown"
133
+ else
134
+ error_message = build_fallback_error_message(raw_body, status)
135
+ error_type = "unknown"
136
+ end
137
+ else
138
+ error_message = build_fallback_error_message(raw_body, status)
139
+ error_type = "unknown"
140
+ end
141
+
142
+ [error_type, error_message, status]
143
+ end
144
+
145
+ # Build fallback error message for non-JSON responses
146
+ #
147
+ # @param raw_body [Object] Raw response body
148
+ # @param status [Integer] HTTP status code
149
+ # @return [String] Human-readable error message
150
+ def build_fallback_error_message(raw_body, status)
151
+ if raw_body.is_a?(String) && !raw_body.empty?
152
+ snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
153
+ snippet += "..." if raw_body.bytesize > 100
154
+ "Non-JSON response: #{snippet}"
155
+ else
156
+ "Unknown error: #{status}"
157
+ end
158
+ end
159
+
160
+ # Parse API response
161
+ # @param response [Hash] Raw API response
162
+ # @return [Hash] Parsed response with text and metadata
163
+ def parse_response(response)
164
+ # Extract text from response
165
+ choice = response.dig("choices", 0)
166
+ text = choice.dig("message", "content") if choice
167
+
168
+ unless text
169
+ raise Ace::LLM::ProviderError, "No text in response from OpenAI"
170
+ end
171
+
172
+ # Extract metadata
173
+ metadata = {
174
+ finish_reason: choice["finish_reason"],
175
+ id: response["id"],
176
+ created: response["created"]
177
+ }
178
+
179
+ # Add token usage if available
180
+ usage = response["usage"]
181
+ if usage
182
+ metadata[:input_tokens] = usage["prompt_tokens"]
183
+ metadata[:output_tokens] = usage["completion_tokens"]
184
+ metadata[:total_tokens] = usage["total_tokens"]
185
+ end
186
+
187
+ # Add model info
188
+ metadata[:model_used] = response["model"] if response["model"]
189
+
190
+ create_response(text, metadata)
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end