lemans 0.0.0.pre → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/lib/lemans/agents/base.rb +30 -0
  7. data/lib/lemans/agents/miniswen.rb +119 -0
  8. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  9. data/lib/lemans/agents/nop.rb +15 -0
  10. data/lib/lemans/agents/oracle.rb +53 -0
  11. data/lib/lemans/agents.rb +21 -0
  12. data/lib/lemans/bench.rb +280 -0
  13. data/lib/lemans/cli/board_reporter.rb +135 -0
  14. data/lib/lemans/cli/progress_reporter.rb +67 -0
  15. data/lib/lemans/cli.rb +181 -0
  16. data/lib/lemans/clobber.rb +79 -0
  17. data/lib/lemans/environments/base.rb +55 -0
  18. data/lib/lemans/environments/daytona/retries.rb +49 -0
  19. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  20. data/lib/lemans/environments/daytona/shell.rb +142 -0
  21. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  22. data/lib/lemans/environments/daytona.rb +175 -0
  23. data/lib/lemans/environments.rb +16 -0
  24. data/lib/lemans/network_policy.rb +66 -0
  25. data/lib/lemans/patch.rb +70 -0
  26. data/lib/lemans/restore_paths.rb +21 -0
  27. data/lib/lemans/results/aggregate.rb +114 -0
  28. data/lib/lemans/results/cost_source.rb +13 -0
  29. data/lib/lemans/results/outcome.rb +36 -0
  30. data/lib/lemans/results/report.rb +149 -0
  31. data/lib/lemans/results/sorting.rb +24 -0
  32. data/lib/lemans/results/tally.rb +19 -0
  33. data/lib/lemans/results/usage.rb +24 -0
  34. data/lib/lemans/run.rb +152 -0
  35. data/lib/lemans/setup.rb +59 -0
  36. data/lib/lemans/setup_files.rb +36 -0
  37. data/lib/lemans/snapshot.rb +55 -0
  38. data/lib/lemans/task.rb +207 -0
  39. data/lib/lemans/tree_digest.rb +24 -0
  40. data/lib/lemans/trial.rb +187 -0
  41. data/lib/lemans/units.rb +44 -0
  42. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  43. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  44. data/lib/lemans/verifier.rb +199 -0
  45. data/lib/lemans/version.rb +5 -0
  46. data/lib/lemans.rb +29 -0
  47. data/lib/miniswen/agent.rb +669 -0
  48. data/lib/miniswen/cli.rb +224 -0
  49. data/lib/miniswen/environment.rb +14 -0
  50. data/lib/miniswen/local.rb +42 -0
  51. data/lib/miniswen/ruby_llm.rb +42 -0
  52. data/lib/miniswen/testing.rb +134 -0
  53. data/lib/miniswen/trajectory.rb +110 -0
  54. data/lib/miniswen/version.rb +5 -0
  55. data/lib/miniswen.rb +48 -0
  56. metadata +160 -7
