turnkit 0.2.2 → 0.2.4

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: be681d2deacaf1e3be9de2eb84eef412a686baf90a8b0c0a41280cf6a76ecc55
4
- data.tar.gz: f0e6d232f50a67ce4a2cd5c46360549b7755a7b6ab100968bd9a2bf16f3cab0a
3
+ metadata.gz: 75121664c1e081304931fbf125db92a9abc8b9062f920c7e33f7759b52ce51ec
4
+ data.tar.gz: ccabe905d199d955d281c936a019995a3bd9bc29c0fc009160ea924de4605835
5
5
  SHA512:
6
- metadata.gz: dc9fbeca56bbdc7e737a56dcbb0caa87eb17186c035f052285532749e1e27546884d020c216c72f008950ad38053fc67dbd71e5cfd8d572f169029d4a78ba116
7
- data.tar.gz: ae8b955e099d1d81026ff34b3bae9e4a5009122e1e8cccaa64aed0675f888c0cb12fcae503781daa6ad710e8f9f56aec2387c1ce856cf66d6850430141b28dfe
6
+ metadata.gz: ff0fa50aabb4c4b4fd9ea6f3ae78b62a4b020522a083f96605028dca2f4ca50a4fb6a9b98b36070e070d38a36b205ebf343823b520f5b0e5b4fe7a06b643cdce
7
+ data.tar.gz: beec35d2fc1f51cc6fe674d12d72e0ec1b44722bdcfab28019e9ab2d2ae313c684125989647e6d5d389f80b2df5f98dd33aa3c154e0af7da0885d2b8bec0221c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4 - 2026-06-06
4
+
5
+ - Add Anthropic prompt cache support for stable system prompt sections.
6
+ - Track cache write tokens and expose model cost totals for turns, conversations, and agents.
7
+ - Calculate costs from RubyLLM model registry pricing with custom rate and calculator overrides.
8
+ - Refresh README usage examples for prompt caching and usage tracking.
9
+
3
10
  ## 0.2.0 - 2026-06-04
4
11
 
5
12
  - Add configurable system prompt sections and custom system prompt builders.
data/README.md CHANGED
@@ -26,33 +26,9 @@ Set a provider key:
26
26
 
27
27
  ```sh
28
28
  export ANTHROPIC_API_KEY=...
29
- # or OPENAI_API_KEY=..., GEMINI_API_KEY=..., OPENROUTER_API_KEY=...
30
29
  ```
31
30
 
32
- TurnKit uses RubyLLM by default. Choose the provider by choosing a RubyLLM model name:
33
-
34
- ```ruby
35
- TurnKit.default_model = "claude-sonnet-4-5" # Anthropic
36
- # TurnKit.default_model = "gpt-4.1-mini" # OpenAI
37
- # TurnKit.default_model = "gemini-2.5-flash" # Gemini
38
- ```
39
-
40
- You can also override the model per agent or per run.
41
-
42
- To use a different model SDK, provide a client object that responds to `chat`:
43
-
44
- ```ruby
45
- class MyClient < TurnKit::Client
46
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
47
- # Call your provider here.
48
- TurnKit::Result.new(text: "provider response", model: model)
49
- end
50
- end
51
-
52
- TurnKit.client = MyClient.new
53
- ```
54
-
55
- Ask an agent:
31
+ Create an agent:
56
32
 
57
33
  ```ruby
58
34
  require "turnkit"
@@ -61,13 +37,39 @@ agent = TurnKit::Agent.new(
61
37
  name: "helper",
62
38
  instructions: "Answer briefly."
63
39
  )
40
+ ```
41
+
42
+ Ask a question:
64
43
 
44
+ ```ruby
65
45
  turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
66
46
  puts turn.output_text
67
47
  ```
68
48
 
69
49
  ## Usage
70
50
 
51
+ ### Models
52
+
53
+ Set the default model:
54
+
55
+ ```ruby
56
+ TurnKit.default_model = "claude-sonnet-4-5"
57
+ ```
58
+
59
+ Use OpenAI:
60
+
61
+ ```sh
62
+ export OPENAI_API_KEY=...
63
+ ```
64
+
65
+ Set an OpenAI model:
66
+
67
+ ```ruby
68
+ TurnKit.default_model = "gpt-4.1-mini"
69
+ ```
70
+
71
+ ### Conversations
72
+
71
73
  Create a conversation:
