turnkit 0.2.4 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75121664c1e081304931fbf125db92a9abc8b9062f920c7e33f7759b52ce51ec
4
- data.tar.gz: ccabe905d199d955d281c936a019995a3bd9bc29c0fc009160ea924de4605835
3
+ metadata.gz: 271ce272a71a97aa2991a580f36205e4cef8e19466e2e480b0ac6f0f0225d51f
4
+ data.tar.gz: b9a0503f499d3eb850e7eece6f508b6fbc206d6398263f6005520b7ef716493b
5
5
  SHA512:
6
- metadata.gz: ff0fa50aabb4c4b4fd9ea6f3ae78b62a4b020522a083f96605028dca2f4ca50a4fb6a9b98b36070e070d38a36b205ebf343823b520f5b0e5b4fe7a06b643cdce
7
- data.tar.gz: beec35d2fc1f51cc6fe674d12d72e0ec1b44722bdcfab28019e9ab2d2ae313c684125989647e6d5d389f80b2df5f98dd33aa3c154e0af7da0885d2b8bec0221c
6
+ metadata.gz: f8772f25a95c44b2ba3d1a17a3e89d0ba142d862e798cee6daef9c54e04deaa3d8dee77deae48b5a77f7b6051b467a14c355aabf5115b1ce89832a27c87eb1b6
7
+ data.tar.gz: 9b12cccaa55c8d791168eca90655e3b9db89409b69fe59f8b45d23bef71aeec296c538696af44e484da9884dfde4ace67bbfd81d4a6647783f1f7f299ef0e485
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5 - 2026-06-06
4
+
5
+ - Add per-agent and per-turn provider thinking configuration.
6
+
3
7
  ## 0.2.4 - 2026-06-06
4
8
 
5
9
  - Add Anthropic prompt cache support for stable system prompt sections.
data/README.md CHANGED
@@ -22,12 +22,21 @@ bundle install
22
22
 
23
23
  ## Quick Start
24
24
 
25
- Set a provider key:
25
+ Set a provider key. TurnKit uses RubyLLM under the hood and defaults to Anthropic Claude:
26
26
 
27
27
  ```sh
28
28
  export ANTHROPIC_API_KEY=...
29
29
  ```
30
30
 
31
+ | Provider | Env var | Example model |
32
+ | --- | --- | --- |
33
+ | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5` |
34
+ | OpenAI | `OPENAI_API_KEY` | `gpt-4.1-mini` |
35
+ | Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` |
36
+
37
+ > [!WARNING]
38
+ > TurnKit defaults to `claude-sonnet-4-5`. If `ANTHROPIC_API_KEY` is unset or blank, set `TurnKit.default_model` to a provider you have configured.
39
+
31
40
  Create an agent:
32
41
 
33
42
  ```ruby
@@ -68,6 +77,52 @@ Set an OpenAI model:
68
77
  TurnKit.default_model = "gpt-4.1-mini"
69
78
  ```
70
79
 
80
+ Use Gemini:
81
+
82
+ ```sh
83
+ export GEMINI_API_KEY=...
84
+ ```
85
+
86
+ Set a Gemini model:
87
+
88
+ ```ruby
89
+ TurnKit.default_model = "gemini-2.5-flash"
90
+ ```
91
+
92
+ ### Thinking
93
+
94
+ Enable provider reasoning or extended thinking per agent:
95
+
96
+ ```ruby
97
+ agent = TurnKit::Agent.new(
98
+ name: "reasoner",
99
+ model: "claude-sonnet-4-5",
100
+ thinking: { budget: 4_000 }
101
+ )
102
+ ```
103
+
104
+ Use effort-based thinking for providers that support it:
105
+
106
+ ```ruby
107
+ agent = TurnKit::Agent.new(
108
+ name: "reasoner",
109
+ model: "gemini-2.5-flash",
110
+ thinking: { effort: :high }
111
+ )
112
+ ```
113
+
114
+ Override or disable thinking for one turn:
115
+
116
+ ```ruby
117
+ conversation = agent.conversation
118
+ conversation.ask("Solve this carefully.", thinking: { budget: 8_000 })
119
+ conversation.ask("Answer quickly.", thinking: nil)
120
+ ```
121
+
122
+ TurnKit passes `thinking` to RubyLLM as `{ effort:, budget: }`. Anthropic requires `budget`; Gemini and OpenRouter can use `effort`, `budget`, or both depending on the model.
123
+
124
+ When the provider reports reasoning usage, TurnKit records it as `thinking_tokens` and includes it in usage totals and cost calculation.
125
+
71
126
  ### Conversations
