Repository navigation
feat: carry InvokeModel server-side context features (W1-01) - #32
Conversation
…ssages bodies)
Opt-in third Bedrock protocol that POSTs Anthropic Messages bodies to
/model/{id}/invoke and /invoke-with-response-stream (SigV4-signed),
reusing the Anthropic protocol's payload assembly and parsing. Converse
stays the default; protocol_for never selects it.
- Extract Converse::EventStream (eventstream POST/decoding/exception
mapping) and share it between ConverseStream and InvokeModel streaming
- finalize_invoke_payload is the single envelope hook (anthropic_version,
de-duplicated anthropic_beta) for later beta/context_management work
- Thinking/redacted_thinking blocks captured verbatim and replayed on
either endpoint (Converse <-> Messages shape translation both ways)
- Bedrock#complete lifts additionalModelRequestFields to top level for
invoke requests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sam Boland <sam.boland@appfolio.com>
Replies that hold a block the fork does not model (compaction, server_tool_use, tool_search_tool_result, or any unknown type) keep their full content-block list as InvokeAnthropic::ContentBlocks, a Content::Raw that replays block-for-block and whose #text is the reply's text. Sync and streaming both rebuild the blocks; the streaming parser follows the recorded event formats. Message#provider_data exposes context_management, usage.iterations and input_transformations as plain hashes. Streamed input tokens now come from message_delta, the count after compaction and server-tool iterations. Two InvokeModel-only params: deferred_tools (names rendered with defer_loading: true) and server_tools (definitions sent as given, e.g. the regex tool-search tool). Converse rendering is unchanged, except that it now raises UnsupportedContentError for a block it cannot carry. Adds live VCR examples for clear_tool_uses, clear_thinking, compaction, tool search, thinking-binding controls and Converse fallback on Opus 5.5 and Sonnet 5.5, plus WebMock examples for the parsing and rendering paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sam Boland <sam.boland@appfolio.com>
|
🤖 Supernova Code Review — View trace — via automation "Code Review Opus 5.5 Medium" (run), installed by Sam Boland (sam.boland@appfolio.com) |
There was a problem hiding this comment.
Adversarial review of the :invoke_anthropic server-side context features. I left two inline findings:
invoke_anthropic/chat.rb: aContent::Rawholding a single Hash block gets turned into an array of key/value pairs byArray(...). Converse handles the same input, so a chat that works there breaks after switching protocols. The same pattern is at lines 86, 175, 237, 245 and 282.providers/bedrock.rb: InvokeModel param normalization only runs incomplete, soProvider#renderreturns a different body than the one actually sent.
I also checked these areas and found nothing actionable: the EventStream refactor (Converse error/status mapping is unchanged), how streamed blocks are rebuilt into ContentBlocks, server_tool_use JSON deltas not leaking into the accumulator's tool calls (they're keyed by index), merging provider_data across stream chunks, the Converse UnsupportedContentError guard, and translating thinking blocks in both directions.
— via automation "Code Review Opus 5.5 Medium" (run), installed by Sam Boland (sam.boland@appfolio.com)
|
|
||
| def format_message_content(msg) | ||
| if msg.content.is_a?(RubyLLM::Content::Raw) | ||
| blocks = Array(msg.content.value) |
There was a problem hiding this comment.
Array(msg.content.value) breaks a Content::Raw that holds a single Hash block. Array({ type: 'text', text: 'hi' }) returns [[:type, "text"], [:text, "hi"]], so InvokeModel gets an array of pairs and returns a 400. Converse handles the same input correctly (format_raw_content uses value.is_a?(Array) ? value : [value]). That means a chat whose Raw message works on Converse fails once it switches to :invoke_anthropic, and the PR is built around switching protocols. The same Array(...value) pattern shows up at lines 86 (system), 237/245 (tool results) and 282 (format_content). Fix: add one helper and use it in all five places:
def raw_blocks(raw)
value = raw.value
value.is_a?(Array) ? value : [value]
endThere was a problem hiding this comment.
Fixed in 6301383. InvokeAnthropic::Chat has a private raw_blocks(value) helper: it returns value for an Array, [] for nil, and [value] otherwise, which is the same rule as Converse's format_raw_content. All six Raw call sites use it. The Array(...) calls on params and tool lists are unchanged. Four new examples in invoke_anthropic_spec.rb each render a single-Hash Raw (assistant content, user content, tool-result content, a passed-through tool_result block) and assert the rendered block equals the Hash. They fail against the old code.
| params = mantle_only_model?(model) ? strip_converse_only_params(params) : normalize_params(params, model:) | ||
| params = if mantle_only_model?(model) | ||
| strip_converse_only_params(params) | ||
| elsif invoke_anthropic_request?(rest[:protocol]) |
There was a problem hiding this comment.
normalize_invoke_params only runs in Bedrock#complete. Provider#render ("the request a completion call would send, without sending it") isn't overridden, so provider.render(..., protocol: :invoke_anthropic, params: { additionalModelRequestFields: { context_management: ... } }) returns a body with additionalModelRequestFields at the top level and context_management still nested inside it. complete lifts both, so render no longer matches what is actually sent. Fix: move the param selection into a private normalize_request_params(params, model:, protocol:) and call it from both complete and a new render override.
There was a problem hiding this comment.
Fixed in 6301383. The params choice (mantle strip, invoke normalize, converse normalize) now lives in one private Bedrock#prepare_params(params, model:, protocol:), called from both complete and a new render override. render uses the same **rest form as complete, because copying Provider#render's full keyword list trips Metrics/ParameterLists. Two new examples in bedrock_spec.rb: with protocol: :invoke_anthropic, context_management moves to the top level and there are no model, stream or additionalModelRequestFields keys; without a protocol, the Converse body is unchanged.
…sation system messages through InvokeModel Render a system message placed after the conversation starts as a role "system" turn instead of merging it into the top-level system, expose usage.cache_creation as provider_data, assert that top-level usage excludes the compaction iteration, and record Sonnet 5 and Opus 5 runs of examples 3 and 4 (Opus 5 refuses the ledger prompt as `cyber`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sam Boland <sam.boland@appfolio.com>
…plete sends them Kernel#Array split a Raw holding one Hash block into key/value pairs; InvokeAnthropic now wraps it the way Converse's format_raw_content does. Bedrock#render now runs the same params preparation as #complete, so an :invoke_anthropic render lifts additionalModelRequestFields and drops model/stream like the request that is actually sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sam Boland <sam.boland@appfolio.com>
samuelboland
left a comment
There was a problem hiding this comment.
Verified: both review findings fixed with specs, :invoke_anthropic is opt-in only, the Converse guard and thinking translation act only on typed blocks (none in the last 50k supernova messages), and supernova does not call render.
What this does
This PR empirically validates Anthropic's server-side context management features (context editing, threshold compaction, tool search, and thinking-binding controls) against AppFolio's Bedrock account, available only via the InvokeModel API with native Anthropic Messages bodies (not Converse, which the fork normally speaks to Bedrock).
It cherry-picks the prior spike (fork PR #30, commit
b5e38127) as a base commit, fills in the gaps that spike left (parsing/replaying the new block types), turns the features on, and records real VCR-cassetted traffic against them. This PR replaces #30 — it targetsmain, includes the spike's commit as its own commit (not squashed), and builds on it; #30 should be closed in favor of this one.Key changes:
Protocols::InvokeAnthropic::ContentBlocks < Content::Raw— the "smallest structure" chosen to carry unmodeled reply block types (compaction,server_tool_use,tool_search_tool_result, or unknown blocks) forward by wrapping the full block list for replay, while#text/#to_sstill join only text blocks.invoke_anthropic/chat.rbandinvoke_anthropic/streaming.rb: request rendering plus sync and streaming response parsing, surfacingcontext_management(includingapplied_edits),usage.iterations,input_transformations, andcache_creationephemeral token counts viaMessage#provider_data. Also rendersdefer_loading: truefor tool search, and places a mid-conversationrole: "system"message as its own system-role turn rather than merging it into top-levelsystem.Message#provider_data: new public reader exposing the extra fields above.RubyLLM::UnsupportedContentError: new error, raised when a message holds content blocks a protocol can't send (e.g. an InvokeModel compaction block replayed through Converse).chat.rbnow guards (reject_messages_api_blocks) against forwarding blocks it can't carry.Bedrock#protocol_fornever auto-selects:invoke_anthropic; it remains strictly opt-in viaprotocol: :invoke_anthropic/config.bedrock_protocol. Converse stays the default.Array(hash)on a single-HashContent::Rawwas corrupting it into key/value pairs instead of[hash]; fixed with a private helper matching Converse'sformat_raw_contentrule, applied at six call sites.Provider#renderpreviously returned a body different from whatBedrock#completeactually sent for the invoke protocol; fixed by extracting a sharedprepare_paramsand adding arenderoverride sorenderandcompleteagree, while leaving the default (Converse)renderbehavior unchanged.Live-recording findings (summarized; also encoded as spec assertions):
clear_tool_uses_20250919): cleared tool uses/tokens as expected, confirmed with follow-up requests on Opus 5.5 and Sonnet 5.5.clear_thinking_20251015): clearing "1 kept turn" clears every earlier turn — a surprising Anthropic server behavior, not a fork bug.compact_20260112): confirmed minimum trigger value is 50,000 (49,999 is rejected with HTTP 400); at 50,000 a compaction block is returned and replaying it is accepted.defer_loading: true): deferred tools are found via search and replayed search blocks are accepted.cyber) on Opus 5, so no clean compacted-summary observation exists for that model — flagged honestly rather than papered over.cache_control: {type: "ephemeral"}confirmed working with a cache-read on repeat requests;cache_creationfields parsed intoprovider_data.CountTokensis denied for the agent's current IAM role (403) — not pursued further, per instructions to stop on access-denied errors.Anthropic'srefusalstop/finish reason is not currently recognized byMessage#content_filtered?— a known gap left unaddressed, flagged as a possible follow-up.Nothing here is wired into supernova yet; that is covered by a later brief. VCR cassettes were scanned for leaked secrets at every recording stage — only
<AWS_...>placeholders were found. Estimated live spend was roughly $1 at list prices, dominated by ~58K-token compaction requests.Type of change
Scope check
Required for new features
No Jira ticket exists for this work. This PR explicitly supersedes/replaces fork PR #30 (the spike this work builds on); whether a separately approved feature issue exists was not established in this session.
Quality check
overcommit --installand all hooks passbundle exec rake vcr:record[provider_name]bundle exec rspecmodels.json,aliases.json)New VCR cassettes were recorded via live calls against AppFolio's Bedrock account (
us-west-2) and scanned for leaked secrets (only<AWS_...>placeholders found), rather than viabundle exec rake vcr:record[provider_name]directly. Targeted/offline specs for all touched areas pass underCI=1(913 examples, 0 failures, 63 pre-existing pending; offlinespec/ruby_llm/protocols/replay: 298 examples, 0 failures) andbundle exec rubocopis clean across 367 files with no new disables. The fullbundle exec rspecsuite was not run in this session; the author relied on targeted/offline runs and CI to cover the rest.AI-generated code
API changes
New:
Message#provider_data,RubyLLM::UnsupportedContentError,Bedrock#renderoverride,Protocols::InvokeAnthropic::ContentBlocks. The default (Converse) behavior ofBedrock#renderis unchanged and explicitly covered by a new spec assertingrenderwithoutprotocol:is unaffected.