72
74
 
73
75
  ```ruby
@@ -75,14 +77,24 @@ agent = TurnKit::Agent.new(
75
77
  name: "writer",
76
78
  instructions: "Write clear release notes."
77
79
  )
80
+ ```
78
81
 
82
+ Add context:
83
+
84
+ ```ruby
79
85
  conversation = agent.conversation(subject: "v1 launch")
80
86
  conversation.say("Mention faster tool execution.")
87
+ ```
88
+
89
+ Run the agent:
81
90
 
91
+ ```ruby
82
92
  turn = conversation.run!
83
93
  puts turn.output_text
84
94
  ```
85
95
 
96
+ ### Tools
97
+
86
98
  Create a tool:
87
99
 
88
100
  ```ruby
@@ -109,158 +121,183 @@ agent = TurnKit::Agent.new(
109
121
  instructions: "Save reports when asked.",
110
122
  tools: [SaveReport]
111
123
  )
124
+ ```
125
+
126
+ Ask for tool use:
112
127
 
128
+ ```ruby
113
129
  turn = agent.conversation.ask("Save a short status report.")
114
130
  puts turn.output_text
115
131
  ```
116
132
 
117
- Add skills:
133
+ ### Skills
134
+
135
+ Load a skill:
118
136
 
119
137
  ```ruby
120
138
  skill = TurnKit::Skill.from_file("skills/research.md")
139
+ ```
121
140
 
141
+ Use the skill:
142
+
143
+ ```ruby
122
144
  agent = TurnKit::Agent.new(
123
145
  name: "researcher",
124
146
  skills: [skill]
125
147
  )
126
148
  ```
127
149
 
128
- List available skills:
150
+ ### Sub-agents
151
+
152
+ Create a sub-agent:
129
153
 
130
154
  ```ruby
131
- research = TurnKit::Skill.from_file(
132
- "skills/research.md",
133
- description: "Use for source-backed research tasks."
155
+ writer = TurnKit::Agent.new(
156
+ name: "writer",
157
+ description: "Draft concise copy."
134
158
  )
159
+ ```
135
160
 