72
127
 
73
128
  Create a conversation:
@@ -130,6 +185,76 @@ turn = agent.conversation.ask("Save a short status report.")
130
185
  puts turn.output_text
131
186
  ```
132
187
 
188
+ #### Defining application tools
189
+
190
+ Tools are classes, not instances. Namespaced tools work fine, and the default tool name comes from the class name: `Assistant::Tools::WebSearch` becomes `web_search`.
191
+
192
+ ```ruby
193
+ module Assistant
194
+ module Tools
195
+ class WebSearch < TurnKit::Tool
196
+ description "Search the web for current information."
197
+ usage_hint "Use when current external information is needed."
198
+
199
+ parameter :objective, :string, required: true
200
+ parameter :search_queries, :array, required: false
201
+
202
+ def call(objective:, search_queries: nil, context:)
203
+ ParallelClient.new.web_search(
204
+ objective: objective,
205
+ search_queries: search_queries
206
+ )
207
+ end
208
+ end
209
+ end
210
+ end
211
+ ```
212
+
213
+ Register tool classes on the agent:
214
+
215
+ ```ruby
216
+ agent = TurnKit::Agent.new(
217
+ name: "researcher",
218
+ tools: [
219
+ Assistant::Tools::WebSearch,
220
+ Assistant::Tools::ReadWebPage
221
+ ]
222
+ )
223
+ ```
224
+
225
+ #### Tool context
226
+
227
+ Every tool receives a `context:` object. Use it for logging, correlation, persistence, and domain scoping:
228
+
229
+ ```ruby
230
+ def call(query:, context:)
231
+ context.turn # The TurnKit::Turn being run
232
+ context.execution # The TurnKit::ToolExecution for this tool call
233
+
234
+ { query: query }
235
+ end
236
+ ```
237
+
238
+ If your application already uses a `context:` keyword for something else, use `turnkit_context:` instead:
239
+
240
+ ```ruby
241
+ def call(query:, turnkit_context:)
242
+ { turn_id: turnkit_context.turn.id, query: query }
243
+ end
244
+ ```
245
+
246
+ #### Tool return values
247
+
248
+ Prefer returning a `Hash`. TurnKit serializes the normalized value as the tool result:
249
+
250
+ | Return value | Stored tool result |
251
+ | --- | --- |
252
+ | `Hash` | Keys are stringified. |
253
+ | `Array` | Wrapped as `{ "items" => [...] }`. |
254
+ | Scalar | Wrapped as `{ "result" => value.to_s }`. |
255
+
256
+ Avoid returning arbitrary objects unless you convert them to a plain Hash or Array first.
257
+
133
258
  ### Skills
134
259
 
135
260
  Load a skill:
@@ -260,7 +385,7 @@ Create a client:
260
385
 
261
386
  ```ruby
262
387
  class MyClient < TurnKit::Client