@@ -0,0 +1,669 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "miniswen/version"
5
+ require "miniswen/ruby_llm"
6
+
7
+ module Miniswen
8
+ # A Ruby port of mini-swe-agent's loop (mini.yaml at commit a83fcae): ask the
9
+ # model for bash tool calls, run them, repeat until it submits or a limit trips.
10
+ class Agent
11
+ SUBMIT_MARKER = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
12
+ MAX_OBSERVATION_CHARS = 10_000
13
+ MAX_CONSECUTIVE_FORMAT_ERRORS = 3
14
+
15
+ # Both finish_reason dialects accepted raw: OpenAI-shaped providers say
16
+ # "length"/"tool_calls", Anthropic says "max_tokens"/"tool_use".
17
+ TRUNCATION_FINISH_REASONS = %w[length max_tokens].freeze
18
+ CLAIMED_TOOL_FINISH_REASONS = %w[tool_calls tool_use].freeze
19
+ # A safety stop, which arrives looking exactly like a model that forgot
20
+ # to call the tool: no content, no tool call, and — since the provider
21
+ # bills nothing for a turn it refused — no tokens either. Only the
22
+ # finish reason tells the two apart, so the run is labelled by it. The
23
+ # retry is unchanged: the nudge still goes back, because matching
24
+ # mini-swe-agent turn for turn is what makes runs comparable.
25
+ REFUSAL_FINISH_REASONS = %w[content_filter refusal safety].freeze
26
+
27
+ # The breakpoint marker Anthropic reads, shaped the way OpenRouter forwards it.
28
+ CACHE_CONTROL = { type: "ephemeral" }.freeze
29
+
30
+ EXEC_ENV = {
31
+ "PAGER" => "cat",
32
+ "MANPAGER" => "cat",
33
+ "LESS" => "-R",
34
+ "PIP_PROGRESS_BAR" => "off",
35
+ "TQDM_DISABLE" => "1"
36
+ }.freeze
37
+
38
+ # Providers that serve local inference (and cost zero)
39
+ LOCAL_PROVIDERS = %i[ollama gpustack].freeze
40
+
41
+ SYSTEM_TEMPLATE = <<~PROMPT
42
+ You are a helpful assistant that can interact with a computer.
43
+ PROMPT
44
+
45
+ INSTANCE_TEMPLATE = <<~PROMPT.freeze
46
+ Please solve this issue: %<instruction>s
47
+
48
+ You can execute bash commands and edit files to implement the necessary changes.
49
+
50
+ ## Recommended Workflow
51
+
52
+ This workflow should be done step-by-step so that you can iterate on your changes and any possible problems.
53
+
54
+ 1. Analyze the codebase by finding and reading relevant files
55
+ 2. Create a script to reproduce the issue
56
+ 3. Edit the source code to resolve the issue
57
+ 4. Verify your fix works by running your script again
58
+ 5. Test edge cases to ensure your fix is robust
59
+ 6. Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
60
+ Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
61
+
62
+ ## Command Execution Rules
63
+
64
+ You are operating in an environment where
65
+
66
+ 1. You issue at least one command
67
+ 2. The system executes the command(s) in a subshell
68
+ 3. You see the result(s)
69
+ 4. You write your next command(s)
70
+
71
+ Each response should include:
72
+
73
+ 1. **Reasoning text** where you explain your analysis and plan
74
+ 2. At least one tool call with your command
75
+
76
+ **CRITICAL REQUIREMENTS:**
77
+
78
+ - Your response SHOULD include reasoning text explaining what you're doing
79
+ - Your response MUST include AT LEAST ONE bash tool call
80
+ - Directory or environment variable changes are not persistent. Every action is executed in a new subshell.
81
+ - However, you can prefix any action with `MY_ENV_VAR=MY_VALUE cd /path/to/working/dir && ...` or write/load environment variables from files
82
+ - Submit your changes and finish your work by issuing the following command: `echo #{SUBMIT_MARKER}`.
83
+ Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
84
+
85
+ Example of a CORRECT response:
86
+ <example_response>
87
+ I need to understand the structure of the repository first. Let me check what files are in the current directory to get a better understanding of the codebase.
88
+
89
+ [Makes bash tool call with {"command": "ls -la"} as arguments]
90
+ </example_response>
91
+
92
+ <system_information>
93
+ %<system_information>s
94
+ </system_information>
95
+
96
+ ## Useful command examples
97
+
98
+ ### Create a new file:
99
+
100
+ ```bash
101
+ cat <<'EOF' > newfile.py
102
+ import numpy as np
103
+ hello = "ciao"
104
+ print(hello)
105
+ EOF
106
+ ```
107
+
108
+ ### Edit files with sed:
109
+ %<macos_sed_note>s
110
+ ```bash
111
+ # Replace all occurrences
112
+ sed -i 's/old_string/new_string/g' filename.py
113
+
114
+ # Replace only first occurrence
115
+ sed -i 's/old_string/new_string/' filename.py
116
+
117
+ # Replace first occurrence on line 1
118
+ sed -i '1s/old_string/new_string/' filename.py
119
+
120
+ # Replace all occurrences in lines 1-10
121
+ sed -i '1,10s/old_string/new_string/g' filename.py
122
+ ```
123
+
124
+ ### View file content:
125
+
126
+ ```bash
127
+ # View specific lines with numbers
128
+ nl -ba filename.py | sed -n '10,20p'
129
+ ```
130
+
131
+ ### Any other command you want to run
132
+
133
+ ```bash
134
+ anything
135
+ ```
136
+ PROMPT
137
+
138
+ MACOS_SED_NOTE = <<~NOTE
139
+ <important>
140
+ You are on MacOS. For all the below examples, you need to use `sed -i ''` instead of `sed -i`.
141
+ </important>
142
+ NOTE
143
+
144
+ NO_TOOL_CALLS_ERROR = "No tool calls found in the response. Every response MUST include at least one tool call."
145
+
146
+ TRUNCATION_ERROR_MESSAGE = <<~MESSAGE
147
+ Your previous response reached the output token limit (finish_reason=%<finish_reason>s) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one bash tool call. If you need to think more, do so briefly.
148
+ MESSAGE
149
+
150
+ TOOL_CALL_ERROR_MESSAGE = <<~MESSAGE.freeze
151
+ Tool call error:
152
+
153
+ <error>
154
+ %<error>s
155
+ </error>
156
+
157
+ Here is general guidance on how to submit correct toolcalls:
158
+
159
+ Every response needs to use the 'bash' tool at least once to execute commands.
160
+
161
+ Call the bash tool with your command as the argument:
162
+ - Tool: bash
163
+ - Arguments: {"command": "your_command_here"}
164
+
165
+ If you want to end the task, please issue the following command: `echo #{SUBMIT_MARKER}`
166
+ without any other command.
167
+ MESSAGE
168
+
169
+ # Thinking that also carries the provider's reasoning_details for verbatim replay.
170
+ class VerbatimThinking < RubyLLM::Thinking
171
+ attr_reader :details
172
+
173
+ def initialize(text: nil, signature: nil, details: nil)
174
+ super(text: text, signature: signature)
175
+ @details = details
176
+ end
177
+ end
178
+
179
+ # Only ever rendered into the request — the completion executes nothing — so no execute body.
180
+ class BashTool < RubyLLM::Tool
181
+ description "Execute a bash command"
182
+ param :command, desc: "The bash command to execute"
183
+
184
+ # ruby_llm would otherwise derive "lemans--miniswen--bash" from the class path.
185
+ def name = "bash"
186
+ end
187
+
188
+ Result = Data.define(:status, :submission, :messages, :steps, :cost_source,
189
+ :input_tokens, :output_tokens, :cached_tokens, :thinking_tokens, :cost_usd,
190
+ :error) do
191
+ def success? = status == :submitted
192
+
193
+ def to_h = super.merge(cost_source: cost_source&.to_h, version: Miniswen::VERSION)
194
+
195
+ def self.from_h(payload)
196
+ data = deep_symbolize(payload)
197
+ source = data[:cost_source]
198
+ new(
199
+ status: data[:status]&.to_sym,
200
+ submission: data[:submission],
201
+ messages: data[:messages] || [],
202
+ steps: data[:steps],
203
+ cost_source: source && CostSource.new(name: source[:name]&.to_sym, model: source[:model],
204
+ priced_as: source[:priced_as], registry: source[:registry]),
205
+ input_tokens: data[:input_tokens], output_tokens: data[:output_tokens],
206
+ cached_tokens: data[:cached_tokens], thinking_tokens: data[:thinking_tokens],
207
+ cost_usd: data[:cost_usd],
208
+ error: data[:error]
209
+ )
210
+ end
211
+
212
+ def self.deep_symbolize(value)
213
+ case value
214
+ when Hash
215
+ value.to_h do |key, item|
216
+ key = key.to_sym
217
+ # Tool-call arguments and reasoning details keep their provider-style string keys.
218
+ [key, %i[arguments reasoning_details].include?(key) ? item : deep_symbolize(item)]
219
+ end
220
+ when Array then value.map { deep_symbolize(_1) }
221
+ else value
222
+ end
223
+ end
224
+ end
225
+
226
+ CostSource = Data.define(:name, :model, :priced_as, :registry) do
227
+ def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
228
+ end
229
+
230
+ attr_reader :messages, :environment
231
+
232
+ private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
233
+ :clock, :reporter
234
+
235
+ # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"). Limits of 0 or nil are disabled.
236
+ def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
237
+ exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
238
+ reporter: nil)
239
+ @provider, @id = model.split("/", 2)
240
+ unless @id
241
+ @id = @provider
242
+ @provider = nil
243
+ end
244
+
245
+ @model = model
246
+ @environment = environment
247
+
248
+ @bash_tool = BashTool.new
249
+
250
+ @max_steps = max_steps.to_i
251
+ @max_time = max_time.to_f
252
+ @max_cost = max_cost
253
+ @exec_timeout = exec_timeout
254
+
255
+ @clock = clock
256
+ @reporter = reporter
257
+ end
258
+
259
+ def run(instruction)
260
+ uname = execute("uname -srvm").output.to_s.strip
261
+ @messages = [
262
+ { role: "system", content: SYSTEM_TEMPLATE },
263
+ { role: "user", content: format(INSTANCE_TEMPLATE,
264
+ instruction: instruction,
265
+ system_information: uname,
266
+ macos_sed_note: uname.start_with?("Darwin") ? "\n#{MACOS_SED_NOTE}" : "") }
267
+ ]
268
+
269
+ @steps = 0
270
+ @cost = 0.0
271
+
272
+ @totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
273
+
274
+ @cost_known = true
275
+ @consecutive_format_errors = 0
276
+ @refused_turns = 0
277
+ @started_at = @clock.call
278
+
279
+ loop do
280
+ (status = limit_reached) and return finish(status)
281
+
282
+ actions = next_actions
283
+ if actions.nil?
284
+ if @consecutive_format_errors >= MAX_CONSECUTIVE_FORMAT_ERRORS
285
+ return finish(@refused_turns.positive? ? :content_filter : :format_error)
286
+ end
287
+
288
+ next
289
+ end
290
+
291
+ actions.each do |action|
292
+ reporter&.on_tool_call(action)
293
+ result = execute(action.fetch(:arguments).fetch("command"))
294
+ # The submit command's output is observed too, so the final tool
295
+ # call has a linked result in the trajectory.
296
+ observe(action, result)
297
+ return finish(:submitted, submission: submission_from(result)) if submitted?(result)
298
+ end
299
+ end
300
+ end
301
+
302
+ def partial_result(error)
303
+ Result.new(
304
+ status: :error, submission: nil, messages: @messages || [], steps: @steps.to_i,
305
+ cost_source: cost_source, cost_usd: @cost_known == false ? nil : @cost.to_f,
306
+ error: error,
307
+ **(@totals || { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 })
308
+ )
309
+ end
310
+
311
+ # The env a remote miniswen needs to drive this model: the resolved
312
+ # provider's required config options, named the way ruby_llm.rb reads
313
+ # them back from ENV on boot (the option upcased).
314
+ def provider_env
315
+ _, provider = resolved
316
+ env = provider.configuration_requirements.to_h { [_1.to_s.upcase, RubyLLM.config.public_send(_1)] }.compact
317
+
318
+ order = ENV["LEMANS_PROVIDER_ORDER"]
319
+ env["LEMANS_PROVIDER_ORDER"] = order if order
320
+ env
321
+ end
322
+
323
+ private
324
+
325
+ def execute(command)
326
+ environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
327
+ end
328
+
329
+ # Checked before the model is asked, so the tripping step is never paid for.
330
+ def limit_reached
331
+ return :step_limit if max_steps.positive? && @steps >= max_steps
332
+ return :time_limit if max_time.positive? && (@clock.call - @started_at) >= max_time
333
+ return :cost_limit if max_cost && @cost_known && @cost >= max_cost
334
+
335
+ nil
336
+ end
337
+
338
+ # One model turn. Returns the actions to run, or nil after appending a
339
+ # format-error message the model gets to react to on its next turn.
340
+ def next_actions
341
+ response = complete(@messages)
342
+ @steps += 1
343
+ @totals.each_key { @totals[_1] += response[_1].to_i }
344
+ track_cost(response)
345
+
346
+ entry = { role: "assistant", timestamp: Time.now.utc.iso8601, content: response[:content].to_s,
347
+ metrics: metrics_from(response) }
348
+ # a refused turn: a safety stop hands back no
349
+ # content, no tool call and no billed tokens.
350
+ entry[:finish_reason] = response[:finish_reason] if response[:finish_reason]
351
+ entry[:thinking] = response[:thinking] if response[:thinking]
352
+ # The provider's opaque handles on this turn's reasoning: replayed back
353
+ # to the provider only, never into the trajectory.
354
+ entry[:thinking_signature] = response[:thinking_signature] if response[:thinking_signature]
355
+ entry[:reasoning_details] = response[:reasoning_details] if response[:reasoning_details]
356
+
357
+ observe_message entry
358
+
359
+ tool_calls = Array(response[:tool_calls])
360
+ if (error = actions_error(tool_calls))
361
+ # The bad calls are kept off the entry the llm replays: an assistant
362
+ # message with unanswered tool calls is a request providers reject.
363
+ entry[:invalid_tool_calls] = tool_calls if tool_calls.any?
364
+ @consecutive_format_errors += 1
365
+ @refused_turns += 1 if refused?(response)
366
+
367
+ observe_message({ role: "user", content: format_error_message(error, response, tool_calls) })
368
+
369
+ return nil
370
+ end
371
+
372
+ @consecutive_format_errors = 0
373
+ @refused_turns = 0
374
+ entry[:tool_calls] = tool_calls
375
+ tool_calls
376
+ end
377
+
378
+ def refused?(response) = REFUSAL_FINISH_REASONS.include?(response[:finish_reason].to_s)
379
+
380
+ # An unpriced completion under a cost ceiling is fatal on the spot: only failing fast stops
381
+ # the spend. Without a ceiling, unknown cost is just a fact to report.
382
+ def track_cost(response)
383
+ cost = response[:cost_usd]
384
+ if cost.nil?
385
+ if @max_cost
386
+ raise AccountingError,
387
+ "#{@model} returned an unpriced completion; cost_limit cannot be enforced"
388
+ end
389
+
390
+ @cost_known = false
391
+ else
392
+ @cost += cost
393
+ end
394
+ end
395
+
396
+ def actions_error(tool_calls)
397
+ return NO_TOOL_CALLS_ERROR if tool_calls.empty?
398
+
399
+ tool_calls.each do |call|
400
+ error = +""
401
+ error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
402
+ arguments = call[:arguments]
403
+ error << "Missing 'command' argument in bash tool call." unless arguments.is_a?(Hash) && arguments["command"]
404
+ return error unless error.empty?
405
+ end
406
+ nil
407
+ end
408
+
409
+ def format_error_message(error, response, tool_calls)
410
+ finish_reason = response[:finish_reason].to_s
411
+ if TRUNCATION_FINISH_REASONS.include?(finish_reason) ||
412
+ (CLAIMED_TOOL_FINISH_REASONS.include?(finish_reason) && tool_calls.empty?)
413
+ format(TRUNCATION_ERROR_MESSAGE, finish_reason: finish_reason)
414
+ else
415
+ format(TOOL_CALL_ERROR_MESSAGE, error: error)
416
+ end
417
+ end
418
+
419
+ def metrics_from(response)
420
+ {
421
+ prompt_tokens: response[:input_tokens].to_i,
422
+ completion_tokens: response[:output_tokens].to_i,
423
+ cached_tokens: response[:cached_tokens].to_i,
424
+ thinking_tokens: response[:thinking_tokens].to_i,
425
+ cost_usd: response[:cost_usd]
426
+ }
427
+ end
428
+
429
+ def submitted?(result)
430
+ result.exit_code.zero? && result.output.to_s.lstrip.lines.first&.strip == SUBMIT_MARKER
431
+ end
432
+
433
+ def submission_from(result)
434
+ result.output.to_s.lstrip.lines.drop(1).join
435
+ end
436
+
437
+ def observe(action, result)
438
+ output = result.output.to_s
439
+ observe_message({
440
+ role: "tool",
441
+ tool_call_id: action[:id],
442
+ content: observation_content(result.exit_code, output),
443
+ observation: { exit_code: result.exit_code, output: truncate(output) }
444
+ })
445
+ end
446
+
447
+ def observe_message(msg)
448
+ @messages << msg
449
+ reporter&.on_message(msg)
450
+ end
451
+
452
+ def observation_content(exit_code, output)
453
+ if output.length < MAX_OBSERVATION_CHARS
454
+ <<~OBSERVATION.strip
455
+ {
456
+ "returncode": #{exit_code},
457
+ "output": #{output.to_json}
458
+ }
459
+ OBSERVATION
460
+ else
461
+ half = MAX_OBSERVATION_CHARS / 2
462
+ <<~OBSERVATION.strip
463
+ {
464
+ "returncode": #{exit_code},
465
+ "output_head": #{output[0, half].to_json},
466
+ "output_tail": #{output[-half, half].to_json},
467
+ "elided_chars": #{output.length - MAX_OBSERVATION_CHARS},
468
+ "warning": "Output too long."
469
+ }
470
+ OBSERVATION
471
+ end
472
+ end
473
+
474
+ def truncate(output)
475
+ return output if output.length <= MAX_OBSERVATION_CHARS
476
+
477
+ half = MAX_OBSERVATION_CHARS / 2
478
+ "#{output[0, half]}\n...[#{output.length - MAX_OBSERVATION_CHARS} characters omitted]...\n#{output[-half, half]}"
479
+ end
480
+
481
+ def finish(status, submission: nil)
482
+ Result.new(
483
+ status: status, submission: submission, messages: @messages, steps: @steps,
484
+ cost_source: cost_source, cost_usd: @cost_known ? @cost : nil, error: nil, **@totals
485
+ )
486
+ end
487
+
488
+ # Provider#complete, not Chat: Chat runs its own loop, and the loop lives above.
489
+ def complete(messages)
490
+ model_info, provider = resolved
491
+ response = provider.complete(
492
+ with_cache_breakpoints(messages.map { as_ruby_llm(_1) }),
493
+ tools: { bash: @bash_tool },
494
+ temperature: nil,
495
+ model: model_info,
496
+ params: routing_params
497
+ )
498
+ payload(response)
499
+ rescue RubyLLM::Error => e
500
+ raise InfrastructureError, "miniswen: the model call failed: #{e.message}"
501
+ rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError => e
502
+ raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
503
+ end
504
+
505
+ # Anthropic bills every token fresh unless the request marks explicit cache
506
+ # breakpoints
507
+ def explicit_cache? = @provider == "openrouter" && @id.to_s.start_with?("anthropic/")
508
+
509
+ def with_cache_breakpoints(messages)
510
+ return messages unless explicit_cache?
511
+
512
+ system = messages.find { _1.role == :system }
513
+ [system, messages.last].compact.uniq.each do |message|
514
+ text = message.content
515
+ next unless text.is_a?(String) && !text.empty?
516
+
517
+ message.content = RubyLLM::Content::Raw.new(
518
+ [{ type: "text", text: text, cache_control: CACHE_CONTROL }]
519
+ )
520
+ end
521
+ messages
522
+ end
523
+
524
+ def routing_params
525
+ order = ENV["LEMANS_PROVIDER_ORDER"]
526
+ return {} unless order && @provider == "openrouter"
527
+
528
+ { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
529
+ end
530
+
531
+ def cost_source
532
+ if local?
533
+ return CostSource.new(name: :local_provider, model: @model,
534
+ priced_as: "#{@provider || info&.provider}/#{@id} ($0.00, local)",
535
+ registry: nil)
536
+ end
537
+ return nil unless info
538
+
539
+ CostSource.new(name: :model_registry, model: @model,
540
+ priced_as: "#{info.provider}/#{info.id}",
541
+ registry: Miniswen.registry_revision)
542
+ end
543
+
544
+ def resolved
545
+ @resolved ||= RubyLLM::Models.resolve(@id, provider: @provider, assume_exists: !@provider.nil?)
546
+ end
547
+
548
+ def as_ruby_llm(entry)
549
+ case entry[:role]
550
+ when "assistant"
551
+ RubyLLM::Message.new(role: :assistant, content: entry[:content],
552
+ thinking: as_ruby_llm_thinking(entry),
553
+ tool_calls: as_ruby_llm_tool_calls(entry[:tool_calls]))
554
+ when "tool"
555
+ RubyLLM::Message.new(role: :tool, content: entry[:content], tool_call_id: entry[:tool_call_id])
556
+ else
557
+ RubyLLM::Message.new(role: entry[:role].to_sym, content: entry[:content])
558
+ end
559
+ end
560
+
561
+ def as_ruby_llm_thinking(entry)
562
+ details = entry[:reasoning_details]
563
+ if details && !details.empty?
564
+ VerbatimThinking.new(text: entry[:thinking], signature: entry[:thinking_signature], details: details)
565
+ else
566
+ RubyLLM::Thinking.build(text: entry[:thinking], signature: entry[:thinking_signature])
567
+ end
568
+ end
569
+
570
+ def as_ruby_llm_tool_calls(tool_calls)
571
+ return nil if tool_calls.nil? || tool_calls.empty?
572
+
573
+ tool_calls.to_h do |call|
574
+ [call[:id], RubyLLM::ToolCall.new(id: call[:id], name: call[:name], arguments: call[:arguments],
575
+ thought_signature: call[:thought_signature])]
576
+ end
577
+ end
578
+
579
+ def payload(response)
580
+ # Providers under load occasionally answer with no completion at all.
581
+ raise InfrastructureError, "miniswen: #{@model} returned an empty completion" if response.nil?
582
+
583
+ tokens = response.tokens
584
+ {
585
+ content: response.content.to_s,
586
+ thinking: response.thinking&.text,
587
+ thinking_signature: response.thinking&.signature,
588
+ reasoning_details: reasoning_details_from(response),
589
+ tool_calls: tool_calls_from(response),
590
+ finish_reason: finish_reason_from(response),
591
+ # ruby_llm's input_tokens is the cache-miss remainder only; the
592
+ # convention counts the whole prompt, cached and cache-write included.
593
+ input_tokens: response.input_tokens.to_i + tokens&.cached.to_i + tokens&.cache_creation.to_i,
594
+ output_tokens: response.output_tokens.to_i,
595
+ cached_tokens: tokens&.cached.to_i,
596
+ thinking_tokens: tokens&.thinking.to_i,
597
+ cost_usd: price(response)
598
+ }
599
+ end
600
+
601
+ def tool_calls_from(response)
602
+ Array(response.tool_calls&.values).map do |call|
603
+ entry = { id: call.id, name: call.name, arguments: normalize_arguments(call.arguments) }
604
+ entry[:thought_signature] = call.thought_signature if call.thought_signature
605
+ entry
606
+ end
607
+ end
608
+
609
+ # Providers hand arguments back parsed; a provider that didn't gets one
610
+ # parse attempt, anything else bounces as a format error.
611
+ def normalize_arguments(arguments)
612
+ arguments = JSON.parse(arguments) if arguments.is_a?(String)
613
+ arguments.is_a?(Hash) ? arguments.transform_keys(&:to_s) : arguments
614
+ rescue JSON::ParserError
615
+ arguments
616
+ end
617
+
618
+ def finish_reason_from(response)
619
+ body = raw_body(response)
620
+ body && (body.dig("choices", 0, "finish_reason") || body["stop_reason"])
621
+ end
622
+
623
+ def reasoning_details_from(response)
624
+ details = raw_body(response)&.dig("choices", 0, "message", "reasoning_details")
625
+ details.is_a?(Array) && !details.empty? ? details : nil
626
+ end
627
+
628
+ def raw_body(response)
629
+ body = response.raw&.body
630
+ body = JSON.parse(body) if body.is_a?(String)
631
+ body.is_a?(Hash) ? body : nil
632
+ rescue JSON::ParserError
633
+ nil
634
+ end
635
+
636
+ def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
637
+
638
+ def info
639
+ return @info if defined?(@info)
640
+
641
+ @info = find_model
642
+ end
643
+
644
+ def find_model
645
+ @provider ? RubyLLM.models.find(@id, @provider) : RubyLLM.models.find(@id)
646
+ rescue RubyLLM::ModelNotFoundError
647
+ nil
648
+ end
649
+
650
+ def price(response)
651
+ return 0.0 if local?
652
+
653
+ input = info&.input_price_per_million
654
+ output = info&.output_price_per_million
655
+ return nil unless input.is_a?(Numeric) && output.is_a?(Numeric)
656
+
657
+ tokens = response.tokens
658
+ cache_read = info.cache_read_input_price_per_million || input
659
+ cache_write = info.cache_write_input_price_per_million || input
660
+ # Providers usually fold thinking into output_tokens; max() bills the
661
+ # larger count once and can never double-bill.
662
+ generated = [response.output_tokens.to_i, tokens&.thinking.to_i].max
663
+ ((response.input_tokens.to_i * input) +
664
+ (tokens&.cached.to_i * cache_read) +
665
+ (tokens&.cache_creation.to_i * cache_write) +
666
+ (generated * output)) / 1_000_000.0
667
+ end
668
+ end
669
+ end