turnkit 0.2.3 → 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: 2c02ad5eef683595c702a33806438f414ed2da9e18c607a8b314bba4ae442404
4
- data.tar.gz: 4da3877b7c20aecae1dd77e6df4497bb64a3909d28419fb1413feb37fa5fa298
3
+ metadata.gz: 271ce272a71a97aa2991a580f36205e4cef8e19466e2e480b0ac6f0f0225d51f
4
+ data.tar.gz: b9a0503f499d3eb850e7eece6f508b6fbc206d6398263f6005520b7ef716493b
5
5
  SHA512:
6
- metadata.gz: b5de4c365826d8a4154d2ee013fe0f7289796b91b63eb34ad81693993eb55b8f8d0282f8415e7798f9eb698d2f6f4aa52b79949e1c89c0c64effe506cf26ef0b
7
- data.tar.gz: b168324cf4f97485ce7854006565441fd0fe67e1f84835805d98d67f27a2a793fe2ce8bd27a6939c6ccbf3cc92023bc93c8aff5e8049fb0b2991a50548d211d6
6
+ metadata.gz: f8772f25a95c44b2ba3d1a17a3e89d0ba142d862e798cee6daef9c54e04deaa3d8dee77deae48b5a77f7b6051b467a14c355aabf5115b1ce89832a27c87eb1b6
7
+ data.tar.gz: 9b12cccaa55c8d791168eca90655e3b9db89409b69fe59f8b45d23bef71aeec296c538696af44e484da9884dfde4ace67bbfd81d4a6647783f1f7f299ef0e485
data/CHANGELOG.md CHANGED
@@ -1,9 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.3 - 2026-06-06
3
+ ## 0.2.5 - 2026-06-06
4
+
5
+ - Add per-agent and per-turn provider thinking configuration.
6
+
7
+ ## 0.2.4 - 2026-06-06
4
8
 
5
9
  - Add Anthropic prompt cache support for stable system prompt sections.
6
- - Track cache write tokens and aggregate model costs on turns.
10
+ - Track cache write tokens and expose model cost totals for turns, conversations, and agents.
11
+ - Calculate costs from RubyLLM model registry pricing with custom rate and calculator overrides.
7
12
  - Refresh README usage examples for prompt caching and usage tracking.
8
13
 
9
14
  ## 0.2.0 - 2026-06-04
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
@@ -37,14 +46,20 @@ agent = TurnKit::Agent.new(
37
46
  name: "helper",
38
47
  instructions: "Answer briefly."
39
48
  )
49
+ ```
40
50
 
51
+ Ask a question:
52
+
53
+ ```ruby
41
54
  turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
42
55
  puts turn.output_text
43
56
  ```
44
57
 
45
58
  ## Usage
46
59
 
47
- Choose a model:
60
+ ### Models
61
+
62
+ Set the default model:
48
63
 
49
64
  ```ruby
50
65
  TurnKit.default_model = "claude-sonnet-4-5"
@@ -56,10 +71,60 @@ Use OpenAI:
56
71
  export OPENAI_API_KEY=...
57
72
  ```
58
73
 
74
+ Set an OpenAI model:
75
+
59
76
  ```ruby
60
77
  TurnKit.default_model = "gpt-4.1-mini"
61
78
  ```
62
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
+
126
+ ### Conversations
127
+
63
128
  Create a conversation:
64
129
 
65
130
  ```ruby
@@ -67,14 +132,24 @@ agent = TurnKit::Agent.new(
67
132
  name: "writer",
68
133
  instructions: "Write clear release notes."
69
134
  )
135
+ ```
70
136
 
137
+ Add context:
138
+
139
+ ```ruby
71
140
  conversation = agent.conversation(subject: "v1 launch")
72
141
  conversation.say("Mention faster tool execution.")
142
+ ```
73
143
 
144
+ Run the agent:
145
+
146
+ ```ruby
74
147
  turn = conversation.run!
75
148
  puts turn.output_text
76
149
  ```
77
150
 
151
+ ### Tools
152
+
78
153
  Create a tool:
79
154
 
80
155
  ```ruby
