anima-core 1.3.0 → 1.5.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/.reek.yml +23 -26
- data/README.md +118 -104
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +16 -5
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +123 -165
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -200
- data/lib/mneme/passive_recall.rb +0 -69
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Surfaces a past message into Aoide's next turn as a `from_mneme`
|
|
6
|
+
# phantom tool pair. Called by Mneme's recall loop when a search hit
|
|
7
|
+
# or a viewed message clears her relevance bar.
|
|
8
|
+
#
|
|
9
|
+
# The persisted {PendingMessage} carries the original +message_id+ in
|
|
10
|
+
# its +source_name+ (and through promotion ends up inside
|
|
11
|
+
# +tool_input.message_id+ of the phantom pair), so the same memory
|
|
12
|
+
# isn't re-surfaced on later cycles — Mneme::Search already excludes
|
|
13
|
+
# Aoide's viewport, and once a recall promotes it lives there.
|
|
14
|
+
#
|
|
15
|
+
# The muse explains +why+ she's surfacing this memory. The reason is
|
|
16
|
+
# logged but not shown to Aoide — keeping the surfaced content itself
|
|
17
|
+
# clean of meta-commentary.
|
|
18
|
+
class SurfaceMemory < ::Tools::Base
|
|
19
|
+
def self.tool_name = "surface_memory"
|
|
20
|
+
|
|
21
|
+
def self.description = "Surface a memory into Aoide's next turn. Use when a specific past message is genuinely useful for what she's working on now. Pass the message_id and a short reason — one sentence explaining why she needs this *now*."
|
|
22
|
+
|
|
23
|
+
def self.input_schema
|
|
24
|
+
{
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
message_id: {type: "integer"},
|
|
28
|
+
why: {type: "string", description: "One-sentence justification — kept for logs, not shown to Aoide."}
|
|
29
|
+
},
|
|
30
|
+
required: %w[message_id why]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param main_session [Session] the session receiving the recall
|
|
35
|
+
def initialize(main_session:, **)
|
|
36
|
+
@main_session = main_session
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def execute(input)
|
|
40
|
+
message_id = input["message_id"].to_i
|
|
41
|
+
why = input["why"].to_s.strip
|
|
42
|
+
|
|
43
|
+
message = Message.find_by(id: message_id)
|
|
44
|
+
return {error: "Message #{message_id} not found"} unless message
|
|
45
|
+
return {error: "Reason cannot be blank"} if why.empty?
|
|
46
|
+
|
|
47
|
+
content = render_snippet(message)
|
|
48
|
+
|
|
49
|
+
@main_session.pending_messages.create!(
|
|
50
|
+
content: content,
|
|
51
|
+
source_type: "recall",
|
|
52
|
+
source_name: message_id.to_s,
|
|
53
|
+
message_type: "from_mneme"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Mneme.logger.info("session=#{@main_session.id} — surfaced message #{message_id}: #{why}")
|
|
57
|
+
|
|
58
|
+
"Surfaced message #{message_id}."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Formats the message as the text Aoide will read when the phantom
|
|
64
|
+
# pair promotes. Headed with origin metadata, bounded by the recall
|
|
65
|
+
# snippet-token budget so long messages don't blow out her viewport.
|
|
66
|
+
#
|
|
67
|
+
# @param message [Message]
|
|
68
|
+
# @return [String]
|
|
69
|
+
def render_snippet(message)
|
|
70
|
+
origin = message.session&.name.presence || "session ##{message.session_id}"
|
|
71
|
+
raw = extract_content(message)
|
|
72
|
+
max_chars = Anima::Settings.recall_max_snippet_tokens * TokenEstimation::BYTES_PER_TOKEN
|
|
73
|
+
"message #{message.id} (#{origin}): #{raw.truncate(max_chars)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_content(message)
|
|
77
|
+
payload = message.payload
|
|
78
|
+
case message.message_type
|
|
79
|
+
when "user_message", "agent_message", "system_message"
|
|
80
|
+
payload["content"].to_s
|
|
81
|
+
when "tool_call"
|
|
82
|
+
payload.dig("tool_input", "thoughts").to_s
|
|
83
|
+
else
|
|
84
|
+
payload["content"].to_s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/mneme.rb
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Mneme — the memory
|
|
4
|
-
# summaries before context is lost.
|
|
3
|
+
# Mneme — the muse of memory. Watches for viewport eviction and creates
|
|
4
|
+
# summaries before context is lost. One of the Three Muses: she remembers
|
|
5
|
+
# while Melete prepares and Aoide performs.
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# main session, creates snapshots, but leaves no trace of its own reasoning.
|
|
7
|
+
# Operates as a phantom LLM loop: observes the main session, creates
|
|
8
|
+
# snapshots, but leaves no trace of her own reasoning.
|
|
9
9
|
module Mneme
|
|
10
|
+
# Estimated token overhead for a synthetic +tool_use+/+tool_result+
|
|
11
|
+
# pair — the wrapper JSON that phantom promotions emit around their
|
|
12
|
+
# content (tool name, input hash, ids, framing). Added to the content's
|
|
13
|
+
# token estimate when sizing phantom pairs in the viewport.
|
|
14
|
+
TOOL_PAIR_OVERHEAD_TOKENS = 50
|
|
15
|
+
|
|
10
16
|
# Dev-only logger that writes to log/mneme.log.
|
|
11
17
|
# In non-development environments returns a null logger so
|
|
12
18
|
# call sites don't need conditionals.
|
data/lib/providers/anthropic.rb
CHANGED
|
@@ -17,6 +17,34 @@ module Providers
|
|
|
17
17
|
# subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
|
|
18
18
|
OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
19
19
|
|
|
20
|
+
# Rate limit header names for extraction
|
|
21
|
+
RATE_LIMIT_HEADERS = {
|
|
22
|
+
"5h_status" => "Anthropic-Ratelimit-Unified-5h-Status",
|
|
23
|
+
"5h_reset" => "Anthropic-Ratelimit-Unified-5h-Reset",
|
|
24
|
+
"5h_utilization" => "Anthropic-Ratelimit-Unified-5h-Utilization",
|
|
25
|
+
"7d_status" => "Anthropic-Ratelimit-Unified-7d-Status",
|
|
26
|
+
"7d_reset" => "Anthropic-Ratelimit-Unified-7d-Reset",
|
|
27
|
+
"7d_utilization" => "Anthropic-Ratelimit-Unified-7d-Utilization"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Response wrapper containing both the parsed body and API metrics.
|
|
31
|
+
# Behaves like a Hash for backward compatibility (delegates to body).
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] body
|
|
34
|
+
# @return [Hash] parsed API response
|
|
35
|
+
# @!attribute [r] api_metrics
|
|
36
|
+
# @return [Hash, nil] rate limits and usage data
|
|
37
|
+
ApiResponse = Data.define(:body, :api_metrics) do
|
|
38
|
+
# Delegate Hash methods to body for backward compatibility.
|
|
39
|
+
# Callers using response["content"] continue to work unchanged.
|
|
40
|
+
def [](key) = body[key]
|
|
41
|
+
def dig(...) = body.dig(...)
|
|
42
|
+
def fetch(...) = body.fetch(...)
|
|
43
|
+
def key?(key) = body.key?(key)
|
|
44
|
+
def to_h = body
|
|
45
|
+
def to_json(...) = body.to_json(...)
|
|
46
|
+
end
|
|
47
|
+
|
|
20
48
|
class Error < StandardError; end
|
|
21
49
|
class AuthenticationError < Error; end
|
|
22
50
|
class TokenFormatError < Error; end
|
|
@@ -76,13 +104,17 @@ module Providers
|
|
|
76
104
|
# @param model [String] Anthropic model identifier
|
|
77
105
|
# @param messages [Array<Hash>] conversation messages
|
|
78
106
|
# @param max_tokens [Integer] maximum tokens in the response
|
|
107
|
+
# @param include_metrics [Boolean] when true, returns an {ApiResponse}
|
|
108
|
+
# wrapper with both body and api_metrics; when false (default),
|
|
109
|
+
# returns just the parsed body Hash for backward compatibility
|
|
79
110
|
# @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
|
|
80
|
-
# @return [Hash] parsed API response
|
|
111
|
+
# @return [Hash, ApiResponse] parsed API response, or wrapper with metrics
|
|
81
112
|
# @raise [TransientError] on network failures or server errors (retryable)
|
|
82
113
|
# @raise [AuthenticationError] on 401/403 (permanent)
|
|
83
114
|
# @raise [Error] on other API errors
|
|
84
|
-
def create_message(model:, messages:, max_tokens:, **options)
|
|
115
|
+
def create_message(model:, messages:, max_tokens:, include_metrics: false, **options)
|
|
85
116
|
wrap_system_prompt!(options)
|
|
117
|
+
annotate_last_message_for_caching!(messages)
|
|
86
118
|
body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
|
|
87
119
|
|
|
88
120
|
response = self.class.post(
|
|
@@ -92,7 +124,7 @@ module Providers
|
|
|
92
124
|
timeout: Anima::Settings.api_timeout
|
|
93
125
|
)
|
|
94
126
|
|
|
95
|
-
handle_response(response)
|
|
127
|
+
handle_response(response, include_metrics: include_metrics)
|
|
96
128
|
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
|
|
97
129
|
raise TransientError, "#{network_error.class}: #{network_error.message}"
|
|
98
130
|
end
|
|
@@ -106,7 +138,6 @@ module Providers
|
|
|
106
138
|
# @return [Integer] estimated input token count
|
|
107
139
|
# @raise [Error] on API errors
|
|
108
140
|
def count_tokens(model:, messages:, **options)
|
|
109
|
-
wrap_system_prompt!(options)
|
|
110
141
|
body = {model: model, messages: messages}.merge(options)
|
|
111
142
|
|
|
112
143
|
response = self.class.post(
|
|
@@ -159,16 +190,56 @@ module Providers
|
|
|
159
190
|
# Wraps the system parameter in the array-of-blocks format required by
|
|
160
191
|
# Anthropic for OAuth tokens. The passphrase block is always present;
|
|
161
192
|
# the caller's prompt (if any) is appended as the second block.
|
|
193
|
+
# The last block is annotated with +cache_control+ so the API caches
|
|
194
|
+
# the entire system prefix (tools are evaluated before system).
|
|
162
195
|
#
|
|
163
196
|
# @param options [Hash] mutable options hash (modified in place)
|
|
164
197
|
# @return [void]
|
|
165
198
|
def wrap_system_prompt!(options)
|
|
166
199
|
prompt = options[:system]
|
|
167
200
|
blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
|
|
168
|
-
blocks << {type: "text", text: prompt
|
|
201
|
+
blocks << {type: "text", text: prompt, cache_control: {type: "ephemeral"}}
|
|
169
202
|
options[:system] = blocks
|
|
170
203
|
end
|
|
171
204
|
|
|
205
|
+
# Annotates the last message's last content block with +cache_control+
|
|
206
|
+
# so every subsequent API call in a tool-use loop hits the prefix cache.
|
|
207
|
+
# String content is normalized to array-of-blocks format since bare
|
|
208
|
+
# strings cannot carry +cache_control+ metadata.
|
|
209
|
+
#
|
|
210
|
+
# Clears stale breakpoints from earlier messages to stay within the
|
|
211
|
+
# Anthropic 4-breakpoint limit (tools + system consume 2).
|
|
212
|
+
#
|
|
213
|
+
# @param messages [Array<Hash>] mutable messages array (modified in place)
|
|
214
|
+
# @return [void]
|
|
215
|
+
def annotate_last_message_for_caching!(messages)
|
|
216
|
+
return if messages.empty?
|
|
217
|
+
|
|
218
|
+
clear_stale_cache_breakpoints!(messages[0...-1])
|
|
219
|
+
|
|
220
|
+
last_msg = messages.last
|
|
221
|
+
content = last_msg[:content]
|
|
222
|
+
|
|
223
|
+
case content
|
|
224
|
+
when String
|
|
225
|
+
last_msg[:content] = [{type: "text", text: content, cache_control: {type: "ephemeral"}}]
|
|
226
|
+
when Array
|
|
227
|
+
last_block = content.last
|
|
228
|
+
last_block[:cache_control] = {type: "ephemeral"} if last_block
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Removes +cache_control+ from content blocks in the given messages.
|
|
233
|
+
# Called before re-annotating the last message to stay within the
|
|
234
|
+
# Anthropic 4-breakpoint limit across tool-loop rounds.
|
|
235
|
+
def clear_stale_cache_breakpoints!(messages)
|
|
236
|
+
messages.each do |msg|
|
|
237
|
+
content = msg[:content]
|
|
238
|
+
next unless content.is_a?(Array)
|
|
239
|
+
content.each { |block| block.delete(:cache_control) if block.is_a?(Hash) }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
172
243
|
def request_headers
|
|
173
244
|
{
|
|
174
245
|
"Authorization" => "Bearer #{token}",
|
|
@@ -178,10 +249,13 @@ module Providers
|
|
|
178
249
|
}
|
|
179
250
|
end
|
|
180
251
|
|
|
181
|
-
def handle_response(response)
|
|
252
|
+
def handle_response(response, include_metrics: false)
|
|
182
253
|
case response.code
|
|
183
254
|
when 200
|
|
184
|
-
response.parsed_response
|
|
255
|
+
body = response.parsed_response
|
|
256
|
+
return body unless include_metrics
|
|
257
|
+
|
|
258
|
+
ApiResponse.new(body: body, api_metrics: extract_api_metrics(response))
|
|
185
259
|
when 400
|
|
186
260
|
raise Error, "Bad request: #{error_message(response)}"
|
|
187
261
|
when 401
|
|
@@ -199,6 +273,37 @@ module Providers
|
|
|
199
273
|
end
|
|
200
274
|
end
|
|
201
275
|
|
|
276
|
+
# Extracts rate limit headers and usage data from an HTTParty response.
|
|
277
|
+
#
|
|
278
|
+
# @param response [HTTParty::Response] raw API response
|
|
279
|
+
# @return [Hash] with "rate_limits" and "usage" string keys
|
|
280
|
+
def extract_api_metrics(response)
|
|
281
|
+
{
|
|
282
|
+
"rate_limits" => extract_rate_limits(response.headers),
|
|
283
|
+
"usage" => response.parsed_response&.dig("usage")
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Extracts rate limit values from response headers.
|
|
288
|
+
#
|
|
289
|
+
# @param headers [Hash] HTTParty headers (case-insensitive)
|
|
290
|
+
# @return [Hash] normalized rate limit data
|
|
291
|
+
def extract_rate_limits(headers)
|
|
292
|
+
return {} unless headers
|
|
293
|
+
|
|
294
|
+
RATE_LIMIT_HEADERS.transform_values do |header_name|
|
|
295
|
+
# HTTParty headers are strings; VCR replays them as arrays
|
|
296
|
+
raw = headers[header_name]
|
|
297
|
+
value = raw.is_a?(Array) ? raw.first : raw
|
|
298
|
+
# Parse numeric values (utilization, reset timestamps)
|
|
299
|
+
case value
|
|
300
|
+
when /\A\d+\z/ then value.to_i
|
|
301
|
+
when /\A\d+\.\d+\z/ then value.to_f
|
|
302
|
+
else value
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
202
307
|
def error_message(response)
|
|
203
308
|
response.parsed_response&.dig("error", "message") || response.message
|
|
204
309
|
rescue JSON::ParserError, NoMethodError
|