Module: Foam::Otel::LLM::HttpAnthropicShim
- Defined in:
- lib/foam/otel/llm/http_anthropic_shim.rb
Overview
Gap-filler for Anthropic API calls made DIRECTLY over the http gem
(HTTP.rb) — the hand-rolled-client pattern (HTTP.headers(...).post(
"https://api.anthropic.com/v1/messages", json: body)) that apps use
instead of an SDK. Presence-checked on HTTP.rb and shape-checked per
call (host + EXACT path + POST), so it is inert for every other
HTTP.rb request in the process and in any app without the gem. The
host may come from the request uri or, for the idiomatic persistent
client (HTTP.persistent(origin).post("/v1/messages", ...)), from the
client's persistent origin.
ONE gen_ai span per logical call: the request runs inside LLM.observe's suppression context. NOTE the contrib http instrumentation does NOT consult the untraced context (GOTCHAS G13, init.rb's UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS) — when that gem is active its transport span NESTS beneath the gen_ai span instead of being suppressed, the contract's sanctioned rendering (SPEC §10: a different layer, like rails-inside-rack).
The app's response stream is NEVER taken away from it: response content is read only when the body is bounded (Content-Length ≤ the parse cap) AND can be handed back — the drained bytes are re-wrapped onto the response so #to_s, #each and #readpartial all still work. Anything else (SSE/event-stream, chunked, oversized, restore-probe failure) skips content capture: activity + observed status survive, the stream is untouched. Error responses carry their OBSERVED status (rule 25); HTTP.rb never raises on 4xx/5xx, so the status IS the error signal — success spans carry NO http.* (fleet gen_ai vocabulary, llm.rb).
Defined Under Namespace
Modules: RequestPatch Classes: BufferedBodyStream, FailingBodyStream
Constant Summary collapse
- SUPPORTED_BELOW =
Gem::Version.new("6.0.0")
- HOSTS =
["api.anthropic.com"].freeze
- MESSAGES_PATH =
"/v1/messages"- BODY_PARSE_BYTE_CAP =
Transport twin of PayloadCapture's byte cap: bodies above this are never materialized or parsed by the shim (rule 49 — bounded work on the caller thread). Content is omitted, activity survives.
32_768- RESPONSE =
Non-streaming JSON Message → response attributes; usage rides output_tokens exactly like the SDK shim.
lambda do |response| h = response.is_a?(Hash) ? response : {} usage = h["usage"].is_a?(Hash) ? h["usage"] : {} LLM.response_attributes( model: h["model"], id: h["id"], finish_reasons: h["stop_reason"] ? [h["stop_reason"]] : nil, input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"], output_messages: h["content"] ) end
- ERROR_DETECTOR =
Observed-status error detection: HTTP.rb never raises on 4xx/5xx — the status IS the error signal (rule 25: observed, never fabricated).
lambda do |response| status = response.respond_to?(:status) ? response.status.to_i : 0 status >= 400 ? status : nil end
- EXTRACTOR =
Response attributes. The status code is stamped ONLY on the error path (the observed error signal — fleet gen_ai spans carry no http.* on success, the posture python's gap-filler also converged on). error.type carries the bare status string ("400"), matching the span status description and semconv's well-known values — never an invented format.
lambda do |response| attrs = {} status = response.respond_to?(:status) ? response.status.to_i : 0 if status >= 400 attrs[LLM::HTTP_RESPONSE_STATUS_CODE] = status attrs[LLM::ERROR_TYPE] = status.to_s end # HTTP::ContentType#to_s is the INSPECT string, not the media # type — read #mime_type (via Response#mime_type), falling back # to the raw header. mime = response.respond_to?(:mime_type) ? response.mime_type.to_s : "" if mime.empty? && response.respond_to?(:headers) mime = response.headers["Content-Type"].to_s end if mime.include?("json") begin raw = HttpAnthropicShim.readable_json_body(response) parsed = raw ? JSON.parse(raw) : nil attrs.merge!(RESPONSE.call(parsed)) if parsed.is_a?(Hash) rescue StandardError nil # unparseable body: activity + status survive, content lost end end attrs end
Class Method Summary collapse
-
.anthropic_messages?(verb, uri, client = nil) ⇒ Boolean
Shape check: is this ONE call an Anthropic Messages API request? uri arrives as whatever the caller passed (String / URI / HTTP::URI — possibly a bare path on a persistent client, whose origin then supplies the host).
- .build_body(contents) ⇒ Object
- .content_encoded?(response) ⇒ Boolean
- .content_length(response) ⇒ Object
- .install! ⇒ Object
- .installed? ⇒ Boolean
-
.persistent_host(client) ⇒ Object
The persistent-client idiom passes a bare path; the origin lives on the client's default options (client.rb joins them only inside #request, after this check runs).
- .present? ⇒ Boolean
-
.readable_json_body(response) ⇒ Object
Reads the finished JSON body for capture WITHOUT taking anything away from the app: only when the body is bounded (declared Content-Length within the cap, no Content-Encoding — a gzip body's declared length is the COMPRESSED size, and the inflated stream could dwarf the cap) and a replacement Body can be built (probed BEFORE consuming — if HTTP.rb's internals ever drift, foam skips capture rather than risk the app's stream).
-
.rearm_failed_body(response, error) ⇒ Object
A read that died under foam must die IDENTICALLY under the app: swap in a body whose reads re-raise the original transport error.
- .request_attributes(body) ⇒ Object
-
.request_body(opts) ⇒ Object
The request body: the patch sees opts BEFORE Client#request normalizes it — usually a plain Hash ({...} from the chainable), sometimes an HTTP::Options (structured :json pre-encoding) — or an encoded string body, parsed best-effort under the byte cap.
- .supported_version? ⇒ Boolean
Class Method Details
.anthropic_messages?(verb, uri, client = nil) ⇒ Boolean
Shape check: is this ONE call an Anthropic Messages API request? uri arrives as whatever the caller passed (String / URI / HTTP::URI — possibly a bare path on a persistent client, whose origin then supplies the host). The path must match EXACTLY: /v1/messages/count_tokens, /v1/messages/batches and friends are real non-inference Anthropic endpoints under the same prefix, and a prefix match would fabricate chat spans for them (contract SPEC §10: an ordinary REST path must never fabricate a gen_ai span).
86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 86 def (verb, uri, client = nil) return false unless verb.to_s.downcase == "post" parsed = uri.is_a?(String) ? URI.parse(uri) : uri host = parsed.respond_to?(:host) ? parsed.host : nil path = parsed.respond_to?(:path) ? parsed.path : nil host ||= persistent_host(client) HOSTS.include?(host) && path.to_s.chomp("/") == MESSAGES_PATH rescue StandardError false end |
.build_body(contents) ⇒ Object
200 201 202 203 204 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 200 def build_body(contents) ::HTTP::Response::Body.new(BufferedBodyStream.new(contents), encoding: contents.encoding) rescue StandardError nil end |
.content_encoded?(response) ⇒ Boolean
191 192 193 194 195 196 197 198 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 191 def content_encoded?(response) return false unless response.respond_to?(:headers) encoding = response.headers["Content-Encoding"].to_s !encoding.empty? && encoding.downcase != "identity" rescue StandardError true # unknown => assume encoded, skip capture end |
.content_length(response) ⇒ Object
184 185 186 187 188 189 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 184 def content_length(response) raw = response.respond_to?(:headers) ? response.headers["Content-Length"] : nil raw.nil? ? nil : Integer(raw.to_s, 10) rescue StandardError nil end |
.install! ⇒ Object
52 53 54 55 56 57 58 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 52 def install! return true if @installed ::HTTP::Client.prepend(RequestPatch) Diagnostics.info("LLM shim installed: http-rb anthropic (POST #{HOSTS.join(', ')}#{MESSAGES_PATH})") @installed = true end |
.installed? ⇒ Boolean
60 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 60 def installed? = @installed |
.persistent_host(client) ⇒ Object
The persistent-client idiom passes a bare path; the origin lives on the client's default options (client.rb joins them only inside #request, after this check runs).
101 102 103 104 105 106 107 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 101 def persistent_host(client) opts = client.respond_to?(:default_options) ? client. : nil base = opts.respond_to?(:persistent) ? opts.persistent : nil base ? URI.parse(base.to_s).host : nil rescue StandardError nil end |
.present? ⇒ Boolean
46 47 48 49 50 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 46 def present? defined?(::HTTP::Client) && ::HTTP::Client.method_defined?(:request) && supported_version? end |
.readable_json_body(response) ⇒ Object
Reads the finished JSON body for capture WITHOUT taking anything away from the app: only when the body is bounded (declared Content-Length within the cap, no Content-Encoding — a gzip body's declared length is the COMPRESSED size, and the inflated stream could dwarf the cap) and a replacement Body can be built (probed BEFORE consuming — if HTTP.rb's internals ever drift, foam skips capture rather than risk the app's stream). The drained bytes are re-wrapped in a fresh HTTP::Response::Body over an EOF-compatible buffer and swapped back, so app-side #to_s, #each and #readpartial behave exactly as if foam had never read the response. A transport failure MID-READ is re-armed: the swapped-in body re-raises the ORIGINAL exception on the app's own read — never a misleading StateError. The read itself inherits the app's HTTP.rb timeout configuration (bytes are capped here; deadlines belong to the client's .timeout).
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 157 def readable_json_body(response) length = content_length(response) return nil unless length&.positive? && length <= BODY_PARSE_BYTE_CAP return nil if content_encoded?(response) return nil unless response.instance_variable_defined?(:@body) return nil unless build_body("") # constructor probe, pre-consumption contents = begin response.body.to_s rescue StandardError, SystemStackError => e rearm_failed_body(response, e) return nil end replacement = build_body(contents) || build_body(contents.dup.force_encoding(Encoding::BINARY)) # No hand-back, no capture: foam never profits from a stream it # failed to return to the app. return nil unless replacement response.instance_variable_set(:@body, replacement) # Belt over the declared length: never parse more than the cap # actually materialized (a lying Content-Length header). contents.bytesize <= BODY_PARSE_BYTE_CAP ? contents : nil rescue StandardError nil end |
.rearm_failed_body(response, error) ⇒ Object
A read that died under foam must die IDENTICALLY under the app: swap in a body whose reads re-raise the original transport error.
208 209 210 211 212 213 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 208 def rearm_failed_body(response, error) replacement = ::HTTP::Response::Body.new(FailingBodyStream.new(error), encoding: Encoding::BINARY) response.instance_variable_set(:@body, replacement) rescue StandardError nil end |
.request_attributes(body) ⇒ Object
131 132 133 134 135 136 137 138 139 140 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 131 def request_attributes(body) body = body.is_a?(Hash) ? body : {} LLM.request_attributes( provider: "anthropic", operation: "chat", model: body["model"] || body[:model], max_tokens: body["max_tokens"] || body[:max_tokens], input_messages: body["messages"] || body[:messages], system_instructions: body["system"] || body[:system] ) end |
.request_body(opts) ⇒ Object
The request body: the patch sees opts BEFORE Client#request normalizes it — usually a plain Hash ({...} from the chainable), sometimes an HTTP::Options (structured :json pre-encoding) — or an encoded string body, parsed best-effort under the byte cap. NEVER consumes streams (file/IO bodies degrade to the bare operation/provider pair).
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 115 def request_body(opts) json, body = if opts.is_a?(Hash) [opts[:json] || opts["json"], opts[:body] || opts["body"]] else [opts.respond_to?(:json) ? opts.json : nil, opts.respond_to?(:body) ? opts.body : nil] end return json if json.is_a?(Hash) return nil unless body.is_a?(String) && body.bytesize <= BODY_PARSE_BYTE_CAP && body.start_with?("{") JSON.parse(body) rescue StandardError nil end |
.supported_version? ⇒ Boolean
62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 62 def supported_version? spec = Gem.loaded_specs["http"] return true if spec.nil? supported = spec.version < SUPPORTED_BELOW unless supported || @version_warned @version_warned = true Diagnostics.warn("http #{spec.version} is outside foam's LLM-shim window (< #{SUPPORTED_BELOW}) " \ "— the HTTP.rb Anthropic shim stays dark; file this with foam support") end supported rescue StandardError true end |