263
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
388
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
264
389
  TurnKit::Result.new(
265
390
  text: "provider response",
266
391
  model: model,
@@ -295,6 +420,17 @@ Install Rails persistence:
295
420
  bin/rails generate turnkit:install
296
421
  ```
297
422
 
423
+ The installer creates:
424
+
425
+ - `config/initializers/turnkit.rb`
426
+ - `app/models/turnkit/conversation.rb`
427
+ - `app/models/turnkit/turn.rb`
428
+ - `app/models/turnkit/message.rb`
429
+ - `app/models/turnkit/tool_execution.rb`
430
+ - a migration for TurnKit persistence
431
+
432
+ The generated migration currently uses `ActiveRecord::Migration[7.1]`. In a newer Rails app, update that version if your app requires it, for example `ActiveRecord::Migration[8.1]`.
433
+
298
434
  Run migrations:
299
435
 
300
436
  ```sh
@@ -307,12 +443,88 @@ Configure Rails:
307
443
  TurnKit.store = TurnKit::ActiveRecordStore.new
308
444
  ```
309
445
 
446
+ Suggested Rails file layout for your application AI code:
447
+
448
+ ```text
449
+ app/models/assistant/
450
+ tools/
451
+ web_search.rb
452
+ read_web_page.rb
453
+ skills/
454
+ prompts/
455
+ ```
456
+
457
+ If you prefer to keep AI infrastructure out of `app/models`, add an autoloaded directory such as:
458
+
459
+ ```text
460
+ app/ai/
461
+ tools/
462
+ skills/
463
+ prompts/
464
+ ```
465
+
310
466
  Reconcile stale turns:
311
467
 
312
468
  ```ruby
313
469
  TurnKit.reconcile_stale!
314
470
  ```
315
471
 
472
+ #### Debugging Rails persistence
473
+
474
+ Inspect the latest persisted turn in a Rails console:
475
+
476
+ ```ruby
477
+ turn = Turnkit::Turn.order(created_at: :desc).first
478
+ turn.status
479
+ turn.error
480
+ turn.output_text
481
+ ```
482
+
483
+ Check whether the model actually called tools:
484
+
485
+ ```ruby
486
+ Turnkit::ToolExecution
487
+ .where(turn_uid: turn.uid)
488
+ .order(:created_at)
489
+ .map { |execution|
490
+ {
491
+ name: execution.tool_name,
492
+ status: execution.status,
493
+ arguments: execution.arguments,
494
+ result_keys: execution.result&.keys,
495
+ error: execution.error
496
+ }
497
+ }
498
+ ```
499
+
500
+ #### Live smoke test
501
+
502
+ Use a model whose provider key is configured, then run a real tool-using turn:
503
+
504
+ ```ruby
505
+ TurnKit.default_model = "gpt-4.1-mini"
506
+
507
+ agent = TurnKit::Agent.new(
508
+ name: "researcher",
509
+ instructions: "Use web_search, then read_web_page, before answering.",
510
+ tools: [
511
+ Assistant::Tools::WebSearch,
512
+ Assistant::Tools::ReadWebPage
513
+ ]
514
+ )
515
+
516
+ turn = agent.conversation.ask(
517
+ "Search for the TurnKit Ruby gem, read the first useful result, then summarize it."
518
+ )
519
+
520
+ puts turn.output_text
521
+
522
+ pp Turnkit::ToolExecution
523
+ .where(turn_uid: turn.id)
524
+ .order(:created_at)
525
+ .pluck(:tool_name, :status, :error)
526
+ ```
527
+
316
528
  ## Options
317
529
 
318
530
  Configure defaults:
@@ -337,7 +549,8 @@ agent = TurnKit::Agent.new(
337
549
  model: "gpt-4.1-mini",
338
550
  max_iterations: 10,
339
551
  timeout: 60,
340
- cost_limit: 0.25
552
+ cost_limit: 0.25,
553
+ thinking: { effort: :low }
341
554
  )
342
555
  ```
343
556
 
@@ -350,6 +563,7 @@ agent = TurnKit::Agent.new(
350
563
  | `timeout` | Limit seconds per root turn. |
351
564
  | `max_tool_executions` | Limit tool calls per root turn. |
352
565
  | `cost_limit` | Limit cost per root turn. |
566
+ | `thinking` | Configure provider reasoning or extended thinking per agent. |
353
567
  | `cost_rates` | Override prices by model. |
354
568
  | `cost_calculator` | Override cost calculation. |
355
569
  | `prompt_cache` | Use provider prompt caching. |
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  module Adapters
5
5
  class RubyLLM < Client
6
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
6
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
7
7
  require "ruby_llm"
8
8
 
9
9
  configure_from_environment
@@ -11,6 +11,7 @@ module TurnKit
11
11
  chat = ::RubyLLM.chat(model: model)
12
12
  add_instructions(chat, instructions, model: model)
13
13
  chat.with_temperature(temperature) if temperature
14
+ apply_thinking(chat, thinking)
14
15
  Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
15
16
  Array(messages).each { |message| add_message(chat, message) }
16
17
 
@@ -27,6 +28,11 @@ module TurnKit
27
28
  config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
28
29
  end
29
30
 
31
+ def apply_thinking(chat, thinking)
32
+ thinking = Agent.normalize_thinking(thinking)
33
+ chat.with_thinking(**thinking) if thinking
34
+ end
35
+
30
36
  def complete_without_tool_execution(chat)
31
37
  provider = chat.instance_variable_get(:@provider)
32
38
  provider.complete(
@@ -123,6 +129,7 @@ module TurnKit
123
129
  output_tokens: token_value(response, :output_tokens),
124
130
  cached_tokens: token_value(response, :cached_tokens),
125
131
  cache_write_tokens: token_value(response, :cache_creation_tokens),
132
+ thinking_tokens: thinking_token_value(response),
126
133
  cost: response_cost(response)
127
134
  )
128
135
  Result.new(
@@ -137,6 +144,10 @@ module TurnKit
137
144
  response.respond_to?(method) ? response.public_send(method).to_i : 0
138
145
  end
139
146
 
147
+ def thinking_token_value(response)
148
+ token_value(response, :thinking_tokens).nonzero? || token_value(response, :reasoning_tokens)
149
+ end
150
+
140
151
  def response_cost(response)
141
152
  return unless response.respond_to?(:cost)
142
153
 
data/lib/turnkit/agent.rb CHANGED
@@ -4,11 +4,11 @@ module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
6
  attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking
8
8
 
9
9
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
10
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
11
- max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil)
11
+ max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil)
12
12
  @name = name.to_s
13
13
  @description = description.to_s
14
14
  @model = model
@@ -27,9 +27,25 @@ module TurnKit
27
27
  @cost_limit = cost_limit
28
28
  @max_depth = max_depth
29
29
  @max_tool_executions = max_tool_executions
30
+ @thinking = self.class.normalize_thinking(thinking)
30
31
  raise ArgumentError, "name is required" if @name.empty?
31
32
  end
32
33
 
34
+ def self.normalize_thinking(value)
35
+ return nil if value.nil?
36
+
37
+ attrs = value.respond_to?(:to_h) ? value.to_h : value
38
+ raise ArgumentError, "thinking must be a hash" unless attrs.is_a?(Hash)
39
+
40
+ attrs = attrs.transform_keys(&:to_sym)
41
+ unknown = attrs.keys - %i[effort budget]
42
+ raise ArgumentError, "unknown thinking attributes: #{unknown.join(", ")}" if unknown.any?
43
+ raise ArgumentError, "thinking requires :effort or :budget" if attrs[:effort].nil? && attrs[:budget].nil?
44
+ raise ArgumentError, "thinking budget must be an Integer" if attrs[:budget] && !attrs[:budget].is_a?(Integer)
45
+
46
+ attrs.slice(:effort, :budget).compact
47
+ end
48
+
33
49
  def conversation(model: nil, subject: nil, metadata: {})
34
50
  store = effective_store
35
51
  record = store.create_conversation(
@@ -53,6 +69,10 @@ module TurnKit
53
69
  model || TurnKit.default_model
54
70
  end
55
71
 
72
+ def effective_thinking
73
+ thinking
74
+ end
75
+
56
76
  def effective_client
57
77
  client || TurnKit.client
58
78
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class Client
5
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
5
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
6
6
  raise NotImplementedError
7
7
  end
8
8
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module TurnKit
4
4
  class Conversation
5
+ THINKING_UNSET = Object.new.freeze
6
+
5
7
  attr_reader :agent, :id, :store, :model, :subject, :metadata
6
8
 
7
9
  def initialize(agent:, record:, store:, model:, subject: nil, metadata: {})
@@ -24,12 +26,15 @@ module TurnKit
24
26
  async ? turn : turn.run!
25
27
  end
26
28
 
27
- def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent)
28
- build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent).run!
29
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
30
+ build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking).run!
29
31
  end
