rubik 0.3.4 → 0.3.5

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +54 -0
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/rust_extractor.rb +9 -2
  7. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  8. data/lib/rubik/buffer/manager.rb +78 -66
  9. data/lib/rubik/config/defaults.rb +1 -0
  10. data/lib/rubik/context/git_lister.rb +3 -1
  11. data/lib/rubik/llm/anthropic.rb +5 -2
  12. data/lib/rubik/llm/openai_compatible.rb +83 -68
  13. data/lib/rubik/lsp/client.rb +320 -96
  14. data/lib/rubik/lsp/manager.rb +83 -12
  15. data/lib/rubik/lsp/server_command.rb +102 -0
  16. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  17. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  18. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  19. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  20. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  21. data/lib/rubik/repl/commands/buffer.rb +122 -88
  22. data/lib/rubik/repl/commands/mcp.rb +120 -92
  23. data/lib/rubik/repl/commands/session.rb +90 -59
  24. data/lib/rubik/repl/commands/workspace.rb +110 -94
  25. data/lib/rubik/repl/shell.rb +10 -1
  26. data/lib/rubik/runtime/discovery.rb +12 -6
  27. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  28. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  29. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  30. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  31. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  32. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  33. data/lib/rubik/version.rb +1 -1
  34. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
4
- data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
3
+ metadata.gz: b8a3ecbfb98ba136f13800cc4a2d9ab68703b30c0086a3297c005abe6991d060
4
+ data.tar.gz: 72e1fff12c37f318f5e1b817d0ef88601110a83099f05edbd10c3d294564027c
5
5
  SHA512:
6
- metadata.gz: c02dbe25a9cc8a3af6a4c24187fc9115ad8ae5077c49e4842e2f03151ade01bedc4cc5e4709056a388a3934151b4a361422c30b1de92158e3d4c6d6d87c56743
7
- data.tar.gz: db9835fd0913e3fad74ab68bdbd25137df95544579b2beed718237d09699e378fb2cbfde7da9341785f407d3aea9a43b98aedec34df1305278b93ddc3434adb6
6
+ metadata.gz: 7a17907eee5524e8053a9f73eb946ebe8766001a64ca17db048240837c1f154f7d2ba3650ce765e8ab830dc6da8840a1ea15e147490a03af1f5203d33b678294
7
+ data.tar.gz: 54d72614c2c6fd31a0ff482144c7953148d780516ea748f248027a6943d0b91c40fdfd64d1867036109f8b666bc3ce9ecb58516f0418f3c775335f249fead2b5
data/ATTRIBUTION.md CHANGED
@@ -121,7 +121,7 @@ SOFTWARE.
121
121
  | **`strings-ansi`** | Piotr Murach | MIT | ANSI-aware string truncation and alignment for REPL formatters. |
122
122
  | **`reline`** | Hiroshi SHIBATA & Ruby Team | Ruby License / BSD-2-Clause | Line-editing, history navigation, autocompletion hooks, and headless terminal adaptation. |
123
123
  | **`prism`** | Kevin Newton & Ruby Core Team | MIT | High-performance Ruby parsing and AST extraction ([`Rubik::AST::RubyExtractor`](lib/rubik/ast/ruby_extractor.rb)). |
124
- | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,800+ unit, integration, and contract tests. |
124
+ | **`minitest`** | Ryan Davis & seattlerb | MIT | Fast, modular test suite powering Rubik's 3,850+ unit, integration, and contract tests. |
125
125
  | **`rubocop`** | Bozhidar Batsov & contributors | MIT | Static code analysis, style enforcement, and architectural hygiene gates. |
126
126
  | **`benchmark`, `logger`, `pstore`, `reline`, `fiddle`** | Ruby core / rubygems teams | Ruby License / BSD-2-Clause | Ruby stdlib gems pinned explicitly in `rubik.gemspec` as they are unbundled from the default set in Ruby 3.4/3.5. |
127
127
 
