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,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