turnkit 0.2.4 → 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 +4 -0
- data/README.md +217 -3
- data/lib/turnkit/adapters/ruby_llm.rb +12 -1
- data/lib/turnkit/agent.rb +22 -2
- data/lib/turnkit/client.rb +1 -1
- data/lib/turnkit/conversation.rb +9 -4
- data/lib/turnkit/cost.rb +9 -4
- data/lib/turnkit/turn.rb +12 -1
- data/lib/turnkit/usage.rb +7 -3
- data/lib/turnkit/version.rb +1 -1
- metadata +1 -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
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
|
|
@@ -68,6 +77,52 @@ Set an OpenAI model:
|
|
|
68
77
|
TurnKit.default_model = "gpt-4.1-mini"
|
|
69
78
|
```
|
|
70
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
|
+
|
|
71
126
|
### Conversations
|
|
72
127
|
|
|
73
128
|
Create a conversation:
|
|
@@ -130,6 +185,76 @@ turn = agent.conversation.ask("Save a short status report.")
|
|
|
130
185
|
puts turn.output_text
|
|
131
186
|
```
|
|
132
187
|
|
|
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
|
+
|
|
133
258
|
### Skills
|
|
134
259
|
|
|
135
260
|
Load a skill:
|
|
@@ -260,7 +385,7 @@ Create a client:
|
|
|
260
385
|
|
|
261
386
|
```ruby
|
|
262
387
|
class MyClient < TurnKit::Client
|
|
263
|
-
def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
|
|
388
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
|
|
264
389
|
TurnKit::Result.new(
|
|
265
390
|
text: "provider response",
|
|
266
391
|
model: model,
|
|
@@ -295,6 +420,17 @@ Install Rails persistence:
|
|
|
295
420
|
bin/rails generate turnkit:install
|
|
296
421
|
```
|
|
297
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
|
+
|
|
298
434
|
Run migrations:
|
|
299
435
|
|
|
300
436
|
```sh
|
|
@@ -307,12 +443,88 @@ Configure Rails:
|
|
|
307
443
|
TurnKit.store = TurnKit::ActiveRecordStore.new
|
|
308
444
|
```
|
|
309
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/
|
|
464
|
+
```
|
|
465
|
+
|
|
310
466
|
Reconcile stale turns:
|
|
311
467
|
|
|
312
468
|
```ruby
|
|
313
469
|
TurnKit.reconcile_stale!
|
|
314
470
|
```
|
|
315
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
|
+
|
|
316
528
|
## Options
|
|
317
529
|
|
|
318
530
|
Configure defaults:
|
|
@@ -337,7 +549,8 @@ agent = TurnKit::Agent.new(
|
|
|
337
549
|
model: "gpt-4.1-mini",
|
|
338
550
|
max_iterations: 10,
|
|
339
551
|
timeout: 60,
|
|
340
|
-
cost_limit: 0.25
|
|
552
|
+
cost_limit: 0.25,
|
|
553
|
+
thinking: { effort: :low }
|
|
341
554
|
)
|
|
342
555
|
```
|
|
343
556
|
|
|
@@ -350,6 +563,7 @@ agent = TurnKit::Agent.new(
|
|
|
350
563
|
| `timeout` | Limit seconds per root turn. |
|
|
351
564
|
| `max_tool_executions` | Limit tool calls per root turn. |
|
|
352
565
|
| `cost_limit` | Limit cost per root turn. |
|
|
566
|
+
| `thinking` | Configure provider reasoning or extended thinking per agent. |
|
|
353
567
|
| `cost_rates` | Override prices by model. |
|
|
354
568
|
| `cost_calculator` | Override cost calculation. |
|
|
355
569
|
| `prompt_cache` | Use provider prompt caching. |
|
|
@@ -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(
|
|
@@ -123,6 +129,7 @@ module TurnKit
|
|
|
123
129
|
output_tokens: token_value(response, :output_tokens),
|
|
124
130
|
cached_tokens: token_value(response, :cached_tokens),
|
|
125
131
|
cache_write_tokens: token_value(response, :cache_creation_tokens),
|
|
132
|
+
thinking_tokens: thinking_token_value(response),
|
|
126
133
|
cost: response_cost(response)
|
|
127
134
|
)
|
|
128
135
|
Result.new(
|
|
@@ -137,6 +144,10 @@ module TurnKit
|
|
|
137
144
|
response.respond_to?(method) ? response.public_send(method).to_i : 0
|
|
138
145
|
end
|
|
139
146
|
|
|
147
|
+
def thinking_token_value(response)
|
|
148
|
+
token_value(response, :thinking_tokens).nonzero? || token_value(response, :reasoning_tokens)
|
|
149
|
+
end
|
|
150
|
+
|
|
140
151
|
def response_cost(response)
|
|
141
152
|
return unless response.respond_to?(:cost)
|
|
142
153
|
|
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(
|
|
@@ -53,6 +69,10 @@ module TurnKit
|
|
|
53
69
|
model || TurnKit.default_model
|
|
54
70
|
end
|
|
55
71
|
|
|
72
|
+
def effective_thinking
|
|
73
|
+
thinking
|
|
74
|
+
end
|
|
75
|
+
|
|
56
76
|
def effective_client
|
|
57
77
|
client || TurnKit.client
|
|
58
78
|
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
|
data/lib/turnkit/cost.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Cost
|
|
5
|
-
COMPONENTS = %i[input output cache_read cache_write].freeze
|
|
5
|
+
COMPONENTS = %i[input output cache_read cache_write thinking].freeze
|
|
6
6
|
PER_MILLION = 1_000_000.0
|
|
7
7
|
|
|
8
|
-
attr_reader :input, :output, :cache_read, :cache_write
|
|
8
|
+
attr_reader :input, :output, :cache_read, :cache_write, :thinking
|
|
9
9
|
|
|
10
10
|
def self.aggregate(costs)
|
|
11
11
|
costs = costs.compact
|
|
@@ -55,6 +55,7 @@ module TurnKit
|
|
|
55
55
|
output: amount(usage.output_tokens, rates[:output] || rates[:output_per_million]),
|
|
56
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
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]),
|
|
58
59
|
strict: true
|
|
59
60
|
)
|
|
60
61
|
end
|
|
@@ -70,7 +71,8 @@ module TurnKit
|
|
|
70
71
|
input: usage.input_tokens,
|
|
71
72
|
output: usage.output_tokens,
|
|
72
73
|
cached: usage.cached_tokens,
|
|
73
|
-
cache_creation: usage.cache_write_tokens
|
|
74
|
+
cache_creation: usage.cache_write_tokens,
|
|
75
|
+
thinking: usage.thinking_tokens
|
|
74
76
|
)
|
|
75
77
|
from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
|
|
76
78
|
else
|
|
@@ -92,6 +94,7 @@ module TurnKit
|
|
|
92
94
|
output: hash[:output],
|
|
93
95
|
cache_read: hash[:cache_read] || hash[:cached_input],
|
|
94
96
|
cache_write: hash[:cache_write] || hash[:cache_creation],
|
|
97
|
+
thinking: hash[:thinking] || hash[:reasoning] || hash[:thinking_output] || hash[:reasoning_output],
|
|
95
98
|
total: hash[:total]
|
|
96
99
|
)
|
|
97
100
|
end
|
|
@@ -119,11 +122,12 @@ module TurnKit
|
|
|
119
122
|
tokens.to_i * price.to_f / PER_MILLION
|
|
120
123
|
end
|
|
121
124
|
|
|
122
|
-
def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, total: nil, strict: false)
|
|
125
|
+
def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, thinking: nil, total: nil, strict: false)
|
|
123
126
|
@input = number(input)
|
|
124
127
|
@output = number(output)
|
|
125
128
|
@cache_read = number(cache_read)
|
|
126
129
|
@cache_write = number(cache_write)
|
|
130
|
+
@thinking = number(thinking)
|
|
127
131
|
@total = number(total)
|
|
128
132
|
@strict = strict
|
|
129
133
|
end
|
|
@@ -142,6 +146,7 @@ module TurnKit
|
|
|
142
146
|
"output" => output,
|
|
143
147
|
"cache_read" => cache_read,
|
|
144
148
|
"cache_write" => cache_write,
|
|
149
|
+
"thinking" => thinking,
|
|
145
150
|
"total" => total
|
|
146
151
|
}.compact
|
|
147
152
|
end
|
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,6 +41,7 @@ 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
|
)
|
|
45
47
|
result_cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
@@ -94,6 +96,7 @@ module TurnKit
|
|
|
94
96
|
|
|
95
97
|
def reload
|
|
96
98
|
@record = store.load_turn(id)
|
|
99
|
+
@thinking = thinking_from_options
|
|
97
100
|
self
|
|
98
101
|
end
|
|
99
102
|
|
|
@@ -106,6 +109,13 @@ module TurnKit
|
|
|
106
109
|
MessageProjection.for(conversation.messages_for_turn(self))
|
|
107
110
|
end
|
|
108
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
|
+
|
|
109
119
|
def persist_assistant_message(result)
|
|
110
120
|
if result.tool_calls?
|
|
111
121
|
conversation.append_message(
|
|
@@ -133,6 +143,7 @@ module TurnKit
|
|
|
133
143
|
"output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
|
|
134
144
|
"cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
|
|
135
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,
|
|
136
147
|
"total_tokens" => current["total_tokens"].to_i + usage.total_tokens
|
|
137
148
|
}
|
|
138
149
|
totals["cost_details"] = aggregate_cost(current["cost_details"], cost).to_h if cost&.total
|
data/lib/turnkit/usage.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
7
|
def self.aggregate(usages)
|
|
8
8
|
usages = usages.compact
|
|
@@ -13,6 +13,7 @@ module TurnKit
|
|
|
13
13
|
output_tokens: usages.sum(&:output_tokens),
|
|
14
14
|
cached_tokens: usages.sum(&:cached_tokens),
|
|
15
15
|
cache_write_tokens: usages.sum(&:cache_write_tokens),
|
|
16
|
+
thinking_tokens: usages.sum(&:thinking_tokens),
|
|
16
17
|
cost: cost
|
|
17
18
|
)
|
|
18
19
|
end
|
|
@@ -29,20 +30,22 @@ module TurnKit
|
|
|
29
30
|
output_tokens: attrs["output_tokens"],
|
|
30
31
|
cached_tokens: attrs["cached_tokens"],
|
|
31
32
|
cache_write_tokens: attrs["cache_write_tokens"],
|
|
33
|
+
thinking_tokens: attrs["thinking_tokens"] || attrs["reasoning_tokens"],
|
|
32
34
|
cost: cost
|
|
33
35
|
)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost: nil)
|
|
38
|
+
def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, thinking_tokens: 0, cost: nil)
|
|
37
39
|
@input_tokens = input_tokens.to_i
|
|
38
40
|
@output_tokens = output_tokens.to_i
|
|
39
41
|
@cached_tokens = cached_tokens.to_i
|
|
40
42
|
@cache_write_tokens = cache_write_tokens.to_i
|
|
43
|
+
@thinking_tokens = thinking_tokens.to_i
|
|
41
44
|
@cost = cost
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def total_tokens
|
|
45
|
-
input_tokens + output_tokens + cached_tokens + cache_write_tokens
|
|
48
|
+
input_tokens + output_tokens + cached_tokens + cache_write_tokens + thinking_tokens
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def to_h
|
|
@@ -51,6 +54,7 @@ module TurnKit
|
|
|
51
54
|
"output_tokens" => output_tokens,
|
|
52
55
|
"cached_tokens" => cached_tokens,
|
|
53
56
|
"cache_write_tokens" => cache_write_tokens,
|
|
57
|
+
"thinking_tokens" => thinking_tokens,
|
|
54
58
|
"total_tokens" => total_tokens,
|
|
55
59
|
"cost" => cost
|
|
56
60
|
}.compact
|
data/lib/turnkit/version.rb
CHANGED