30
32
 
31
- def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent)
33
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
32
34
  snapshot = latest_message_sequence
35
+ effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
36
+ options = { "trigger_message_id" => trigger_message_id }.compact
37
+ options["thinking"] = effective_thinking
33
38
  record = store.create_turn(
34
39
  "conversation_id" => id,
35
40
  "agent_name" => agent.name,
@@ -39,7 +44,7 @@ module TurnKit
39
44
  "context_message_sequence" => snapshot,
40
45
  "status" => "pending",
41
46
  "model" => model || self.model || agent.effective_model,
42
- "options" => { "trigger_message_id" => trigger_message_id }.compact
47
+ "options" => options
43
48
  )
44
49
  Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
45
50
  end
data/lib/turnkit/cost.rb CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  module TurnKit
4
4
  class Cost
5
- COMPONENTS = %i[input output cache_read cache_write].freeze
5
+ COMPONENTS = %i[input output cache_read cache_write thinking].freeze
6
6
  PER_MILLION = 1_000_000.0
7
7
 
8
- attr_reader :input, :output, :cache_read, :cache_write
8
+ attr_reader :input, :output, :cache_read, :cache_write, :thinking
9
9
 
10
10
  def self.aggregate(costs)
11
11
  costs = costs.compact
@@ -55,6 +55,7 @@ module TurnKit
55
55
  output: amount(usage.output_tokens, rates[:output] || rates[:output_per_million]),
