ace-llm-providers-cli 0.27.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/llm/providers/claude.yml +24 -0
  3. data/.ace-defaults/llm/providers/codex.yml +22 -0
  4. data/.ace-defaults/llm/providers/codexoss.yml +13 -0
  5. data/.ace-defaults/llm/providers/gemini.yml +32 -0
  6. data/.ace-defaults/llm/providers/opencode.yml +26 -0
  7. data/.ace-defaults/llm/providers/pi.yml +43 -0
  8. data/CHANGELOG.md +457 -0
  9. data/LICENSE +21 -0
  10. data/README.md +36 -0
  11. data/Rakefile +14 -0
  12. data/exe/ace-llm-providers-cli-check +76 -0
  13. data/lib/ace/llm/providers/cli/atoms/args_normalizer.rb +82 -0
  14. data/lib/ace/llm/providers/cli/atoms/auth_checker.rb +74 -0
  15. data/lib/ace/llm/providers/cli/atoms/command_formatters.rb +19 -0
  16. data/lib/ace/llm/providers/cli/atoms/command_rewriter.rb +75 -0
  17. data/lib/ace/llm/providers/cli/atoms/execution_context.rb +28 -0
  18. data/lib/ace/llm/providers/cli/atoms/provider_detector.rb +48 -0
  19. data/lib/ace/llm/providers/cli/atoms/session_finders/claude_session_finder.rb +79 -0
  20. data/lib/ace/llm/providers/cli/atoms/session_finders/codex_session_finder.rb +84 -0
  21. data/lib/ace/llm/providers/cli/atoms/session_finders/gemini_session_finder.rb +66 -0
  22. data/lib/ace/llm/providers/cli/atoms/session_finders/open_code_session_finder.rb +119 -0
  23. data/lib/ace/llm/providers/cli/atoms/session_finders/pi_session_finder.rb +87 -0
  24. data/lib/ace/llm/providers/cli/atoms/skill_command_rewriter.rb +30 -0
  25. data/lib/ace/llm/providers/cli/atoms/worktree_dir_resolver.rb +56 -0
  26. data/lib/ace/llm/providers/cli/claude_code_client.rb +358 -0
  27. data/lib/ace/llm/providers/cli/claude_oai_client.rb +322 -0
  28. data/lib/ace/llm/providers/cli/cli_args_support.rb +19 -0
  29. data/lib/ace/llm/providers/cli/codex_client.rb +291 -0
  30. data/lib/ace/llm/providers/cli/codex_oai_client.rb +274 -0
  31. data/lib/ace/llm/providers/cli/gemini_client.rb +346 -0
  32. data/lib/ace/llm/providers/cli/molecules/health_checker.rb +80 -0
  33. data/lib/ace/llm/providers/cli/molecules/safe_capture.rb +153 -0
  34. data/lib/ace/llm/providers/cli/molecules/session_finder.rb +44 -0
  35. data/lib/ace/llm/providers/cli/molecules/skill_name_reader.rb +64 -0
  36. data/lib/ace/llm/providers/cli/open_code_client.rb +271 -0
  37. data/lib/ace/llm/providers/cli/pi_client.rb +331 -0
  38. data/lib/ace/llm/providers/cli/version.rb +11 -0
  39. data/lib/ace/llm/providers/cli.rb +47 -0
  40. metadata +139 -0
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
6
+
7
+ require_relative "cli_args_support"
8
+ require_relative "atoms/execution_context"
9
+ require_relative "atoms/command_rewriter"
10
+ require_relative "atoms/command_formatters"
11
+ require_relative "molecules/skill_name_reader"
12
+
13
+ module Ace
14
+ module LLM
15
+ module Providers
16
+ module CLI
17
+ # Client for interacting with Pi CLI
18
+ # Provides access to multiple AI providers through Pi's unified platform
19
+ # with skill command rewriting support
20
+ class PiClient < Ace::LLM::Organisms::BaseClient
21
+ include CliArgsSupport
22
+
23
+ API_BASE_URL = "https://pi.dev"
24
+ DEFAULT_GENERATION_CONFIG = {}.freeze
25
+
26
+ def self.provider_name
27
+ "pi"
28
+ end
29
+
30
+ DEFAULT_MODEL = "zai/glm-4.7"
31
+
32
+ def initialize(model: nil, **options)
33
+ @model = model || DEFAULT_MODEL
34
+ @options = options
35
+ @generation_config = options[:generation_config] || {}
36
+ @skill_name_reader = Molecules::SkillNameReader.new
37
+ end
38
+
39
+ def needs_credentials?
40
+ false
41
+ end
42
+
43
+ # Generate a response from the LLM
44
+ # @param messages [Array<Hash>] Conversation messages
45
+ # @param options [Hash] Generation options
46
+ # @return [Hash] Response with text and metadata
47
+ def generate(messages, **options)
48
+ validate_pi_availability!
49
+
50
+ prompt = format_messages_as_prompt(messages)
51
+ full_prompt, system_prompt = build_full_prompt(prompt, options)
52
+ subprocess_env = options[:subprocess_env]
53
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
54
+ working_dir: options[:working_dir],
55
+ subprocess_env: subprocess_env
56
+ )
57
+ full_prompt = rewrite_skill_commands(full_prompt, working_dir: working_dir)
58
+
59
+ cmd = build_pi_command(full_prompt, options, system_prompt: system_prompt)
60
+ stdout, stderr, status = execute_pi_command(cmd, working_dir: working_dir)
61
+
62
+ parse_pi_response(stdout, stderr, status, full_prompt, options)
63
+ rescue => e
64
+ handle_pi_error(e)
65
+ end
66
+
67
+ # List available Pi models
68
+ def list_models
69
+ [
70
+ {id: "zai/glm-4.7", name: "GLM 4.7", description: "ZAI default model", context_size: 128_000},
71
+ {id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6", description: "Anthropic flagship", context_size: 200_000},
72
+ {id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5", description: "Anthropic balanced", context_size: 200_000},
73
+ {id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5", description: "Anthropic fast", context_size: 200_000},
74
+ {id: "google-gemini-cli/gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Google advanced", context_size: 1_000_000},
75
+ {id: "google-gemini-cli/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Google fast", context_size: 1_000_000},
76
+ {id: "openai-codex/gpt-5.2", name: "GPT 5.2", description: "OpenAI model", context_size: 128_000}
77
+ ]
78
+ end
79
+
80
+ private
81
+
82
+ def format_messages_as_prompt(messages)
83
+ return messages if messages.is_a?(String)
84
+
85
+ formatted = messages.map do |msg|
86
+ role = msg[:role] || msg["role"]
87
+ content = msg[:content] || msg["content"]
88
+
89
+ case role
90
+ when "system"
91
+ "System: #{content}"
92
+ when "user"
93
+ "User: #{content}"
94
+ when "assistant"
95
+ "Assistant: #{content}"
96
+ else
97
+ content
98
+ end
99
+ end
100
+
101
+ formatted.join("\n\n")
102
+ end
103
+
104
+ # Build full prompt, using --system-prompt flag for system content
105
+ # when possible, otherwise prepending to prompt.
106
+ #
107
+ # @param prompt [String] The main user prompt
108
+ # @param options [Hash] Options that may contain system instruction keys
109
+ # @return [Array(String, String)] [prompt, system_prompt] pair
110
+ def build_full_prompt(prompt, options)
111
+ prompt_str = prompt.to_s
112
+
113
+ # If prompt already has system instruction from message formatting, use as-is
114
+ return [prompt_str, nil] if prompt_str.start_with?("System:")
115
+
116
+ system_content = options[:system_instruction] ||
117
+ options[:system] ||
118
+ options[:system_prompt] ||
119
+ @generation_config[:system_prompt]
120
+
121
+ [prompt_str, system_content]
122
+ end
123
+
124
+ # Rewrite /name → /skill:name in the prompt for known skills
125
+ def rewrite_skill_commands(prompt, working_dir: nil)
126
+ skills_dir = resolve_skills_dir(working_dir: working_dir)
127
+ return prompt unless skills_dir
128
+
129
+ skill_names = @skill_name_reader.call(skills_dir)
130
+ return prompt if skill_names.empty?
131
+
132
+ Atoms::CommandRewriter.call(prompt, skill_names: skill_names, formatter: Atoms::CommandFormatters::PI_FORMATTER)
133
+ end
134
+
135
+ def resolve_skills_dir(working_dir: nil)
136
+ configured = @options[:skills_dir] || @generation_config[:skills_dir]
137
+ return configured if configured && Dir.exist?(configured)
138
+
139
+ working_dir ||= Atoms::ExecutionContext.resolve_working_dir
140
+ candidate_dir = File.join(working_dir, ".pi", "skills")
141
+ candidate_dir if Dir.exist?(candidate_dir)
142
+ end
143
+
144
+ # Build the pi command array
145
+ #
146
+ # @param full_prompt [String] The complete prompt
147
+ # @param options [Hash] Generation options
148
+ # @param system_prompt [String, nil] System prompt for --system-prompt flag
149
+ # @return [Array<String>] Command array
150
+ def build_pi_command(full_prompt, options, system_prompt: nil)
151
+ cmd = ["pi"]
152
+
153
+ # Print mode (non-interactive, one-shot)
154
+ cmd << "-p" << full_prompt.to_s
155
+
156
+ # No session (stateless)
157
+ cmd << "--no-session"
158
+
159
+ # No skills (we handle skill content ourselves in one-shot mode)
160
+ cmd << "--no-skills"
161
+
162
+ # System prompt via native flag if available
163
+ if system_prompt
164
+ cmd << "--system-prompt" << system_prompt
165
+ end
166
+
167
+ # Provider/model from the model string (format: "provider/model")
168
+ model_to_use = @model || @generation_config[:model] || DEFAULT_MODEL
169
+ provider_name, model_id = split_provider_model(model_to_use)
170
+ if provider_name && model_id
171
+ cmd << "--provider" << provider_name
172
+ cmd << "--model" << model_id
173
+ end
174
+
175
+ # User CLI args after generated flags (last-wins precedence)
176
+ cmd.concat(normalized_cli_args(options))
177
+
178
+ cmd
179
+ end
180
+
181
+ # Split "provider/model" into ["provider", "model"]
182
+ # Handles multi-segment providers like "google-gemini-cli/gemini-2.5-pro"
183
+ # Also handles nested providers like "openrouter:openai/gpt-oss-120b"
184
+ def split_provider_model(model_string)
185
+ return [nil, nil] unless model_string
186
+
187
+ # Check for nested provider pattern (e.g., "openrouter:openai/model")
188
+ if model_string.count(":") > 0
189
+ parts = model_string.split(":", 2)
190
+ if parts.length == 2 && parts[1].include?("/")
191
+ # Nested provider: "openrouter:openai/model" -> ["openrouter", "openai/model"]
192
+ return [parts[0], parts[1]]
193
+ end
194
+ end
195
+
196
+ # Standard provider/model format
197
+ parts = model_string.split("/", 2)
198
+ return [nil, nil] unless parts.length == 2
199
+
200
+ [parts[0], parts[1]]
201
+ end
202
+
203
+ def execute_pi_command(cmd, timeout: nil, working_dir: nil)
204
+ timeout_val = timeout || @options[:timeout] || 120
205
+ Molecules::SafeCapture.call(
206
+ cmd,
207
+ timeout: timeout_val,
208
+ stdin_data: "",
209
+ chdir: working_dir,
210
+ provider_name: "Pi"
211
+ )
212
+ end
213
+
214
+ def parse_pi_response(stdout, stderr, status, prompt, options)
215
+ unless status.success?
216
+ error_msg = stderr.empty? ? stdout : stderr
217
+
218
+ if error_msg.include?("401") || error_msg.include?("Unauthorized")
219
+ raise Ace::LLM::AuthenticationError, "Pi authentication failed. Run 'pi login' to configure credentials."
220
+ end
221
+
222
+ raise Ace::LLM::ProviderError, "Pi CLI failed: #{error_msg}"
223
+ end
224
+
225
+ # Detect NDJSON: starts with {"type":"
226
+ if stdout.strip.start_with?('{"type":"')
227
+ text, usage = parse_ndjson(stdout)
228
+ response = {"usage" => normalize_usage(usage)}
229
+ else
230
+ # Plain text output
231
+ text = stdout.strip
232
+ response = {}
233
+ end
234
+
235
+ metadata = build_metadata(response, text, prompt, options)
236
+
237
+ {
238
+ text: text,
239
+ metadata: metadata
240
+ }
241
+ end
242
+
243
+ def build_metadata(response, text, prompt, options)
244
+ usage = response["usage"] || {}
245
+
246
+ prompt_tokens = usage["input_tokens"] || (prompt.to_s.length / 4).round
247
+ output_tokens = usage["output_tokens"] || (text.length / 4).round
248
+
249
+ {
250
+ provider: "pi",
251
+ model: @model || DEFAULT_MODEL,
252
+ input_tokens: prompt_tokens,
253
+ output_tokens: output_tokens,
254
+ total_tokens: prompt_tokens + output_tokens,
255
+ finish_reason: response["finish_reason"] || "success",
256
+ timestamp: Time.now.utc.iso8601
257
+ }
258
+ end
259
+
260
+ # Parse NDJSON output from Pi CLI when --mode json is used.
261
+ # NDJSON is one JSON object per line, with event types like message_end, agent_end.
262
+ #
263
+ # @param stdout [String] The raw stdout from Pi CLI
264
+ # @return [Array<String, Hash>] Tuple of [extracted_text, usage_hash]
265
+ def parse_ndjson(stdout)
266
+ lines = stdout.split("\n")
267
+ text_parts = []
268
+ usage = nil
269
+
270
+ lines.each do |line|
271
+ next if line.strip.empty?
272
+ event = JSON.parse(line)
273
+ case event["type"]
274
+ when "message_end"
275
+ # Extract text from content array
276
+ content = event.dig("message", "content") || []
277
+ content.each do |c|
278
+ text_parts << c["text"] if c["type"] == "text"
279
+ end
280
+ usage = event.dig("message", "usage")
281
+ when "agent_end"
282
+ # Fallback: extract from messages array
283
+ messages = event["messages"] || []
284
+ messages.each do |msg|
285
+ content = msg["content"] || []
286
+ content.each do |c|
287
+ text_parts << c["text"] if c["type"] == "text"
288
+ end
289
+ end
290
+ usage = messages.dig(0, "usage") if usage.nil?
291
+ end
292
+ end
293
+
294
+ text = text_parts.join("")
295
+ [text, usage || {}]
296
+ rescue JSON::ParserError
297
+ # If parsing fails, treat as plain text
298
+ [stdout.strip, {}]
299
+ end
300
+
301
+ # Normalize Pi usage field names to our standard format.
302
+ # Pi uses "input"/"output", we normalize to "input_tokens"/"output_tokens".
303
+ #
304
+ # @param usage [Hash] Raw usage hash from Pi response
305
+ # @return [Hash] Normalized usage hash
306
+ def normalize_usage(usage)
307
+ return {} unless usage
308
+ {
309
+ "input_tokens" => usage["input"] || usage["input_tokens"],
310
+ "output_tokens" => usage["output"] || usage["output_tokens"]
311
+ }.compact
312
+ end
313
+
314
+ def pi_available?
315
+ system("which pi > /dev/null 2>&1")
316
+ end
317
+
318
+ def validate_pi_availability!
319
+ unless pi_available?
320
+ raise Ace::LLM::ProviderError, "Pi CLI not found. Install from: https://pi.dev"
321
+ end
322
+ end
323
+
324
+ def handle_pi_error(error)
325
+ raise error
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Providers
6
+ module CLI
7
+ VERSION = "0.27.0"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/llm"
4
+ require_relative "cli/version"
5
+ require_relative "cli/molecules/safe_capture"
6
+
7
+ module Ace
8
+ module LLM
9
+ module Providers
10
+ module CLI
11
+ # Main entry point for CLI providers
12
+ # Simply requires the provider client classes
13
+ # Configuration comes from YAML files in .ace-defaults/llm/providers/
14
+ class << self
15
+ def setup
16
+ # Require all CLI provider client classes
17
+ require_cli_providers
18
+ end
19
+
20
+ private
21
+
22
+ def require_cli_providers
23
+ # Require each CLI provider client
24
+ providers = %w[
25
+ claude_code_client
26
+ claude_oai_client
27
+ codex_client
28
+ open_code_client
29
+ codex_oai_client
30
+ gemini_client
31
+ pi_client
32
+ ]
33
+
34
+ providers.each do |provider|
35
+ require_relative "cli/#{provider}"
36
+ rescue LoadError => e
37
+ warn "Could not load CLI provider #{provider}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+
42
+ # Auto-setup on require
43
+ setup
44
+ end
45
+ end
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-llm-providers-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.27.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.26'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.26'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.21'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.21'
68
+ description: Extends ace-llm with CLI-based LLM providers like Claude Code, Codex,
69
+ Gemini CLI, OpenCode, and pi-agent
70
+ email:
71
+ - mc@cs3b.com
72
+ executables:
73
+ - ace-llm-providers-cli-check
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".ace-defaults/llm/providers/claude.yml"
78
+ - ".ace-defaults/llm/providers/codex.yml"
79
+ - ".ace-defaults/llm/providers/codexoss.yml"
80
+ - ".ace-defaults/llm/providers/gemini.yml"
81
+ - ".ace-defaults/llm/providers/opencode.yml"
82
+ - ".ace-defaults/llm/providers/pi.yml"
83
+ - CHANGELOG.md
84
+ - LICENSE
85
+ - README.md
86
+ - Rakefile
87
+ - exe/ace-llm-providers-cli-check
88
+ - lib/ace/llm/providers/cli.rb
89
+ - lib/ace/llm/providers/cli/atoms/args_normalizer.rb
90
+ - lib/ace/llm/providers/cli/atoms/auth_checker.rb
91
+ - lib/ace/llm/providers/cli/atoms/command_formatters.rb
92
+ - lib/ace/llm/providers/cli/atoms/command_rewriter.rb
93
+ - lib/ace/llm/providers/cli/atoms/execution_context.rb
94
+ - lib/ace/llm/providers/cli/atoms/provider_detector.rb
95
+ - lib/ace/llm/providers/cli/atoms/session_finders/claude_session_finder.rb
96
+ - lib/ace/llm/providers/cli/atoms/session_finders/codex_session_finder.rb
97
+ - lib/ace/llm/providers/cli/atoms/session_finders/gemini_session_finder.rb
98
+ - lib/ace/llm/providers/cli/atoms/session_finders/open_code_session_finder.rb
99
+ - lib/ace/llm/providers/cli/atoms/session_finders/pi_session_finder.rb
100
+ - lib/ace/llm/providers/cli/atoms/skill_command_rewriter.rb
101
+ - lib/ace/llm/providers/cli/atoms/worktree_dir_resolver.rb
102
+ - lib/ace/llm/providers/cli/claude_code_client.rb
103
+ - lib/ace/llm/providers/cli/claude_oai_client.rb
104
+ - lib/ace/llm/providers/cli/cli_args_support.rb
105
+ - lib/ace/llm/providers/cli/codex_client.rb
106
+ - lib/ace/llm/providers/cli/codex_oai_client.rb
107
+ - lib/ace/llm/providers/cli/gemini_client.rb
108
+ - lib/ace/llm/providers/cli/molecules/health_checker.rb
109
+ - lib/ace/llm/providers/cli/molecules/safe_capture.rb
110
+ - lib/ace/llm/providers/cli/molecules/session_finder.rb
111
+ - lib/ace/llm/providers/cli/molecules/skill_name_reader.rb
112
+ - lib/ace/llm/providers/cli/open_code_client.rb
113
+ - lib/ace/llm/providers/cli/pi_client.rb
114
+ - lib/ace/llm/providers/cli/version.rb
115
+ homepage: https://github.com/cs3b/ace
116
+ licenses:
117
+ - MIT
118
+ metadata:
119
+ homepage_uri: https://github.com/cs3b/ace
120
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-llm-providers-cli/
121
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-llm-providers-cli/CHANGELOG.md
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: 3.2.0
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubygems_version: 3.6.9
137
+ specification_version: 4
138
+ summary: CLI-based LLM providers for ace-llm
139
+ test_files: []