136
- agent = TurnKit::Agent.new(
137
- name: "researcher",
138
- instructions: "Prefer primary sources.",
139
- tools: [WebSearch, ReadWebPage],
140
- available_skills: [research]
161
+ Delegate to it:
162
+
163
+ ```ruby
164
+ editor = TurnKit::Agent.new(
165
+ name: "editor",
166
+ sub_agents: [writer]
141
167
  )
142
168
  ```
143
169
 
144
- Add subject context:
170
+ Ask the parent agent:
145
171
 
146
172
  ```ruby
147
- article = Article.find(1)
148
- conversation = agent.conversation(subject: article)
173
+ turn = editor.conversation.ask("Ask the writer for three headlines.")
174
+ puts turn.output_text
149
175
  ```
150
176
 
151
- Choose prompt sections:
177
+ ### Usage and costs
178
+
179
+ Inspect token usage:
152
180
 
153
181
  ```ruby
154
- agent = TurnKit::Agent.new(
155
- name: "writer",
156
- instructions: "Write plainly.",
157
- prompt_sections: %i[agent instructions tools environment]
158
- )
182
+ turn.usage.total_tokens
183
+ conversation.usage.total_tokens
184
+ agent.usage.total_tokens
159
185
  ```
160
186
 
161
- Build a custom prompt:
187
+ Inspect costs:
162
188
 
163
189
  ```ruby
164
- agent = TurnKit::Agent.new(
165
- name: "custom",
166
- instructions: "Answer in JSON.",
167
- system_prompt: ->(prompt) {
168
- [
169
- prompt.agent_section,
170
- prompt.instructions_section,
171
- "Return only valid JSON."
172
- ].compact.join("\n\n")
190
+ turn.cost.total
191
+ conversation.cost.total
192
+ agent.cost.total
193
+ ```
194
+
195
+ Use RubyLLM registry prices by default.
196
+
197
+ Override model rates:
198
+
199
+ ```ruby
200
+ TurnKit.cost_rates = {
201
+ "my-model" => {
202
+ input: 0.25,
203
+ output: 1.00,
204
+ cached_input: 0.05,
205
+ cache_creation: 0.25
173
206
  }
174
- )
207
+ }
175
208
  ```
176
209
 
177
- Use safe prompt data blocks for pipeline-specific prompts:
210
+ Override cost calculation:
178
211
 
179
212
  ```ruby
180
- agent = TurnKit::Agent.new(
181
- name: "researcher",
182
- system_prompt: ->(prompt) {
183
- [
184
- prompt.section(:agent),
185
- prompt.section(:behavior),
186
- prompt.untrusted_section(
187
- :retrieval_context,
188
- ExternalSearch.results_for("turnkit"),
189
- label: "Retrieved external evidence."
190
- ),
191
- prompt.section(:tools),
192
- prompt.section(:environment)
193
- ].compact.join("\n\n")
213
+ TurnKit.cost_calculator = ->(usage, model) do
214
+ {
215
+ input: usage.input_tokens * 0.25 / 1_000_000.0,
216
+ output: usage.output_tokens * 1.00 / 1_000_000.0
194
217
  }
195
- )
218
+ end
196
219
  ```
197
220
 
198
- Choose a prompt mode:
221
+ Limit turn cost:
199
222
 
200
223
  ```ruby
201
- TurnKit::Agent.new(name: "main", prompt_mode: :full) # default sections
202
- TurnKit::Agent.new(name: "worker", prompt_mode: :minimal) # agent, instructions, behavior, tools, environment
203
- TurnKit::Agent.new(name: "raw", prompt_mode: :none) # tiny TurnKit identity prompt
224
+ agent = TurnKit::Agent.new(
225
+ name: "analyst",
226
+ cost_limit: 0.25
227
+ )
204
228
  ```
205
229
 
206
- TurnKit automatically uses the minimal prompt mode for delegated sub-agent turns unless the child agent sets its own `prompt_mode`.
230
+ ### Prompt caching
207
231
 
208
- Inject live context on each turn:
232
+ Enable prompt caching:
209
233
 
210
234
  ```ruby
211
- TurnKit.context_contributors << ->(context) {
212
- TurnKit::LiveContextContribution.new(
213
- name: "account",
214
- content: AccountSummary.for(context.conversation.metadata["account_id"]),
215
- trusted: false
216
- )
217
- }
235
+ TurnKit.prompt_cache = :auto
218
236
  ```
219
237
 
220
- Live context and subject context are rendered below `TurnKit::SystemPrompt::CACHE_BOUNDARY`, so provider adapters can reuse the stable prefix in the future.
238
+ Disable prompt caching:
239
+
240
+ ```ruby
241
+ TurnKit.prompt_cache = :off
242
+ ```
221
243
 
222
- Add model-specific prompt guidance:
244
+ Split custom prompts:
223
245
 
224
246
  ```ruby
225
- TurnKit.model_prompt_contributors[/claude/] = ->(context) {
226
- TurnKit::PromptContribution.new(
227
- stable_prefix: "Provider guidance for #{context.model}.",
228
- section_overrides: {
229
- behavior: "Be concise, tool-aware, and explicit about uncertainty."
230
- }
231
- )
232
- }
247
+ agent = TurnKit::Agent.new(
248
+ name: "cached",
249
+ system_prompt: [
250
+ "Stable instructions and tool guidance.",
251
+ TurnKit::SystemPrompt::CACHE_BOUNDARY,
252
+ "Dynamic subject and live context."
253
+ ].join("\n")
254
+ )
233
255
  ```
234
256
 
235
- Inspect prompt shape without storing raw prompt text:
257
+ ### Custom clients
258
+
259
+ Create a client:
236
260
 
237
261
  ```ruby
238
- prompt = TurnKit::SystemPrompt.new(agent: agent, turn: turn, conversation: conversation)
239
- prompt.report
240
- # => { "chars" => ..., "hash" => ..., "stable_chars" => ..., "dynamic_chars" => ... }
262
+ class MyClient < TurnKit::Client
263
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
264
+ TurnKit::Result.new(
265
+ text: "provider response",
266
+ model: model,
267
+ usage: TurnKit::Usage.new(
268
+ input_tokens: 100,
269
+ output_tokens: 20,
270
+ cached_tokens: 80,
271
+ cache_write_tokens: 100
272
+ )
273
+ )
274
+ end
275
+ end
241
276
  ```
242
277
 
243
- Delegate to sub-agents:
278
+ Use the client:
244
279
 
245
280
  ```ruby
246
- writer = TurnKit::Agent.new(
247
- name: "writer",
248
- description: "Draft concise copy."
249
- )
281
+ TurnKit.client = MyClient.new
282
+ ```
250
283
 
251
- editor = TurnKit::Agent.new(
252
- name: "editor",
253
- sub_agents: [writer]
254
- )
284
+ Split cache sections:
255
285
 
256
- turn = editor.conversation.ask("Ask the writer for three headlines.")
257
- puts turn.output_text
286
+ ```ruby
287
+ stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
258
288
  ```
259
289
 
290
+ ### Rails
291
+
260
292
  Install Rails persistence:
261
293
 
262
294
  ```sh
263
295
  bin/rails generate turnkit:install
296
+ ```
297
+
298
+ Run migrations:
299
+
300
+ ```sh
264
301
  bin/rails db:migrate
265
302
  ```
266
303
 
@@ -268,8 +305,6 @@ Configure Rails:
268
305
 
269
306
  ```ruby
270
307
  TurnKit.store = TurnKit::ActiveRecordStore.new
271
- TurnKit.default_model = "claude-sonnet-4-5"
272
- TurnKit.timeout = 300
273
308
  ```
274
309
 
275
310
  Reconcile stale turns:
@@ -289,9 +324,12 @@ TurnKit.timeout = 300
289
324
  TurnKit.max_depth = 3
290
325
  TurnKit.max_tool_executions = 100
291
326
  TurnKit.cost_limit = nil
327
+ TurnKit.cost_rates = {}
328
+ TurnKit.cost_calculator = nil
329
+ TurnKit.prompt_cache = :auto
292
330
  ```
293
331
 
294
- Override defaults per agent:
332
+ Override an agent:
295
333
 
296
334
  ```ruby
297
335
  agent = TurnKit::Agent.new(
@@ -303,29 +341,18 @@ agent = TurnKit::Agent.new(
303
341
  )
304
342
  ```
305
343
 
306
- Override the model for a single conversation or turn:
307
-
308
- ```ruby
309
- conversation = agent.conversation(model: "claude-opus-4-1")
310
- turn = conversation.run!(model: "gpt-4.1-mini")
311
- ```
312
-
313
344
  | Option | Description |
314
345
  | --- | --- |
315
- | `default_model` | Set the default RubyLLM model. The model name determines the provider. |
316
- | `client` | Set the model client. Defaults to `TurnKit::Adapters::RubyLLM.new`. |
346
+ | `default_model` | Set the default RubyLLM model. |
347
+ | `client` | Set the model client. |
317
348
  | `store` | Set the conversation store. |
318
349
  | `max_iterations` | Limit model calls per turn. |
319
350
  | `timeout` | Limit seconds per root turn. |
320
- | `max_depth` | Limit sub-agent nesting. |
321
351
  | `max_tool_executions` | Limit tool calls per root turn. |
322
352
  | `cost_limit` | Limit cost per root turn. |
323
- | `prompt_sections` | Set default system prompt sections. |
324
- | `prompt_behavior` | Override the default behavior section text. |
325
- | `prompt_data_max_chars` | Limit data-block content rendered into prompts. |
326
- | `context_contributors` | Add live per-turn prompt context blocks. |
327
- | `system_prompt_contributors` | Add global prompt prefix/suffix/section overrides. |
328
- | `model_prompt_contributors` | Add model-matched prompt contributions. |
353
+ | `cost_rates` | Override prices by model. |
354
+ | `cost_calculator` | Override cost calculation. |
355
+ | `prompt_cache` | Use provider prompt caching. |
329
356
 
330
357
  ## Contributing
331
358
 
@@ -9,7 +9,7 @@ module TurnKit
9
9
  configure_from_environment
10
10
 
11
11
  chat = ::RubyLLM.chat(model: model)
12
- chat.with_instructions(instructions) if instructions && !instructions.empty?
12
+ add_instructions(chat, instructions, model: model)
13
13
  chat.with_temperature(temperature) if temperature
14
14
  Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
15
15
  Array(messages).each { |message| add_message(chat, message) }
@@ -55,6 +55,37 @@ module TurnKit
55
55
  )
56
56
  end
57
57
 
58
+ def add_instructions(chat, instructions, model:)
59
+ return if instructions.nil? || instructions.empty?
60
+
61
+ if prompt_cache_enabled? && anthropic_model?(model) && instructions.include?(SystemPrompt::CACHE_BOUNDARY)
62
+ stable, dynamic = SystemPrompt.split_cache_boundary(instructions)
63
+ add_system_message(chat, stable, cache: true)
64
+ add_system_message(chat, dynamic, cache: false)
65
+ else
66
+ chat.with_instructions(instructions)
67
+ end
68
+ end
69
+
70
+ def add_system_message(chat, content, cache: false)
71
+ content = content.to_s.strip
72
+ return if content.empty?
73
+
74
+ if cache
75
+ content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
76
+ end
77
+
78
+ chat.add_message(role: :system, content: content)
79
+ end
80
+
81
+ def prompt_cache_enabled?
82
+ TurnKit.prompt_cache != :off
83
+ end
84
+
85
+ def anthropic_model?(model)
86
+ model.to_s.start_with?("claude")
87
+ end
88
+
58
89
  def ruby_llm_tool_calls(tool_calls)
59
90
  return nil if tool_calls.nil? || tool_calls.empty?
60
91
 
@@ -88,9 +119,11 @@ module TurnKit
88
119
  ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
89
120
  end
90
121
  usage = Usage.new(
91
- input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : 0,
92
- output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : 0,
93
- cached_tokens: response.respond_to?(:cached_tokens) ? response.cached_tokens : 0
122
+ input_tokens: token_value(response, :input_tokens),
123
+ output_tokens: token_value(response, :output_tokens),
124
+ cached_tokens: token_value(response, :cached_tokens),
125
+ cache_write_tokens: token_value(response, :cache_creation_tokens),
126
+ cost: response_cost(response)
94
127
  )
95
128
  Result.new(
96
129
  text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
@@ -99,6 +132,16 @@ module TurnKit
99
132
  model: response.respond_to?(:model_id) ? response.model_id : model
100
133
  )
101
134
  end
135
+
136
+ def token_value(response, method)
137
+ response.respond_to?(method) ? response.public_send(method).to_i : 0
138
+ end
139
+
140
+ def response_cost(response)
141
+ return unless response.respond_to?(:cost)
142
+
143
+ response.cost&.total
144
+ end
102
145
  end
103
146
  end
104
147
  end
data/lib/turnkit/agent.rb CHANGED
@@ -41,6 +41,14 @@ module TurnKit
41
41
  Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
42
42
  end
43
43
 
44
+ def cost
45
+ Cost.from_records(effective_store.list_turns(agent_name: name))
46
+ end
47
+
48
+ def usage
49
+ Usage.from_records(effective_store.list_turns(agent_name: name))
50
+ end
51
+
44
52
  def effective_model
45
53
  model || TurnKit.default_model
46
54
  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
@@ -48,6 +48,14 @@ module TurnKit
48
48
  store.list_messages(id).map { |attrs| Message.new(attrs) }
49
49
  end
50
50
 
51
+ def usage
52
+ Usage.from_records(store.list_turns(conversation_id: id))
53
+ end
54
+
55
+ def cost
56
+ Cost.from_records(store.list_turns(conversation_id: id))
57
+ end
58
+
51
59
  def messages_for_turn(turn)
52
60
  store.list_messages(id, through_sequence: turn.context_message_sequence, turn_id: turn.id).map { |attrs| Message.new(attrs) }
53
61
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Cost
5
+ COMPONENTS = %i[input output cache_read cache_write].freeze
6
+ PER_MILLION = 1_000_000.0
7
+
8
+ attr_reader :input, :output, :cache_read, :cache_write
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
+ strict: true
59
+ )
60
+ end
61
+
62
+ def self.from_ruby_llm(usage, model)
63
+ require "ruby_llm"
64
+
65
+ model_info = ::RubyLLM.models.find(model) if model
66
+ return new unless model_info
67
+
68
+ if defined?(::RubyLLM::Cost)
69
+ tokens = ::RubyLLM::Tokens.new(
70
+ input: usage.input_tokens,
71
+ output: usage.output_tokens,
72
+ cached: usage.cached_tokens,
73
+ cache_creation: usage.cache_write_tokens
74
+ )
75
+ from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
76
+ else
77
+ from_rates(
78
+ usage,
79
+ input: model_info.input_price_per_million,
80
+ output: model_info.output_price_per_million,
81
+ cached_input: model_info.pricing&.text_tokens&.cached_input
82
+ )
83
+ end
84
+ rescue LoadError, StandardError
85
+ new
86
+ end
87
+
88
+ def self.from_hash(hash)
89
+ hash = hash.transform_keys(&:to_sym)
90
+ new(
91
+ input: hash[:input],
92
+ output: hash[:output],
93
+ cache_read: hash[:cache_read] || hash[:cached_input],
94
+ cache_write: hash[:cache_write] || hash[:cache_creation],
95
+ total: hash[:total]
96
+ )
97
+ end
98
+
99
+ def self.custom_cost(usage, model)
100
+ return unless TurnKit.cost_calculator
101
+
102
+ value = TurnKit.cost_calculator.call(usage, model)
103
+ case value
104
+ when nil
105
+ nil
106
+ when Cost
107
+ value
108
+ when Hash
109
+ from_hash(value)
110
+ else
111
+ new(total: value)
112
+ end
113
+ end
114
+
115
+ def self.amount(tokens, price)
116
+ return nil if tokens.to_i.positive? && price.nil?
117
+ return 0.0 if tokens.to_i.zero?
118
+
119
+ tokens.to_i * price.to_f / PER_MILLION
120
+ end
121
+
122
+ def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, total: nil, strict: false)
123
+ @input = number(input)
124
+ @output = number(output)
125
+ @cache_read = number(cache_read)
126
+ @cache_write = number(cache_write)
127
+ @total = number(total)
128
+ @strict = strict
129
+ end
130
+
131
+ def total
132
+ return @total if @total
133
+ return nil if @strict && COMPONENTS.any? { |component| public_send(component).nil? }
134
+
135
+ values = COMPONENTS.filter_map { |component| public_send(component) }
136
+ values.empty? ? nil : values.sum
137
+ end
138
+
139
+ def to_h
140
+ {
141
+ "input" => input,
142
+ "output" => output,
143
+ "cache_read" => cache_read,
144
+ "cache_write" => cache_write,
145
+ "total" => total
146
+ }.compact
147
+ end
148
+
149
+ private
150
+ def number(value)
151
+ value.nil? ? nil : value.to_f
152
+ end
153
+ end
154
+ 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
@@ -42,9 +42,10 @@ module TurnKit
42
42
  instructions: agent.system_prompt_for(turn: self, conversation: conversation),
43
43
  metadata: { turn_id: id, conversation_id: conversation.id }
44
44
  )
45
+ result_cost = Cost.from_usage(result.usage, model: result.model || model)
45
46
 
46
- budget.add_usage!(result.usage)
47
- add_usage!(result.usage)
47
+ budget.add_cost!(result_cost.total)
48
+ add_usage!(result.usage, cost: result_cost)
48
49
  persist_assistant_message(result)
49
50
 
50
51
  if result.tool_calls?
@@ -79,6 +80,14 @@ module TurnKit
79
80
  @record["output_text"].to_s
80
81
  end
81
82
 
83
+ def usage
84
+ Usage.from_h(@record["usage"] || {})
85
+ end
86
+
87
+ def cost
88
+ Cost.from_record(@record)
89
+ end
90
+
82
91
  def tool_executions
83
92
  store.list_tool_executions(turn_id: id).map { |attrs| ToolExecution.new(attrs) }
84
93
  end
@@ -117,15 +126,25 @@ module TurnKit
117
126
  update!(status: "completed", output_text: message, completed_at: Clock.now)
118
127
  end
119
128
 
120
- def add_usage!(usage)
129
+ def add_usage!(usage, cost: nil)
121
130
  current = @record["usage"] || {}
122
131
  totals = {
123
132
  "input_tokens" => current["input_tokens"].to_i + usage.input_tokens,
124
133
  "output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
125
134
  "cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
135
+ "cache_write_tokens" => current["cache_write_tokens"].to_i + usage.cache_write_tokens,
126
136
  "total_tokens" => current["total_tokens"].to_i + usage.total_tokens
127
137
  }
128
- update!(usage: totals, heartbeat_at: Clock.now)
138
+ totals["cost_details"] = aggregate_cost(current["cost_details"], cost).to_h if cost&.total
139
+ attributes = { usage: totals, heartbeat_at: Clock.now }
140
+ attributes[:cost] = @record["cost"].to_f + cost.total if cost&.total
141
+ update!(attributes)
142
+ end
143
+
144
+ def aggregate_cost(current, cost)
145
+ return cost unless current
146
+
147
+ Cost.aggregate([ Cost.from_hash(current), cost ])
129
148
  end
130
149
 
131
150
  def update!(attributes)
data/lib/turnkit/usage.rb CHANGED
@@ -2,17 +2,47 @@
2
2
 
3
3
  module TurnKit
4
4
  class Usage
5
- attr_reader :input_tokens, :output_tokens, :cached_tokens, :cost
5
+ attr_reader :input_tokens, :output_tokens, :cached_tokens, :cache_write_tokens, :cost
6
6
 
7
- def initialize(input_tokens: 0, output_tokens: 0, cached_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
+ cost: cost
17
+ )
18
+ end
19
+
20
+ def self.from_records(records)
21
+ aggregate(records.map { |record| from_h(record.fetch("usage", {})) })
22
+ end
23
+
24
+ def self.from_h(hash)
25
+ attrs = hash.transform_keys(&:to_s)
26
+ cost = attrs["cost"] unless attrs["cost"].is_a?(Hash)
27
+ new(
28
+ input_tokens: attrs["input_tokens"],
29
+ output_tokens: attrs["output_tokens"],
30
+ cached_tokens: attrs["cached_tokens"],
31
+ cache_write_tokens: attrs["cache_write_tokens"],
32
+ cost: cost
33
+ )
34
+ end
35
+
36
+ def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost: nil)
8
37
  @input_tokens = input_tokens.to_i
9
38
  @output_tokens = output_tokens.to_i
10
39
  @cached_tokens = cached_tokens.to_i
40
+ @cache_write_tokens = cache_write_tokens.to_i
11
41
  @cost = cost
12
42
  end
13
43
 
14
44
  def total_tokens
15
- input_tokens + output_tokens + cached_tokens
45
+ input_tokens + output_tokens + cached_tokens + cache_write_tokens
16
46
  end
17
47
 
18
48
  def to_h
@@ -20,6 +50,7 @@ module TurnKit
20
50
  "input_tokens" => input_tokens,
21
51
  "output_tokens" => output_tokens,
22
52
  "cached_tokens" => cached_tokens,
53
+ "cache_write_tokens" => cache_write_tokens,
23
54
  "total_tokens" => total_tokens,
24
55
  "cost" => cost
25
56
  }.compact
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
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"
@@ -41,7 +42,8 @@ module TurnKit
41
42
  class << self
42
43
  attr_accessor :default_model, :client, :store, :logger
43
44
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
44
- attr_accessor :cost_limit
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
@@ -56,6 +58,8 @@ module TurnKit
56
58
  self.timeout = 300
57
59
  self.max_depth = 3
58
60
  self.max_tool_executions = 100
61
+ self.prompt_cache = :auto
62
+ self.cost_rates = {}
59
63
  self.prompt_sections = SystemPrompt::DEFAULT_SECTIONS.dup
60
64
  self.prompt_data_max_chars = 20_000
61
65
  self.available_skills = []
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -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