56
56
  cache_read: amount(usage.cached_tokens, rates[:cache_read] || rates[:cached_input] || rates[:cache_read_input_per_million] || rates[:cached_input_per_million]),
57
57
  cache_write: amount(usage.cache_write_tokens, rates[:cache_write] || rates[:cache_creation] || rates[:cache_write_input_per_million] || rates[:cache_creation_input_per_million]),
58
+ thinking: amount(usage.thinking_tokens, rates[:thinking] || rates[:reasoning] || rates[:thinking_output] || rates[:reasoning_output] || rates[:thinking_output_per_million] || rates[:reasoning_output_per_million]),
58
59
  strict: true
59
60
  )
60
61
  end
@@ -70,7 +71,8 @@ module TurnKit
70
71
  input: usage.input_tokens,
71
72
  output: usage.output_tokens,
72
73
  cached: usage.cached_tokens,
73
- cache_creation: usage.cache_write_tokens
74
+ cache_creation: usage.cache_write_tokens,
75
+ thinking: usage.thinking_tokens
74
76
  )
75
77
  from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
76
78
  else
@@ -92,6 +94,7 @@ module TurnKit
92
94
  output: hash[:output],
93
95
  cache_read: hash[:cache_read] || hash[:cached_input],
94
96
  cache_write: hash[:cache_write] || hash[:cache_creation],
97
+ thinking: hash[:thinking] || hash[:reasoning] || hash[:thinking_output] || hash[:reasoning_output],
95
98
  total: hash[:total]
96
99
  )
97
100
  end
@@ -119,11 +122,12 @@ module TurnKit
119
122
  tokens.to_i * price.to_f / PER_MILLION
120
123
  end
121
124
 
122
- def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, total: nil, strict: false)
125
+ def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, thinking: nil, total: nil, strict: false)
123
126
  @input = number(input)
124
127
  @output = number(output)
125
128
  @cache_read = number(cache_read)
126
129
  @cache_write = number(cache_write)
130
+ @thinking = number(thinking)
127
131
  @total = number(total)
128
132
  @strict = strict
129
133
  end
@@ -142,6 +146,7 @@ module TurnKit
142
146
  "output" => output,
143
147
  "cache_read" => cache_read,
144
148
  "cache_write" => cache_write,
149
+ "thinking" => thinking,
145
150
  "total" => total
146
151
  }.compact
147
152
  end
data/lib/turnkit/turn.rb CHANGED
@@ -6,7 +6,7 @@ module TurnKit
6
6
 
7
7
  attr_reader :agent, :conversation, :store, :budget, :depth
8
8
  attr_reader :id, :conversation_id, :agent_name, :parent_turn_id, :parent_tool_execution_id
9
- attr_reader :root_turn_id, :context_message_sequence, :model
9
+ attr_reader :root_turn_id, :context_message_sequence, :model, :thinking
10
10
  attr_reader :started_at
11
11
 
12
12
  def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
@@ -22,6 +22,7 @@ module TurnKit
22
22
  @root_turn_id = @record["root_turn_id"] || id
23
23
  @context_message_sequence = @record["context_message_sequence"].to_i
24
24
  @model = @record["model"] || agent.effective_model
25
+ @thinking = thinking_from_options
25
26
  @started_at = @record["started_at"]
