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 +4 -4
- data/CHANGELOG.md +7 -2
- data/README.md +329 -26
- data/lib/turnkit/adapters/ruby_llm.rb +20 -2
- data/lib/turnkit/agent.rb +30 -2
- data/lib/turnkit/budget.rb +6 -2
- data/lib/turnkit/client.rb +1 -1
- data/lib/turnkit/conversation.rb +17 -4
- data/lib/turnkit/cost.rb +159 -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 +32 -5
- data/lib/turnkit/usage.rb +36 -3
- 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: 271ce272a71a97aa2991a580f36205e4cef8e19466e2e480b0ac6f0f0225d51f
|
|
4
|
+
data.tar.gz: b9a0503f499d3eb850e7eece6f508b6fbc206d6398263f6005520b7ef716493b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
## 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
```ruby
|
|
165
|
-
record = TurnKit.store.load_turn(turn.id)
|
|
166
|
-
record.fetch("usage")
|
|
167
|
-
```
|
|
382
|
+
### Custom clients
|
|
168
383
|
|
|
169
|
-
|
|
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
|
-
|
|
403
|
+
Use the client:
|
|
189
404
|
|
|
190
405
|
```ruby
|
|
191
|
-
|
|
406
|
+
TurnKit.client = MyClient.new
|
|
192
407
|
```
|
|
193
408
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Send `dynamic` as normal prompt content.
|
|
197
|
-
|
|
198
|
-
Use a custom client:
|
|
409
|
+
Split cache sections:
|
|
199
410
|
|
|
200
411
|
```ruby
|
|
201
|
-
|
|
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
|
-
|
|
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
|
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/client.rb
CHANGED
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -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" =>
|
|
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
|
data/lib/turnkit/cost.rb
ADDED
|
@@ -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
|
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
|
@@ -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.
|
|
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 +
|
|
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
|
|
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
|
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.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
|