turnkit 0.2.3 → 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 +4 -4
- data/CHANGELOG.md +3 -2
- data/README.md +112 -23
- data/lib/turnkit/adapters/ruby_llm.rb +8 -1
- data/lib/turnkit/agent.rb +8 -0
- data/lib/turnkit/budget.rb +6 -2
- data/lib/turnkit/conversation.rb +8 -0
- data/lib/turnkit/cost.rb +154 -0
- data/lib/turnkit/memory_store.rb +2 -1
- data/lib/turnkit/store.rb +1 -1
- data/lib/turnkit/stores/active_record_store.rb +2 -1
- data/lib/turnkit/turn.rb +20 -4
- data/lib/turnkit/usage.rb +29 -0
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +3 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75121664c1e081304931fbf125db92a9abc8b9062f920c7e33f7759b52ce51ec
|
|
4
|
+
data.tar.gz: ccabe905d199d955d281c936a019995a3bd9bc29c0fc009160ea924de4605835
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff0fa50aabb4c4b4fd9ea6f3ae78b62a4b020522a083f96605028dca2f4ca50a4fb6a9b98b36070e070d38a36b205ebf343823b520f5b0e5b4fe7a06b643cdce
|
|
7
|
+
data.tar.gz: beec35d2fc1f51cc6fe674d12d72e0ec1b44722bdcfab28019e9ab2d2ae313c684125989647e6d5d389f80b2df5f98dd33aa3c154e0af7da0885d2b8bec0221c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
3
|
+
## 0.2.4 - 2026-06-06
|
|
4
4
|
|
|
5
5
|
- Add Anthropic prompt cache support for stable system prompt sections.
|
|
6
|
-
- Track cache write tokens and
|
|
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.
|
|
7
8
|
- Refresh README usage examples for prompt caching and usage tracking.
|
|
8
9
|
|
|
9
10
|
## 0.2.0 - 2026-06-04
|
data/README.md
CHANGED
|
@@ -37,14 +37,20 @@ agent = TurnKit::Agent.new(
|
|
|
37
37
|
name: "helper",
|
|
38
38
|
instructions: "Answer briefly."
|
|
39
39
|
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Ask a question:
|
|
40
43
|
|
|
44
|
+
```ruby
|
|
41
45
|
turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
|
|
42
46
|
puts turn.output_text
|
|
43
47
|
```
|
|
44
48
|
|
|
45
49
|
## Usage
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
### Models
|
|
52
|
+
|
|
53
|
+
Set the default model:
|
|
48
54
|
|
|
49
55
|
```ruby
|
|
50
56
|
TurnKit.default_model = "claude-sonnet-4-5"
|
|
@@ -56,10 +62,14 @@ Use OpenAI:
|
|
|
56
62
|
export OPENAI_API_KEY=...
|
|
57
63
|
```
|
|
58
64
|
|
|
65
|
+
Set an OpenAI model:
|
|
66
|
+
|
|
59
67
|
```ruby
|
|
60
68
|
TurnKit.default_model = "gpt-4.1-mini"
|
|
61
69
|
```
|
|
62
70
|
|
|
71
|
+
### Conversations
|
|
72
|
+
|
|
63
73
|
Create a conversation:
|
|
64
74
|
|
|
65
75
|
```ruby
|
|
@@ -67,14 +77,24 @@ agent = TurnKit::Agent.new(
|
|
|
67
77
|
name: "writer",
|
|
68
78
|
instructions: "Write clear release notes."
|
|
69
79
|
)
|
|
80
|
+
```
|
|
70
81
|
|
|
82
|
+
Add context:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
71
85
|
conversation = agent.conversation(subject: "v1 launch")
|
|
72
86
|
conversation.say("Mention faster tool execution.")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run the agent:
|
|
73
90
|
|
|
91
|
+
```ruby
|
|
74
92
|
turn = conversation.run!
|
|
75
93
|
puts turn.output_text
|
|
76
94
|
```
|
|
77
95
|
|
|
96
|
+
### Tools
|
|
97
|
+
|
|
78
98
|
Create a tool:
|
|
79
99
|
|
|
80
100
|
```ruby
|
|
@@ -93,7 +113,7 @@ class SaveReport < TurnKit::Tool
|
|
|
93
113
|
end
|
|
94
114
|
```
|
|
95
115
|
|
|
96
|
-
Use
|
|
116
|
+
Use the tool:
|
|
97
117
|
|
|
98
118
|
```ruby
|
|
99
119
|
agent = TurnKit::Agent.new(
|
|
@@ -101,40 +121,115 @@ agent = TurnKit::Agent.new(
|
|
|
101
121
|
instructions: "Save reports when asked.",
|
|
102
122
|
tools: [SaveReport]
|
|
103
123
|
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Ask for tool use:
|
|
104
127
|
|
|
128
|
+
```ruby
|
|
105
129
|
turn = agent.conversation.ask("Save a short status report.")
|
|
106
130
|
puts turn.output_text
|
|
107
131
|
```
|
|
108
132
|
|
|
109
|
-
|
|
133
|
+
### Skills
|
|
134
|
+
|
|
135
|
+
Load a skill:
|
|
110
136
|
|
|
111
137
|
```ruby
|
|
112
138
|
skill = TurnKit::Skill.from_file("skills/research.md")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Use the skill:
|
|
113
142
|
|
|
143
|
+
```ruby
|
|
114
144
|
agent = TurnKit::Agent.new(
|
|
115
145
|
name: "researcher",
|
|
116
146
|
skills: [skill]
|
|
117
147
|
)
|
|
118
148
|
```
|
|
119
149
|
|
|
120
|
-
|
|
150
|
+
### Sub-agents
|
|
151
|
+
|
|
152
|
+
Create a sub-agent:
|
|
121
153
|
|
|
122
154
|
```ruby
|
|
123
155
|
writer = TurnKit::Agent.new(
|
|
124
156
|
name: "writer",
|
|
125
157
|
description: "Draft concise copy."
|
|
126
158
|
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Delegate to it:
|
|
127
162
|
|
|
163
|
+
```ruby
|
|
128
164
|
editor = TurnKit::Agent.new(
|
|
129
165
|
name: "editor",
|
|
130
166
|
sub_agents: [writer]
|
|
131
167
|
)
|
|
168
|
+
```
|
|
132
169
|
|
|
170
|
+
Ask the parent agent:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
133
173
|
turn = editor.conversation.ask("Ask the writer for three headlines.")
|
|
134
174
|
puts turn.output_text
|
|
135
175
|
```
|
|
136
176
|
|
|
137
|
-
|
|
177
|
+
### Usage and costs
|
|
178
|
+
|
|
179
|
+
Inspect token usage:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
turn.usage.total_tokens
|
|
183
|
+
conversation.usage.total_tokens
|
|
184
|
+
agent.usage.total_tokens
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Inspect costs:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
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
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Override cost calculation:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
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
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Limit turn cost:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
agent = TurnKit::Agent.new(
|
|
225
|
+
name: "analyst",
|
|
226
|
+
cost_limit: 0.25
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Prompt caching
|
|
231
|
+
|
|
232
|
+
Enable prompt caching:
|
|
138
233
|
|
|
139
234
|
```ruby
|
|
140
235
|
TurnKit.prompt_cache = :auto
|
|
@@ -159,14 +254,9 @@ agent = TurnKit::Agent.new(
|
|
|
159
254
|
)
|
|
160
255
|
```
|
|
161
256
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
```ruby
|
|
165
|
-
record = TurnKit.store.load_turn(turn.id)
|
|
166
|
-
record.fetch("usage")
|
|
167
|
-
```
|
|
257
|
+
### Custom clients
|
|
168
258
|
|
|
169
|
-
|
|
259
|
+
Create a client:
|
|
170
260
|
|
|
171
261
|
```ruby
|
|
172
262
|
class MyClient < TurnKit::Client
|
|
@@ -185,22 +275,20 @@ class MyClient < TurnKit::Client
|
|
|
185
275
|
end
|
|
186
276
|
```
|
|
187
277
|
|
|
188
|
-
|
|
278
|
+
Use the client:
|
|
189
279
|
|
|
190
280
|
```ruby
|
|
191
|
-
|
|
281
|
+
TurnKit.client = MyClient.new
|
|
192
282
|
```
|
|
193
283
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Send `dynamic` as normal prompt content.
|
|
197
|
-
|
|
198
|
-
Use a custom client:
|
|
284
|
+
Split cache sections:
|
|
199
285
|
|
|
200
286
|
```ruby
|
|
201
|
-
|
|
287
|
+
stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
|
|
202
288
|
```
|
|
203
289
|
|
|
290
|
+
### Rails
|
|
291
|
+
|
|
204
292
|
Install Rails persistence:
|
|
205
293
|
|
|
206
294
|
```sh
|
|
@@ -217,7 +305,6 @@ Configure Rails:
|
|
|
217
305
|
|
|
218
306
|
```ruby
|
|
219
307
|
TurnKit.store = TurnKit::ActiveRecordStore.new
|
|
220
|
-
TurnKit.default_model = "claude-sonnet-4-5"
|
|
221
308
|
```
|
|
222
309
|
|
|
223
310
|
Reconcile stale turns:
|
|
@@ -237,6 +324,8 @@ TurnKit.timeout = 300
|
|
|
237
324
|
TurnKit.max_depth = 3
|
|
238
325
|
TurnKit.max_tool_executions = 100
|
|
239
326
|
TurnKit.cost_limit = nil
|
|
327
|
+
TurnKit.cost_rates = {}
|
|
328
|
+
TurnKit.cost_calculator = nil
|
|
240
329
|
TurnKit.prompt_cache = :auto
|
|
241
330
|
```
|
|
242
331
|
|
|
@@ -259,11 +348,11 @@ agent = TurnKit::Agent.new(
|
|
|
259
348
|
| `store` | Set the conversation store. |
|
|
260
349
|
| `max_iterations` | Limit model calls per turn. |
|
|
261
350
|
| `timeout` | Limit seconds per root turn. |
|
|
262
|
-
| `max_depth` | Limit sub-agent nesting. |
|
|
263
351
|
| `max_tool_executions` | Limit tool calls per root turn. |
|
|
264
352
|
| `cost_limit` | Limit cost per root turn. |
|
|
353
|
+
| `cost_rates` | Override prices by model. |
|
|
354
|
+
| `cost_calculator` | Override cost calculation. |
|
|
265
355
|
| `prompt_cache` | Use provider prompt caching. |
|
|
266
|
-
| `prompt_sections` | Set default prompt sections. |
|
|
267
356
|
|
|
268
357
|
## Contributing
|
|
269
358
|
|
|
@@ -122,7 +122,8 @@ module TurnKit
|
|
|
122
122
|
input_tokens: token_value(response, :input_tokens),
|
|
123
123
|
output_tokens: token_value(response, :output_tokens),
|
|
124
124
|
cached_tokens: token_value(response, :cached_tokens),
|
|
125
|
-
cache_write_tokens: token_value(response, :cache_creation_tokens)
|
|
125
|
+
cache_write_tokens: token_value(response, :cache_creation_tokens),
|
|
126
|
+
cost: response_cost(response)
|
|
126
127
|
)
|
|
127
128
|
Result.new(
|
|
128
129
|
text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
|
|
@@ -135,6 +136,12 @@ module TurnKit
|
|
|
135
136
|
def token_value(response, method)
|
|
136
137
|
response.respond_to?(method) ? response.public_send(method).to_i : 0
|
|
137
138
|
end
|
|
139
|
+
|
|
140
|
+
def response_cost(response)
|
|
141
|
+
return unless response.respond_to?(:cost)
|
|
142
|
+
|
|
143
|
+
response.cost&.total
|
|
144
|
+
end
|
|
138
145
|
end
|
|
139
146
|
end
|
|
140
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
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -32,10 +32,14 @@ module TurnKit
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def add_usage!(usage)
|
|
35
|
-
|
|
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 +=
|
|
42
|
+
@cost += cost.to_f
|
|
39
43
|
raise Error, "cost limit reached" if @cost > cost_limit
|
|
40
44
|
end
|
|
41
45
|
end
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -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
|
data/lib/turnkit/cost.rb
ADDED
|
@@ -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
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -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.
|
|
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,7 +126,7 @@ 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,
|
|
@@ -126,11 +135,18 @@ module TurnKit
|
|
|
126
135
|
"cache_write_tokens" => current["cache_write_tokens"].to_i + usage.cache_write_tokens,
|
|
127
136
|
"total_tokens" => current["total_tokens"].to_i + usage.total_tokens
|
|
128
137
|
}
|
|
138
|
+
totals["cost_details"] = aggregate_cost(current["cost_details"], cost).to_h if cost&.total
|
|
129
139
|
attributes = { usage: totals, heartbeat_at: Clock.now }
|
|
130
|
-
attributes[:cost] = @record["cost"].to_f +
|
|
140
|
+
attributes[:cost] = @record["cost"].to_f + cost.total if cost&.total
|
|
131
141
|
update!(attributes)
|
|
132
142
|
end
|
|
133
143
|
|
|
144
|
+
def aggregate_cost(current, cost)
|
|
145
|
+
return cost unless current
|
|
146
|
+
|
|
147
|
+
Cost.aggregate([ Cost.from_hash(current), cost ])
|
|
148
|
+
end
|
|
149
|
+
|
|
134
150
|
def update!(attributes)
|
|
135
151
|
@record = store.update_turn(id, attributes)
|
|
136
152
|
@started_at = @record["started_at"]
|
data/lib/turnkit/usage.rb
CHANGED
|
@@ -4,6 +4,35 @@ module TurnKit
|
|
|
4
4
|
class Usage
|
|
5
5
|
attr_reader :input_tokens, :output_tokens, :cached_tokens, :cache_write_tokens, :cost
|
|
6
6
|
|
|
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
|
+
|
|
7
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
|
data/lib/turnkit/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.4
|
|
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
|