26
27
  @budget = budget || agent.build_budget
27
28
  @depth = depth
@@ -40,6 +41,7 @@ module TurnKit
40
41
  messages: llm_messages,
41
42
  tools: agent.effective_tools,
42
43
  instructions: agent.system_prompt_for(turn: self, conversation: conversation),
44
+ thinking: thinking,
43
45
  metadata: { turn_id: id, conversation_id: conversation.id }
44
46
  )
45
47
  result_cost = Cost.from_usage(result.usage, model: result.model || model)
@@ -94,6 +96,7 @@ module TurnKit
94
96
 
95
97
  def reload
96
98
  @record = store.load_turn(id)
99
+ @thinking = thinking_from_options
97
100
  self
98
101
  end
99
102
 
@@ -106,6 +109,13 @@ module TurnKit
106
109
  MessageProjection.for(conversation.messages_for_turn(self))
107
110
  end
108
111
 
112
+ def thinking_from_options
113
+ options = (@record["options"] || {}).transform_keys(&:to_s)
114
+ return Agent.normalize_thinking(options["thinking"]) if options.key?("thinking")
115
+
116
+ agent.effective_thinking
117
+ end
118
+
109
119
  def persist_assistant_message(result)
110
120
  if result.tool_calls?
111
121
  conversation.append_message(
@@ -133,6 +143,7 @@ module TurnKit
133
143
  "output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
134
144
  "cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
135
145
  "cache_write_tokens" => current["cache_write_tokens"].to_i + usage.cache_write_tokens,
146
+ "thinking_tokens" => current["thinking_tokens"].to_i + usage.thinking_tokens,
136
147
  "total_tokens" => current["total_tokens"].to_i + usage.total_tokens
137
148
  }
138
149
  totals["cost_details"] = aggregate_cost(current["cost_details"], cost).to_h if cost&.total
data/lib/turnkit/usage.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class Usage
5
- attr_reader :input_tokens, :output_tokens, :cached_tokens, :cache_write_tokens, :cost
5
+ attr_reader :input_tokens, :output_tokens, :cached_tokens, :cache_write_tokens, :thinking_tokens, :cost
6
6
 
7
7
  def self.aggregate(usages)
8
8
  usages = usages.compact
@@ -13,6 +13,7 @@ module TurnKit
13
13
  output_tokens: usages.sum(&:output_tokens),
14
14
  cached_tokens: usages.sum(&:cached_tokens),
15
15
  cache_write_tokens: usages.sum(&:cache_write_tokens),
16
+ thinking_tokens: usages.sum(&:thinking_tokens),
16
17
  cost: cost
17
18
  )
18
19
  end
@@ -29,20 +30,22 @@ module TurnKit
29
30
  output_tokens: attrs["output_tokens"],
30
31
  cached_tokens: attrs["cached_tokens"],
31
32
  cache_write_tokens: attrs["cache_write_tokens"],
33
+ thinking_tokens: attrs["thinking_tokens"] || attrs["reasoning_tokens"],
32
34
  cost: cost
33
35
  )
34
36
  end
35
37
 
36
- def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost: nil)
38
+ def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, thinking_tokens: 0, cost: nil)
37
39
  @input_tokens = input_tokens.to_i
38
40
  @output_tokens = output_tokens.to_i
39
41
  @cached_tokens = cached_tokens.to_i
40
42
  @cache_write_tokens = cache_write_tokens.to_i
43
+ @thinking_tokens = thinking_tokens.to_i
41
44
  @cost = cost
42
45
  end
43
46
 
44
47
  def total_tokens
45
- input_tokens + output_tokens + cached_tokens + cache_write_tokens
48
+ input_tokens + output_tokens + cached_tokens + cache_write_tokens + thinking_tokens
46
49
  end
47
50
 
48
51
  def to_h
@@ -51,6 +54,7 @@ module TurnKit
51
54
  "output_tokens" => output_tokens,
52
55
  "cached_tokens" => cached_tokens,
53
56
  "cache_write_tokens" => cache_write_tokens,
57
+ "thinking_tokens" => thinking_tokens,
54
58
  "total_tokens" => total_tokens,
55
59
  "cost" => cost
56
60
  }.compact
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch