rubik 0.3.3 → 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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +102 -0
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +99 -69
- data/lib/rubik/config/defaults.rb +17 -20
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- data/lib/rubik/platform/process.rb +6 -6
- data/lib/rubik/platform/sanitize.rb +37 -16
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +183 -155
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +266 -246
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
data/lib/rubik/llm/client.rb
CHANGED
|
@@ -234,87 +234,118 @@ module Rubik
|
|
|
234
234
|
)
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
-
def self.
|
|
238
|
-
|
|
239
|
-
on_retry: nil, http_factory: nil)
|
|
240
|
-
begin
|
|
241
|
-
uri = URI.parse(url_str.to_s)
|
|
242
|
-
rescue URI::InvalidURIError => e
|
|
243
|
-
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
244
|
-
end
|
|
245
|
-
|
|
237
|
+
def self.parse_and_validate_uri(url_str)
|
|
238
|
+
uri = URI.parse(url_str.to_s)
|
|
246
239
|
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
247
240
|
raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
|
|
248
241
|
end
|
|
249
242
|
|
|
243
|
+
uri
|
|
244
|
+
rescue URI::InvalidURIError => e
|
|
245
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def self.perform_post_request(uri, headers, payload, timeout:, open_timeout:, http_factory:)
|
|
249
|
+
if http_factory
|
|
250
|
+
http = http_factory.call(uri)
|
|
251
|
+
req = build_post_request(uri, headers, payload)
|
|
252
|
+
http.request(req)
|
|
253
|
+
else
|
|
254
|
+
with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
|
|
255
|
+
req = build_post_request(uri, headers, payload)
|
|
256
|
+
http.request(req)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Rate limiting is transitory and self-healing — it gets its own
|
|
262
|
+
# larger budget (max_429_retries), a Retry-After-aware delay, and
|
|
263
|
+
# a typed error when exhausted so callers can report "provider
|
|
264
|
+
# throttled" distinctly. The generic 5xx budget is untouched, so
|
|
265
|
+
# one 429 burst cannot exhaust the transport-failure budget.
|
|
266
|
+
def self.handle_429_retry(res, rate_limit_retries:, max_429_retries:, start_time:, backoff_base:, max_wall_clock:, on_retry:)
|
|
267
|
+
if rate_limit_retries < max_429_retries
|
|
268
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
269
|
+
delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
|
|
270
|
+
if elapsed + delay <= max_wall_clock
|
|
271
|
+
rate_limit_retries += 1
|
|
272
|
+
on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
|
|
273
|
+
sleep(delay)
|
|
274
|
+
return rate_limit_retries
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
raise rate_limit_error(res, rate_limit_retries)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def self.attempt_backoff_retry(retries:, max_retries:, start_time:, backoff_base:, max_wall_clock:, reason:, on_retry:)
|
|
281
|
+
return nil unless retries < max_retries
|
|
282
|
+
|
|
283
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
284
|
+
delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
|
|
285
|
+
return nil if elapsed + delay > max_wall_clock
|
|
286
|
+
|
|
287
|
+
retries += 1
|
|
288
|
+
on_retry&.call(retries, max_retries, reason)
|
|
289
|
+
sleep(delay)
|
|
290
|
+
retries
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A body without a charset arrives tagged ASCII-8BIT; embedding it
|
|
294
|
+
# raw makes every ClientError message binary and the UTF-8 output
|
|
295
|
+
# layer raises inside the REPL's rescue clause (masking the real
|
|
296
|
+
# HTTP error as Encoding::CompatibilityError).
|
|
297
|
+
# Exhausted 5xx is transport-class (provider outage) → transient;
|
|
298
|
+
# a 4xx is content/auth/moderation → permanent. One raise point
|
|
299
|
+
# for both, the flag decided by status (issue #106).
|
|
300
|
+
def self.parse_json_response(res, uri:, status:)
|
|
301
|
+
unless status >= 200 && status < 300
|
|
302
|
+
raise ClientError.new(
|
|
303
|
+
"HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
|
|
304
|
+
transient: status >= 500
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
begin
|
|
309
|
+
JSON.parse(res.body)
|
|
310
|
+
rescue JSON::ParserError => e
|
|
311
|
+
raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
|
|
316
|
+
max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
|
|
317
|
+
on_retry: nil, http_factory: nil)
|
|
318
|
+
uri = parse_and_validate_uri(url_str)
|
|
250
319
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
251
320
|
retries = 0
|
|
252
321
|
rate_limit_retries = 0
|
|
253
322
|
|
|
254
323
|
loop do
|
|
255
|
-
res =
|
|
256
|
-
if http_factory
|
|
257
|
-
http = http_factory.call(uri)
|
|
258
|
-
req = build_post_request(uri, headers, payload)
|
|
259
|
-
http.request(req)
|
|
260
|
-
else
|
|
261
|
-
with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
|
|
262
|
-
req = build_post_request(uri, headers, payload)
|
|
263
|
-
http.request(req)
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
324
|
+
res = perform_post_request(uri, headers, payload, timeout: timeout, open_timeout: open_timeout, http_factory: http_factory)
|
|
267
325
|
status = res.code.to_i
|
|
268
326
|
|
|
269
327
|
if status == 429
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
277
|
-
delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
|
|
278
|
-
if elapsed + delay <= max_wall_clock
|
|
279
|
-
rate_limit_retries += 1
|
|
280
|
-
on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
|
|
281
|
-
sleep(delay)
|
|
282
|
-
next
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
raise rate_limit_error(res, rate_limit_retries)
|
|
328
|
+
rate_limit_retries = handle_429_retry(
|
|
329
|
+
res, rate_limit_retries: rate_limit_retries, max_429_retries: max_429_retries,
|
|
330
|
+
start_time: start_time, backoff_base: backoff_base, max_wall_clock: max_wall_clock,
|
|
331
|
+
on_retry: on_retry
|
|
332
|
+
)
|
|
333
|
+
next
|
|
286
334
|
end
|
|
287
335
|
|
|
288
|
-
if
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
336
|
+
if status >= 500
|
|
337
|
+
new_retries = attempt_backoff_retry(
|
|
338
|
+
retries: retries, max_retries: max_retries, start_time: start_time,
|
|
339
|
+
backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: "HTTP #{status}",
|
|
340
|
+
on_retry: on_retry
|
|
341
|
+
)
|
|
342
|
+
if new_retries
|
|
343
|
+
retries = new_retries
|
|
295
344
|
next
|
|
296
345
|
end
|
|
297
346
|
end
|
|
298
347
|
|
|
299
|
-
|
|
300
|
-
# A body without a charset arrives tagged ASCII-8BIT; embedding it
|
|
301
|
-
# raw makes every ClientError message binary and the UTF-8 output
|
|
302
|
-
# layer raises inside the REPL's rescue clause (masking the real
|
|
303
|
-
# HTTP error as Encoding::CompatibilityError).
|
|
304
|
-
# Exhausted 5xx is transport-class (provider outage) → transient;
|
|
305
|
-
# a 4xx is content/auth/moderation → permanent. One raise point
|
|
306
|
-
# for both, the flag decided by status (issue #106).
|
|
307
|
-
raise ClientError.new(
|
|
308
|
-
"HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
|
|
309
|
-
transient: status >= 500
|
|
310
|
-
)
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
begin
|
|
314
|
-
return JSON.parse(res.body)
|
|
315
|
-
rescue JSON::ParserError => e
|
|
316
|
-
raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
|
|
317
|
-
end
|
|
348
|
+
return parse_json_response(res, uri: uri, status: status)
|
|
318
349
|
rescue ClientError
|
|
319
350
|
raise
|
|
320
351
|
rescue Net::ReadTimeout
|
|
@@ -323,15 +354,14 @@ module Rubik
|
|
|
323
354
|
rescue Interrupt
|
|
324
355
|
raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
|
|
325
356
|
rescue *RETRYABLE_ERRORS => e
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
end
|
|
357
|
+
new_retries = attempt_backoff_retry(
|
|
358
|
+
retries: retries, max_retries: max_retries, start_time: start_time,
|
|
359
|
+
backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: e.class.name,
|
|
360
|
+
on_retry: on_retry
|
|
361
|
+
)
|
|
362
|
+
if new_retries
|
|
363
|
+
retries = new_retries
|
|
364
|
+
next
|
|
335
365
|
end
|
|
336
366
|
raise ClientError.new(
|
|
337
367
|
"Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
|
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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)
|