@@ -93,7 +168,7 @@ class SaveReport < TurnKit::Tool
93
168
  end
94
169
  ```
95
170
 
96
- Use a tool:
171
+ Use the tool:
97
172
 
98
173
  ```ruby
99
174
  agent = TurnKit::Agent.new(
@@ -101,40 +176,185 @@ agent = TurnKit::Agent.new(
101
176
  instructions: "Save reports when asked.",
102
177
  tools: [SaveReport]
103
178
  )
179
+ ```
180
+
181
+ Ask for tool use:
104
182
 
183
+ ```ruby
105
184
  turn = agent.conversation.ask("Save a short status report.")
106
185
  puts turn.output_text
107
186
  ```
108
187
 
109
- Add skills:
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
+
258
+ ### Skills
259
+
260
+ Load a skill:
110
261
 
111
262
  ```ruby
112
263
  skill = TurnKit::Skill.from_file("skills/research.md")
264
+ ```
265
+
266
+ Use the skill:
113
267
 
268
+ ```ruby
114
269
  agent = TurnKit::Agent.new(
115
270
  name: "researcher",
116
271
  skills: [skill]
117
272
  )
118
273
  ```
119
274
 
120
- Delegate to sub-agents:
275
+ ### Sub-agents
276
+
277
+ Create a sub-agent:
121
278
 
122
279
  ```ruby
123
280
  writer = TurnKit::Agent.new(
124
281
  name: "writer",
125
282
  description: "Draft concise copy."
126
283
  )
284
+ ```
285
+
286
+ Delegate to it:
127
287
 
288
+ ```ruby
128
289
  editor = TurnKit::Agent.new(
129
290
  name: "editor",
130
291
  sub_agents: [writer]
131
292
  )
293
+ ```
294
+
295
+ Ask the parent agent:
132
296
 
297
+ ```ruby
133
298
  turn = editor.conversation.ask("Ask the writer for three headlines.")
134
299
  puts turn.output_text
135
300
  ```
136
301
 
137
- Use prompt caching:
302
+ ### Usage and costs
303
+
304
+ Inspect token usage:
305
+
306
+ ```ruby
307
+ turn.usage.total_tokens
308
+ conversation.usage.total_tokens
309
+ agent.usage.total_tokens
310
+ ```
311
+
312
+ Inspect costs:
313
+
314
+ ```ruby
315
+ turn.cost.total
316
+ conversation.cost.total
317
+ agent.cost.total
318
+ ```
319
+
320
+ Use RubyLLM registry prices by default.
321
+
322
+ Override model rates:
323
+
324
+ ```ruby
325
+ TurnKit.cost_rates = {
326
+ "my-model" => {
327
+ input: 0.25,
328
+ output: 1.00,
329
+ cached_input: 0.05,
330
+ cache_creation: 0.25
331
+ }
332
+ }
333
+ ```
334
+
335
+ Override cost calculation:
336
+
337
+ ```ruby
338
+ TurnKit.cost_calculator = ->(usage, model) do
339
+ {
340
+ input: usage.input_tokens * 0.25 / 1_000_000.0,
341
+ output: usage.output_tokens * 1.00 / 1_000_000.0
342
+ }
343
+ end
344
+ ```
345
+
346
+ Limit turn cost:
347
+
348
+ ```ruby
349
+ agent = TurnKit::Agent.new(
350
+ name: "analyst",
351
+ cost_limit: 0.25
352
+ )
353
+ ```
354
+
355
+ ### Prompt caching
356
+
357
+ Enable prompt caching:
138
358
 
139
359
  ```ruby
140
360
  TurnKit.prompt_cache = :auto
@@ -159,18 +379,13 @@ agent = TurnKit::Agent.new(
159
379
  )
160
380
  ```
161
381
 
162
- Inspect usage:
163
-
164
- ```ruby
165
- record = TurnKit.store.load_turn(turn.id)
166
- record.fetch("usage")
167
- ```
382
+ ### Custom clients
168
383
 
169
- Return usage from custom clients:
384
+ Create a client:
170
385
 
171
386
  ```ruby
172
387
  class MyClient < TurnKit::Client
173
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
388
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
174
389
  TurnKit::Result.new(
175
390
  text: "provider response",
176
391
  model: model,
@@ -185,28 +400,37 @@ class MyClient < TurnKit::Client
185
400
  end
186
401
  ```
187
402
 
188
- Split instructions inside custom clients:
403
+ Use the client:
189
404
 
190
405
  ```ruby
191
- stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
406
+ TurnKit.client = MyClient.new
192
407
  ```
193
408
 
194
- Send `stable` with provider cache controls.
195
-
196
- Send `dynamic` as normal prompt content.
197
-
198
- Use a custom client:
409
+ Split cache sections:
199
410
 
200
411
  ```ruby
201
- TurnKit.client = MyClient.new
412
+ stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
202
413
  ```
203
414
 
415
+ ### Rails
416
+
204
417
  Install Rails persistence:
205
418
 
206
419
  ```sh
207
420
  bin/rails generate turnkit:install
208
421
  ```
209
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
+
210
434
  Run migrations:
211
435
 
212
436
  ```sh
@@ -217,7 +441,26 @@ Configure Rails:
217
441
 
218
442
  ```ruby
219
443
  TurnKit.store = TurnKit::ActiveRecordStore.new
220
- TurnKit.default_model = "claude-sonnet-4-5"
444
+ ```
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/
221
464
  ```
222
465
 
223
466
  Reconcile stale turns:
@@ -226,6 +469,62 @@ Reconcile stale turns:
226
469
  TurnKit.reconcile_stale!
227
470
  ```
228
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
+
229
528
  ## Options
230
529
 
231
530
  Configure defaults:
@@ -237,6 +536,8 @@ TurnKit.timeout = 300
237
536
  TurnKit.max_depth = 3
238
537
  TurnKit.max_tool_executions = 100
239
538
  TurnKit.cost_limit = nil
539
+ TurnKit.cost_rates = {}
540
+ TurnKit.cost_calculator = nil
240
541
  TurnKit.prompt_cache = :auto
241
542
  ```
242
543
 
@@ -248,7 +549,8 @@ agent = TurnKit::Agent.new(
248
549
  model: "gpt-4.1-mini",
249
550
  max_iterations: 10,
250
551
  timeout: 60,
251
- cost_limit: 0.25
552
+ cost_limit: 0.25,
553
+ thinking: { effort: :low }
252
554
  )
253
555
  ```
254
556
 
@@ -259,11 +561,12 @@ agent = TurnKit::Agent.new(
259
561
  | `store` | Set the conversation store. |
260
562
  | `max_iterations` | Limit model calls per turn. |
261
563
  | `timeout` | Limit seconds per root turn. |
262
- | `max_depth` | Limit sub-agent nesting. |
263
564
  | `max_tool_executions` | Limit tool calls per root turn. |
264
565
  | `cost_limit` | Limit cost per root turn. |
566
+ | `thinking` | Configure provider reasoning or extended thinking per agent. |
567
+ | `cost_rates` | Override prices by model. |
568
+ | `cost_calculator` | Override cost calculation. |
265
569
  | `prompt_cache` | Use provider prompt caching. |
266
- | `prompt_sections` | Set default prompt sections. |
267
570
 
268
571
  ## Contributing
269
572
 
@@ -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(
@@ -122,7 +128,9 @@ module TurnKit
122
128
  input_tokens: token_value(response, :input_tokens),
123
129
  output_tokens: token_value(response, :output_tokens),
124
130
  cached_tokens: token_value(response, :cached_tokens),
125
- cache_write_tokens: token_value(response, :cache_creation_tokens)
131
+ cache_write_tokens: token_value(response, :cache_creation_tokens),
132
+ thinking_tokens: thinking_token_value(response),
133
+ cost: response_cost(response)
126
134
  )
127
135
  Result.new(
128
136
  text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
@@ -135,6 +143,16 @@ module TurnKit
135
143
  def token_value(response, method)
136
144
  response.respond_to?(method) ? response.public_send(method).to_i : 0
137
145
  end
146
+
147
+ def thinking_token_value(response)
148
+ token_value(response, :thinking_tokens).nonzero? || token_value(response, :reasoning_tokens)
149
+ end
150
+
151
+ def response_cost(response)
152
+ return unless response.respond_to?(:cost)
153
+
154
+ response.cost&.total
155
+ end
138
156
  end
139
157
  end
140
158
  end
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(
@@ -41,10 +57,22 @@ module TurnKit
41
57
  Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
42
58
  end
43
59
 
60
+ def cost
61
+ Cost.from_records(effective_store.list_turns(agent_name: name))
62
+ end
63
+
64
+ def usage
65
+ Usage.from_records(effective_store.list_turns(agent_name: name))
66
+ end
67
+
44
68
  def effective_model
45
69
  model || TurnKit.default_model
46
70
  end
47
71
 
72
+ def effective_thinking
73
+ thinking
74
+ end
75
+
48
76
  def effective_client
49
77
  client || TurnKit.client
50
78
  end
@@ -32,10 +32,14 @@ module TurnKit
32
32
  end
33
33
 
34
34
  def add_usage!(usage)
35
- return unless usage&.cost && cost_limit
35
+ add_cost!(usage&.cost)
36
+ end
37
+
38
+ def add_cost!(cost)
39
+ return unless cost && cost_limit
36
40
 
37
41
  @mutex.synchronize do
38
- @cost += usage.cost.to_f
42
+ @cost += cost.to_f
39
43
  raise Error, "cost limit reached" if @cost > cost_limit
40
44
  end
41
45
  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
@@ -48,6 +53,14 @@ module TurnKit
48
53
  store.list_messages(id).map { |attrs| Message.new(attrs) }
49
54
  end
50
55
 
56
+ def usage
57
+ Usage.from_records(store.list_turns(conversation_id: id))
58
+ end
59
+
60
+ def cost
61
+ Cost.from_records(store.list_turns(conversation_id: id))
62
+ end
63
+
51
64
  def messages_for_turn(turn)
52
65
  store.list_messages(id, through_sequence: turn.context_message_sequence, turn_id: turn.id).map { |attrs| Message.new(attrs) }
53
66
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Cost
5
+ COMPONENTS = %i[input output cache_read cache_write thinking].freeze
6
+ PER_MILLION = 1_000_000.0
7
+
8
+ attr_reader :input, :output, :cache_read, :cache_write, :thinking
9
+
10
+ def self.aggregate(costs)
11
+ costs = costs.compact
12
+ return new unless costs.any?
13
+
14
+ if costs.any? { |cost| COMPONENTS.any? { |component| !cost.public_send(component).nil? } }
15
+ values = COMPONENTS.to_h do |component|
16
+ amounts = costs.filter_map { |cost| cost.public_send(component) }
17
+ [ component, amounts.any? ? amounts.sum : nil ]
18
+ end
19
+ return new(**values)
20
+ end
21
+
22
+ totals = costs.map(&:total)
23
+ return new(total: totals.sum) if totals.none?(&:nil?)
24
+
25
+ new
26
+ end
27
+
28
+ def self.from_usage(usage, model: nil)
29
+ return new(total: usage.cost) if usage.cost
30
+
31
+ custom = custom_cost(usage, model)
32
+ return custom if custom
33
+
34
+ rates = TurnKit.cost_rates[model.to_s] || TurnKit.cost_rates[model&.to_sym]
35
+ rates ? from_rates(usage, rates) : from_ruby_llm(usage, model)
36
+ end
37
+
38
+ def self.from_records(records)
39
+ aggregate(records.map { |record| from_record(record) })
40
+ end
41
+
42
+ def self.from_record(record)
43
+ attrs = record.transform_keys(&:to_s)
44
+ usage = attrs["usage"] || {}
45
+ return from_hash(usage["cost_details"] || usage[:cost_details]) if usage["cost_details"] || usage[:cost_details]
46
+ return new(total: attrs["cost"]) if attrs["cost"]
47
+
48
+ from_usage(Usage.from_h(usage), model: attrs["model"])
49
+ end
50
+
51
+ def self.from_rates(usage, rates)
52
+ rates = rates.transform_keys(&:to_sym)
53
+ new(
54
+ input: amount(usage.input_tokens, rates[:input] || rates[:input_per_million]),
55
+ output: amount(usage.output_tokens, rates[:output] || rates[:output_per_million]),
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
+ 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]),
59
+ strict: true
60
+ )
61
+ end
62
+
63
+ def self.from_ruby_llm(usage, model)
64
+ require "ruby_llm"
65
+
66
+ model_info = ::RubyLLM.models.find(model) if model
67
+ return new unless model_info
68
+
69
+ if defined?(::RubyLLM::Cost)
70
+ tokens = ::RubyLLM::Tokens.new(
71
+ input: usage.input_tokens,
72
+ output: usage.output_tokens,
73
+ cached: usage.cached_tokens,
74
+ cache_creation: usage.cache_write_tokens,
75
+ thinking: usage.thinking_tokens
76
+ )
77
+ from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
78
+ else
79
+ from_rates(
80
+ usage,
81
+ input: model_info.input_price_per_million,
82
+ output: model_info.output_price_per_million,
83
+ cached_input: model_info.pricing&.text_tokens&.cached_input
84
+ )
85
+ end
86
+ rescue LoadError, StandardError
87
+ new
88
+ end
89
+
90
+ def self.from_hash(hash)
91
+ hash = hash.transform_keys(&:to_sym)
92
+ new(
93
+ input: hash[:input],
94
+ output: hash[:output],
95
+ cache_read: hash[:cache_read] || hash[:cached_input],
96
+ cache_write: hash[:cache_write] || hash[:cache_creation],
97
+ thinking: hash[:thinking] || hash[:reasoning] || hash[:thinking_output] || hash[:reasoning_output],
98
+ total: hash[:total]
99
+ )
100
+ end
101
+
102
+ def self.custom_cost(usage, model)
103
+ return unless TurnKit.cost_calculator
104
+
105
+ value = TurnKit.cost_calculator.call(usage, model)
106
+ case value
107
+ when nil
108
+ nil
109
+ when Cost
110
+ value
111
+ when Hash
112
+ from_hash(value)
113
+ else
114
+ new(total: value)
115
+ end
116
+ end
117
+
118
+ def self.amount(tokens, price)
119
+ return nil if tokens.to_i.positive? && price.nil?
120
+ return 0.0 if tokens.to_i.zero?
121
+
122
+ tokens.to_i * price.to_f / PER_MILLION
123
+ end
124
+
125
+ def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, thinking: nil, total: nil, strict: false)
126
+ @input = number(input)
127
+ @output = number(output)
128
+ @cache_read = number(cache_read)
129
+ @cache_write = number(cache_write)
130
+ @thinking = number(thinking)
131
+ @total = number(total)
132
+ @strict = strict
133
+ end
134
+
135
+ def total
136
+ return @total if @total
137
+ return nil if @strict && COMPONENTS.any? { |component| public_send(component).nil? }
138
+
139
+ values = COMPONENTS.filter_map { |component| public_send(component) }
140
+ values.empty? ? nil : values.sum
141
+ end
142
+
143
+ def to_h
144
+ {
145
+ "input" => input,
146
+ "output" => output,
147
+ "cache_read" => cache_read,
148
+ "cache_write" => cache_write,
149
+ "thinking" => thinking,
150
+ "total" => total
151
+ }.compact
152
+ end
153
+
154
+ private
155
+ def number(value)
156
+ value.nil? ? nil : value.to_f
157
+ end
158
+ end
159
+ end
@@ -68,11 +68,12 @@ module TurnKit
68
68
  end
69
69
  end
70
70
 
71
- def list_turns(root_turn_id: nil, conversation_id: nil)
71
+ def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
72
72
  @mutex.synchronize do
73
73
  rows = @turns.values
74
74
  rows = rows.select { |turn| turn["root_turn_id"] == root_turn_id } if root_turn_id
75
75
  rows = rows.select { |turn| turn["conversation_id"] == conversation_id } if conversation_id
76
+ rows = rows.select { |turn| turn["agent_name"] == agent_name } if agent_name
76
77
  rows.sort_by { |turn| [ turn["created_at"].to_f, turn["id"] ] }.map { |turn| duplicate(turn) }
77
78
  end
78
79
  end
data/lib/turnkit/store.rb CHANGED
@@ -12,7 +12,7 @@ module TurnKit
12
12
  def create_turn(_attributes) = raise(NotImplementedError)
13
13
  def load_turn(_id) = raise(NotImplementedError)
14
14
  def update_turn(_id, _attributes) = raise(NotImplementedError)
15
- def list_turns(root_turn_id: nil, conversation_id: nil) = raise(NotImplementedError)
15
+ def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
16
16
 
17
17
  def create_tool_execution(_attributes) = raise(NotImplementedError)
18
18
  def load_tool_execution(_id) = raise(NotImplementedError)
@@ -89,10 +89,11 @@ module TurnKit
89
89
  turn_hash(record)
90
90
  end
91
91
 
92
- def list_turns(root_turn_id: nil, conversation_id: nil)
92
+ def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
93
93
  scope = turn_class.all
94
94
  scope = scope.where(root_turn_uid: root_turn_id) if root_turn_id
95
95
  scope = scope.where(conversation_uid: conversation_id) if conversation_id
96
+ scope = scope.where(agent_name: agent_name) if agent_name
96
97
  scope.order(:created_at, :uid).map { |record| turn_hash(record) }
97
98
  end
98
99
 
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,11 +41,13 @@ 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
  )
47
+ result_cost = Cost.from_usage(result.usage, model: result.model || model)
45
48
 
46
- budget.add_usage!(result.usage)
47
- add_usage!(result.usage)
49
+ budget.add_cost!(result_cost.total)
50
+ add_usage!(result.usage, cost: result_cost)
48
51
  persist_assistant_message(result)
49
52
 
50
53
  if result.tool_calls?
@@ -79,12 +82,21 @@ module TurnKit
79
82
  @record["output_text"].to_s
80
83
  end
81
84
 
85
+ def usage
86
+ Usage.from_h(@record["usage"] || {})
87
+ end
88
+
89
+ def cost
90
+ Cost.from_record(@record)
91
+ end
92
+
82
93
  def tool_executions
83
94
  store.list_tool_executions(turn_id: id).map { |attrs| ToolExecution.new(attrs) }
84
95
  end
85
96
 
86
97
  def reload
87
98
  @record = store.load_turn(id)
99
+ @thinking = thinking_from_options
88
100
  self
89
101
  end
90
102
 
@@ -97,6 +109,13 @@ module TurnKit
97
109
  MessageProjection.for(conversation.messages_for_turn(self))
98
110
  end
99
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
+
100
119
  def persist_assistant_message(result)
101
120
  if result.tool_calls?
102
121
  conversation.append_message(
@@ -117,20 +136,28 @@ module TurnKit
117
136
  update!(status: "completed", output_text: message, completed_at: Clock.now)
118
137
  end
119
138
 
120
- def add_usage!(usage)
139
+ def add_usage!(usage, cost: nil)
121
140
  current = @record["usage"] || {}
122
141
  totals = {
123
142
  "input_tokens" => current["input_tokens"].to_i + usage.input_tokens,
124
143
  "output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
125
144
  "cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
126
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,
127
147
  "total_tokens" => current["total_tokens"].to_i + usage.total_tokens
128
148
  }
149
+ totals["cost_details"] = aggregate_cost(current["cost_details"], cost).to_h if cost&.total
129
150
  attributes = { usage: totals, heartbeat_at: Clock.now }
130
- attributes[:cost] = @record["cost"].to_f + usage.cost.to_f if usage.cost
151
+ attributes[:cost] = @record["cost"].to_f + cost.total if cost&.total
131
152
  update!(attributes)
132
153
  end
133
154
 
155
+ def aggregate_cost(current, cost)
156
+ return cost unless current
157
+
158
+ Cost.aggregate([ Cost.from_hash(current), cost ])
159
+ end
160
+
134
161
  def update!(attributes)
135
162
  @record = store.update_turn(id, attributes)
136
163
  @started_at = @record["started_at"]
data/lib/turnkit/usage.rb CHANGED
@@ -2,18 +2,50 @@
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
- def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost: nil)
7
+ def self.aggregate(usages)
8
+ usages = usages.compact
9
+ costs = usages.map(&:cost).compact
10
+ cost = costs.sum if costs.any?
11
+ new(
12
+ input_tokens: usages.sum(&:input_tokens),
13
+ output_tokens: usages.sum(&:output_tokens),
14
+ cached_tokens: usages.sum(&:cached_tokens),
15
+ cache_write_tokens: usages.sum(&:cache_write_tokens),
16
+ thinking_tokens: usages.sum(&:thinking_tokens),
17
+ cost: cost
18
+ )
19
+ end
20
+
21
+ def self.from_records(records)
22
+ aggregate(records.map { |record| from_h(record.fetch("usage", {})) })
23
+ end
24
+
25
+ def self.from_h(hash)
26
+ attrs = hash.transform_keys(&:to_s)
27
+ cost = attrs["cost"] unless attrs["cost"].is_a?(Hash)
28
+ new(
29
+ input_tokens: attrs["input_tokens"],
30
+ output_tokens: attrs["output_tokens"],
31
+ cached_tokens: attrs["cached_tokens"],
32
+ cache_write_tokens: attrs["cache_write_tokens"],
33
+ thinking_tokens: attrs["thinking_tokens"] || attrs["reasoning_tokens"],
34
+ cost: cost
35
+ )
36
+ end
37
+
38
+ def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, thinking_tokens: 0, cost: nil)
8
39
  @input_tokens = input_tokens.to_i
