lemans 1.5.2 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76cc9a613af13ed9f651f518b8410214a514df72d68b5034183a9e83b4997545
4
- data.tar.gz: 3ad78386ced9049f0087b9fe99e1e160f60eb8f0fbebb2acce565a9cdbe4007e
3
+ metadata.gz: c2d58a5beef57da26020d8d076d001fc508b676fa8156bd8bf8782f0a1cbf545
4
+ data.tar.gz: c647edc664022a6d2590da8790c532aa533e4df4cc8e4274628bed6e60a40b7a
5
5
  SHA512:
6
- metadata.gz: 656ffa9f0ea81a73c82876e7d06fbd1ee3692b46a86f8383ad6bb11797474fdda3d402f25094b7f49ea9ba260afd3c00924d524104865b1438bd16c4e37e2c66
7
- data.tar.gz: 2bef818afe5e980fe808a3a63349ed4e8b116bf7cc09d05797a477528565d5c09050ea561011e03eb12d5badb9474f22e6372e0ccff8ab50a8b530acd13bf287
6
+ metadata.gz: a7c995c51cec8fc1fbe38a2d299762ea7908d74bc1136a7bcbaf85d3765f1534e304331ffd81aef1985ef3c87882c8decd32b4b276184497ced82fb421f41146
7
+ data.tar.gz: f227a2b18e947740abb0b838643df69ca26e95d6f9690780af66992bbf63c310501c68c01faef28be0e1e27bedaa2ea4319bada71f0e7b38952cef5c99e2a574
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.6.0] - 2026-11-03
4
+
5
+ - Miniswen: upgrade ruby_llm to 2.1.1 (use its new built-in caching, finish reasons and reasoning replay, keep the ~10 min retry window, honor `RUBYLLM_DEBUG`, allow JSON 3).
6
+ - Miniswen: send `prompt_cache_key` for every OpenRouter model, not only Muse, so a session keeps its cache without a pinned provider order.
7
+ - Miniswen: turn on prompt caching for every model, so models that need it (like Claude, including on the Anthropic API) cache, and those that cache automatically are unaffected.
8
+ - Miniswen: OpenRouter runs cost what OpenRouter actually billed (including discounts or a pricier provider), with registry prices as the rare fallback.
9
+ - Miniswen: trajectories record which upstream provider served each OpenRouter turn.
10
+ - `lemans-remote`: forward all provider keys RubyLLM reads, not only `OPENROUTER_API_KEY`.
11
+ - Daytona: don't hang until the timeout on unclosed heredocs or quotes.
12
+ - Miniswen: run commands in the task's `workdir`, not the image's `WORKDIR`.
13
+ - Setup: clear bootsnap caches when applying an environment patch, so a cache compiled before the patch can't hand the agent the removed code.
14
+ - Secrets filter: redact every `*_KEY` variable, not only `*_API_KEY` (e.g., `AWS_SECRET_ACCESS_KEY`).
15
+ - Trials keep their git state outside the app dir, so the agent's `.git` can't affect results.
16
+ - Miniswen: `--jail` stops even when miniswen is killed, so agent processes can't run into verification.
17
+ - Verifier: reject results when Minitest's core methods are redefined outside its gems.
18
+ - Verifier: recorded test failures score 0.0 even if the tests exit successfully.
19
+ - Verifier: tests that never ran score 0.0, so a run can't pass by skipping them.
20
+ - `miniswen-installed`: a step in a multistep task without a result fails instead of reusing the previous one.
21
+ - Docker: run commands as root, so non-root images work.
22
+
3
23
  ## [1.5.2] - 2026-10-14
4
24
 
5
25
  - `lemans --require/-r [command]`: load agent, backend plugins (`lemans -r plugin.rb restart RUN`)
data/README.md CHANGED
@@ -340,6 +340,7 @@ See [examples](./examples) for real-world extensions:
340
340
 
341
341
  - [Apple Container backend](./examples/apple-container.rb)
342
342
  - [Claude Code agent](./examples/claude-code.rb)
343
+ - [Codex agent](./examples/codex/README.md)
343
344
 
344
345
  ## CLI
345
346
 
data/exe/lemans-remote CHANGED
@@ -51,8 +51,9 @@
51
51
  # exe/lemans-remote snapshots
52
52
  # exe/lemans-remote deprovision [--name SNAPSHOT]
53
53
  #
54
- # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
55
- # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
54
+ # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN), every
55
+ # provider key RubyLLM reads (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, ...),
56
+ # LEMANS_PROVIDER_ORDER (or OPENROUTER_PROVIDER_ORDER);
56
57
  # forward extras with --env KEY.
57
58
 
58
59
  lib_path = File.expand_path("../lib", __dir__)
@@ -101,7 +102,8 @@ module LemansRemote # :nodoc: all
101
102
  REMOTE_META = "/tmp/lemans-remote-run.json"
102
103
  REMOTE_REQUIRES_DIR = "/tmp/lemans-remote-requires"
103
104
 
104
- ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER].freeze
105
+ ENV_ALLOWLIST = (RubyLLM::Provider.providers.values.flat_map(&:configuration_requirements).map { it.to_s.upcase } +
106
+ %w[LEMANS_PROVIDER_ORDER OPENROUTER_PROVIDER_ORDER]).uniq.freeze
105
107
 
106
108
  Backend = Lemans::Environments::Daytona
107
109
 
@@ -44,7 +44,7 @@ module Lemans
44
44
  private
45
45
 
46
46
  def obtain_result(task, environment, history)
47
- agent = agent_for(environment)
47
+ agent = agent_for(environment, workdir: task.environment.workdir)
48
48
  begin
49
49
  agent.run(task.instruction, history:)
