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.
- checksums.yaml +7 -0
- data/.ace-defaults/llm/config.yml +31 -0
- data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
- data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
- data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
- data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
- data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
- data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
- data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
- data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
- data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
- data/.ace-defaults/llm/providers/anthropic.yml +34 -0
- data/.ace-defaults/llm/providers/google.yml +36 -0
- data/.ace-defaults/llm/providers/groq.yml +29 -0
- data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
- data/.ace-defaults/llm/providers/mistral.yml +33 -0
- data/.ace-defaults/llm/providers/openai.yml +33 -0
- data/.ace-defaults/llm/providers/openrouter.yml +45 -0
- data/.ace-defaults/llm/providers/togetherai.yml +26 -0
- data/.ace-defaults/llm/providers/xai.yml +30 -0
- data/.ace-defaults/llm/providers/zai.yml +18 -0
- data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
- data/CHANGELOG.md +641 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-llm +25 -0
- data/handbook/guides/llm-query-tool-reference.g.md +683 -0
- data/handbook/templates/agent/plan-mode.template.md +48 -0
- data/lib/ace/llm/atoms/env_reader.rb +155 -0
- data/lib/ace/llm/atoms/error_classifier.rb +200 -0
- data/lib/ace/llm/atoms/http_client.rb +162 -0
- data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
- data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
- data/lib/ace/llm/cli/commands/query.rb +280 -0
- data/lib/ace/llm/cli.rb +24 -0
- data/lib/ace/llm/configuration.rb +180 -0
- data/lib/ace/llm/models/fallback_config.rb +216 -0
- data/lib/ace/llm/molecules/client_registry.rb +336 -0
- data/lib/ace/llm/molecules/config_loader.rb +39 -0
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
- data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
- data/lib/ace/llm/molecules/format_handlers.rb +183 -0
- data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
- data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
- data/lib/ace/llm/molecules/preset_loader.rb +99 -0
- data/lib/ace/llm/molecules/provider_loader.rb +198 -0
- data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
- data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
- data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
- data/lib/ace/llm/organisms/base_client.rb +264 -0
- data/lib/ace/llm/organisms/google_client.rb +187 -0
- data/lib/ace/llm/organisms/groq_client.rb +197 -0
- data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
- data/lib/ace/llm/organisms/mistral_client.rb +180 -0
- data/lib/ace/llm/organisms/openai_client.rb +195 -0
- data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
- data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
- data/lib/ace/llm/organisms/xai_client.rb +213 -0
- data/lib/ace/llm/organisms/zai_client.rb +149 -0
- data/lib/ace/llm/query_interface.rb +455 -0
- data/lib/ace/llm/version.rb +7 -0
- data/lib/ace/llm.rb +61 -0
- 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
|