9
40
  @output_tokens = output_tokens.to_i
10
41
  @cached_tokens = cached_tokens.to_i
11
42
  @cache_write_tokens = cache_write_tokens.to_i
43
+ @thinking_tokens = thinking_tokens.to_i
12
44
  @cost = cost
13
45
  end
14
46
 
15
47
  def total_tokens
16
- input_tokens + output_tokens + cached_tokens + cache_write_tokens
48
+ input_tokens + output_tokens + cached_tokens + cache_write_tokens + thinking_tokens
17
49
  end
18
50
 
19
51
  def to_h
@@ -22,6 +54,7 @@ module TurnKit
22
54
  "output_tokens" => output_tokens,
23
55
  "cached_tokens" => cached_tokens,
24
56
  "cache_write_tokens" => cache_write_tokens,
57
+ "thinking_tokens" => thinking_tokens,
25
58
  "total_tokens" => total_tokens,
26
59
  "cost" => cost
27
60
  }.compact
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.5"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "turnkit/version"
10
10
  require_relative "turnkit/error"
11
11
  require_relative "turnkit/id"
12
12
  require_relative "turnkit/clock"
13
+ require_relative "turnkit/cost"
13
14
  require_relative "turnkit/budget"
14
15
  require_relative "turnkit/agent"