50
50
  rescue InfrastructureError, ::Miniswen::InfrastructureError => e
@@ -55,12 +55,13 @@ module Lemans
55
55
  # The run as miniswen records it, signatures included: what a recovery continues from
56
56
  def raw_result_for(run_result) = JSON.generate(run_result.to_h)
57
57
 
58
- def agent_for(environment)
58
+ def agent_for(environment, workdir: nil)
59
59
  raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
60
60
 
61
61
  ::Miniswen::Agent.new(
62
62
  model: model.to_s,
63
63
  environment: environment,
64
+ workdir: workdir,
64
65
  max_steps: profile.step_limit,
65
66
  max_time: profile.timeout,
66
67
  max_cost: profile.cost_limit,
@@ -74,8 +75,8 @@ module Lemans
74
75
  when :format_error
75
76
  "#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive responses without a valid bash tool call"
76
77
  when :content_filter
77
- "the provider stopped the model: #{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive turns " \
78
- "ended with #{::Miniswen::Agent::REFUSAL_FINISH_REASONS.join("/")} and no tool call"
78
+ "the provider stopped the model with content_filter: " \
79
+ "#{::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS} consecutive turns ended with no valid tool call"
79
80
  end
80
81
  end
81
82
 
@@ -34,7 +34,8 @@ module Lemans
34
34
  # comes from a file the sandbox wrote.
35
35
  def obtain_result(task, environment, history)
36
36
  upload_history(environment, history) if history
37
- run = environment.exec(command_for(task, history:), timeout: outer_timeout, env: provider_env(environment))
37
+ command = "rm -f #{RESULTS_PATH} && #{command_for(task, history:)}"
38
+ run = environment.exec(command, timeout: outer_timeout, env: provider_env(environment))
38
39
 
39
40
  begin
40
41
  Tempfile.create(%w[miniswen .result.json]) do |file|
@@ -75,13 +75,15 @@ module Lemans
75
75
  status_file = "/tmp/lemans-#{run_id}.status"
76
76
  log_file = "/tmp/lemans-#{run_id}.log"
77
77
  exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
78
+ # Encoded, so an unclosed heredoc or quote can't swallow the status line.
79
+ run = "bash -c \"$(printf %s #{[ command ].pack("m0")} | base64 -d)\" < /dev/null"
78
80
 
79
81
  sandbox.process.execute_session_command(
80
82
  session_id: @session_id,
81
83
  req: ::Daytona::SessionExecuteRequest.new(
82
84
  # A subshell, not a brace group: `set -e`/`exit` would take the
83
85
  # session's own shell down and leave the status line unwritten.
84
- command: "(\n#{exports}\n#{command}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
86
+ command: "(\n#{exports}\n#{run}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
85
87
  "echo $? > #{Shellwords.escape(status_file)}",
86
88
  run_async: true
87
89
  )
@@ -42,7 +42,7 @@ module Lemans
42
42
  def exec(command, timeout: nil, env: {})
43
43
  timeout ||= DEFAULT_TIMEOUT
44
44
  started = now
45
- argv = [ "exec" ]
45
+ argv = [ "exec", "--user", "0" ]
46
46
  env = proxy_env.merge(env) if network.allowlist?
47
47
  env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
48
48
  # The in-container timeout is what actually kills the process
@@ -53,7 +53,7 @@ module Lemans
53
53
  end
54
54
 
55
55
  def upload(local_path, remote_path)
56
- docker!("exec", container, "mkdir", "-p", File.dirname(remote_path.to_s))
56
+ docker!("exec", "--user", "0", container, "mkdir", "-p", File.dirname(remote_path.to_s))
57
57
  docker!("cp", local_path.to_s, "#{container}:#{remote_path}")
58
58
  end
59
59
 
@@ -4,8 +4,8 @@ module Lemans
4
4
  # Redacts known secret values (API credentials) from everything the store persists.
5
5
  class SecretsFilter
6
6
  # Credentials follow the <PROVIDER>_<KIND> environment convention
7
- # (DAYTONA_API_KEY, OPENROUTER_API_KEY, DAYTONA_TOKEN, ...).
8
- def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(API_KEY|TOKEN|SECRET|JWT)\z/) })
7
+ # (DAYTONA_API_KEY, OPENROUTER_API_KEY, AWS_SECRET_ACCESS_KEY, DAYTONA_TOKEN, ...).
8
+ def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(KEY|TOKEN|SECRET|JWT)\z/) })
9
9
 
10
10
  def initialize(secrets, replacement: "<filtered>", min_length: 8)
11
11
  @replacement = replacement
@@ -9,8 +9,11 @@ module Lemans
9
9
  # The agent's work as one git patch, diffed against a baseline sealed before
10
10
  # its first turn
11
11
  class Patch
12
- REMOTE_PATCH = "/tmp/lemans-agent.patch"
13
- REMOTE_INDEX = "/tmp/lemans-patch.idx"
12
+ GIT_DIR = "/var/lib/lemans/git"
13
+ GIT = "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git"
14
+ INIT = "#{GIT} init -q --bare #{GIT_DIR}".freeze
15
+ REMOTE_PATCH = "#{GIT_DIR}/agent.patch".freeze
16
+ REMOTE_INDEX = "#{GIT_DIR}/patch.idx".freeze
14
17
 
15
18
  private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
16
19
 
@@ -77,7 +80,7 @@ module Lemans
77
80
  return unless savepoint
78
81
 
79
82
  environment.exec!(
80
- "#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd && #{git} reset -q",
83
+ "#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd",
81
84
  timeout:
82
85
  )
83
86
  end
@@ -112,16 +115,14 @@ module Lemans
112
115
  nil
113
116
  end
114
117
 
