samagotchi 0.64.0 → 0.66.0
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 +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- metadata +17 -1
|
@@ -287,10 +287,13 @@ module Samagotchi
|
|
|
287
287
|
sentences: RecapPrompt::DEFAULT_SENTENCES,
|
|
288
288
|
client: nil,
|
|
289
289
|
store: nil,
|
|
290
|
+
session_id: -> {},
|
|
290
291
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
291
292
|
raise ArgumentError, "IdleRecap requires an engine" unless engine
|
|
292
293
|
|
|
293
294
|
@engine = engine
|
|
295
|
+
# The recapped session's id, sent with the request (IdleClient#summarize).
|
|
296
|
+
@session_id = session_id
|
|
294
297
|
@target = target || lambda {
|
|
295
298
|
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
296
299
|
}
|
|
@@ -534,7 +537,7 @@ module Samagotchi
|
|
|
534
537
|
def client_for(asked)
|
|
535
538
|
return @client_override if @client_override
|
|
536
539
|
|
|
537
|
-
key =
|
|
540
|
+
key = asked
|
|
538
541
|
unless @client && @client_key == key
|
|
539
542
|
@client = IdleClient.for(asked, timeout: @timeout)
|
|
540
543
|
@client_key = key
|
|
@@ -544,7 +547,7 @@ module Samagotchi
|
|
|
544
547
|
|
|
545
548
|
def spawn_summarize(client, prompt)
|
|
546
549
|
Thread.new do
|
|
547
|
-
client.summarize(prompt)
|
|
550
|
+
client.summarize(prompt, session_id: @session_id.call)
|
|
548
551
|
rescue StandardError => e
|
|
549
552
|
# No recap this time (the next idle window tries again); say why.
|
|
550
553
|
Log.exception(:recap, "summarize_failed", e)
|
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# Where a side request goes (a recap, a plugin's ctx.ask_model, a
|
|
5
|
-
# broadcast's triage): a model on a host's
|
|
5
|
+
# broadcast's triage): a model on a host's chat API, asked through its
|
|
6
6
|
# own IdleClient (IdleClient.for), apart from the session's turns.
|
|
7
|
-
# @!attribute base_url [String] the host's
|
|
7
|
+
# @!attribute base_url [String] the host's API base (HostEntry#api_base_url)
|
|
8
8
|
# @!attribute api_key_env [String, nil] the variable holding its key
|
|
9
9
|
# @!attribute model [String] the model id sent to the server
|
|
10
10
|
# @!attribute label [String] the model as configured, for logs
|
|
11
|
-
|
|
11
|
+
# @!attribute api [Symbol] the wire the side client talks (LLM::Adapters):
|
|
12
|
+
# a chat host's api, else :openai (a raw host's server, or a bare url)
|
|
13
|
+
# @!attribute auth [Symbol, nil] the header its key goes in (the host's
|
|
14
|
+
# auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
|
|
15
|
+
# @!attribute dialect [String, nil] an api: anthropic host's dialect:
|
|
16
|
+
# (LLM::Dialect), so a side request gets its auth and fields
|
|
17
|
+
IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
|
|
18
|
+
def initialize(api: :openai, auth: nil, dialect: nil, **) = super
|
|
19
|
+
|
|
20
|
+
# The api a side request to +entry+ talks: a chat host's own, else the
|
|
21
|
+
# OpenAI API (a raw host's llama.cpp serves /v1 too).
|
|
22
|
+
def self.api_of(entry) = entry.chat? ? entry.api : :openai
|
|
23
|
+
|
|
24
|
+
# The dialect a side request to +entry+ talks (only a chat host has one).
|
|
25
|
+
def self.dialect_of(entry) = entry.chat? ? entry.dialect : nil
|
|
26
|
+
|
|
12
27
|
# A configured side model can't be resolved.
|
|
13
28
|
# @!attribute reason [Symbol] :host_ref_unknown (no such host),
|
|
14
29
|
# :model_host_mismatch (the model names another host than host_ref)
|
|
@@ -32,8 +47,9 @@ module Samagotchi
|
|
|
32
47
|
# @return [IdleTarget]
|
|
33
48
|
def self.of_model(host_registry, name)
|
|
34
49
|
resolved = host_registry.resolve(name)
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
entry = resolved.entry
|
|
51
|
+
new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
|
|
52
|
+
label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
# A side model's settings (recap.*, broadcast.triage_*) as a target:
|
|
@@ -55,12 +71,18 @@ module Samagotchi
|
|
|
55
71
|
|
|
56
72
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
57
73
|
api_key_env = nil
|
|
74
|
+
api = :openai
|
|
75
|
+
auth = nil
|
|
76
|
+
dialect = nil
|
|
58
77
|
if host_ref
|
|
59
78
|
entry = host_registry.find_entry(host_ref)
|
|
60
79
|
raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
|
|
61
80
|
|
|
62
|
-
base_url = entry.
|
|
81
|
+
base_url = entry.api_base_url
|
|
63
82
|
api_key_env = entry.api_key_env
|
|
83
|
+
api = api_of(entry)
|
|
84
|
+
auth = entry.auth
|
|
85
|
+
dialect = dialect_of(entry)
|
|
64
86
|
named = parsed&.host_conflict || parsed&.host_name
|
|
65
87
|
if named && named != entry.name
|
|
66
88
|
raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
|
|
@@ -72,7 +94,8 @@ module Samagotchi
|
|
|
72
94
|
end
|
|
73
95
|
raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
|
|
74
96
|
|
|
75
|
-
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip
|
|
97
|
+
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
|
|
98
|
+
dialect: dialect)
|
|
76
99
|
end
|
|
77
100
|
|
|
78
101
|
# Whether the settings fix a host (+base_url+, +host_ref+, or a model
|
|
@@ -1067,9 +1067,17 @@ module Samagotchi
|
|
|
1067
1067
|
# The error an unknown tool name gets back. A shell-like name with
|
|
1068
1068
|
# `execute` offered this turn points at `execute`; every other unknown
|
|
1069
1069
|
# name lists what is offered. Neither echoes the wrong name.
|
|
1070
|
+
#
|
|
1071
|
+
# A tool the session's toolkit leaves out is a real one, so its error
|
|
1072
|
+
# does name it (deliberately, against the rule above): the model learns
|
|
1073
|
+
# it exists and that only the user can load it.
|
|
1070
1074
|
def unknown_tool_error(name, layers)
|
|
1071
1075
|
names = @tools.names(layers: layers)
|
|
1072
|
-
if
|
|
1076
|
+
if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
|
|
1077
|
+
toolkit = @tools.toolkit&.name
|
|
1078
|
+
"Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
|
|
1079
|
+
"here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
|
|
1080
|
+
elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
|
|
1073
1081
|
"Error: no such tool. Shell commands run with `execute` (same arguments)."
|
|
1074
1082
|
else
|
|
1075
1083
|
"Error: no such tool. Available: #{names.join(", ")}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "anthropic_messages"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# Which chat adapter talks a host's api: the one place that names the
|
|
9
|
+
# adapter classes. A host whose api is a key of BY_API runs the chat loop
|
|
10
|
+
# (HostRegistry::HostEntry#chat?); any other api (nil, raw) the
|
|
11
|
+
# raw-prompt loop.
|
|
12
|
+
module Adapters
|
|
13
|
+
BY_API = { openai: OpenAIChat, anthropic: AnthropicMessages }.freeze
|
|
14
|
+
|
|
15
|
+
# true when +api+ (a Symbol or String) has a chat adapter.
|
|
16
|
+
def self.chat_api?(api) = !api.nil? && BY_API.key?(api.to_sym)
|
|
17
|
+
|
|
18
|
+
# A host entry's adapter (its api, API base, name, key and remoteness).
|
|
19
|
+
# @param entry [HostRegistry::HostEntry]
|
|
20
|
+
# @param options [Hash] more adapter options (models_ttl:, first_token_timeout:, ...)
|
|
21
|
+
# @raise [ArgumentError] the entry's api has no chat adapter
|
|
22
|
+
def self.for(entry, **)
|
|
23
|
+
adapter_class(entry.api).for(entry, **)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# An adapter by api, for a side request that has a url rather than a
|
|
27
|
+
# host entry (IdleClient: a recap, ctx.ask_model, a broadcast's triage).
|
|
28
|
+
# @param api [Symbol, String] a key of BY_API
|
|
29
|
+
# @param options [Hash] more adapter options (stream:, retries:, timeout:, ...)
|
|
30
|
+
# @raise [ArgumentError] +api+ has no chat adapter
|
|
31
|
+
def self.build(api:, base_url:, host_name:, api_key_env: nil, **)
|
|
32
|
+
adapter_class(api).new(base_url: base_url, host_name: host_name, api_key_env: api_key_env, **)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.adapter_class(api)
|
|
36
|
+
BY_API.fetch(api&.to_sym) { raise ArgumentError, "no chat adapter for api #{api.inspect}" }
|
|
37
|
+
end
|
|
38
|
+
private_class_method :adapter_class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "usage"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# A Messages response put together as ChatResponse, from a whole body
|
|
9
|
+
# (.from_message) or a stream's events (#add, by content-block index):
|
|
10
|
+
# the content blocks
|
|
11
|
+
# in order (kept as received, string keys: ChatResponse#provider_content,
|
|
12
|
+
# which ChatLoop saves and sends back), the text and reasoning they
|
|
13
|
+
# carry, the tool calls, the usage and the stop reason in the chat
|
|
14
|
+
# loop's terms.
|
|
15
|
+
#
|
|
16
|
+
# Usage: Messages counts input_tokens without the cache, so the prompt
|
|
17
|
+
# is input + cache reads + cache writes, in the OpenAI shape the rest of
|
|
18
|
+
# chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
|
|
19
|
+
# cache_write_tokens). Every field is optional (Splash sends no cache
|
|
20
|
+
# writes), and a later value wins per field.
|
|
21
|
+
class AnthropicAssembly
|
|
22
|
+
# stop_reason → ChatResponse#finish_reason (the chat loop's words);
|
|
23
|
+
# pause_turn and anything else pass as they are.
|
|
24
|
+
FINISH_REASONS = {
|
|
25
|
+
"end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
|
|
26
|
+
"model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
|
|
27
|
+
}.freeze
|
|
28
|
+
USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
|
|
29
|
+
# Blocks whose text is the answer or the reasoning.
|
|
30
|
+
THINKING = "thinking"
|
|
31
|
+
TEXT = "text"
|
|
32
|
+
|
|
33
|
+
def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
|
|
34
|
+
|
|
35
|
+
# A non-streamed body (a `message` object).
|
|
36
|
+
def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@blocks = []
|
|
40
|
+
# A streamed block by its index, and a tool_use's input JSON so far.
|
|
41
|
+
@by_index = {}
|
|
42
|
+
@json = {}
|
|
43
|
+
@usage = {}
|
|
44
|
+
@stop_reason = nil
|
|
45
|
+
@stop_details = nil
|
|
46
|
+
@model = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A whole message: its model, usage, stop reason and blocks.
|
|
50
|
+
def add_message(message)
|
|
51
|
+
return unless message.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
@model = served(message["model"]) || @model
|
|
54
|
+
merge_usage(message["usage"])
|
|
55
|
+
@stop_reason = message["stop_reason"] if message["stop_reason"]
|
|
56
|
+
@stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
|
|
57
|
+
Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One streamed event: message_start (served model, usage),
|
|
61
|
+
# content_block_start (text / thinking / redacted_thinking / tool_use),
|
|
62
|
+
# content_block_delta (text_delta, thinking_delta, signature_delta,
|
|
63
|
+
# input_json_delta), content_block_stop, message_delta (stop reason,
|
|
64
|
+
# stop_details, usage); ping, message_stop and anything else change nothing.
|
|
65
|
+
# @return [Array(String, String)] the event's text and reasoning
|
|
66
|
+
def add(event)
|
|
67
|
+
case event["type"]
|
|
68
|
+
when "message_start" then start_message(event["message"])
|
|
69
|
+
when "content_block_start" then start_block(event["index"], event["content_block"])
|
|
70
|
+
when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
|
|
71
|
+
when "message_delta"
|
|
72
|
+
if event["delta"].is_a?(Hash)
|
|
73
|
+
@stop_reason = event.dig("delta", "stop_reason") || @stop_reason
|
|
74
|
+
@stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
|
|
75
|
+
end
|
|
76
|
+
merge_usage(event["usage"])
|
|
77
|
+
["", ""]
|
|
78
|
+
else ["", ""]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a streamed +event+ shows something (a block started, a delta):
|
|
83
|
+
# what the first-token limit waits for.
|
|
84
|
+
def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
|
|
85
|
+
|
|
86
|
+
# Whether a streamed +event+ carries part of a tool call.
|
|
87
|
+
def self.tool_call_delta?(event)
|
|
88
|
+
case event["type"]
|
|
89
|
+
when "content_block_start" then event.dig("content_block", "type") == "tool_use"
|
|
90
|
+
when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
|
|
91
|
+
else false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# +event+ as on_delta passes it on: one that carries usage gets the
|
|
96
|
+
# usage so far in the OpenAI shape as its `usage` (SessionMetrics,
|
|
97
|
+
# ContextStatus and ctx.js read that).
|
|
98
|
+
def with_usage(event)
|
|
99
|
+
return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
|
|
100
|
+
|
|
101
|
+
event.merge("usage" => payload)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The usage so far as an OpenAI-shaped `usage` object, nil when the
|
|
105
|
+
# server sent no counts, or only zeros (Fireworks' message_start: its
|
|
106
|
+
# counts come in the last message_delta; a 0 read as an empty context
|
|
107
|
+
# in the web's ctx meter while the answer streamed).
|
|
108
|
+
def usage_payload
|
|
109
|
+
return nil if @usage.values.none?(&:positive?)
|
|
110
|
+
|
|
111
|
+
read = @usage["cache_read_input_tokens"].to_i
|
|
112
|
+
write = @usage["cache_creation_input_tokens"].to_i
|
|
113
|
+
{ "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
|
|
114
|
+
"prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def response
|
|
118
|
+
blocks = content
|
|
119
|
+
ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
|
|
120
|
+
tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
|
|
121
|
+
model: @model, provider_content: blocks, stop_details: @stop_details)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def usage
|
|
127
|
+
payload = usage_payload
|
|
128
|
+
(payload && Usage.from_payload({ "usage" => payload })) || Usage.none
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def start_message(message)
|
|
132
|
+
return ["", ""] unless message.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
@model = served(message["model"]) || @model
|
|
135
|
+
merge_usage(message["usage"])
|
|
136
|
+
["", ""]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def start_block(index, block)
|
|
140
|
+
return ["", ""] unless block.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
block = block.dup
|
|
143
|
+
@blocks << block
|
|
144
|
+
@by_index[index] = block
|
|
145
|
+
case block["type"]
|
|
146
|
+
when TEXT then [block["text"].to_s, ""]
|
|
147
|
+
when THINKING then ["", block["thinking"].to_s]
|
|
148
|
+
else ["", ""]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_delta(block, index, delta)
|
|
153
|
+
return ["", ""] unless block && delta.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
case delta["type"]
|
|
156
|
+
when "text_delta"
|
|
157
|
+
block["text"] = block["text"].to_s + delta["text"].to_s
|
|
158
|
+
[delta["text"].to_s, ""]
|
|
159
|
+
when "thinking_delta"
|
|
160
|
+
block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
|
|
161
|
+
["", delta["thinking"].to_s]
|
|
162
|
+
when "signature_delta"
|
|
163
|
+
block["signature"] = block["signature"].to_s + delta["signature"].to_s
|
|
164
|
+
["", ""]
|
|
165
|
+
when "input_json_delta"
|
|
166
|
+
(@json[index] ||= +"") << delta["partial_json"].to_s
|
|
167
|
+
block["input"] = @json[index]
|
|
168
|
+
["", ""]
|
|
169
|
+
else ["", ""]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def merge_usage(usage)
|
|
174
|
+
return unless usage.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The blocks to keep and send back: an empty or blank text block is
|
|
180
|
+
# no text (Splash opens one after a thinking cut by max_tokens, and
|
|
181
|
+
# Messages refuses one sent back), a tool_use's input is an object,
|
|
182
|
+
# and a thinking block has a signature, "" when it came without one
|
|
183
|
+
# (a sent-back thinking block needs the key; Splash sends "", a
|
|
184
|
+
# provider may send none).
|
|
185
|
+
def content
|
|
186
|
+
@blocks.filter_map do |block|
|
|
187
|
+
type = block["type"].to_s
|
|
188
|
+
next if type == TEXT && block["text"].to_s.strip.empty?
|
|
189
|
+
next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
|
|
190
|
+
next block unless type == "tool_use"
|
|
191
|
+
|
|
192
|
+
input = OpenAIChat.parse_arguments(block["input"])
|
|
193
|
+
block.merge("input" => input.is_a?(Hash) ? input : {})
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def joined(blocks, type, key)
|
|
198
|
+
blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The calls with their arguments as OpenAIChat.parse_arguments reads
|
|
202
|
+
# them: invalid JSON stays a String, so the tool reports it.
|
|
203
|
+
def tool_calls
|
|
204
|
+
@blocks.select { |block| block["type"] == "tool_use" }.map do |block|
|
|
205
|
+
ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|