15
16
  require_relative "turnkit/client"
@@ -42,6 +43,7 @@ module TurnKit
42
43
  attr_accessor :default_model, :client, :store, :logger
43
44
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
44
45
  attr_accessor :cost_limit, :prompt_cache
46
+ attr_accessor :cost_rates, :cost_calculator
45
47
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
46
48
  attr_accessor :prompt_data_max_chars, :context_contributors
47
49
  attr_accessor :system_prompt_contributors, :model_prompt_contributors
@@ -57,6 +59,7 @@ module TurnKit
57
59
  self.max_depth = 3
58
60
  self.max_tool_executions = 100
59
61
  self.prompt_cache = :auto
62
+ self.cost_rates = {}
60
63
  self.prompt_sections = SystemPrompt::DEFAULT_SECTIONS.dup
61
64
  self.prompt_data_max_chars = 20_000
62
65
  self.available_skills = []
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.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
@@ -43,6 +43,7 @@ files:
43
43
  - lib/turnkit/client.rb
44
44
  - lib/turnkit/clock.rb
45
45
  - lib/turnkit/conversation.rb
46
+ - lib/turnkit/cost.rb
46
47
  - lib/turnkit/error.rb
47
48
  - lib/turnkit/generators/turnkit/install/templates/conversation.rb
48
49
  - lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb