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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/env_reader"
4
+ require_relative "../atoms/http_client"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Organisms
9
+ # BaseClient provides common functionality for all LLM provider clients
10
+ class BaseClient
11
+ # Default generation configuration
12
+ DEFAULT_GENERATION_CONFIG = {
13
+ temperature: 0.7,
14
+ max_tokens: nil,
15
+ top_p: nil,
16
+ top_k: nil
17
+ }.freeze
18
+
19
+ # Default separator for concatenating system prompts
20
+ # Can be overridden by subclasses if needed
21
+ DEFAULT_SYSTEM_PROMPT_SEPARATOR = "\n\n---\n\n"
22
+
23
+ attr_reader :model, :base_url, :api_key, :generation_config, :http_client
24
+
25
+ # Initialize base client with common configuration
26
+ # @param api_key [String, nil] API key (uses env if nil)
27
+ # @param model [String, nil] Model to use (uses default if nil)
28
+ # @param options [Hash] Additional options
29
+ def initialize(api_key: nil, model: nil, **options)
30
+ # Prevent direct instantiation of abstract base class
31
+ if instance_of?(BaseClient)
32
+ raise NotImplementedError, "BaseClient is abstract and cannot be instantiated directly"
33
+ end
34
+
35
+ @model = model || default_model
36
+ @base_url = options.fetch(:base_url, self.class::API_BASE_URL)
37
+ @generation_config = self.class::DEFAULT_GENERATION_CONFIG.merge(
38
+ options.fetch(:generation_config, {})
39
+ )
40
+
41
+ # Setup API key
42
+ @api_key = api_key || fetch_api_key_from_env
43
+
44
+ # Setup HTTP client
45
+ @http_client = Atoms::HTTPClient.new(
46
+ timeout: options.fetch(:timeout, 30),
47
+ max_retries: options.fetch(:max_retries, 3)
48
+ )
49
+
50
+ @options = options
51
+ end
52
+
53
+ # Generate a response from the LLM
54
+ # @param messages [Array<Hash>] Conversation messages
55
+ # @param options [Hash] Generation options
56
+ # @return [Hash] Response with text and metadata
57
+ def generate(messages, **options)
58
+ raise NotImplementedError, "Subclasses must implement #generate"
59
+ end
60
+
61
+ # Get the provider name for this client
62
+ # @return [String] Provider name
63
+ def provider_name
64
+ self.class.provider_name
65
+ end
66
+
67
+ # Get the provider name (class method)
68
+ # @return [String] Provider name
69
+ def self.provider_name
70
+ raise NotImplementedError, "#{name} must implement .provider_name"
71
+ end
72
+
73
+ # Check if this client needs API credentials
74
+ # @return [Boolean] True if credentials are required
75
+ def needs_credentials?
76
+ true
77
+ end
78
+
79
+ protected
80
+
81
+ # Get the default model for this provider
82
+ # @return [String] Default model name
83
+ def default_model
84
+ self.class::DEFAULT_MODEL
85
+ rescue NameError
86
+ "default"
87
+ end
88
+
89
+ # Fetch API key from environment
90
+ # @return [String, nil] API key
91
+ def fetch_api_key_from_env
92
+ return nil unless needs_credentials?
93
+
94
+ key = Atoms::EnvReader.get_api_key(provider_name)
95
+ if key.nil? || key.empty?
96
+ raise Ace::LLM::AuthenticationError,
97
+ "No API key found for #{provider_name}. " \
98
+ "Please set #{api_key_env_vars.join(" or ")}"
99
+ end
100
+ key
101
+ end
102
+
103
+ # Get environment variable names for API key
104
+ # @return [Array<String>] Environment variable names
105
+ def api_key_env_vars
106
+ case provider_name.downcase
107
+ when "google"
108
+ ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
109
+ when "openai"
110
+ ["OPENAI_API_KEY"]
111
+ when "anthropic"
112
+ ["ANTHROPIC_API_KEY"]
113
+ when "mistral"
114
+ ["MISTRAL_API_KEY"]
115
+ when "togetherai"
116
+ ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"]
117
+ else
118
+ ["#{provider_name.upcase}_API_KEY"]
119
+ end
120
+ end
121
+
122
+ # Build messages for API request
123
+ # @param messages [Array<Hash>, String] Messages or single prompt
124
+ # @return [Array<Hash>] Formatted messages
125
+ def build_messages(messages)
126
+ if messages.is_a?(String)
127
+ [{role: "user", content: messages}]
128
+ elsif messages.is_a?(Array)
129
+ messages
130
+ else
131
+ raise ArgumentError, "Messages must be a string or array"
132
+ end
133
+ end
134
+
135
+ # Extract generation options
136
+ # @param options [Hash] Raw options
137
+ # @return [Hash] Generation parameters
138
+ def extract_generation_options(options)
139
+ gen_opts = @generation_config.dup
140
+
141
+ # Override with provided options
142
+ gen_opts[:temperature] = options[:temperature] if options[:temperature]
143
+ gen_opts[:max_tokens] = options[:max_tokens] if options[:max_tokens]
144
+ gen_opts[:top_p] = options[:top_p] if options[:top_p]
145
+ gen_opts[:top_k] = options[:top_k] if options[:top_k]
146
+
147
+ # Pass through system_append for providers that support it
148
+ gen_opts[:system_append] = options[:system_append] if options[:system_append]
149
+
150
+ # Remove nil values
151
+ gen_opts.compact
152
+ end
153
+
154
+ private
155
+
156
+ # Concatenate system prompts with clear separator
157
+ # @param base_content [String, nil] Base system message
158
+ # @param append_content [String, nil] Content to append
159
+ # @return [String, nil] Concatenated system message or nil if both empty
160
+ def concatenate_system_prompts(base_content, append_content)
161
+ # Return nil if both are empty
162
+ return nil if (base_content.nil? || base_content.empty?) &&
163
+ (append_content.nil? || append_content.empty?)
164
+
165
+ # Return base if append is empty
166
+ return base_content if append_content.nil? || append_content.empty?
167
+
168
+ # Return append if base is empty
169
+ return append_content if base_content.nil? || base_content.empty?
170
+
171
+ # Concatenate both with clear separator
172
+ "#{base_content}#{DEFAULT_SYSTEM_PROMPT_SEPARATOR}#{append_content}"
173
+ end
174
+
175
+ # Process messages array with system_append
176
+ # Handles concatenation of system_append with existing system message
177
+ # @param messages [Array<Hash>] Original messages
178
+ # @param system_append [String, nil] Content to append to system message
179
+ # @return [Array<Hash>] Processed messages with deep copies
180
+ def process_messages_with_system_append(messages, system_append)
181
+ return deep_copy_messages(messages) if system_append.nil? || system_append.empty?
182
+
183
+ # Deep copy to avoid mutating original
184
+ processed = deep_copy_messages(messages)
185
+
186
+ # Find existing system message
187
+ system_index = processed.find_index { |m| m[:role] == "system" }
188
+
189
+ if system_index
190
+ # Concatenate with existing system message
191
+ existing_content = processed[system_index][:content]
192
+ processed[system_index][:content] = concatenate_system_prompts(existing_content, system_append)
193
+ else
194
+ # Add new system message at the beginning
195
+ processed.unshift({role: "system", content: system_append})
196
+ end
197
+
198
+ processed
199
+ end
200
+
201
+ # Deep copy messages array to avoid mutations
202
+ # @param messages [Array<Hash>] Original messages
203
+ # @return [Array<Hash>] Deep copied messages
204
+ def deep_copy_messages(messages)
205
+ messages.map do |msg|
206
+ {
207
+ role: msg[:role],
208
+ content: msg[:content]
209
+ }
210
+ end
211
+ end
212
+
213
+ # Create response structure
214
+ # @param text [String] Response text
215
+ # @param metadata [Hash] Response metadata
216
+ # @return [Hash] Structured response
217
+ def create_response(text, metadata = {})
218
+ {
219
+ text: text,
220
+ metadata: {
221
+ provider: provider_name,
222
+ model: @model
223
+ }.merge(metadata)
224
+ }
225
+ end
226
+
227
+ # Handle API errors
228
+ # @param error [Exception] The error to handle
229
+ # @raise [ProviderError] Wrapped provider error
230
+ def handle_api_error(error)
231
+ case error
232
+ when Faraday::TimeoutError
233
+ raise Ace::LLM::ProviderError, "Request timeout for #{provider_name}: #{error.message}"
234
+ when Faraday::ConnectionFailed
235
+ raise Ace::LLM::ProviderError, "Connection failed for #{provider_name}: #{error.message}"
236
+ when Faraday::ClientError
237
+ handle_client_error(error)
238
+ else
239
+ raise Ace::LLM::ProviderError, "#{provider_name} API error: #{error.message}"
240
+ end
241
+ end
242
+
243
+ # Handle client errors (4xx responses)
244
+ # @param error [Faraday::ClientError] Client error
245
+ def handle_client_error(error)
246
+ status = error.response[:status] if error.respond_to?(:response)
247
+
248
+ case status
249
+ when 401
250
+ raise Ace::LLM::AuthenticationError, "Invalid API key for #{provider_name}"
251
+ when 403
252
+ raise Ace::LLM::AuthenticationError, "Access forbidden for #{provider_name}"
253
+ when 404
254
+ raise Ace::LLM::ProviderError, "Resource not found for #{provider_name}"
255
+ when 429
256
+ raise Ace::LLM::ProviderError, "Rate limit exceeded for #{provider_name}"
257
+ else
258
+ raise Ace::LLM::ProviderError, "#{provider_name} error (#{status}): #{error.message}"
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_client"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Organisms
8
+ # GoogleClient handles interactions with Google's Gemini API
9
+ class GoogleClient < BaseClient
10
+ API_BASE_URL = "https://generativelanguage.googleapis.com"
11
+ DEFAULT_MODEL = "gemini-2.5-flash"
12
+
13
+ # Mapping from internal keys to Gemini API camelCase keys
14
+ GENERATION_KEY_MAPPING = {
15
+ temperature: :temperature,
16
+ max_tokens: :maxOutputTokens,
17
+ top_p: :topP,
18
+ top_k: :topK
19
+ }.freeze
20
+
21
+ DEFAULT_GENERATION_CONFIG = {
22
+ temperature: 0.7,
23
+ max_tokens: nil,
24
+ top_p: nil,
25
+ top_k: nil
26
+ }.freeze
27
+
28
+ # Get the provider name
29
+ # @return [String] Provider name
30
+ def self.provider_name
31
+ "google"
32
+ end
33
+
34
+ # Generate a response from Google's Gemini
35
+ # @param messages [Array<Hash>, String] Messages or prompt
36
+ # @param options [Hash] Generation options
37
+ # @return [Hash] Response with text and metadata
38
+ def generate(messages, **options)
39
+ messages_array = build_messages(messages)
40
+ generation_params = extract_generation_options(options)
41
+
42
+ request_body = build_request_body(messages_array, generation_params)
43
+ response = make_api_request(request_body)
44
+
45
+ parse_response(response)
46
+ rescue => e
47
+ handle_api_error(e)
48
+ end
49
+
50
+ private
51
+
52
+ # Build request body for Gemini API
53
+ # @param messages [Array<Hash>] Messages
54
+ # @param generation_params [Hash] Generation parameters
55
+ # @return [Hash] Request body
56
+ def build_request_body(messages, generation_params)
57
+ # Handle system_append - use shared helper for deep copy and concatenation
58
+ processed_messages = process_messages_with_system_append(
59
+ messages,
60
+ generation_params[:system_append]
61
+ )
62
+
63
+ # Convert messages to Gemini format
64
+ contents = processed_messages.map do |msg|
65
+ {
66
+ role: (msg[:role] == "assistant") ? "model" : "user",
67
+ parts: [{text: msg[:content]}]
68
+ }
69
+ end
70
+
71
+ request = {
72
+ contents: contents
73
+ }
74
+
75
+ # Add generation config (use nil? to preserve zero values like temperature: 0)
76
+ generation_config = {}
77
+ GENERATION_KEY_MAPPING.each do |internal_key, api_key|
78
+ value = generation_params[internal_key]
79
+ generation_config[api_key] = value unless value.nil?
80
+ end
81
+ request[:generationConfig] = generation_config unless generation_config.empty?
82
+
83
+ request
84
+ end
85
+
86
+ # Make API request to Gemini
87
+ # @param body [Hash] Request body
88
+ # @return [Hash] API response
89
+ def make_api_request(body)
90
+ url = "#{@base_url}/v1beta/models/#{@model}:generateContent"
91
+
92
+ response = @http_client.post(
93
+ url,
94
+ body,
95
+ headers: {
96
+ "Content-Type" => "application/json",
97
+ "x-goog-api-key" => @api_key
98
+ }
99
+ )
100
+
101
+ unless response.success?
102
+ _error_type, error_message, status = parse_error_response(response)
103
+ raise Ace::LLM::ProviderError, "Google API error (#{status}): #{error_message}"
104
+ end
105
+
106
+ response.body
107
+ end
108
+
109
+ # Parse error response from API
110
+ # Extracts error details from the response body, handling both JSON (Hash)
111
+ # and non-JSON (String) responses gracefully.
112
+ #
113
+ # @param response [Faraday::Response] Failed API response
114
+ # @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
115
+ def parse_error_response(response)
116
+ raw_body = response.body
117
+ status = response.status
118
+
119
+ case raw_body
120
+ in Hash => error_body
121
+ error_obj = error_body["error"]
122
+ case error_obj
123
+ when Hash
124
+ error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
125
+ error_type = error_obj["type"] || "unknown"
126
+ when String
127
+ error_message = error_obj
128
+ error_type = "unknown"
129
+ else
130
+ error_message = build_fallback_error_message(raw_body, status)
131
+ error_type = "unknown"
132
+ end
133
+ else
134
+ error_message = build_fallback_error_message(raw_body, status)
135
+ error_type = "unknown"
136
+ end
137
+
138
+ [error_type, error_message, status]
139
+ end
140
+
141
+ # Build fallback error message for non-JSON responses
142
+ #
143
+ # @param raw_body [Object] Raw response body
144
+ # @param status [Integer] HTTP status code
145
+ # @return [String] Human-readable error message
146
+ def build_fallback_error_message(raw_body, status)
147
+ if raw_body.is_a?(String) && !raw_body.empty?
148
+ snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
149
+ snippet += "..." if raw_body.bytesize > 100
150
+ "Non-JSON response: #{snippet}"
151
+ else
152
+ "Unknown error: #{status}"
153
+ end
154
+ end
155
+
156
+ # Parse API response
157
+ # @param response [Hash] Raw API response
158
+ # @return [Hash] Parsed response with text and metadata
159
+ def parse_response(response)
160
+ # Extract text from response
161
+ candidate = response.dig("candidates", 0)
162
+ text = candidate.dig("content", "parts", 0, "text") if candidate
163
+
164
+ unless text
165
+ raise Ace::LLM::ProviderError, "No text in response from Google"
166
+ end
167
+
168
+ # Extract metadata
169
+ metadata = {
170
+ finish_reason: candidate["finishReason"],
171
+ safety_ratings: candidate["safetyRatings"]
172
+ }
173
+
174
+ # Add token counts if available
175
+ usage_metadata = response["usageMetadata"]
176
+ if usage_metadata
177
+ metadata[:input_tokens] = usage_metadata["promptTokenCount"]
178
+ metadata[:output_tokens] = usage_metadata["candidatesTokenCount"]
179
+ metadata[:total_tokens] = usage_metadata["totalTokenCount"]
180
+ end
181
+
182
+ create_response(text, metadata)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_client"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Organisms
8
+ # GroqClient handles interactions with Groq's API
9
+ class GroqClient < BaseClient
10
+ API_BASE_URL = "https://api.groq.com/openai/v1"
11
+ DEFAULT_MODEL = "openai/gpt-oss-120b"
12
+
13
+ # Generation parameters to include in API request
14
+ GENERATION_KEYS = %i[temperature max_tokens top_p frequency_penalty presence_penalty stop].freeze
15
+
16
+ DEFAULT_GENERATION_CONFIG = {
17
+ temperature: 0.7,
18
+ max_tokens: 4096,
19
+ top_p: nil,
20
+ frequency_penalty: nil,
21
+ presence_penalty: nil
22
+ }.freeze
23
+
24
+ # Get the provider name
25
+ # @return [String] Provider name
26
+ def self.provider_name
27
+ "groq"
28
+ end
29
+
30
+ # Generate a response from Groq
31
+ # @param messages [Array<Hash>, String] Messages or prompt
32
+ # @param options [Hash] Generation options
33
+ # @return [Hash] Response with text and metadata
34
+ def generate(messages, **options)
35
+ messages_array = build_messages(messages)
36
+ generation_params = extract_generation_options(options)
37
+
38
+ request_body = build_request_body(messages_array, generation_params)
39
+ response = make_api_request(request_body)
40
+
41
+ parse_response(response)
42
+ rescue Ace::LLM::ProviderError
43
+ raise
44
+ rescue => e
45
+ handle_api_error(e)
46
+ end
47
+
48
+ private
49
+
50
+ # Build request body for Groq API
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
+ # Handle system_append - use shared helper for deep copy and concatenation
56
+ processed_messages = process_messages_with_system_append(
57
+ messages,
58
+ generation_params[:system_append]
59
+ )
60
+
61
+ request = {
62
+ model: @model,
63
+ messages: processed_messages
64
+ }
65
+
66
+ # Add generation parameters (use nil? to preserve zero values like temperature: 0)
67
+ GENERATION_KEYS.each do |key|
68
+ request[key] = generation_params[key] unless generation_params[key].nil?
69
+ end
70
+
71
+ # Streaming disabled (not implemented in ace-llm)
72
+ request[:stream] = false
73
+
74
+ request
75
+ end
76
+
77
+ # Extract generation options including Groq-specific ones
78
+ # @param options [Hash] Raw options
79
+ # @return [Hash] Generation parameters
80
+ def extract_generation_options(options)
81
+ gen_opts = super
82
+
83
+ # Add Groq-specific options (OpenAI-compatible)
84
+ # Use key? check to preserve zero-valued params
85
+ gen_opts[:frequency_penalty] = options[:frequency_penalty] if options.key?(:frequency_penalty)
86
+ gen_opts[:presence_penalty] = options[:presence_penalty] if options.key?(:presence_penalty)
87
+ gen_opts[:stop] = options[:stop] if options.key?(:stop)
88
+
89
+ gen_opts.compact
90
+ end
91
+
92
+ # Make API request to Groq
93
+ # @param body [Hash] Request body
94
+ # @return [Hash] API response
95
+ def make_api_request(body)
96
+ url = "#{@base_url}/chat/completions"
97
+
98
+ response = @http_client.post(
99
+ url,
100
+ body,
101
+ headers: {
102
+ "Content-Type" => "application/json",
103
+ "Authorization" => "Bearer #{@api_key}"
104
+ }
105
+ )
106
+
107
+ unless response.success?
108
+ error_type, error_message, status = parse_error_response(response)
109
+ raise Ace::LLM::ProviderError, "Groq API error (#{status}): #{error_type} - #{error_message}"
110
+ end
111
+
112
+ response.body
113
+ end
114
+
115
+ # Parse error response from API
116
+ # Extracts error details from the response body, handling both JSON (Hash)
117
+ # and non-JSON (String) responses gracefully.
118
+ #
119
+ # @param response [Faraday::Response] Failed API response
120
+ # @return [Array(String, String, Integer)] Tuple of [error_type, error_message, status]
121
+ def parse_error_response(response)
122
+ raw_body = response.body
123
+ status = response.status
124
+
125
+ case raw_body
126
+ in Hash => error_body
127
+ error_obj = error_body["error"]
128
+ case error_obj
129
+ when Hash
130
+ error_message = error_obj["message"] || build_fallback_error_message(raw_body, status)
131
+ error_type = error_obj["type"] || "unknown"
132
+ when String
133
+ error_message = error_obj
134
+ error_type = "unknown"
135
+ else
136
+ error_message = build_fallback_error_message(raw_body, status)
137
+ error_type = "unknown"
138
+ end
139
+ else
140
+ error_message = build_fallback_error_message(raw_body, status)
141
+ error_type = "unknown"
142
+ end
143
+
144
+ [error_type, error_message, status]
145
+ end
146
+
147
+ # Build fallback error message for non-JSON responses
148
+ #
149
+ # @param raw_body [Object] Raw response body
150
+ # @param status [Integer] HTTP status code
151
+ # @return [String] Human-readable error message
152
+ def build_fallback_error_message(raw_body, status)
153
+ if raw_body.is_a?(String) && !raw_body.empty?
154
+ snippet = raw_body.byteslice(0, 100)&.scrub || raw_body[0, 100]
155
+ snippet += "..." if raw_body.bytesize > 100
156
+ "Non-JSON response: #{snippet}"
157
+ else
158
+ "Unknown error: #{status}"
159
+ end
160
+ end
161
+
162
+ # Parse API response
163
+ # @param response [Hash] Raw API response
164
+ # @return [Hash] Parsed response with text and metadata
165
+ def parse_response(response)
166
+ # Extract text from response
167
+ choice = response.dig("choices", 0)
168
+ text = choice.dig("message", "content") if choice
169
+
170
+ unless text
171
+ raise Ace::LLM::ProviderError, "No text in response from Groq"
172
+ end
173
+
174
+ # Extract metadata
175
+ metadata = {
176
+ finish_reason: choice["finish_reason"],
177
+ id: response["id"],
178
+ created: response["created"]
179
+ }
180
+
181
+ # Add token usage if available
182
+ usage = response["usage"]
183
+ if usage
184
+ metadata[:input_tokens] = usage["prompt_tokens"]
185
+ metadata[:output_tokens] = usage["completion_tokens"]
186
+ metadata[:total_tokens] = usage["total_tokens"]
187
+ end
188
+
189
+ # Add model info
190
+ metadata[:model_used] = response["model"] if response["model"]
191
+
192
+ create_response(text, metadata)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end