115
- # `safe.directory` because the sandbox may run the tree as a different user
116
- # than built it, and git refuses to read a repo it thinks is someone else's.
117
- def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
118
+ def git = "#{GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{GIT_DIR} --work-tree=."
118
119
 
119
120
  # Untracked files only reach a diff through an index, so both sides are
120
121
  # staged into a scratch one and hashed. Rebuilt from empty each time, so a
121
122
  # stale entry cannot survive into the second tree.
122
123
  def write_tree
123
124
  result = environment.exec(
124
- "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
125
+ "#{INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
125
126
  "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
126
127
  timeout:
127
128
  )
@@ -52,7 +52,7 @@ module Lemans
52
52
  workdir = Shellwords.escape(task.environment.workdir)
53
53
  environment.exec!(
54
54
  "cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
55
- "find . -path ./.git -prune -o -exec touch -h {} + && " \
55
+ "#{clear_bootsnap_caches} && #{unify_mtimes} && " \
56
56
  "rm -rf .git && git init -q && git add -A && " \
57
57
  "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
58
58
  timeout:
@@ -62,6 +62,10 @@ module Lemans
62
62
  def run_commands!(environment)
63
63
  commands.each { environment.exec!(it, timeout:) }
64
64
  end
65
+
66
+ def clear_bootsnap_caches = 'rm -rf "${BOOTSNAP_CACHE_DIR:-tmp/cache}"/bootsnap*'
67
+
68
+ def unify_mtimes = "find . -path ./.git -prune -o -exec touch -h {} +"
65
69
  end
66
70
  end
67
71
  end
@@ -7,7 +7,7 @@ module Lemans
7
7
  # The sealed baseline of a task's graded surfaces: a git tree written before
8
8
  # the agent's first turn, checked out over the live tree at verification.
9
9
  class Snapshot
10
- REMOTE_INDEX = "/tmp/lemans-baseline.idx"
10
+ REMOTE_INDEX = "#{Patch::GIT_DIR}/baseline.idx".freeze
11
11
 
12
12
  private attr_reader :task, :environment, :workdir, :paths, :timeout, :baseline
13
13
 
@@ -26,7 +26,7 @@ module Lemans
26
26
  return if paths.empty?
27
27
 
28
28
  result = environment.exec(
29
- "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
29
+ "#{Patch::INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
30
30
  "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
31
31
  timeout:
32
32
  )
@@ -51,7 +51,7 @@ module Lemans
51
51
 
52
52
  private
53
53
 
54
- def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
54
+ def git = "#{Patch::GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{Patch::GIT_DIR} --work-tree=."
55
55
  end
56
56
  end
57
57
  end
@@ -36,7 +36,7 @@ module LemansReport
36
36
  end
37
37
 
38
38
  def report
39
- abort "lemans: a false assertion no longer raises, so no result can be trusted" if tampered?
39
+ abort "lemans: Minitest was tampered with, so no result can be trusted" if tampered?
40
40
 
41
41
  graded = @results.select { graded?(it) }
42
42
  prior = existing.fetch("checks", {})
@@ -66,6 +66,9 @@ module LemansReport
66
66
  private
67
67
 
68
68
  def tampered?
69
+ gem = Gem.loaded_specs["minitest"].full_gem_path
70
+ return true unless Minitest::Test.instance_method(:run).source_location.first.start_with?("#{gem}/")
71
+
69
72
  Minitest::Test.new("probe").assert(false)
70
73
  true
71
74
  rescue Minitest::Assertion
@@ -61,7 +61,7 @@ module Lemans
61
61
  def visit_class_node(node) = scoped(node) { super }
62
62
 
63
63
  def visit_def_node(node)
64
- return super unless node.name.start_with?("test_")
64
+ return super unless node.name.start_with?("test_") && node.parameters.nil?
65
65
 
66
66
  within("#{@scope.join("::")}##{node.name}", node) { super }
67
67
  end
@@ -100,8 +100,8 @@ module Lemans
100
100
 
101
101
  result = environment.exec(command, timeout:, env:)
102
102
 
103
- reward = read_reward(result)
104
103
  checks = read_checks
104
+ reward = read_reward(result, checks)
105
105
  features = (self.class.features_from(checks.fetch("checks", {}), TestScanner.for(task)&.features) if checks && task.final_step?)
106
106
  Verification.new(reward:, credit: credit_from(checks, reward), features:, logs: result.output.to_s)
107
107
  end
@@ -110,10 +110,10 @@ module Lemans
110
110
  [ task.verifier.preverify, task.verifier.command ].compact.map { "( #{it} )" }.join(" && ")
111
111
  end
112
112
 
113
- def read_reward(command_result)
113
+ def read_reward(command_result, checks)
114
114
  reward_path = task.verifier.reward_path
115
115
  present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
116
- return reward_from_exit(command_result) unless present.success?
116
+ return reward_from_exit(command_result, checks) unless present.success?
117
117
 
118
118
  result = environment.exec("cat #{Shellwords.escape(reward_path)}")
119
119
  raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
@@ -154,9 +154,9 @@ module Lemans
154
154
  (base_credit + (1 - base_credit) * (passed.to_f / total)).round(2)
155
155
  end
156
156
 
157
- def reward_from_exit(command_result)
157
+ def reward_from_exit(command_result, checks)
158
158
  case command_result.exit_code
159
- when 0 then 1.0
159
+ when 0 then clean_exit_reward(checks)
160
160
  when 1 then 0.0
161
161
  else
162
162
  raise VerifierError,
@@ -164,6 +164,13 @@ module Lemans
164
164
  end
165
165
  end
166
166
 
167
+ def clean_exit_reward(checks)
168
+ return 0.0 if checks&.dig("failures")&.any?
169
+ return 0.0 if TestScanner.for(task)&.tests&.any? { !checks&.dig("checks")&.key?(it) }
170
+
171
+ 1.0
172
+ end
173
+
167
174
  def download_evidence(collector)
168
175
  return unless collector
169
176
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.5.2"
4
+ VERSION = "1.6.0"
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "securerandom"
5
+ require "shellwords"
5
6
  require "time"
6
7
  require "miniswen/version"
7
8
  require "miniswen/ruby_llm"
@@ -14,21 +15,6 @@ module Miniswen
14
15
  MAX_OBSERVATION_CHARS = 10_000
15
16
  MAX_CONSECUTIVE_FORMAT_ERRORS = 3
16
17
 
17
- # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
18
- # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
19
- TRUNCATION_FINISH_REASONS = %w[length max_tokens model_context_window_exceeded].freeze
20
- CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
21
- # A safety stop, which arrives looking exactly like a model that forgot
22
- # to call the tool: no content, no tool call, and — since the provider
23
- # bills nothing for a turn it refused — no tokens either. Only the
24
- # finish reason tells the two apart, so the run is labelled by it. The
25
- # retry is unchanged: the nudge still goes back, because matching
26
- # mini-swe-agent turn for turn is what makes runs comparable.
27
- REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
28
-
29
- # The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
30
- CACHE_CONTROL = { type: "ephemeral" }.freeze
31
-
32
18
  EXEC_ENV = {
33
19
  "PAGER" => "cat",
34
20
  "MANPAGER" => "cat",
@@ -37,9 +23,6 @@ module Miniswen
37
23
  "TQDM_DISABLE" => "1"
38
24
  }.freeze
39
25
 
40
- # Providers that serve local inference (and cost zero)
41
- LOCAL_PROVIDERS = %i[ollama gpustack].freeze
42
-
43
26
  SYSTEM_TEMPLATE = <<~PROMPT
44
27
  You are a helpful assistant that can interact with a computer.
45
28
  PROMPT
@@ -168,20 +151,10 @@ module Miniswen
168
151
  without any other command.
169
152
  MESSAGE
170
153
 
171
- # Thinking that also carries the provider's reasoning_details for verbatim replay.
172
- class VerbatimThinking < RubyLLM::Thinking
173
- attr_reader :details
174
-
175
- def initialize(text: nil, signature: nil, details: nil)
176
- super(text: text, signature: signature)
177
- @details = details
178
- end
179
- end
180
-
181
154
  # Only ever rendered into the request — the completion executes nothing — so no execute body.
182
155
  class BashTool < RubyLLM::Tool
183
156
  description "Execute a bash command"
184
- param :command, desc: "The bash command to execute"
157
+ parameter :command, description: "The bash command to execute"
185
158
 
186
159
  # ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
187
160
  def name = "bash"
@@ -233,13 +206,13 @@ module Miniswen
233
206
 
234
207
  attr_reader :messages, :environment
235
208
 
236
- private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
209
+ private attr_reader :workdir, :max_steps, :max_time, :max_cost, :exec_timeout, :max_output_tokens,
237
210
  :cache_id, :clock, :reporter
238
211
 
239
212
  # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
240
213
  # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
241
214
  # Limits of 0 or nil are disabled.
242
- def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
215
+ def initialize(model:, environment:, workdir: nil, max_steps: 0, max_time: 0, max_cost: nil,
243
216
  exec_timeout: 30, max_output_tokens: 0,
244
217
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
245
218
  reporter: nil)
@@ -252,6 +225,7 @@ module Miniswen
252
225
 
253
226
  @model = model
254
227
  @environment = environment
228
+ @workdir = workdir
255
229
 
256
230
  @bash_tool = BashTool.new
257
231
 
@@ -339,6 +313,7 @@ module Miniswen
339
313
  @steps = 0
340
314
  @cost = 0.0
341
315
  @cost_known = true
316
+ @cost_reported = false
342
317
  @totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
343
318
  @started_at = @clock.call
344
319
  end
@@ -350,6 +325,7 @@ module Miniswen
350
325
  @steps = history.steps.to_i
351
326
  @cost = history.cost_usd.to_f
352
327
  @cost_known = !history.cost_usd.nil?
328
+ @cost_reported = history.cost_source&.name == :provider_reported
353
329
  @totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
354
330
  cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
355
331
  @started_at = @clock.call - elapsed(history.messages)
@@ -371,6 +347,7 @@ module Miniswen
371
347
  end
372
348
 
373
349
  def execute(command)
350
+ command = "cd #{Shellwords.escape(workdir)}; #{command}" if workdir
374
351
  environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
375
352
  end
376
353
 
@@ -399,6 +376,7 @@ module Miniswen
399
376
  # content, no tool call and no billed tokens.
400
377
  entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
401
378
  entry[:thinking] = response[:thinking] if response[:thinking]
379
+ entry[:provider] = response[:provider] if response[:provider]
402
380
  # The provider's opaque handles on this turn's reasoning: replayed back
403
381
  # to the provider only, never into the trajectory.
404
382
  entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
@@ -412,7 +390,7 @@ module Miniswen
412
390
  # message with unanswered tool calls is a request providers reject.
413
391
  entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
414
392
  @consecutive_format_errors += 1
415
- @refused_turns += 1 if refused?(response)
393
+ @refused_turns += 1 if response[:refused]
416
394
 
417
395
  observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
418
396
 
@@ -425,8 +403,6 @@ module Miniswen
425
403
  tool_calls
426
404
  end
427
405
 
428
- def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
429
-
430
406
  # An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
431
407
  # the spend. Without a ceiling, unknown cost is just a fact to report.
432
408
  def track_cost(response)
@@ -441,6 +417,7 @@ module Miniswen
441
417
  else
442
418
  @cost += cost
443
419
  end
420
+ @cost_reported = true if response[:reported]
444
421
  end
445
422
 
446
423
  def actions_error(tool_calls)
@@ -466,10 +443,8 @@ module Miniswen
466
443
  end
467
444
 
468
445
  def format_error_message(error, response, tool_calls)
469
- finish_reason = response[:finish_reason].to_s
470
- if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
471
- (CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
472
- format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
446
+ if response[:truncated] || (response[:finish_reason] == "tool_calls" && tool_calls.empty?)
447
+ format(TRUNCATION_ERROR_MESSAGE, finish_reason: response[:finish_reason])
473
448
  else
474
449
  format(TOOL_CALL_ERROR_MESSAGE, error: error)
475
450
  end
@@ -548,11 +523,13 @@ module Miniswen
548
523
  def complete(messages)
549
524
  model_info, provider = resolved
550
525
  response = provider.complete(
551
- with_cache_breakpoints(messages.map { as_ruby_llm(it) }),
526
+ messages.map { as_ruby_llm(it) },
552
527
  tools: { bash: @bash_tool },
553
528
  temperature: nil,
554
529
  model: model_info,
555
- params: routing_params.merge(output_cap_params(model_info)),
530
+ provider_options: routing_params,
531
+ max_output_tokens: output_cap,
532
+ caching: {},
556
533
  thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
557
534
  )
558
535
  payload(response)
@@ -564,30 +541,10 @@ module Miniswen
564
541
  raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
565
542
  end
566
543
 
567
- # Anthropic bills every token fresh unless the request marks explicit cache
568
- # breakpoints
569
- def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
570
-
571
- def with_cache_breakpoints(messages)
572
- return messages unless explicit_cache?
573
-
574
- system = messages.find { it.role == :system }
575
- [ system, messages.last ].compact.uniq.each do |message|
576
- text = message.content
577
- next unless text.is_a?(String) && !text.empty?
578
-
579
- message.content = RubyLLM::Content::Raw.new(
580
- [ { type: "text", text: text, cache_control: CACHE_CONTROL } ]
581
- )
582
- end
583
- messages
584
- end
585
-
586
544
  def routing_params
587
545
  return {} unless @provider == "openrouter"
588
546
 
589
- params = {}
590
- params[:prompt_cache_key] = cache_id if @id.start_with?("meta/muse-")
547
+ params = { prompt_cache_key: cache_id }
591
548
  if (order = provider_order)
592
549
  params[:provider] = { order: order.split(",").map(&:strip), allow_fallbacks: false }
593
550
  end
@@ -596,20 +553,8 @@ module Miniswen
596
553
 
597
554
  def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
598
555
 
599
- # OpenAI itself retired `max_tokens` for its reasoning models; the
600
- # OpenAI-compatible providers and Anthropic still read it.
601
- def output_cap_params(model_info)
602
- return {} if max_output_tokens.zero?
603
-
604
- cap = [ info&.max_tokens, max_output_tokens ].compact.min
605
- provider_class = RubyLLM::Provider.providers[model_info.provider.to_sym]
606
- if [ RubyLLM::Providers::OpenAI, RubyLLM::Providers::Azure ].include?(provider_class)
607
- { max_completion_tokens: cap }
608
- elsif provider_class <= RubyLLM::Providers::OpenAI || provider_class <= RubyLLM::Providers::Anthropic
609
- { max_tokens: cap }
610
- else
611
- {}
612
- end
556
+ def output_cap
557
+ [ info&.max_output_tokens, max_output_tokens ].compact.min unless max_output_tokens.zero?
613
558
  end
614
559
 
615
560
  def cost_source
@@ -618,12 +563,12 @@ module Miniswen
618
563
  priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
619
564
  registry: nil)
620
565
  end
621
- return nil unless info
622
- if free?
623
- return CostSource.new(name: :free_model, model: @model,
624
- priced_as: "#{info.provider}/#{info.id} ($0.00, free)",
566
+ if @cost_reported
567
+ return CostSource.new(name: :provider_reported, model: @model,
568
+ priced_as: "#{@provider || info&.provider}/#{@id} (as billed, else the registry price)",
625
569
  registry: Miniswen.registry_revision)
626
570
  end
571
+ return nil unless info
627
572
 
628
573
  CostSource.new(name: :model_registry, model: @model,
629
574
  priced_as: "#{info.provider}/#{info.id}",
@@ -631,14 +576,16 @@ module Miniswen
631
576
  end
632
577
 
633
578
  def resolved
634
- @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
579
+ @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_model_exists: !@provider.nil?)
635
580
  end
636
581
 
637
582
  def as_ruby_llm(entry)
638
583
  case entry[:role]
639
584
  when "assistant"
640
- RubyLLM::Message.new(role: :assistant, content: entry[:content],
641
- thinking: as_ruby_llm_thinking(entry),
585
+ thinking = RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
586
+ RubyLLM::Message.new(role: :assistant, content: entry[:content], thinking:,
587
+ raw_reasoning: entry[:reasoning_details],
588
+ usage_entries: [ producing_entry ],
642
589
  tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
643
590
  when "tool"
644
591
  RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
@@ -647,13 +594,11 @@ module Miniswen
647
594
  end
648
595
  end
649
596
 
650
- def as_ruby_llm_thinking(entry)
651
- details = entry[:reasoning_details]
652
- if details && !details.empty?
653
- VerbatimThinking.new(text: entry[:thinking], signature: entry[:thinking_signature], details: details)
654
- else
655
- RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
656
- end
597
+ # A rebuilt turn must name its provider, or ruby_llm drops its thinking signature.
598
+ def producing_entry
599
+ model_info, provider = resolved
600
+ RubyLLM::Accounting::Usage::Entry.new(operation: :chat, provider: provider.slug, model: model_info.id,
601
+ status: :succeeded)
657
602
  end
658
603
 
659
604
  def as_ruby_llm_tool_calls(tool_calls)
@@ -674,16 +619,20 @@ module Miniswen
674
619
  content: response.content.to_s,
675
620
  thinking: response.thinking&.text,
676
621
  thinking_signature: response.thinking&.signature,
677
- reasoning_details: reasoning_details_from(response),
622
+ reasoning_details: response.raw_reasoning,
678
623
  tool_calls: tool_calls_from(response),
679
- finish_reason: finish_reason_from(response),
624
+ finish_reason: response.finish_reason&.to_s,
625
+ truncated: response.max_tokens?,
626
+ refused: response.content_filtered?,
680
627
  # ruby_llm's input_tokens is the cache-miss remainder only; the
681
628
  # convention counts the whole prompt, cached and cache-write included.
682
- input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
683
- output_tokens: response.output_tokens.to_i,
684
- cached_tokens: tokens&.cached.to_i,
685
- thinking_tokens: tokens&.thinking.to_i,
686
- cost_usd: price(response)
629
+ input_tokens: tokens.input.to_i + tokens.cache_read.to_i + tokens.cache_write.to_i,
630
+ output_tokens: tokens.output.to_i,
631
+ cached_tokens: tokens.cache_read.to_i,
632
+ thinking_tokens: tokens.thinking.to_i,
633
+ cost_usd: price(response),
634
+ reported: !tokens.reported_cost.nil?,
635
+ provider: response.raw&.body&.dig("provider")
687
636
  }
688
637
  end
689
638
 
@@ -704,27 +653,7 @@ module Miniswen
704
653
  arguments
705
654
  end
706
655
 
707
- def finish_reason_from(response)
708
- body = raw_body(response)
709
- body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
710
- end
711
-
712
- def reasoning_details_from(response)
713
- details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
714
- details.is_a?(Array) && !details.empty? ? details : nil
715
- end
716
-
717
- def raw_body(response)
718
- body = response.raw&.body
719
- body = JSON.parse(body) if body.is_a?(String)
720
- body.is_a?(Hash) ? body : nil
721
- rescue JSON::ParserError
722
- nil
723
- end
724
-
725
- def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
726
-
727
- def free? = info&.provider == "openrouter" && info.input_price_per_million.nil? && info.output_price_per_million.nil?
656
+ def local? = RubyLLM::Provider.providers[(@provider || info&.provider)&.to_sym]&.local? || false
728
657
 
729
658
  def info
730
659
  return @info if defined?(@info)
@@ -733,28 +662,15 @@ module Miniswen
733
662
  end
734
663
 
735
664
  def find_model
736
- @provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
665
+ RubyLLM.models.find(@id, provider: @provider)
737
666
  rescue RubyLLM::ModelNotFoundError
738
667
  nil
739
668
  end
740
669
 
741
670
  def price(response)
742
- return 0.0 if local? || free?
671
+ return 0.0 if local? || (response.tokens.to_h.empty? && response.tokens.reported_cost.nil?)
743
672
 
744
- input = info&.input_price_per_million
745
- output = info&.output_price_per_million
746
- return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
747
-
748
- tokens = response.tokens
749
- cache_read = info.cache_read_input_price_per_million || input
750
- cache_write = info.cache_write_input_price_per_million || input
751
- # Providers usually fold thinking into output_tokens; max() bills the
752
- # larger count once and can never double-bill.
753
- generated = [ response.output_tokens.to_i, tokens&.thinking.to_i ].max
754
- ((response.input_tokens.to_i * input) +
755
- (tokens&.cached.to_i * cache_read) +
756
- (tokens&.cache_creation.to_i * cache_write) +
757
- (generated * output)) / 1_000_000.0
673
+ info ? info.cost_for(response.tokens).total : response.tokens.reported_cost
758
674
  end
759
675
  end
760
676
  end
data/lib/miniswen/cli.rb CHANGED
@@ -99,7 +99,7 @@ module Miniswen
99
99
  end
100
100
 
101
101
  def refresh_registry_and_exit
102
- refreshed = Miniswen.refresh_registry!(persist: true)
102
+ refreshed = Miniswen.refresh_registry!
103
103
  $stdout.puts Miniswen.registry_revision if refreshed
104
104
  Kernel.exit(refreshed ? 0 : 1)
105
105
  end
@@ -37,7 +37,13 @@ module Miniswen
37
37
  def start
38
38
  FileUtils.rm_f(@socket)
39
39
  server = UNIXServer.new(@socket)
40
- @pid = fork { self.class.serve(server) { handle(it) } }
40
+ check, @lifeline = IO.pipe
41
+ @pid = fork do
42
+ @lifeline.close
43
+ Thread.new { check.wait_readable; exit! }
44
+ self.class.serve(server) { handle(it) }
45
+ end
46
+ check.close
41
47
  self
42
48
  end
43
49
 
data/lib/miniswen/jail.rb CHANGED
@@ -22,7 +22,8 @@ module Miniswen
22
22
  def start
23
23
  _, @stdout, @stderr, @holder = Open3.popen3(
24
24
  container_env,
25
- "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup,
25
+ "setpriv", "--pdeathsig=KILL", "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc",
26
+ "sh", "-c", setup,
26
27
  pgroup: true, unsetenv_others: true
27
28
  )
28
29
  raise InfrastructureError, "jail did not start: #{@stderr.read}" unless @stdout.gets == "ready\n"
@@ -4,8 +4,8 @@ require "ruby_llm"
4
4
 
5
5
  # Logging configuration
6
6
  RubyLLM.configure do |config|
7
- config.log_level = ENV.fetch("RUBYLLM_LOG_LEVEL", "info").to_sym
8
- config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
7
+ config.log_level = ENV["RUBYLLM_LOG_LEVEL"].to_sym if ENV["RUBYLLM_LOG_LEVEL"]
8
+ config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1" || ENV["RUBYLLM_DEBUG"] == "1"
9
9
  end
10
10
 
11
11
  # About ten minutes of retries (1, 2, 4, ... 256s plus jitter): provider
@@ -13,6 +13,7 @@ end
13
13
  RubyLLM.configure do |config|
14
14
  config.max_retries = 9
15
15
  config.retry_interval = 1
16
+ config.retry_max_interval = 256
16
17
  end
17
18
 
18
19
  # ruby_llm reads no API keys from ENV on its own; the conventional variable is the provider's
@@ -27,36 +28,37 @@ RubyLLM.configure do |config|
27
28
  end
28
29
 
29
30
  module Miniswen
30
- # OpenRouter requires reasoning_details replayed exactly as received; ruby_llm
31
- # rebuilds them from its collapsed text+signature pair, and providers that
32
- # sign each block separately reject that as a corrupted thought signature.
33
- module VerbatimReasoningDetails
34
- def format_thinking(msg)
35
- details = msg.thinking.respond_to?(:details) ? msg.thinking.details : nil
36
- details && !details.empty? ? { reasoning_details: details } : super
37
- end
38
- end
39
-
40
- # ruby_llm lists SSL errors as fatal, but a handshake reset never sent the
41
- # request; a 200 with a truncated JSON body is the same dropped connection
42
- # one step later.
31
+ # A 200 cut off mid-body is a dropped connection, but ruby_llm doesn't retry it.
43
32
  module RetryTransientFailures
44
- def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
33
+ def retry_exceptions = super + [ Faraday::ParsingError ]
45
34
  end
46
35
 
47
36
  # ruby_llm parses tool-call arguments while it builds the message, so one
48
37
  # malformed call would raise and take the whole completion with it. The raw
49
38
  # string goes through instead and bounces as a format error.
50
39
  module LenientToolArguments
51
- def parse_tool_call_arguments(tool_call)
40
+ def parse_tool_call_arguments(tool_call, **)
52
41
  super
53
- rescue JSON::ParserError
42
+ rescue RubyLLM::ToolCallParseError
54
43
  tool_call.dig("function", "arguments")
55
44
  end
45
+
46
+ def parse_function_call_arguments(arguments, **)
47
+ super
48
+ rescue RubyLLM::ToolCallParseError
49
+ arguments
50
+ end
51
+
52
+ def parse_arguments(arguments, *)
53
+ super
54
+ rescue RubyLLM::ToolCallParseError
55
+ arguments
56
+ end
56
57
  end
57
58
  end
58
59
 
59
- RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
60
- RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
61
- RubyLLM::Providers::OpenAI::Tools.prepend(Miniswen::LenientToolArguments)
62
- RubyLLM::Providers::OpenAI::Tools.singleton_class.prepend(Miniswen::LenientToolArguments)
60
+ RubyLLM::Transport::Connection.singleton_class.prepend(Miniswen::RetryTransientFailures)
61
+ [ RubyLLM::Protocols::ChatCompletions::Tools, RubyLLM::Protocols::Responses::Chat, RubyLLM::Protocols::Cohere::Tools ].each do |parser|
62
+ parser.prepend(Miniswen::LenientToolArguments)
63
+ parser.singleton_class.prepend(Miniswen::LenientToolArguments)
64
+ end
@@ -21,22 +21,22 @@ module Miniswen
21
21
 
22
22
  # $20/M input (fresh and cached) and $400/M output price the default answer
23
23
  # (95 fresh + 5 cached input tokens, 20 output tokens) at exactly $0.01.
24
- PRICED_MODEL = RubyLLM::Model::Info.new(
25
- id: "test", name: "Test", provider: "test",
24
+ PRICED_MODEL = RubyLLM::Model.new(
25
+ id: "test", name: "Test", provider: "test", max_output_tokens: 4096,
26
26
  capabilities: %w[function_calling],
27
27
  modalities: { input: %w[text], output: %w[text] },
28
28
  pricing: { text_tokens: { standard: {
29
- input_per_million: 20.0, cached_input_per_million: 20.0, output_per_million: 400.0
29
+ input_per_million: 20.0, cache_read_input_per_million: 20.0, output_per_million: 400.0
30
30
  } } }
31
31
  )
32
32
 
33
- UNPRICED_MODEL = RubyLLM::Model::Info.new(
33
+ UNPRICED_MODEL = RubyLLM::Model.new(
34
34
  id: "test-unpriced", name: "Test unpriced", provider: "test",
35
35
  capabilities: %w[function_calling],
36
36
  modalities: { input: %w[text], output: %w[text] }
37
37
  )
38
38
 
39
- FREE_MODEL = RubyLLM::Model::Info.new(
39
+ FREE_MODEL = RubyLLM::Model.new(
40
40
  id: "test-free", name: "Test free", provider: "openrouter",
41
41
  capabilities: %w[function_calling],
42
42
  modalities: { input: %w[text], output: %w[text] }
@@ -46,13 +46,11 @@ module Miniswen
46
46
 
47
47
  # Serves the test models from ruby_llm's registry lookups.
48
48
  module FindTestModels
49
- def find(model_id, provider = nil)
49
+ def find(model_id, **)
50
50
  MODELS[model_id] || super
51
51
  end
52
52
  end
53
53
 
54
- RawResponse = Data.define(:body)
55
-
56
54
  SUBMIT = { content: "Done.", cmd: "echo #{Agent::SUBMIT_MARKER}" }.freeze
57
55
 
58
56
  # A shell made of a Hash. `echo` behaves like echo, so the submit marker
@@ -71,6 +69,7 @@ module Miniswen
71
69
 
72
70
  def exec(command, timeout: nil, env: nil) # rubocop:disable Lint/UnusedMethodArgument
73
71
  @commands << command
72
+ command = command.sub(/\Acd .+?; /, "")
74
73
  return result(0, command.delete_prefix("echo ")) if command.start_with?("echo ")
75
74
 
76
75
  exit_code, output = @canned.fetch(command, [ 0, "" ])
@@ -91,7 +90,7 @@ module Miniswen
91
90
 
92
91
  # Each answer is a command string, or a hash with :cmd (one command or many)
93
92
  # and/or :content, plus optional overrides (:tool_calls, :finish_reason,
94
- # :thinking, token counts). Overrides given here apply to every answer.
93
+ # :thinking, :raw, token counts). Overrides given here apply to every answer.
95
94
  def stub_llm(*answers, **overrides)
96
95
  RubyLLM::Test.stub_responses(*answers.map { llm_answer(it, **overrides) })
97
96
  end
@@ -110,20 +109,17 @@ module Miniswen
110
109
  signature: answer[:thinking_signature]),
111
110
  input_tokens: answer.fetch(:input_tokens, 95),
112
111
  output_tokens: answer.fetch(:output_tokens, 20),
113
- cached_tokens: answer.fetch(:cached_tokens, 5),
112
+ cache_read_tokens: answer.fetch(:cached_tokens, 5),
114
113
  thinking_tokens: answer[:thinking] && 40,
115
- raw: RawResponse.new(body: raw_body_for(answer, calls))
114
+ raw_reasoning: answer[:reasoning_details],
115
+ reported_cost: answer[:reported_cost],
116
+ raw: answer[:raw],
117
+ finish_reason: answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" }
116
118
  )
117
119
  end
118
120
 
119
121
  private
120
122
 
121
- def raw_body_for(answer, calls)
122
- choice = { "finish_reason" => answer.fetch(:finish_reason) { calls.empty? ? "stop" : "tool_calls" } }
123
- choice["message"] = { "reasoning_details" => answer[:reasoning_details] } if answer[:reasoning_details]
124
- { "choices" => [ choice ] }
125
- end
126
-
127
123
  def tool_calls_for(answer)
128
124
  calls = answer[:tool_calls] || Array(answer[:cmd]).map { { name: "bash", arguments: { "command" => it } } }
129
125
  calls.to_h do |call|
@@ -78,7 +78,8 @@ module Miniswen
78
78
  step[:llm_call_count] = 1
79
79
  end
80
80
  # ATIF has no field for how a turn ended
81
- step[:extra] = { finish_reason: message[:finish_reason] } if message[:finish_reason]
81
+ extra = { finish_reason: message[:finish_reason], provider: message[:provider] }.compact
82
+ step[:extra] = extra unless extra.empty?
82
83
  step
83
84
  end
84
85
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.5.2"
4
+ VERSION = "1.6.0"
5
5
  end
data/lib/miniswen.rb CHANGED
@@ -16,14 +16,10 @@ module Miniswen # :nodoc:
16
16
  class AccountingError < Error; end
17
17
 
18
18
  class << self
19
- def refresh_registry!(persist: false)
19
+ def refresh_registry!
20
20
  return true if @ruby_llm_refreshed
21
21
 
22
- RubyLLM.models.refresh!
23
- # save_to_json writes to the registry file every boot loads from (the
24
- # gem's own models.json by default), so a persisted refresh outlives
25
- # this process — later runs in the same environment boot from it.
26
- RubyLLM.models.save_to_json if persist
22
+ RubyLLM.models.refresh
27
23
  @ruby_llm_refreshed = true
28
24
  rescue StandardError => e
29
25
  warn "Failed to refresh RubyLLM registry: #{e.message}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -53,34 +53,26 @@ dependencies:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0.203'
56
- - !ruby/object:Gem::Dependency
57
- name: json
58
- requirement: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - "<"
61
- - !ruby/object:Gem::Version
62
- version: '3'
63
- type: :runtime
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - "<"
68
- - !ruby/object:Gem::Version
69
- version: '3'
70
56
  - !ruby/object:Gem::Dependency
71
57
  name: faraday
72
58
  requirement: !ruby/object:Gem::Requirement
73
59
  requirements:
74
60
  - - "~>"
75
61
  - !ruby/object:Gem::Version
76
- version: '2.10'
62
+ version: '2.14'
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 2.14.4
77
66
  type: :runtime
78
67
  prerelease: false
79
68
  version_requirements: !ruby/object:Gem::Requirement
80
69
  requirements:
81
70
  - - "~>"
82
71
  - !ruby/object:Gem::Version
83
- version: '2.10'
72
+ version: '2.14'
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 2.14.4
84
76
  - !ruby/object:Gem::Dependency
85
77
  name: faraday-multipart
86
78
  requirement: !ruby/object:Gem::Requirement
@@ -143,14 +135,14 @@ dependencies:
143
135
  requirements:
144
136
  - - "~>"
145
137
  - !ruby/object:Gem::Version
146
- version: '1.16'
138
+ version: 2.1.1
147
139
  type: :runtime
148
140
  prerelease: false
149
141
  version_requirements: !ruby/object:Gem::Requirement
150
142
  requirements:
151
143
  - - "~>"
152
144
  - !ruby/object:Gem::Version
153
- version: '1.16'
145
+ version: 2.1.1
154
146
  - !ruby/object:Gem::Dependency
155
147
  name: thor
156
148
  requirement: !ruby/object:Gem::Requirement