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,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Molecules
|
|
9
|
+
# FileIoHandler provides file I/O utilities for LLM query commands
|
|
10
|
+
# This is a molecule - it handles specific file operations with validation
|
|
11
|
+
class FileIoHandler
|
|
12
|
+
# File extensions that indicate different output formats
|
|
13
|
+
FORMAT_EXTENSIONS = {
|
|
14
|
+
".json" => "json",
|
|
15
|
+
".md" => "markdown",
|
|
16
|
+
".markdown" => "markdown",
|
|
17
|
+
".txt" => "text",
|
|
18
|
+
".text" => "text"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Maximum file size to read (10MB)
|
|
22
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
# Initialize file I/O handler
|
|
25
|
+
# @param options [Hash] Configuration options
|
|
26
|
+
# @option options [Integer] :max_file_size Maximum file size to read
|
|
27
|
+
def initialize(**options)
|
|
28
|
+
@max_file_size = options.fetch(:max_file_size, MAX_FILE_SIZE)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Detect if input is a file path or inline content
|
|
32
|
+
# @param input [String] Input string to analyze
|
|
33
|
+
# @return [Boolean] True if input appears to be a file path
|
|
34
|
+
def file_path?(input)
|
|
35
|
+
return false if input.nil? || input.strip.empty?
|
|
36
|
+
|
|
37
|
+
# File paths must be single line strings
|
|
38
|
+
input_str = input.strip
|
|
39
|
+
return false if input_str.include?("\n") || input_str.include?("\r")
|
|
40
|
+
|
|
41
|
+
# Only consider it a file path if the file actually exists
|
|
42
|
+
begin
|
|
43
|
+
path = Pathname.new(input_str)
|
|
44
|
+
File.exist?(path.to_s)
|
|
45
|
+
rescue ArgumentError, SystemCallError
|
|
46
|
+
# Invalid path characters or other path-related errors
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Read content from file or return inline content
|
|
52
|
+
# @param input [String] File path or inline content
|
|
53
|
+
# @param auto_detect [Boolean] Whether to auto-detect file vs inline content
|
|
54
|
+
# @return [String] Content text
|
|
55
|
+
# @raise [Error] If file cannot be read or is too large
|
|
56
|
+
def read_content(input, auto_detect: true)
|
|
57
|
+
if auto_detect && file_path?(input)
|
|
58
|
+
read_file_content(input.strip)
|
|
59
|
+
else
|
|
60
|
+
validate_inline_content(input)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Read content from a file with size validation
|
|
65
|
+
# @param file_path [String] Path to file to read
|
|
66
|
+
# @return [String] File content
|
|
67
|
+
# @raise [Error] If file cannot be read or is too large
|
|
68
|
+
def read_file_content(file_path)
|
|
69
|
+
path = Pathname.new(file_path).expand_path
|
|
70
|
+
|
|
71
|
+
# Check file size
|
|
72
|
+
file_size = File.size(path)
|
|
73
|
+
if file_size > @max_file_size
|
|
74
|
+
raise Ace::LLM::Error, "File too large: #{file_size} bytes (max: #{@max_file_size} bytes)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Read file content
|
|
78
|
+
File.read(path)
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
raise Ace::LLM::Error, "File not found: #{file_path}"
|
|
81
|
+
rescue Errno::EACCES
|
|
82
|
+
raise Ace::LLM::Error, "Permission denied reading file: #{file_path}"
|
|
83
|
+
rescue SystemCallError => e
|
|
84
|
+
raise Ace::LLM::Error, "Error reading file: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Validate inline content
|
|
88
|
+
# @param content [String] Content to validate
|
|
89
|
+
# @return [String] The content (unchanged if valid)
|
|
90
|
+
# @raise [Error] If content is invalid
|
|
91
|
+
def validate_inline_content(content)
|
|
92
|
+
raise Ace::LLM::Error, "Content cannot be nil or empty" if content.nil? || content.strip.empty?
|
|
93
|
+
content
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Write content to file with format handling
|
|
97
|
+
# @param content [String] Content to write
|
|
98
|
+
# @param file_path [String] Output file path
|
|
99
|
+
# @param format [String, nil] Format override (json, markdown, text)
|
|
100
|
+
# @param force [Boolean] Whether to force overwrite without confirmation
|
|
101
|
+
# @return [String] Inferred or specified format
|
|
102
|
+
# @raise [Error] If file cannot be written
|
|
103
|
+
def write_content(content, file_path, format: nil, force: false)
|
|
104
|
+
path = Pathname.new(file_path).expand_path
|
|
105
|
+
|
|
106
|
+
# Check if file exists and handle overwrite
|
|
107
|
+
if !force && File.exist?(path)
|
|
108
|
+
raise Ace::LLM::Error, "File already exists: #{file_path}. Use --force to overwrite."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Ensure parent directory exists
|
|
112
|
+
FileUtils.mkdir_p(path.dirname)
|
|
113
|
+
|
|
114
|
+
# Write content
|
|
115
|
+
File.write(path, content)
|
|
116
|
+
|
|
117
|
+
# Return format (inferred from extension or specified)
|
|
118
|
+
format || infer_format(file_path)
|
|
119
|
+
rescue Errno::EACCES
|
|
120
|
+
raise Ace::LLM::Error, "Permission denied writing to: #{file_path}"
|
|
121
|
+
rescue SystemCallError => e
|
|
122
|
+
raise Ace::LLM::Error, "Error writing file: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Infer output format from file extension
|
|
126
|
+
# @param file_path [String] File path
|
|
127
|
+
# @return [String] Format name (json, markdown, or text)
|
|
128
|
+
def infer_format(file_path)
|
|
129
|
+
return "text" if file_path.nil? || file_path.empty?
|
|
130
|
+
|
|
131
|
+
ext = File.extname(file_path).downcase
|
|
132
|
+
FORMAT_EXTENSIONS.fetch(ext, "text")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if a path is safe to write to
|
|
136
|
+
# @param path [String] Path to check
|
|
137
|
+
# @return [Boolean] True if path appears safe
|
|
138
|
+
def safe_path?(path)
|
|
139
|
+
return false if path.nil? || path.empty?
|
|
140
|
+
|
|
141
|
+
# Reject paths with null bytes
|
|
142
|
+
return false if path.include?("\0")
|
|
143
|
+
|
|
144
|
+
# Reject paths trying to traverse up directories
|
|
145
|
+
return false if path.include?("..")
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
expanded = Pathname.new(path).expand_path
|
|
149
|
+
# Must be absolute after expansion
|
|
150
|
+
expanded.absolute?
|
|
151
|
+
rescue
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Molecules
|
|
9
|
+
# Format handlers for different output formats
|
|
10
|
+
module FormatHandlers
|
|
11
|
+
# Base format handler class
|
|
12
|
+
class Base
|
|
13
|
+
# Format response for output
|
|
14
|
+
# @param response [Hash] Response with :text and normalized metadata
|
|
15
|
+
# @param options [Hash] Additional formatting options
|
|
16
|
+
# @return [String] Formatted output
|
|
17
|
+
def format(response, **options)
|
|
18
|
+
raise NotImplementedError, "Subclasses must implement #format"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Generate summary for stdout when writing to file
|
|
22
|
+
# @param response [Hash] Response with :text and normalized metadata
|
|
23
|
+
# @param file_path [String] Output file path
|
|
24
|
+
# @return [String] Summary text
|
|
25
|
+
def generate_summary(response, file_path)
|
|
26
|
+
metadata = response[:metadata] || {}
|
|
27
|
+
|
|
28
|
+
summary_parts = []
|
|
29
|
+
summary_parts << "Response saved to: #{file_path}"
|
|
30
|
+
|
|
31
|
+
if metadata[:provider]
|
|
32
|
+
summary_parts << if metadata[:model]
|
|
33
|
+
"Provider: #{metadata[:provider]} (#{metadata[:model]})"
|
|
34
|
+
else
|
|
35
|
+
"Provider: #{metadata[:provider]}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
summary_parts << "Execution time: #{metadata[:took]}s" if metadata[:took]
|
|
40
|
+
|
|
41
|
+
if metadata[:input_tokens] && metadata[:output_tokens]
|
|
42
|
+
tokens_info = "Tokens: #{metadata[:input_tokens]} input, #{metadata[:output_tokens]} output"
|
|
43
|
+
if metadata[:cached_tokens] && metadata[:cached_tokens] > 0
|
|
44
|
+
tokens_info += ", #{metadata[:cached_tokens]} cached"
|
|
45
|
+
end
|
|
46
|
+
summary_parts << tokens_info
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Add cost information if available
|
|
50
|
+
if metadata[:cost]
|
|
51
|
+
cost_info = build_cost_summary(metadata[:cost])
|
|
52
|
+
summary_parts << cost_info if cost_info
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
summary_parts.join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build cost summary string from cost metadata
|
|
59
|
+
# @param cost_data [Hash] Cost breakdown data
|
|
60
|
+
# @return [String, nil] Formatted cost summary
|
|
61
|
+
def build_cost_summary(cost_data)
|
|
62
|
+
return nil unless cost_data && cost_data[:total]
|
|
63
|
+
|
|
64
|
+
cost_parts = []
|
|
65
|
+
cost_parts << "Cost: $#{format_cost(cost_data[:total])}"
|
|
66
|
+
|
|
67
|
+
if cost_data[:input] || cost_data[:output]
|
|
68
|
+
breakdown = []
|
|
69
|
+
breakdown << "input: $#{format_cost(cost_data[:input])}" if cost_data[:input]
|
|
70
|
+
breakdown << "output: $#{format_cost(cost_data[:output])}" if cost_data[:output]
|
|
71
|
+
|
|
72
|
+
if cost_data[:cache_creation] && cost_data[:cache_creation] > 0
|
|
73
|
+
breakdown << "cache creation: $#{format_cost(cost_data[:cache_creation])}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if cost_data[:cache_read] && cost_data[:cache_read] > 0
|
|
77
|
+
breakdown << "cache read: $#{format_cost(cost_data[:cache_read])}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
cost_parts << " (#{breakdown.join(", ")})" unless breakdown.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
cost_parts.join
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Format cost value for display
|
|
87
|
+
# @param cost [Float, Numeric] Cost value
|
|
88
|
+
# @return [String] Formatted cost string
|
|
89
|
+
def format_cost(cost)
|
|
90
|
+
return "0.000000" if cost.nil? || cost.zero?
|
|
91
|
+
|
|
92
|
+
sprintf("%.6f", cost)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
protected
|
|
96
|
+
|
|
97
|
+
# Validate response structure
|
|
98
|
+
# @param response [Hash] Response to validate
|
|
99
|
+
# @raise [Error] If response is invalid
|
|
100
|
+
def validate_response(response)
|
|
101
|
+
return if response.is_a?(Hash) && response[:text]
|
|
102
|
+
|
|
103
|
+
raise Ace::LLM::Error, "Invalid response format: missing :text field"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# JSON format handler
|
|
108
|
+
class JSON < Base
|
|
109
|
+
# Format response as JSON with full metadata
|
|
110
|
+
# @param response [Hash] Response with :text and normalized metadata
|
|
111
|
+
# @param options [Hash] Additional formatting options
|
|
112
|
+
# @return [String] JSON formatted output
|
|
113
|
+
def format(response, **_options)
|
|
114
|
+
validate_response(response)
|
|
115
|
+
|
|
116
|
+
output = {
|
|
117
|
+
text: response[:text],
|
|
118
|
+
metadata: response[:metadata] || {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
::JSON.pretty_generate(output)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Markdown format handler
|
|
126
|
+
class Markdown < Base
|
|
127
|
+
# Format response as Markdown with YAML front matter
|
|
128
|
+
# @param response [Hash] Response with :text and normalized metadata
|
|
129
|
+
# @param options [Hash] Additional formatting options
|
|
130
|
+
# @return [String] Markdown formatted output
|
|
131
|
+
def format(response, **_options)
|
|
132
|
+
validate_response(response)
|
|
133
|
+
|
|
134
|
+
metadata = response[:metadata] || {}
|
|
135
|
+
content = response[:text]
|
|
136
|
+
|
|
137
|
+
if metadata.empty?
|
|
138
|
+
content
|
|
139
|
+
else
|
|
140
|
+
# Create YAML front matter
|
|
141
|
+
yaml_front_matter = metadata.to_yaml.chomp
|
|
142
|
+
"#{yaml_front_matter}\n---\n\n#{content}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Plain text format handler
|
|
148
|
+
class Text < Base
|
|
149
|
+
# Format response as plain text (content only)
|
|
150
|
+
# @param response [Hash] Response with :text and normalized metadata
|
|
151
|
+
# @param options [Hash] Additional formatting options
|
|
152
|
+
# @return [String] Plain text output
|
|
153
|
+
def format(response, **_options)
|
|
154
|
+
validate_response(response)
|
|
155
|
+
response[:text]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Factory method to get format handler
|
|
160
|
+
# @param format [String] Format type (json, markdown, text)
|
|
161
|
+
# @return [Base] Format handler instance
|
|
162
|
+
def self.get_handler(format)
|
|
163
|
+
case format.to_s.downcase
|
|
164
|
+
when "json"
|
|
165
|
+
JSON.new
|
|
166
|
+
when "markdown", "md"
|
|
167
|
+
Markdown.new
|
|
168
|
+
when "text", "txt"
|
|
169
|
+
Text.new
|
|
170
|
+
else
|
|
171
|
+
raise Ace::LLM::Error, "Unsupported format: #{format}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Get list of supported formats
|
|
176
|
+
# @return [Array<String>] List of supported formats
|
|
177
|
+
def self.supported_formats
|
|
178
|
+
["json", "markdown", "text"]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_registry"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module LLM
|
|
7
|
+
module Molecules
|
|
8
|
+
# LlmAliasResolver resolves LLM aliases to their actual model names
|
|
9
|
+
# Now delegates to ClientRegistry which manages aliases from provider configs
|
|
10
|
+
class LlmAliasResolver
|
|
11
|
+
attr_reader :registry
|
|
12
|
+
|
|
13
|
+
# Initialize alias resolver with optional registry
|
|
14
|
+
# @param registry [ClientRegistry, nil] Optional registry to use
|
|
15
|
+
def initialize(registry: nil)
|
|
16
|
+
@registry = registry || ClientRegistry.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Resolve an alias or model name to its actual provider:model format
|
|
20
|
+
# @param input [String] The input model name or alias
|
|
21
|
+
# @return [String] The resolved provider:model format
|
|
22
|
+
def resolve(input)
|
|
23
|
+
@registry.resolve_alias(input)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if a given input is an alias
|
|
27
|
+
# @param input [String] The input to check
|
|
28
|
+
# @return [Boolean] True if the input is a recognized alias
|
|
29
|
+
def alias?(input)
|
|
30
|
+
resolved = @registry.resolve_alias(input)
|
|
31
|
+
resolved != input
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get all available aliases
|
|
35
|
+
# @return [Hash] Hash containing global and model aliases
|
|
36
|
+
def available_aliases
|
|
37
|
+
@registry.available_aliases
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get aliases for a specific provider
|
|
41
|
+
# @param provider [String] Provider name
|
|
42
|
+
# @return [Hash] Provider-specific model aliases
|
|
43
|
+
def provider_aliases(provider)
|
|
44
|
+
normalized = provider.to_s.strip.downcase.gsub(/[-_]/, "")
|
|
45
|
+
@registry.available_aliases[:model][normalized] || {}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Molecules
|
|
6
|
+
# Shared parameter extraction for OpenAI-compatible providers
|
|
7
|
+
# Preserves zero values using nil? check (0 is a valid penalty value)
|
|
8
|
+
module OpenAICompatibleParams
|
|
9
|
+
# Extract OpenAI-compatible generation options
|
|
10
|
+
# @param options [Hash] Raw options from caller
|
|
11
|
+
# @param gen_opts [Hash] Generation options to augment
|
|
12
|
+
# @return [Hash] Augmented generation options
|
|
13
|
+
def extract_openai_compatible_options(options, gen_opts)
|
|
14
|
+
gen_opts[:frequency_penalty] = options[:frequency_penalty] unless options[:frequency_penalty].nil?
|
|
15
|
+
gen_opts[:presence_penalty] = options[:presence_penalty] unless options[:presence_penalty].nil?
|
|
16
|
+
gen_opts
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/config"
|
|
4
|
+
require_relative "config_loader"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Molecules
|
|
9
|
+
# Loads named execution presets from llm/presets/*.yml via config cascade.
|
|
10
|
+
class PresetLoader
|
|
11
|
+
class << self
|
|
12
|
+
def load(name)
|
|
13
|
+
preset_name = normalize_preset_name(name)
|
|
14
|
+
preset_hash = resolve_preset(preset_name)
|
|
15
|
+
if preset_hash.nil? || preset_hash.empty?
|
|
16
|
+
raise ConfigurationError,
|
|
17
|
+
"Preset '#{preset_name}' not found. Define .ace/llm/presets/#{preset_name}.yml"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
preset_hash
|
|
21
|
+
rescue ConfigurationError
|
|
22
|
+
raise
|
|
23
|
+
rescue => e
|
|
24
|
+
raise ConfigurationError, "Failed to load preset '#{preset_name}': #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load_for_provider(provider, preset_name)
|
|
28
|
+
normalized_provider = normalize_provider_name(provider)
|
|
29
|
+
normalized_preset_name = normalize_preset_name(preset_name)
|
|
30
|
+
|
|
31
|
+
global_preset = resolve_preset(normalized_preset_name)
|
|
32
|
+
provider_preset = resolve_preset("#{normalized_provider}/#{normalized_preset_name}")
|
|
33
|
+
|
|
34
|
+
if global_preset.empty? && provider_preset.empty?
|
|
35
|
+
raise ConfigurationError,
|
|
36
|
+
"Preset '#{normalized_preset_name}' not found for provider '#{normalized_provider}'. " \
|
|
37
|
+
"Define .ace/llm/presets/#{normalized_preset_name}.yml or " \
|
|
38
|
+
".ace/llm/presets/#{normalized_provider}/#{normalized_preset_name}.yml"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Ace::Support::Config::Models::Config.wrap(
|
|
42
|
+
global_preset,
|
|
43
|
+
provider_preset,
|
|
44
|
+
source: "llm_preset_overlay"
|
|
45
|
+
)
|
|
46
|
+
rescue ConfigurationError
|
|
47
|
+
raise
|
|
48
|
+
rescue => e
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"Failed to load preset '#{normalized_preset_name}' for provider '#{normalized_provider}': #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def config_resolver
|
|
56
|
+
Ace::Support::Config.create(
|
|
57
|
+
config_dir: ".ace",
|
|
58
|
+
defaults_dir: ".ace-defaults",
|
|
59
|
+
gem_path: ConfigLoader.gem_root
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_preset_name(name)
|
|
64
|
+
preset_name = name.to_s.strip
|
|
65
|
+
raise ConfigurationError, "Preset name cannot be empty" if preset_name.empty?
|
|
66
|
+
|
|
67
|
+
preset_name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalize_provider_name(provider)
|
|
71
|
+
normalized_provider = provider.to_s.strip.downcase.gsub(/[-_]/, "")
|
|
72
|
+
raise ConfigurationError, "Provider name cannot be empty" if normalized_provider.empty?
|
|
73
|
+
|
|
74
|
+
normalized_provider
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_preset(preset_name)
|
|
78
|
+
config = config_resolver.resolve_namespace("llm", filename: "presets/#{preset_name}")
|
|
79
|
+
preset_hash = deep_stringify_keys(config.to_h)
|
|
80
|
+
preset_hash.is_a?(Hash) ? preset_hash : {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deep_stringify_keys(value)
|
|
84
|
+
case value
|
|
85
|
+
when Hash
|
|
86
|
+
value.each_with_object({}) do |(k, v), result|
|
|
87
|
+
result[k.to_s] = deep_stringify_keys(v)
|
|
88
|
+
end
|
|
89
|
+
when Array
|
|
90
|
+
value.map { |item| deep_stringify_keys(item) }
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|