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

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).

Returns:

  • (Boolean)


86
87
88
89
90
91
92
93
94
95
96
# File 'lib/foam/otel/llm/http_anthropic_shim.rb', line 86

def anthropic_messages?(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

Returns:

  • (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

Returns:

  • (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.default_options : nil
  base = opts.respond_to?(:persistent) ? opts.persistent : nil
  base ? URI.parse(base.to_s).host : nil
rescue StandardError
  nil
end

.present?Boolean

Returns:

  • (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

Returns:

  • (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