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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Providers
8
+ module CLI
9
+ module Atoms
10
+ module SessionFinders
11
+ # Finds an OpenCode session by scanning its JSON storage.
12
+ #
13
+ # OpenCode stores data under ~/.local/share/opencode/storage/
14
+ # Project mapping: `project/*.json` with `worktree` field matching working_dir
15
+ # Session: `session/<hash>/*.json` with `id` (ses_ prefix)
16
+ # Messages: 3-level chain: `message/<sid>/` -> `part/<mid>/` -> text content
17
+ class OpenCodeSessionFinder
18
+ DEFAULT_BASE = File.expand_path("~/.local/share/opencode/storage").freeze
19
+
20
+ # @param working_dir [String] project directory to match
21
+ # @param prompt [String] expected first user message
22
+ # @param base_path [String] override base path for testing
23
+ # @param max_candidates [Integer] max sessions to scan
24
+ # @return [Hash, nil] { session_id:, session_path: } or nil
25
+ def self.call(working_dir:, prompt:, base_path: DEFAULT_BASE, max_candidates: 5)
26
+ # Verify the working_dir is a known OpenCode project (nil-gate).
27
+ # OpenCode sessions don't store a project reference, so we can't filter
28
+ # sessions by project — prompt matching is the primary identification.
29
+ project_id = find_project_id(base_path, working_dir)
30
+ return nil unless project_id
31
+
32
+ sessions = find_sessions(base_path, max_candidates)
33
+
34
+ sessions.each do |session_path, session_data|
35
+ session_id = session_data["id"]
36
+ next unless session_id
37
+
38
+ if first_message_matches?(base_path, session_id, prompt)
39
+ return {session_id: session_id, session_path: session_path}
40
+ end
41
+ end
42
+
43
+ nil
44
+ rescue
45
+ nil
46
+ end
47
+
48
+ def self.find_project_id(base_path, working_dir)
49
+ project_dir = File.join(base_path, "project")
50
+ return nil unless File.directory?(project_dir)
51
+
52
+ expanded = File.expand_path(working_dir)
53
+ Dir.glob(File.join(project_dir, "*.json")).each do |path|
54
+ data = JSON.parse(File.read(path))
55
+ return data["id"] if data["worktree"] == expanded
56
+ rescue
57
+ next
58
+ end
59
+ nil
60
+ end
61
+
62
+ def self.find_sessions(base_path, max_candidates)
63
+ session_base = File.join(base_path, "session")
64
+ return [] unless File.directory?(session_base)
65
+
66
+ Dir.glob(File.join(session_base, "**", "*.json"))
67
+ .sort_by { |f| -File.mtime(f).to_f }
68
+ .first(max_candidates)
69
+ .filter_map do |path|
70
+ data = JSON.parse(File.read(path))
71
+ [path, data]
72
+ rescue
73
+ nil
74
+ end
75
+ end
76
+
77
+ def self.first_message_matches?(base_path, session_id, prompt)
78
+ message_dir = File.join(base_path, "message", session_id)
79
+ return false unless File.directory?(message_dir)
80
+
81
+ # Find the earliest message file
82
+ message_files = Dir.glob(File.join(message_dir, "*.json"))
83
+ .sort_by { |f| File.mtime(f).to_f }
84
+
85
+ message_files.each do |msg_path|
86
+ msg_data = JSON.parse(File.read(msg_path))
87
+ next unless msg_data["role"] == "user"
88
+
89
+ message_id = msg_data["id"]
90
+ next unless message_id
91
+
92
+ # Check parts for text content
93
+ part_dir = File.join(base_path, "part", message_id)
94
+ next unless File.directory?(part_dir)
95
+
96
+ Dir.glob(File.join(part_dir, "*.json")).each do |part_path|
97
+ part_data = JSON.parse(File.read(part_path))
98
+ text = part_data["text"]
99
+ return true if text.is_a?(String) && text.strip == prompt.strip
100
+ end
101
+
102
+ return false
103
+ rescue
104
+ next
105
+ end
106
+
107
+ false
108
+ rescue
109
+ false
110
+ end
111
+
112
+ private_class_method :find_project_id, :find_sessions, :first_message_matches?
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Providers
8
+ module CLI
9
+ module Atoms
10
+ module SessionFinders
11
+ # Finds a Pi agent session by scanning JSONL session files.
12
+ #
13
+ # Pi stores sessions under ~/.pi/agent/sessions/<encoded-path>/*.jsonl
14
+ # Path encoding: replace `/` with `-`, wrap in `--`
15
+ # Session ID: `type:"session"` entry → `id` field
16
+ # First user message: `message.role:"user"` + `message.content[0].text`
17
+ class PiSessionFinder
18
+ DEFAULT_BASE = File.expand_path("~/.pi/agent/sessions").freeze
19
+
20
+ # @param working_dir [String] project directory to match
21
+ # @param prompt [String] expected first user message
22
+ # @param base_path [String] override base path for testing
23
+ # @param max_candidates [Integer] max files to scan
24
+ # @return [Hash, nil] { session_id:, session_path: } or nil
25
+ def self.call(working_dir:, prompt:, base_path: DEFAULT_BASE, max_candidates: 5)
26
+ encoded = encode_path(working_dir)
27
+ project_dir = File.join(base_path, encoded)
28
+ return nil unless File.directory?(project_dir)
29
+
30
+ candidates = Dir.glob(File.join(project_dir, "*.jsonl"))
31
+ .sort_by { |f| -File.mtime(f).to_f }
32
+ .first(max_candidates)
33
+
34
+ candidates.each do |path|
35
+ result = scan_file(path, prompt)
36
+ return result if result
37
+ end
38
+
39
+ nil
40
+ rescue
41
+ nil
42
+ end
43
+
44
+ def self.encode_path(path)
45
+ "--#{path.tr("/", "-")}--"
46
+ end
47
+
48
+ def self.scan_file(path, prompt)
49
+ session_id = nil
50
+ File.foreach(path) do |line|
51
+ entry = JSON.parse(line)
52
+
53
+ # Extract session ID from session-type entry
54
+ if entry["type"] == "session" && entry["id"]
55
+ session_id = entry["id"]
56
+ end
57
+
58
+ # Check for user message match
59
+ message = entry["message"]
60
+ next unless message.is_a?(Hash) && message["role"] == "user"
61
+
62
+ content = message["content"]
63
+ text = if content.is_a?(Array)
64
+ content.first&.dig("text")
65
+ elsif content.is_a?(String)
66
+ content
67
+ end
68
+
69
+ if text.is_a?(String) && text.strip == prompt.strip
70
+ return {session_id: session_id, session_path: path}
71
+ end
72
+ rescue JSON::ParserError
73
+ next
74
+ end
75
+ nil
76
+ rescue
77
+ nil
78
+ end
79
+
80
+ private_class_method :encode_path, :scan_file
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_rewriter"
4
+ require_relative "command_formatters"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Providers
9
+ module CLI
10
+ module Atoms
11
+ # Convenience wrapper for Pi-style skill rewriting.
12
+ # Delegates to CommandRewriter with PI_FORMATTER.
13
+ #
14
+ # Transforms `/name` → `/skill:name` for known skill names,
15
+ # enabling Pi CLI to discover and invoke skills correctly.
16
+ class SkillCommandRewriter
17
+ # Rewrite skill command references in a prompt string.
18
+ #
19
+ # @param prompt [String] The prompt text to rewrite
20
+ # @param skill_names [Array<String>] Known skill names (e.g. ["ace-onboard", "ace-git-commit"])
21
+ # @return [String] Prompt with `/name` rewritten to `/skill:name`
22
+ def self.call(prompt, skill_names:)
23
+ CommandRewriter.call(prompt, skill_names: skill_names, formatter: CommandFormatters::PI_FORMATTER)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Providers
6
+ module CLI
7
+ module Atoms
8
+ # Detects if the current directory is a git worktree and returns
9
+ # the common git dir path that needs to be writable for sandbox tools.
10
+ #
11
+ # In a worktree, `.git` is a file containing `gitdir: <path>` pointing
12
+ # to the worktree metadata under the parent repo's `.git/worktrees/`.
13
+ # The parent `.git/` directory must be writable for index.lock etc.
14
+ class WorktreeDirResolver
15
+ # @param working_dir [String] directory to check (default: Dir.pwd)
16
+ # @return [String, nil] path to common .git dir, or nil if not a worktree
17
+ def self.call(working_dir: Dir.pwd)
18
+ new.call(working_dir: working_dir)
19
+ end
20
+
21
+ def call(working_dir: Dir.pwd)
22
+ dot_git = File.join(working_dir, ".git")
23
+
24
+ # If .git is a directory (normal repo) or doesn't exist, not a worktree
25
+ return nil unless File.file?(dot_git)
26
+
27
+ content = File.read(dot_git).strip
28
+ return nil unless content.start_with?("gitdir:")
29
+
30
+ gitdir = content.sub(/\Agitdir:\s*/, "")
31
+
32
+ # Resolve relative paths against working_dir
33
+ gitdir = File.expand_path(gitdir, working_dir) unless gitdir.start_with?("/")
34
+
35
+ # Walk up from gitdir to find the common .git/ directory
36
+ # Typical path: /repo/.git/worktrees/<name> → we want /repo/.git
37
+ path = gitdir
38
+ while path != "/" && path != "."
39
+ basename = File.basename(path)
40
+ parent = File.dirname(path)
41
+
42
+ if basename == "worktrees" && File.directory?(File.join(parent, "refs"))
43
+ return parent
44
+ end
45
+
46
+ path = parent
47
+ end
48
+
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,358 @@
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
+
10
+ module Ace
11
+ module LLM
12
+ module Providers
13
+ module CLI
14
+ # Client for interacting with Claude Code via the Claude CLI
15
+ # Provides access to Claude Code models through subprocess execution
16
+ class ClaudeCodeClient < Ace::LLM::Organisms::BaseClient
17
+ include CliArgsSupport
18
+
19
+ # Not used for CLI interaction but required by BaseClient
20
+ API_BASE_URL = "https://claude.ai"
21
+ DEFAULT_GENERATION_CONFIG = {}.freeze
22
+
23
+ # Provider registration - auto-registers as "claude"
24
+ def self.provider_name
25
+ "claude"
26
+ end
27
+
28
+ # Default model (can be overridden by config)
29
+ DEFAULT_MODEL = "claude-sonnet-4-0"
30
+
31
+ def initialize(model: nil, **options)
32
+ @model = model || DEFAULT_MODEL
33
+ # Skip normal BaseClient initialization that requires API key
34
+ @options = options
35
+ @generation_config = options[:generation_config] || {}
36
+ end
37
+
38
+ # Override to indicate this client doesn't need API credentials
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_claude_availability!
49
+
50
+ # Convert messages to prompt format
51
+ prompt = format_messages_as_prompt(messages)
52
+
53
+ cmd = build_claude_command(options)
54
+ subprocess_env = options.delete(:subprocess_env)
55
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
56
+ working_dir: options[:working_dir],
57
+ subprocess_env: subprocess_env
58
+ )
59
+ stdout, stderr, status = execute_claude_command(
60
+ cmd,
61
+ prompt,
62
+ subprocess_env: subprocess_env,
63
+ working_dir: working_dir
64
+ )
65
+
66
+ parse_claude_response(stdout, stderr, status, prompt, options)
67
+ rescue => e
68
+ handle_claude_error(e)
69
+ end
70
+
71
+ # List available Claude Code models
72
+ def list_models
73
+ # Return models based on what the CLI supports
74
+ # This is a simplified list - actual models come from YAML config
75
+ [
76
+ {id: "claude-opus-4-1", name: "Claude Opus 4.1", description: "Most capable model", context_size: 200_000},
77
+ {id: "claude-sonnet-4-0", name: "Claude Sonnet 4.0", description: "Balanced model", context_size: 200_000},
78
+ {id: "claude-3-5-haiku-latest", name: "Claude Haiku 3.5", description: "Fast model", context_size: 200_000}
79
+ ]
80
+ end
81
+
82
+ private
83
+
84
+ def format_messages_as_prompt(messages)
85
+ # Handle both array of message hashes and string prompt
86
+ return messages if messages.is_a?(String)
87
+
88
+ # Convert array of messages to formatted prompt
89
+ formatted = messages.map do |msg|
90
+ role = msg[:role] || msg["role"]
91
+ content = msg[:content] || msg["content"]
92
+
93
+ case role
94
+ when "system"
95
+ "System: #{content}"
96
+ when "user"
97
+ "User: #{content}"
98
+ when "assistant"
99
+ "Assistant: #{content}"
100
+ else
101
+ content
102
+ end
103
+ end
104
+
105
+ formatted.join("\n\n")
106
+ end
107
+
108
+ def claude_available?
109
+ system("which claude > /dev/null 2>&1")
110
+ end
111
+
112
+ def validate_claude_availability!
113
+ unless claude_available?
114
+ raise Ace::LLM::ProviderError, "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-cli"
115
+ end
116
+
117
+ # Check if Claude is authenticated (quick check)
118
+ unless claude_authenticated?
119
+ raise Ace::LLM::AuthenticationError, "Claude authentication required. Run 'claude setup-token' to configure"
120
+ end
121
+ end
122
+
123
+ def claude_authenticated?
124
+ # Quick check if Claude can execute (will fail fast if not authenticated)
125
+ # Using a minimal test that should complete quickly
126
+ cmd = ["claude", "--version"]
127
+ stdout, _, status = Open3.capture3(*cmd)
128
+ status.success? && (stdout.include?("Claude") || stdout.include?("claude"))
129
+ rescue
130
+ false
131
+ end
132
+
133
+ def build_claude_command(options)
134
+ cmd = ["claude"]
135
+ cmd << "-p"
136
+
137
+ # Always use JSON output for consistent parsing
138
+ cmd << "--output-format" << "json"
139
+
140
+ # Add model selection if not default
141
+ if @model && @model != DEFAULT_MODEL
142
+ cmd << "--model" << @model
143
+ end
144
+
145
+ # Prompt is passed via stdin to avoid exceeding Linux MAX_ARG_STRLEN
146
+ # (128KB per-argument limit). System content is already embedded in the
147
+ # formatted prompt via format_messages_as_prompt.
148
+
149
+ # Add max tokens if provided
150
+ max_tokens = options[:max_tokens] || @generation_config[:max_tokens]
151
+ if max_tokens
152
+ cmd << "--max-tokens" << max_tokens.to_s
153
+ end
154
+
155
+ # User CLI args last so they take precedence (last-wins in most CLIs)
156
+ cmd.concat(normalized_cli_args(options))
157
+
158
+ cmd
159
+ end
160
+
161
+ def execute_claude_command(cmd, prompt, subprocess_env: nil, working_dir: nil)
162
+ timeout_val = @options[:timeout] || 120
163
+ # Clear CLAUDECODE env var so `claude -p` (non-interactive, one-shot mode)
164
+ # can run as a subprocess from within a Claude Code session.
165
+ # The guard was added in Claude Code v2.1.41 to prevent nested interactive
166
+ # sessions, but -p mode doesn't share session state.
167
+ env = {"CLAUDECODE" => nil}
168
+ env.merge!(subprocess_env) if subprocess_env
169
+ debug_subprocess("spawn timeout=#{timeout_val}s cmd=#{cmd.join(" ")} prompt_bytes=#{prompt.to_s.bytesize}")
170
+ Molecules::SafeCapture.call(
171
+ cmd,
172
+ timeout: timeout_val,
173
+ stdin_data: prompt.to_s,
174
+ chdir: working_dir,
175
+ env: env,
176
+ provider_name: "Claude"
177
+ )
178
+ end
179
+
180
+ def parse_claude_response(stdout, stderr, status, prompt, options)
181
+ unless status.success?
182
+ error_msg = stderr.empty? ? stdout : stderr
183
+ raise Ace::LLM::ProviderError, "Claude CLI failed: #{error_msg}"
184
+ end
185
+
186
+ begin
187
+ # Allow duplicate keys to avoid warnings from Claude CLI output
188
+ # Some versions of Claude CLI may return JSON with duplicate keys
189
+ response = JSON.parse(stdout, allow_duplicate_key: true)
190
+ rescue JSON::ParserError => e
191
+ raise Ace::LLM::ProviderError, "Failed to parse Claude response: #{e.message}"
192
+ end
193
+
194
+ if response["is_error"]
195
+ raise Ace::LLM::ProviderError, build_response_error(response)
196
+ end
197
+
198
+ # Extract the text result
199
+ text = extract_claude_text(response)
200
+
201
+ if text.strip.empty?
202
+ raise Ace::LLM::ProviderError, build_response_error(response)
203
+ end
204
+
205
+ # Build metadata
206
+ metadata = build_metadata(response, prompt, options)
207
+
208
+ # Return hash compatible with ace-llm format
209
+ {
210
+ text: text,
211
+ metadata: metadata
212
+ }
213
+ end
214
+
215
+ def extract_claude_text(response)
216
+ direct_result = response["result"]
217
+ direct_response = response["response"]
218
+ direct_message = response["message"]
219
+ direct_text = response["text"]
220
+
221
+ candidates = [
222
+ direct_result,
223
+ direct_response,
224
+ direct_message,
225
+ direct_text,
226
+ response["content"]
227
+ ]
228
+
229
+ candidates.each do |candidate|
230
+ text = extract_claude_text_value(candidate)
231
+ return text unless text.empty?
232
+ end
233
+
234
+ ""
235
+ end
236
+
237
+ def extract_claude_text_value(value)
238
+ case value
239
+ when String
240
+ text = value.to_s.strip
241
+ return text unless text.empty?
242
+ when Hash
243
+ nested_candidates = [
244
+ value["text"],
245
+ value["content"],
246
+ value["response"],
247
+ value["result"]
248
+ ]
249
+
250
+ nested_candidates.each do |candidate|
251
+ text = extract_claude_text_value(candidate)
252
+ return text unless text.empty?
253
+ end
254
+
255
+ if value["messages"].is_a?(Array)
256
+ text = extract_claude_text_value(value["messages"])
257
+ return text unless text.empty?
258
+ end
259
+
260
+ if value["parts"].is_a?(Array)
261
+ text = extract_claude_text_value(value["parts"])
262
+ return text unless text.empty?
263
+ end
264
+
265
+ if value["choices"].is_a?(Array)
266
+ text = extract_claude_text_value(value["choices"])
267
+ return text unless text.empty?
268
+ end
269
+ when Array
270
+ value.each do |entry|
271
+ text = extract_claude_text_value(entry)
272
+ return text unless text.empty?
273
+ end
274
+ end
275
+
276
+ ""
277
+ end
278
+
279
+ def build_metadata(response, prompt, options)
280
+ usage = response["usage"] || {}
281
+
282
+ # Build standard metadata structure
283
+ metadata = {
284
+ provider: "claude",
285
+ model: @model || DEFAULT_MODEL,
286
+ input_tokens: usage["input_tokens"] || 0,
287
+ output_tokens: usage["output_tokens"] || 0,
288
+ total_tokens: (usage["input_tokens"] || 0) + (usage["output_tokens"] || 0),
289
+ cached_tokens: usage["cache_read_input_tokens"] || 0,
290
+ finish_reason: response["subtype"] || "success",
291
+ took: (response["duration_ms"] || 0) / 1000.0,
292
+ timestamp: Time.now.utc.iso8601
293
+ }
294
+
295
+ # Add cost information if available
296
+ if response["total_cost_usd"]
297
+ metadata[:cost] = {
298
+ input_cost: 0.0, # Claude provides total only
299
+ output_cost: 0.0,
300
+ total_cost: response["total_cost_usd"],
301
+ currency: "USD"
302
+ }
303
+ end
304
+
305
+ # Add session ID if available
306
+ metadata[:session_id] = response["session_id"] if response["session_id"]
307
+
308
+ # Add any Claude-specific data
309
+ metadata[:provider_specific] = {
310
+ uuid: response["uuid"],
311
+ service_tier: usage["service_tier"],
312
+ duration_api_ms: response["duration_api_ms"],
313
+ cache_creation_tokens: usage["cache_creation_input_tokens"]
314
+ }.compact
315
+
316
+ metadata
317
+ end
318
+
319
+ def handle_claude_error(error)
320
+ # Re-raise the error for proper handling by the base client error flow
321
+ raise error
322
+ end
323
+
324
+ def build_response_error(response)
325
+ summary = {
326
+ "type" => response["type"],
327
+ "subtype" => response["subtype"],
328
+ "stop_reason" => response["stop_reason"],
329
+ "is_error" => response["is_error"],
330
+ "session_id" => response["session_id"],
331
+ "duration_ms" => response["duration_ms"]
332
+ }.compact
333
+
334
+ result_preview = response["result"].to_s.strip
335
+ if response["is_error"] || !result_preview.empty?
336
+ details = summary.map { |key, value| "#{key}=#{value}" }.join(", ")
337
+ message = result_preview.empty? ? "no result text provided" : result_preview
338
+ return "Claude CLI returned an error payload without review text: #{message} (#{details})"
339
+ end
340
+
341
+ details = summary.map { |key, value| "#{key}=#{value}" }.join(", ")
342
+ if details.empty?
343
+ "Claude CLI returned empty response (exit 0 but no output text)"
344
+ else
345
+ "Claude CLI returned empty response (exit 0, no output text; #{details})"
346
+ end
347
+ end
348
+
349
+ def debug_subprocess(message)
350
+ return unless ENV["ACE_LLM_DEBUG_SUBPROCESS"] == "1"
351
+
352
+ warn("[ClaudeCodeClient] #{message}")
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end