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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +155 -128
- data/lib/turnkit/adapters/ruby_llm.rb +47 -4
- 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 +23 -4
- data/lib/turnkit/usage.rb +34 -3
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +5 -1
- metadata +3 -2
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
### Sub-agents
|
|
151
|
+
|
|
152
|
+
Create a sub-agent:
|
|
129
153
|
|
|
130
154
|
```ruby
|
|
131
|
-
|
|
132
|
-
"
|
|
133
|
-
description: "
|
|
155
|
+
writer = TurnKit::Agent.new(
|
|
156
|
+
name: "writer",
|
|
157
|
+
description: "Draft concise copy."
|
|
134
158
|
)
|
|
159
|
+
```
|
|
135
160
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
170
|
+
Ask the parent agent:
|
|
145
171
|
|
|
146
172
|
```ruby
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
turn = editor.conversation.ask("Ask the writer for three headlines.")
|
|
174
|
+
puts turn.output_text
|
|
149
175
|
```
|
|
150
176
|
|
|
151
|
-
|
|
177
|
+
### Usage and costs
|
|
178
|
+
|
|
179
|
+
Inspect token usage:
|
|
152
180
|
|
|
153
181
|
```ruby
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
187
|
+
Inspect costs:
|
|
162
188
|
|
|
163
189
|
```ruby
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
210
|
+
Override cost calculation:
|
|
178
211
|
|
|
179
212
|
```ruby
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
221
|
+
Limit turn cost:
|
|
199
222
|
|
|
200
223
|
```ruby
|
|
201
|
-
TurnKit::Agent.new(
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
agent = TurnKit::Agent.new(
|
|
225
|
+
name: "analyst",
|
|
226
|
+
cost_limit: 0.25
|
|
227
|
+
)
|
|
204
228
|
```
|
|
205
229
|
|
|
206
|
-
|
|
230
|
+
### Prompt caching
|
|
207
231
|
|
|
208
|
-
|
|
232
|
+
Enable prompt caching:
|
|
209
233
|
|
|
210
234
|
```ruby
|
|
211
|
-
TurnKit.
|
|
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
|
-
|
|
238
|
+
Disable prompt caching:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
TurnKit.prompt_cache = :off
|
|
242
|
+
```
|
|
221
243
|
|
|
222
|
-
|
|
244
|
+
Split custom prompts:
|
|
223
245
|
|
|
224
246
|
```ruby
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
257
|
+
### Custom clients
|
|
258
|
+
|
|
259
|
+
Create a client:
|
|
236
260
|
|
|
237
261
|
```ruby
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
278
|
+
Use the client:
|
|
244
279
|
|
|
245
280
|
```ruby
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
description: "Draft concise copy."
|
|
249
|
-
)
|
|
281
|
+
TurnKit.client = MyClient.new
|
|
282
|
+
```
|
|
250
283
|
|
|
251
|
-
|
|
252
|
-
name: "editor",
|
|
253
|
-
sub_agents: [writer]
|
|
254
|
-
)
|
|
284
|
+
Split cache sections:
|
|
255
285
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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.
|
|
316
|
-
| `client` | Set the model client.
|
|
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
|
-
| `
|
|
324
|
-
| `
|
|
325
|
-
| `
|
|
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
|
|
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
|
|
92
|
-
output_tokens: response
|
|
93
|
-
cached_tokens: response
|
|
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
|
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,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
|
-
|
|
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
|
|
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
|
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"
|
|
@@ -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.
|
|
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-
|
|
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
|