data/CHANGELOG.md CHANGED
@@ -10,6 +10,60 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
 
11
11
  Nothing yet.
12
12
 
13
+ ## [0.3.5] - 2026-10-24
14
+
15
+ A vakanuvis-durability patch: the chronicler's digest pipeline stops
16
+ losing in-flight work and serving stale reads, LSP diagnostics and
17
+ document sync get hardened across the board, and AST call
18
+ extraction runs measurably cheaper.
19
+
20
+ ### Fixed
21
+
22
+ - **vakanuvis recall and digest are durable (#266, #267, #268,
23
+ #270, #271, #272):** in-place memory and wiki edits now register
24
+ as durable content updates for digest dirty tracking; superseded
25
+ records no longer surface as exact recall hits or pass smoke
26
+ checks; annal write failures and harness shutdown no longer
27
+ strand targeted digest jobs or events enqueued after the STOP
28
+ marker; the pending digest queue is bounded when the distiller is
29
+ unavailable; and the rogue-memory sweep preserves canonical
30
+ `mem.*` aliases, superseding raw duplicates in the canonical
31
+ line's favor.
32
+ - **LSP diagnostics and sync hardened (#252, closes #245–#251):**
33
+ diagnostic freshness is tracked per canonical document — stale
34
+ versioned publications are rejected and pending or unversioned
35
+ analysis is reported accurately; document state and LSP frame
36
+ writes are serialized, and transport failures propagate to wake
37
+ pending requests; text-document synchronization capabilities are
38
+ honored (unsupported dynamic registrations rejected, save
39
+ notifications sent when supported); server selection is separated
40
+ from document language IDs, with crash history retained across
41
+ short-lived restarts; and structured executable/argv server
42
+ configuration is supported through discovery, repair and client
43
+ startup without invoking a shell.
44
+ - **Anthropic API key resolution hardened (#261):** the reported
45
+ hardcoded-secret finding is confirmed a false positive (the
46
+ literal env-var name in error messages); `resolve_api_key` now
47
+ falls back to `ANTHROPIC_API_KEY` defensively when the configured
48
+ environment-variable name is blank or empty.
49
+
50
+ ### Performance
51
+
52
+ - **AST call extraction is cheaper (#277):** the Go, Rust and
53
+ TypeScript extractors use zero-capture lookahead regexes and
54
+ frozen keyword-exclusion Sets — 35–80% fewer allocations and
55
+ ~25–27% faster in hot call-extraction paths, verified
56
+ output-identical.
57
+
58
+ ### Changed
59
+
60
+ - **No-behavior-change decompositions (#254–#265):** oversized
61
+ methods across the OpenAI-compatible provider, goal-turn driving,
62
+ the MCP/workspace/session/model/context command registrations,
63
+ buffer registration and apply, subagent spawn and adapter process
64
+ management were split into helpers — same behavior, reviewable
65
+ units.
66
+
13
67
  ## [0.3.4] - 2026-10-23
14
68
 
15
69
  A security-hardening patch: three injection/traversal findings in
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
+ require_relative "../log"
5
6
 
6
7
  module Rubik
7
8
  module Adapter
@@ -19,17 +20,19 @@ module Rubik
19
20
  pid = wait_thr.pid
20
21
  begin
21
22
  stdin.close
22
- rescue
23
- nil
23
+ rescue StandardError => e
24
+ Rubik::Log.debug(:adapter, "stdin close failed during process execution: #{e.class}: #{e.message}")
24
25
  end
25
26
  t_out = Thread.new do
26
27
  stdout.read
27
- rescue
28
+ rescue StandardError => e
29
+ Rubik::Log.debug(:adapter, "stdout read failed during process execution: #{e.class}: #{e.message}")
28
30
  ""
29
31
  end
30
32
  t_err = Thread.new do
31
33
  stderr.read
32
- rescue
34
+ rescue StandardError => e
35
+ Rubik::Log.debug(:adapter, "stderr read failed during process execution: #{e.class}: #{e.message}")
33
36
  ""
34
37
  end
35
38
  begin
@@ -40,13 +43,13 @@ module Rubik
40
43
  ensure
41
44
  begin
42
45
  t_out.kill
43
- rescue
44
- nil
46
+ rescue StandardError => e
47
+ Rubik::Log.debug(:adapter, "t_out kill failed: #{e.class}: #{e.message}")
45
48
  end
46
49
  begin
47
50
  t_err.kill
48
- rescue
49
- nil
51
+ rescue StandardError => e
52
+ Rubik::Log.debug(:adapter, "t_err kill failed: #{e.class}: #{e.message}")
50
53
  end
51
54
  end
52
55
  end
@@ -8,6 +8,10 @@ module Rubik
8
8
  class GoExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -105,9 +109,12 @@ module Rubik
105
109
  )
106
110
  end
107
111
 
112
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
113
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~25% speedup, ~80% fewer allocations).
108
114
  def extract_calls(body, self_name)
109
- calls = body.scan(/\b([A-Za-z0-9_]+)\s*\(/).flatten.uniq
110
- calls.reject { |c| c == self_name || ["func", "if", "for", "return", "make", "append", "len", "cap", "panic", "recover"].include?(c) }
115
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=\s*\()/).uniq
116
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
117
+ calls
111
118
  end
112
119
 
113
120
  def find_closing_brace(start_idx)
@@ -8,6 +8,10 @@ module Rubik
8
8
  class RustExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"]
13
+ ).freeze
14
+
11
15
  def self.extract(file_path, content = nil)
12
16
  new(file_path, content).extract
13
17
  end
@@ -91,9 +95,12 @@ module Rubik
91
95
 
92
96
  private
93
97
 
98
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
99
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~80% fewer allocations).
94
100
  def extract_calls(body, self_name)
95
- calls = body.scan(/\b([A-Za-z0-9_]+)(?:!|\s*\()/).flatten.uniq
96
- calls.reject { |c| c == self_name || ["fn", "pub", "async", "if", "for", "while", "loop", "match", "return", "let", "mut"].include?(c) }
101
+ calls = body.scan(/\b[A-Za-z0-9_]+(?=!|\s*\()/).uniq
102
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
103
+ calls
97
104
  end
98
105
 
99
106
  def find_closing_brace(start_idx)
@@ -8,6 +8,14 @@ module Rubik
8
8
  class TypeScriptExtractor
9
9
  include BodyLoader
10
10
 
11
+ EXCLUDED_KEYWORDS = Set.new(
12
+ [
13
+ "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
14
+ "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
15
+ "await", "super", "class", "interface", "type", "const", "let", "var"
16
+ ]
17
+ ).freeze
18
+
11
19
  def self.extract(file_path, content = nil)
12
20
  new(file_path, content).extract
13
21
  end
@@ -227,14 +235,12 @@ module Rubik
227
235
  [sym, next_i]
228
236
  end
229
237
 
238
+ # PERF: Uses zero-capture lookahead and frozen Set lookup to eliminate
239
+ # intermediate 2D scan arrays, flatten/uniq allocations, and per-call array creation (~27% speedup, ~35% fewer allocations).
230
240
  def extract_calls(body, self_name)
231
- calls = body.scan(/\b([A-Za-z0-9_$]+)\s*\(/).flatten.uniq
232
- keywords = [
233
- "if", "else", "for", "while", "do", "switch", "case", "return", "catch", "function", "import",
234
- "require", "typeof", "instanceof", "throw", "new", "try", "finally", "delete", "void", "yield",
235
- "await", "super", "class", "interface", "type", "const", "let", "var"
236
- ]
237
- calls.reject { |c| c == self_name || keywords.include?(c) }
241
+ calls = body.scan(/\b[A-Za-z0-9_$]+(?=\s*\()/).uniq
242
+ calls.delete_if { |c| c == self_name || EXCLUDED_KEYWORDS.include?(c) }
243
+ calls
238
244
  end
239
245
 
240
246
  def find_closing_brace(start_idx)
@@ -317,82 +317,20 @@ module Rubik
317
317
  @monitor.synchronize do
318
318
  applied_paths = []
319
319
  conflicts = []
320
- keys_to_apply = if target_paths
321
- Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
322
- else
323
- @dirty_files.keys.dup
324
- end
325
-
320
+ keys_to_apply = determine_keys_to_apply(target_paths)
326
321
  effective_policy = (policy || external_modification_policy).to_s.downcase
327
322
 
328
323
  keys_to_apply.each_with_index do |rel_path, index|
329
324
  next unless @dirty_files.key?(rel_path)
325
+ next unless validate_and_clean_garbage(rel_path)
330
326
 
331
- unless PathValidator.valid?(rel_path)
332
- @dirty_files.delete(rel_path)
333
- @buffer.delete(rel_path)
334
- @original_hashes.delete(rel_path)
335
- @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
336
- next
337
- end
338
-
339
- baseline = @original_hashes[rel_path]
340
- current_disk = disk_hash(rel_path)
341
-
342
- conflict = if baseline
343
- current_disk != baseline
344
- else
345
- !current_disk.nil?
346
- end
347
-
348
- if conflict && effective_policy != "overwrite"
327
+ if external_conflict?(rel_path) && effective_policy != "overwrite"
349
328
  conflicts << rel_path
350
329
  @event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
351
330
  next
352
331
  end
353
332
 
354
- full_path = File.expand_path(rel_path, @root_dir)
355
-
356
- # TOCTOU close: re-verify containment immediately before the
357
- # atomic write — a symlink planted between write-time sanitize
358
- # and apply! must not redirect the write outside the workspace.
359
- # Fail loud: an untrustworthy path is an attack signal or a
360
- # mid-flight root misconfiguration, not a content problem; the
361
- # entry stays dirty for a retry after remediation.
362
- containment_kind, = containment_root(full_path)
363
- unless containment_kind
364
- @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
365
- # Commit what this run has seen so "apply! rewrites
366
- # last_conflicts on every run" holds for readers (AutoApply's
367
- # report_conflicts) even on the failure path.
368
- @last_conflicts = conflicts
369
- raise apply_failure(
370
- "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
371
- "(possible symlink swap since buffering). Re-verify the workspace and retry.",
372
- kind: :containment_blocked, applied: applied_paths, failed: rel_path,
373
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
374
- )
375
- end
376
-
377
- content = @buffer[rel_path]
378
-
379
- begin
380
- Rubik::Platform.atomic_write(full_path, content)
381
- rescue StandardError => e
382
- @last_conflicts = conflicts
383
- raise apply_failure(
384
- "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
385
- "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
386
- "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
387
- kind: :write_failed, applied: applied_paths, failed: rel_path,
388
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
389
- ), cause: e
390
- end
391
-
392
- @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
393
- @dirty_files.delete(rel_path)
394
- @buffer.delete(rel_path)
395
- applied_paths << rel_path
333
+ apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
396
334
  end
397
335
 
398
336
  @last_conflicts = conflicts
@@ -443,6 +381,80 @@ module Rubik
443
381
 
444
382
  private
445
383
 
384
+ def determine_keys_to_apply(target_paths)
385
+ if target_paths
386
+ Array(target_paths).map { |p| sanitize_path(p, for_write: true) }
387
+ else
388
+ @dirty_files.keys.dup
389
+ end
390
+ end
391
+
392
+ def validate_and_clean_garbage(rel_path)
393
+ return true if PathValidator.valid?(rel_path)
394
+
395
+ @dirty_files.delete(rel_path)
396
+ @buffer.delete(rel_path)
397
+ @original_hashes.delete(rel_path)
398
+ @event_bus&.emit(:buffer_garbage_skipped, path: rel_path)
399
+ false
400
+ end
401
+
402
+ def external_conflict?(rel_path)
403
+ baseline = @original_hashes[rel_path]
404
+ current_disk = disk_hash(rel_path)
405
+
406
+ if baseline
407
+ current_disk != baseline
408
+ else
409
+ !current_disk.nil?
410
+ end
411
+ end
412
+
413
+ def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
414
+ full_path = File.expand_path(rel_path, @root_dir)
415
+
416
+ # TOCTOU close: re-verify containment immediately before the
417
+ # atomic write — a symlink planted between write-time sanitize
418
+ # and apply! must not redirect the write outside the workspace.
419
+ # Fail loud: an untrustworthy path is an attack signal or a
420
+ # mid-flight root misconfiguration, not a content problem; the
421
+ # entry stays dirty for a retry after remediation.
422
+ containment_kind, = containment_root(full_path)
423
+ unless containment_kind
424
+ @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
425
+ # Commit what this run has seen so "apply! rewrites
426
+ # last_conflicts on every run" holds for readers (AutoApply's
427
+ # report_conflicts) even on the failure path.
428
+ @last_conflicts = conflicts
429
+ raise apply_failure(
430
+ "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
431
+ "(possible symlink swap since buffering). Re-verify the workspace and retry.",
432
+ kind: :containment_blocked, applied: applied_paths, failed: rel_path,
433
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
434
+ )
435
+ end
436
+
437
+ content = @buffer[rel_path]
438
+
439
+ begin
440
+ Rubik::Platform.atomic_write(full_path, content)
441
+ rescue StandardError => e
442
+ @last_conflicts = conflicts
443
+ raise apply_failure(
444
+ "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
445
+ "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
446
+ "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
447
+ kind: :write_failed, applied: applied_paths, failed: rel_path,
448
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
449
+ ), cause: e
450
+ end
451
+
452
+ @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
453
+ @dirty_files.delete(rel_path)
454
+ @buffer.delete(rel_path)
455
+ applied_paths << rel_path
456
+ end
457
+
446
458
  # Single source of boundary containment for both the sanitize_path
447
459
  # entry check and the apply! re-verification. Returns [kind, dir] with
448
460
  # kind :root / :external (dir is the LEXICAL matched root/external
@@ -171,6 +171,7 @@ module Rubik
171
171
  "distiller" => true,
172
172
  "max_steps" => 8,
173
173
  "flush_events" => 10,
174
+ "max_digest_jobs" => 100,
174
175
  "model" => nil,
175
176
  "retention" => "supersede",
176
177
  "root_turns" => true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "../log"
4
5
 
5
6
  module Rubik
6
7
  module Context
@@ -27,7 +28,8 @@ module Rubik
27
28
  res.map! { |p| p.tr("\\", "/") } if out.include?("\\")
28
29
  res.uniq!
29
30
  res
30
- rescue StandardError
31
+ rescue StandardError => e
32
+ Rubik::Log.debug(:context, "git ls-files execution failed for #{root_dir}: #{e.class}: #{e.message}")
31
33
  nil
32
34
  end
33
35
 
@@ -56,8 +56,11 @@ module Rubik
56
56
  return opts[:api_key] if opts.key?(:api_key) && !opts[:api_key].nil?
57
57
  return @api_key if @api_key && !@api_key.to_s.empty?
58
58
 
59
- env_name = opts[:api_key_env] || @api_key_env || "ANTHROPIC_API_KEY"
60
- ENV.fetch(env_name, nil)
59
+ env_name = opts[:api_key_env]
60
+ env_name = @api_key_env if env_name.nil? || env_name.to_s.empty?
61
+ env_name = "ANTHROPIC_API_KEY" if env_name.nil? || env_name.to_s.empty?
62
+
63
+ ENV.fetch(env_name.to_s, nil)
61
64
  end
62
65
 
63
66
  def complete(messages, opts = {})
@@ -50,20 +50,11 @@ module Rubik
50
50
  end
51
51
 
52
52
  def complete(messages, opts = {})
53
- resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
54
53
  target_model = opts[:model] || @model
55
54
  target_endpoint = self.class.normalize_endpoint(opts[:endpoint] || @endpoint)
56
55
  messages = Rubik::LLM::MessageSanitizer.normalize(messages)
57
56
 
58
- req_headers = { "Content-Type" => "application/json" }
59
- req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
60
-
61
- # Merge custom headers (e.g. OpenRouter HTTP-Referer, X-Title)
62
- @headers.each do |k, v|
63
- req_headers[k.to_s] = v.to_s
64
- end
65
- req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
66
-
57
+ req_headers = build_headers(opts)
67
58
  formatted_msgs = format_messages(messages)
68
59
  payload = build_payload(target_model, formatted_msgs, opts, stream: false)
69
60
 
@@ -79,73 +70,17 @@ module Rubik
79
70
  on_retry: opts[:on_retry]
80
71
  )
81
72
 
82
- root_json = res_json["data"].is_a?(Hash) && res_json["data"]["choices"] ? res_json["data"] : res_json
83
- msg = root_json.dig("choices", 0, "message") || {}
84
- text = msg["content"] || ""
85
- thought = msg["reasoning_content"] || msg["reasoning"] || msg["thought"]
86
- thought = nil if thought.to_s.empty?
87
- usage = root_json["usage"] || res_json["usage"] || {}
88
-
89
- raw_tool_calls = msg["tool_calls"] || []
90
- parsed_tool_calls = raw_tool_calls.map do |tc|
91
- fn = tc["function"] || {}
92
- args = fn["arguments"]
93
- malformed = nil
94
- if args.is_a?(String)
95
- begin
96
- parsed = JSON.parse(args)
97
- if parsed.is_a?(Hash)
98
- args = parsed
99
- else
100
- malformed = args
101
- args = {}
102
- end
103
- rescue JSON::ParserError
104
- # Issue #55 3.4: the degrade carries the rejected payload so
105
- # the executor can echo it back to the model in the error row.
106
- malformed = args
107
- args = {}
108
- end
109
- elsif !args.is_a?(Hash) && !args.nil?
110
- malformed = args.to_s
111
- args = {}
112
- end
113
- row = {
114
- "id" => tc["id"] || "call_#{rand(1000..9999)}",
115
- "tool" => fn["name"],
116
- "args" => args || {}
117
- }
118
- row["malformed_args"] = malformed if malformed
119
- row
120
- end
121
-
122
- in_tokens = usage["prompt_tokens"] || usage["input_tokens"] || estimate_tokens(messages.map { |m| m[:content] || m["content"] }.join)
123
- out_tokens = usage["completion_tokens"] || usage["output_tokens"] || estimate_tokens(text)
124
-
125
- Response.new(
126
- text: text,
127
- usage: {
128
- input_tokens: in_tokens,
129
- output_tokens: out_tokens
130
- },
131
- raw: res_json,
132
- tool_calls: parsed_tool_calls,
133
- thought: thought
134
- )
73
+ build_response(res_json, messages)
135
74
  end
136
75
 
137
76
  # Streaming variant. Yields Chunks. Without a block, returns an
138
77
  # Enumerator (via `to_enum`) so the caller can chain `.each`.
139
78
  def stream_chat(messages, opts = {}, &block)
140
- resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
141
79
  target_model = opts[:model] || @model
142
80
  target_endpoint = self.class.normalize_endpoint(opts[:endpoint] || @endpoint)
143
81
  messages = Rubik::LLM::MessageSanitizer.normalize(messages)
144
82
 
145
- req_headers = { "Content-Type" => "application/json" }
146
- req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
147
- @headers.each { |k, v| req_headers[k.to_s] = v.to_s }
148
- req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
83
+ req_headers = build_headers(opts)
149
84
 
150
85
  formatted_msgs = format_messages(messages)
151
86
  payload = build_payload(target_model, formatted_msgs, opts, stream: true)
@@ -172,6 +107,86 @@ module Rubik
172
107
 
173
108
  private
174
109
 
110
+ def build_headers(opts = {})
111
+ resolved_key = opts[:api_key] || @api_key || (ENV.fetch(@api_key_env, nil) if @api_key_env)
112
+ req_headers = { "Content-Type" => "application/json" }
113
+ req_headers["Authorization"] = "Bearer #{resolved_key}" if resolved_key && !resolved_key.empty?
114
+
115
+ # Merge custom headers (e.g. OpenRouter HTTP-Referer, X-Title)
116
+ @headers.each do |k, v|
117
+ req_headers[k.to_s] = v.to_s
118
+ end
119
+ req_headers.merge!(opts[:headers]) if opts[:headers].is_a?(Hash)
120
+ req_headers
121
+ end
122
+
123
+ def build_response(res_json, messages)
124
+ root_json = res_json["data"].is_a?(Hash) && res_json["data"]["choices"] ? res_json["data"] : res_json
125
+ msg = root_json.dig("choices", 0, "message") || {}
126
+ text = msg["content"] || ""
127
+ thought = msg["reasoning_content"] || msg["reasoning"] || msg["thought"]
128
+ thought = nil if thought.to_s.empty?
129
+ usage = root_json["usage"] || res_json["usage"] || {}
130
+
131
+ parsed_tool_calls = parse_tool_calls(msg["tool_calls"] || [])
132
+ in_tokens, out_tokens = calculate_usage(usage, messages, text)
133
+
134
+ Response.new(
135
+ text: text,
136
+ usage: {
137
+ input_tokens: in_tokens,
138
+ output_tokens: out_tokens
139
+ },
140
+ raw: res_json,
141
+ tool_calls: parsed_tool_calls,
142
+ thought: thought
143
+ )
144
+ end
145
+
146
+ def parse_tool_calls(raw_tool_calls)
147
+ raw_tool_calls.map do |tc|
148
+ fn = tc["function"] || {}
149
+ args, malformed = parse_tool_args(fn["arguments"])
150
+ row = {
151
+ "id" => tc["id"] || "call_#{rand(1000..9999)}",
152
+ "tool" => fn["name"],
153
+ "args" => args || {}
154
+ }
155
+ row["malformed_args"] = malformed if malformed
156
+ row
157
+ end
158
+ end
159
+
160
+ def parse_tool_args(args)
161
+ malformed = nil
162
+ if args.is_a?(String)
163
+ begin
164
+ parsed = JSON.parse(args)
165
+ if parsed.is_a?(Hash)
166
+ args = parsed
167
+ else
168
+ malformed = args
169
+ args = {}
170
+ end
171
+ rescue JSON::ParserError
172
+ # Issue #55 3.4: the degrade carries the rejected payload so
173
+ # the executor can echo it back to the model in the error row.
174
+ malformed = args
175
+ args = {}
176
+ end
177
+ elsif !args.is_a?(Hash) && !args.nil?
178
+ malformed = args.to_s
179
+ args = {}
180
+ end
181
+ [args, malformed]
182
+ end
183
+
184
+ def calculate_usage(usage, messages, text)
185
+ in_tokens = usage["prompt_tokens"] || usage["input_tokens"] || estimate_tokens(messages.map { |m| m[:content] || m["content"] }.join)
186
+ out_tokens = usage["completion_tokens"] || usage["output_tokens"] || estimate_tokens(text)
187
+ [in_tokens, out_tokens]
188
+ end
189
+
175
190
  def reasoning_model?(model_name, opts)
176
191
  return true if opts.key?(:reasoning_effort)
177
192
  return true if model_name.to_s.match?(/\A(o[134](-[a-z0-9]+)?|o1|o3|o4)\b/i)