foam-otel 2.0.0 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 767e21dce7e53eb8a74e430c736f2ba94e22c882022733fff7fb8edb058c118e
4
- data.tar.gz: 4d9e7b6789d79daec4eb0482a22ac68520263c0a267e1b4e4def7eae97d9e642
3
+ metadata.gz: 65833871aff8351bab7083f4a9d636afd5704cd1327e95bc4af117437d12ad85
4
+ data.tar.gz: 206b1b6bfe1633b44267283c775b63ae47cd429be0bc9260c27ab89fd4f8b6ed
5
5
  SHA512:
6
- metadata.gz: 4512202149fa0ee2b50093b7485b07373a09ed29315b6489d3e59d0c111ecf3d023e77e49f9af271ca97df33210e2028ba3376e4a43516284bd6f0fe195a8627
7
- data.tar.gz: 9d15c2c183dfdac3ca20f0a3eccae5c6f2e45134206a697604a1b70e7a9046c775d0b5e805662a162aee2b0d89e62f810cdea8e43a72b9f15db783f1b07ccb0d
6
+ metadata.gz: c7c97cf55df3769670636aab31b8dafa53ee8975b4648bd1339e167f5af63245609355b547e25eefcaefd68f155e8e5934e739a54f012a5137231f343a966a2e
7
+ data.tar.gz: eb600f006bdce6aabf407e8b7df5f1e2fe7ece7a7f7590ac956689a6d1c148e0b5a1d32a4095606608d931f3792a908bb901fe06f804e49f52eda366b6e97650
data/GOTCHAS.md CHANGED
@@ -542,9 +542,16 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
542
542
  own HTTP send must not produce a SECOND span under the gen_ai span.
543
543
  - **Sources**: installed source — openai `resources/chat/completions.rb:119`
544
544
  / `resources/responses.rb:96`, anthropic `resources/messages.rb:68`,
545
- gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44` (the
546
- patched call sites); opentelemetry-common `utilities.rb:104-117`
547
- (`untraced` the suppression context the http contrib honors). Ruling:
545
+ gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44`,
546
+ ruby-openai `lib/openai/client.rb` (`#chat(parameters:)`/`#embeddings`
547
+ the COMMUNITY gem sharing the official gem's OpenAI namespace and
548
+ lib/openai.rb entrypoint: presence is discriminated by the
549
+ `parameters:`-keyword SHAPE, never the bare method name), http (HTTP.rb)
550
+ `lib/http/client.rb#request` (the hand-rolled Anthropic transport seam,
551
+ shape-checked per call); opentelemetry-common `utilities.rb:104-117`
552
+ (`untraced` — the suppression context net_http/excon honor; contrib
553
+ `-http` does NOT consult it (G13/UNGUARDABLE) — its transport span NESTS
554
+ under the gen_ai span, the contract's sanctioned rendering). Ruling:
548
555
  LLM provider parity + testing-mandatory (decision log 2026-07-26).
549
556
  - **Decision & why**: every patch routes through ONE guarded core
550
557
  (`Foam::Otel::LLM.observe`, `lib/foam/otel/llm.rb`): request extraction
@@ -555,10 +562,23 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
555
562
  Presence-checked constants + explicit version windows per SDK; emission
556
563
  gated on foam owning traces. Wire names are the GenAI semconv strings the
557
564
  fleet's js/python gap-fillers emit — content attributes included, RAW.
565
+ The transport shim (HTTP.rb) additionally must never be the FIRST reader
566
+ of the app's response: content is parsed only for bounded JSON bodies
567
+ (Content-Length ≤ its byte cap) and the drained bytes are re-wrapped onto
568
+ the response so app-side `#to_s`/`#each`/`#readpartial` are untouched;
569
+ SSE/chunked/oversized bodies skip content capture entirely. HTTP.rb never
570
+ raises on 4xx/5xx, so `LLM.observe`'s `error_detector:` turns the
571
+ OBSERVED status into the ERROR span (`error.type` = the bare code string).
558
572
  - **Test**: `spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
559
- `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — real SDKs against
560
- a local fake provider (only the 3rd party mocked), per-provider activity +
561
- RAW-content assertions, error-path re-raise, and the fail-to-dark set.
573
+ `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb`,
574
+ `spec/llm_ruby_openai_spec.rb` (isolated subprocess the two openai gems
575
+ can never share an interpreter; both cross-gem discriminator negatives
576
+ pinned here and in `spec/llm_openai_spec.rb`),
577
+ `spec/llm_http_anthropic_spec.rb` (streaming-reader safety, SSE
578
+ never-consume, byte-cap, near-miss-path negatives, persistent-client) —
579
+ real SDKs against a local fake provider (only the 3rd party mocked),
580
+ per-provider activity + RAW-content assertions, error-path re-raise, and
581
+ the fail-to-dark set.
562
582
 
563
583
  ## F12: Session stamping must beat the freeze (spans) and the buffer (logs)
564
584
 
@@ -937,7 +957,7 @@ exfiltratable — the value-pattern secret layer is the required second control
937
957
 
938
958
  ---
939
959
 
940
- ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29)
960
+ ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29; HTTP::Client twin added in 2.1.0)
941
961
 
942
962
  - **Trap**: Every module prepended onto a class shares ONE private-method
943
963
  namespace. When two observability vendors prepend same-named private
@@ -949,6 +969,12 @@ exfiltratable — the value-pattern secret layer is the required second control
949
969
  `annotate_span_with_response!` (3-arg vs 2-arg). Foam ships that contrib
950
970
  patch for capture, so a foam + agent process CONTAINS the collision — and
951
971
  foam's promise is coexistence: the app must keep working.
972
+ THE SAME COLLISION EXISTS ON A SECOND CLASS: dd-trace-rb's httprb patch
973
+ and the contrib `opentelemetry-instrumentation-http` patch both define
974
+ `annotate_span_with_response!` on `HTTP::Client` (3-arg vs 2-arg) —
975
+ upstream tracks the combination as unsupported (dd-trace-rb#3575,
976
+ opentelemetry-ruby#1625, httprb/http#838). A `datadog/auto_instrument` +
977
+ contrib-http process crashes every HTTP.rb request the same way.
952
978
  - **Sources**:
953
979
  - Installed source — the two colliding definitions:
954
980
  opentelemetry-instrumentation-net_http 0.29.0
@@ -957,6 +983,14 @@ exfiltratable — the value-pattern secret layer is the required second control
957
983
  3-arg spelling of the same private name (verified live in the
958
984
  coexistence gate; backtrace pinned in
959
985
  docs/decisions/export-isolation-ruby.md §A).
986
+ - The HTTP::Client twin: dd-trace-rb
987
+ `lib/datadog/tracing/contrib/httprb/instrumentation.rb`
988
+ (3-arg `annotate_span_with_response!(span, response, options)`) vs
989
+ contrib opentelemetry-instrumentation-http `patches/client.rb`
990
+ (2-arg); upstream reports
991
+ https://github.com/DataDog/dd-trace-rb/issues/3575 ,
992
+ https://github.com/open-telemetry/opentelemetry-ruby/issues/1625 ,
993
+ https://github.com/httprb/http/issues/838 .
960
994
  - Ruby semantics: prepended modules join the class's ancestor chain and
961
995
  private methods resolve through the SAME chain for every caller — there
962
996
  is no per-module helper namespace.
@@ -964,7 +998,8 @@ exfiltratable — the value-pattern secret layer is the required second control
964
998
  resolves the name through the same shared chain — a rename breaks the
965
999
  gem being "fixed"), and foam must never edit another vendor's module. So
966
1000
  foam owns the collision instead: after the instrumentation sweep, init
967
- scans Net::HTTP's prepend chain for private-helper names defined by two
1001
+ scans the prepend chains of Net::HTTP AND `HTTP::Client` (when the http
1002
+ gem is loaded) for private-helper names defined by two
968
1003
  or more foreign modules at NON-identical positional signatures (names the
969
1004
  class itself defines are super-chains, never shielded) and prepends ONE
970
1005
  frontmost dispatch method per colliding name — routing by caller source
@@ -979,17 +1014,27 @@ exfiltratable — the value-pattern secret layer is the required second control
979
1014
  - **Mitigation**: with the shield installed the coexistence gate's
980
1015
  patched-client probe went from 3/3 crashes to 3/3 passes under the real
981
1016
  agent, with both vendors' helpers verifiably executing again.
982
- - **Residual**: an agent that patches AFTER foam's init lands ahead of the
983
- shield and re-exposes the raw collision until a later init/fork re-scan;
984
- agents overwhelmingly boot first (initializer/preload) the order the
985
- coexistence gate proves. Identical-signature collisions are left alone
1017
+ - **Residual**: the scan is one-shot at init and only shields collisions
1018
+ BOTH of whose definitions are already on the chain an agent that
1019
+ patches AFTER foam's init lands ahead of the shield and re-exposes the
1020
+ raw collision until a later init/fork re-scan. For Net::HTTP agents
1021
+ overwhelmingly boot first (initializer/preload — the order the
1022
+ coexistence gate proves). The HTTP::Client twin is MORE order-sensitive:
1023
+ `defined?(::HTTP::Client)` must be true at init (the http gem required
1024
+ first — Bundler.require satisfies this) and both vendor patches must
1025
+ already be installed; a `Foam::Otel.init` that runs before
1026
+ `Datadog.configure` leaves that collision unshielded — initialize foam
1027
+ AFTER the vendor agents (the recommended order for every coexistence
1028
+ bridge). Identical-signature collisions are left alone
986
1029
  (no crash class; dispatch could not disambiguate semantics).
987
1030
  - **Test**: `spec/prepend_collision_shield_spec.rb` (the unshielded RED
988
1031
  control, both-vendors-work dispatch, idempotence, no-op purity on
989
1032
  collision-free chains, super-chain and identical-signature exclusions,
990
1033
  fall-through to the real error, rule-9 scan degradation, caller-source
991
- dispatch across two on-disk gem roots, and the real Net::HTTP + real
992
- contrib patch field scenario red→green in a forked child);
1034
+ dispatch across two on-disk gem roots, the real Net::HTTP + real
1035
+ contrib patch field scenario red→green in a forked child, and the real
1036
+ `HTTP::Client` field scenario — 2-arg × 3-arg vendor-shaped collision on
1037
+ the real class, red→green in a forked child);
993
1038
  `test-apps/ruby-coexistence/run-verify.sh` (the patched-client probe is a
994
1039
  REQUIRED pass on every vendor posture).
995
1040
 
data/README.md CHANGED
@@ -100,7 +100,7 @@ or erase further specific fields (see below).
100
100
  > Set the standard contrib lever:
101
101
  > `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
102
102
 
103
- ### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
103
+ ### The LLM surface — OpenAI, Anthropic, Gemini (plus ruby_llm, ruby-openai, and hand-rolled HTTP.rb Anthropic)
104
104
 
105
105
  The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
106
106
  presence-checked shims over the SDKs' public call sites, emitting the standard
@@ -117,11 +117,22 @@ js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
117
117
  said `chat`). Covered: the official `openai` SDK
118
118
  (chat.completions.create + responses.create), the official `anthropic` SDK
119
119
  (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
120
- Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
120
+ Ruby SDK), `ruby_llm` (one seam covering all its providers), the community
121
+ `ruby-openai` gem (`client.chat` / `client.embeddings` — presence is
122
+ discriminated from the official gem by call shape, since both reopen the
123
+ `OpenAI` namespace; embeddings emit tokens + models, never the input
124
+ documents), and hand-rolled HTTP.rb Anthropic clients (`HTTP.post` /
125
+ `HTTP.persistent(...).post` to `api.anthropic.com/v1/messages` — exactly
126
+ that endpoint; HTTP.rb's non-raising 4xx/5xx become ERROR spans with the
127
+ observed status, and foam never consumes a response stream: SSE, chunked,
128
+ and oversized bodies pass through untouched, and buffered JSON bodies are
129
+ handed back readable). Absent SDK =
121
130
  inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
122
131
  model call, and the SDK's own error re-raises identically. One model call =
123
132
  one `gen_ai` client span (the SDK's HTTP send rides the same suppression
124
- context foam's exporters use). Streaming call sites (`#stream`/`#stream_raw`
133
+ context foam's exporters use; transport gems that ignore that context, like
134
+ the contrib `http` instrumentation, nest beneath it instead). Streaming
135
+ call sites (`#stream`/`#stream_raw`
125
136
  on the official SDKs) are a recorded follow-up.
126
137
 
127
138
  ### Session stitching — browser `session.id` on backend telemetry
data/RESEARCH.md CHANGED
@@ -246,8 +246,16 @@ SDK — foam ships its own thin gap-filler shims (LANDED,
246
246
  shims patch the SDKs' public call sites — official `openai`
247
247
  (chat.completions.create + responses.create), official `anthropic`
248
248
  (messages.create), `gemini-ai` (generate_content / stream_generate_content;
249
- Google ships no official Ruby SDK), and `ruby_llm` (Provider#complete, one
250
- seam covering all its providers) emitting the same GenAI semconv attribute
249
+ Google ships no official Ruby SDK), `ruby_llm` (Provider#complete, one
250
+ seam covering all its providers), `ruby-openai` (GRADUATED from tier-3 in
251
+ 2.1.0: the long-dominant COMMUNITY OpenAI client, `OpenAI::Client#chat
252
+ (parameters:)`/`#embeddings` — it shares the official gem's `OpenAI`
253
+ namespace and `lib/openai.rb` entrypoint, so the two gems can never load in
254
+ one process and presence is discriminated by the `parameters:`-keyword
255
+ SHAPE, never the bare method name; specs run in an isolated subprocess for
256
+ the same reason, `spec/llm_ruby_openai_spec.rb`), and `http` (HTTP.rb —
257
+ the hand-rolled Anthropic transport seam, `HTTP::Client#request`, below) —
258
+ emitting the same GenAI semconv attribute
251
259
  set the fleet's js/python gap-fillers emit. `gen_ai.operation.name` matches
252
260
  the fleet per API shape: `chat` on the chat seams, `generate_content` on the
253
261
  Gemini shim (aligned in 1.2.1 — Ruby previously emitted `chat` for Gemini, a
@@ -267,7 +275,11 @@ the model call is never breakable by telemetry (GOTCHAS F11). LLM testing is
267
275
  MANDATORY and landed: per-provider specs against the REAL SDKs with only the
268
276
  3rd party mocked (`spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
269
277
  `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — the last is the
270
- wire-level Gemini proof over a local fake provider).
278
+ wire-level Gemini proof over a local fake provider
279
+ `spec/llm_ruby_openai_spec.rb`, and `spec/llm_http_anthropic_spec.rb`,
280
+ which additionally pins the transport shim's never-first-reader guarantee:
281
+ streaming reads survive capture, SSE and oversized bodies are never
282
+ consumed, and the observed-status error path).
271
283
 
272
284
  - `-anthropic` (in the registry / `all` meta-gem) is **context-propagation ONLY**
273
285
  — its sole patch stores/restores OTel context across the SDK's connection pool;
@@ -278,12 +290,26 @@ wire-level Gemini proof over a local fake provider).
278
290
  but is NOT bundled: foam's own `ruby_llm` shim emits the fleet-identical
279
291
  attribute set instead, avoiding a non-registry supply-chain dependency and
280
292
  a cross-language semconv divergence.
281
- - `ruby-openai` (community gem), `langchainrb`, AWS Bedrock (via `-aws_sdk`,
293
+ - `http` (HTTP.rb) hand-rolled Anthropic clients: **TRANSPORT-LEVEL SHIM,
294
+ DELIBERATELY BOUNDED (2.1.0).** SDK-less apps that POST
295
+ `api.anthropic.com/v1/messages` directly (a live customer pattern) get the
296
+ full gen_ai set via a per-call shape check — known host + EXACT
297
+ distinctive path + POST (contract SPEC §10's transport-hook rules;
298
+ near-miss endpoints like `/v1/messages/count_tokens` and
299
+ `/v1/messages/batches` are pinned NEGATIVE, never fabricated). Scope is a
300
+ closed set — ONE provider, ONE transport: foam-otel 0.1.x shipped broad
301
+ transport classifiers and RETIRED them (the ecosystem churns too fast to
302
+ chase from a shared gem), and that ruling stands; extending this seam to
303
+ more hosts/transports (Net::HTTP, Faraday hand-rolls) is a per-addition
304
+ fleet ruling with this same census entry updated, never a silent patch.
305
+ Embeddings note (fleet parity): NO shim captures embeddings INPUT —
306
+ python's contrib openai-v2 path emits none; tokens + models only.
307
+ - `langchainrb`, AWS Bedrock (via `-aws_sdk`,
282
308
  which emits no gen_ai attributes), `llm.rb`: **TIER3-recipe** —
283
309
  hand-written spans via helpers + passthroughs with `gen_ai.*` semconv + token
284
310
  counts + trace/log correlation. An uncovered library the FDE hits becomes a
285
311
  filed package need (the FDE activates, never authors); until then the tier-3
286
- recipe covers it.
312
+ recipe covers it — the path `ruby-openai` itself took to graduation.
287
313
  - No mainstream library is CANNOT-COVER — no finding against the helper set.
288
314
 
289
315
  Sources: contrib `-anthropic` CHANGELOG; https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm ;
@@ -620,6 +620,12 @@ module Foam
620
620
  # vendor private-helper collisions and shim them. A collision-free
621
621
  # chain (the common case) installs NOTHING.
622
622
  PrependCollisionShield.install!(::Net::HTTP) if defined?(::Net::HTTP)
623
+ # The HTTP.rb ("http" gem) twin of the same collision: dd-trace-rb's
624
+ # httprb patch and the contrib http patch both define the private
625
+ # helper `annotate_span_with_response!` on HTTP::Client at
626
+ # non-identical signatures (3-arg vs 2-arg) — without the shield,
627
+ # EVERY HTTP.rb request crashes once both agents are loaded.
628
+ PrependCollisionShield.install!(::HTTP::Client) if defined?(::HTTP::Client)
623
629
  rescue StandardError => e
624
630
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
625
631
  end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Foam
6
+ module Otel
7
+ module LLM
8
+ # Gap-filler for Anthropic API calls made DIRECTLY over the `http` gem
9
+ # (HTTP.rb) — the hand-rolled-client pattern (HTTP.headers(...).post(
10
+ # "https://api.anthropic.com/v1/messages", json: body)) that apps use
11
+ # instead of an SDK. Presence-checked on HTTP.rb and shape-checked per
12
+ # call (host + EXACT path + POST), so it is inert for every other
13
+ # HTTP.rb request in the process and in any app without the gem. The
14
+ # host may come from the request uri or, for the idiomatic persistent
15
+ # client (HTTP.persistent(origin).post("/v1/messages", ...)), from the
16
+ # client's persistent origin.
17
+ #
18
+ # ONE gen_ai span per logical call: the request runs inside
19
+ # LLM.observe's suppression context. NOTE the contrib http
20
+ # instrumentation does NOT consult the untraced context (GOTCHAS G13,
21
+ # init.rb's UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS) — when that gem
22
+ # is active its transport span NESTS beneath the gen_ai span instead
23
+ # of being suppressed, the contract's sanctioned rendering (SPEC §10:
24
+ # a different layer, like rails-inside-rack).
25
+ #
26
+ # The app's response stream is NEVER taken away from it: response
27
+ # content is read only when the body is bounded (Content-Length ≤ the
28
+ # parse cap) AND can be handed back — the drained bytes are re-wrapped
29
+ # onto the response so #to_s, #each and #readpartial all still work.
30
+ # Anything else (SSE/event-stream, chunked, oversized, restore-probe
31
+ # failure) skips content capture: activity + observed status survive,
32
+ # the stream is untouched. Error responses carry their OBSERVED status
33
+ # (rule 25); HTTP.rb never raises on 4xx/5xx, so the status IS the
34
+ # error signal — success spans carry NO http.* (fleet gen_ai
35
+ # vocabulary, llm.rb).
36
+ module HttpAnthropicShim
37
+ SUPPORTED_BELOW = Gem::Version.new("6.0.0")
38
+ HOSTS = ["api.anthropic.com"].freeze
39
+ MESSAGES_PATH = "/v1/messages"
40
+ # Transport twin of PayloadCapture's byte cap: bodies above this are
41
+ # never materialized or parsed by the shim (rule 49 — bounded work
42
+ # on the caller thread). Content is omitted, activity survives.
43
+ BODY_PARSE_BYTE_CAP = 32_768
44
+
45
+ class << self
46
+ def present?
47
+ defined?(::HTTP::Client) &&
48
+ ::HTTP::Client.method_defined?(:request) &&
49
+ supported_version?
50
+ end
51
+
52
+ def install!
53
+ return true if @installed
54
+
55
+ ::HTTP::Client.prepend(RequestPatch)
56
+ Diagnostics.info("LLM shim installed: http-rb anthropic (POST #{HOSTS.join(', ')}#{MESSAGES_PATH})")
57
+ @installed = true
58
+ end
59
+
60
+ def installed? = @installed
61
+
62
+ def supported_version?
63
+ spec = Gem.loaded_specs["http"]
64
+ return true if spec.nil?
65
+
66
+ supported = spec.version < SUPPORTED_BELOW
67
+ unless supported || @version_warned
68
+ @version_warned = true
69
+ Diagnostics.warn("http #{spec.version} is outside foam's LLM-shim window (< #{SUPPORTED_BELOW}) " \
70
+ "— the HTTP.rb Anthropic shim stays dark; file this with foam support")
71
+ end
72
+ supported
73
+ rescue StandardError
74
+ true
75
+ end
76
+
77
+ # Shape check: is this ONE call an Anthropic Messages API request?
78
+ # uri arrives as whatever the caller passed (String / URI /
79
+ # HTTP::URI — possibly a bare path on a persistent client, whose
80
+ # origin then supplies the host). The path must match EXACTLY:
81
+ # /v1/messages/count_tokens, /v1/messages/batches and friends are
82
+ # real non-inference Anthropic endpoints under the same prefix,
83
+ # and a prefix match would fabricate chat spans for them
84
+ # (contract SPEC §10: an ordinary REST path must never fabricate
85
+ # a gen_ai span).
86
+ def anthropic_messages?(verb, uri, client = nil)
87
+ return false unless verb.to_s.downcase == "post"
88
+
89
+ parsed = uri.is_a?(String) ? URI.parse(uri) : uri
90
+ host = parsed.respond_to?(:host) ? parsed.host : nil
91
+ path = parsed.respond_to?(:path) ? parsed.path : nil
92
+ host ||= persistent_host(client)
93
+ HOSTS.include?(host) && path.to_s.chomp("/") == MESSAGES_PATH
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ # The persistent-client idiom passes a bare path; the origin lives
99
+ # on the client's default options (client.rb joins them only
100
+ # inside #request, after this check runs).
101
+ def persistent_host(client)
102
+ opts = client.respond_to?(:default_options) ? client.default_options : nil
103
+ base = opts.respond_to?(:persistent) ? opts.persistent : nil
104
+ base ? URI.parse(base.to_s).host : nil
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ # The request body: the patch sees opts BEFORE Client#request
110
+ # normalizes it — usually a plain Hash ({json: {...}} from the
111
+ # chainable), sometimes an HTTP::Options (structured :json
112
+ # pre-encoding) — or an encoded string body, parsed best-effort
113
+ # under the byte cap. NEVER consumes streams (file/IO bodies
114
+ # degrade to the bare operation/provider pair).
115
+ def request_body(opts)
116
+ json, body =
117
+ if opts.is_a?(Hash)
118
+ [opts[:json] || opts["json"], opts[:body] || opts["body"]]
119
+ else
120
+ [opts.respond_to?(:json) ? opts.json : nil,
121
+ opts.respond_to?(:body) ? opts.body : nil]
122
+ end
123
+ return json if json.is_a?(Hash)
124
+ return nil unless body.is_a?(String) && body.bytesize <= BODY_PARSE_BYTE_CAP && body.start_with?("{")
125
+
126
+ JSON.parse(body)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def request_attributes(body)
132
+ body = body.is_a?(Hash) ? body : {}
133
+ LLM.request_attributes(
134
+ provider: "anthropic", operation: "chat",
135
+ model: body["model"] || body[:model],
136
+ max_tokens: body["max_tokens"] || body[:max_tokens],
137
+ input_messages: body["messages"] || body[:messages],
138
+ system_instructions: body["system"] || body[:system]
139
+ )
140
+ end
141
+
142
+ # Reads the finished JSON body for capture WITHOUT taking anything
143
+ # away from the app: only when the body is bounded (declared
144
+ # Content-Length within the cap) and a replacement Body can be
145
+ # built (probed BEFORE consuming — if HTTP.rb's internals ever
146
+ # drift, foam skips capture rather than risk the app's stream).
147
+ # The drained bytes are re-wrapped in a fresh HTTP::Response::Body
148
+ # over an EOF-compatible buffer and swapped back, so app-side
149
+ # #to_s, #each and #readpartial behave exactly as if foam had
150
+ # never read the response.
151
+ def readable_json_body(response)
152
+ length = content_length(response)
153
+ return nil unless length&.positive? && length <= BODY_PARSE_BYTE_CAP
154
+ return nil unless response.instance_variable_defined?(:@body)
155
+ return nil unless build_body("") # constructor probe, pre-consumption
156
+
157
+ contents = response.body.to_s
158
+ replacement = build_body(contents)
159
+ response.instance_variable_set(:@body, replacement) if replacement
160
+ contents
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def content_length(response)
166
+ raw = response.respond_to?(:headers) ? response.headers["Content-Length"] : nil
167
+ raw.nil? ? nil : Integer(raw.to_s, 10)
168
+ rescue StandardError
169
+ nil
170
+ end
171
+
172
+ def build_body(contents)
173
+ ::HTTP::Response::Body.new(BufferedBodyStream.new(contents), encoding: contents.encoding)
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+
179
+ # Matches HTTP::Connection's readpartial contract (nil at EOF, never
180
+ # EOFError) so a restored Body streams exactly like a live one.
181
+ class BufferedBodyStream
182
+ CHUNK = 16_384
183
+
184
+ def initialize(contents)
185
+ @io = StringIO.new(contents)
186
+ end
187
+
188
+ def readpartial(size = CHUNK, outbuf = nil)
189
+ return nil if @io.eof?
190
+
191
+ outbuf ? @io.readpartial(size, outbuf) : @io.readpartial(size)
192
+ end
193
+ end
194
+
195
+ # Non-streaming JSON Message → response attributes; usage rides
196
+ # {input_tokens, output_tokens} exactly like the SDK shim.
197
+ RESPONSE = lambda do |response|
198
+ h = response.is_a?(Hash) ? response : {}
199
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
200
+ LLM.response_attributes(
201
+ model: h["model"],
202
+ id: h["id"],
203
+ finish_reasons: h["stop_reason"] ? [h["stop_reason"]] : nil,
204
+ input_tokens: usage["input_tokens"],
205
+ output_tokens: usage["output_tokens"],
206
+ output_messages: h["content"]
207
+ )
208
+ end
209
+
210
+ module RequestPatch
211
+ def request(verb, uri, opts = {})
212
+ unless Foam::Otel::LLM.emit? && HttpAnthropicShim.anthropic_messages?(verb, uri, self)
213
+ return super
214
+ end
215
+
216
+ body = HttpAnthropicShim.request_body(opts)
217
+ Foam::Otel::LLM.observe(
218
+ provider: "anthropic",
219
+ request: Foam::Otel::LLM.safe_request(provider: "anthropic") do
220
+ HttpAnthropicShim.request_attributes(body)
221
+ end,
222
+ error_detector: HttpAnthropicShim::ERROR_DETECTOR,
223
+ response_extractor: HttpAnthropicShim::EXTRACTOR
224
+ ) { super }
225
+ end
226
+ end
227
+
228
+ # Observed-status error detection: HTTP.rb never raises on 4xx/5xx —
229
+ # the status IS the error signal (rule 25: observed, never fabricated).
230
+ ERROR_DETECTOR = lambda do |response|
231
+ status = response.respond_to?(:status) ? response.status.to_i : 0
232
+ status >= 400 ? status : nil
233
+ end
234
+
235
+ # Response attributes. The status code is stamped ONLY on the error
236
+ # path (the observed error signal — fleet gen_ai spans carry no
237
+ # http.* on success; python removed exactly this success-path stamp
238
+ # as a deliberate wire correction). error.type carries the bare
239
+ # status string ("400"), matching the span status description and
240
+ # semconv's well-known values — never an invented format.
241
+ EXTRACTOR = lambda do |response|
242
+ attrs = {}
243
+ status = response.respond_to?(:status) ? response.status.to_i : 0
244
+ if status >= 400
245
+ attrs[LLM::HTTP_RESPONSE_STATUS_CODE] = status
246
+ attrs[LLM::ERROR_TYPE] = status.to_s
247
+ end
248
+ # HTTP::ContentType#to_s is the INSPECT string, not the media
249
+ # type — read #mime_type (via Response#mime_type), falling back
250
+ # to the raw header.
251
+ mime = response.respond_to?(:mime_type) ? response.mime_type.to_s : ""
252
+ if mime.empty? && response.respond_to?(:headers)
253
+ mime = response.headers["Content-Type"].to_s
254
+ end
255
+ if mime.include?("json")
256
+ begin
257
+ raw = HttpAnthropicShim.readable_json_body(response)
258
+ parsed = raw ? JSON.parse(raw) : nil
259
+ attrs.merge!(RESPONSE.call(parsed)) if parsed.is_a?(Hash)
260
+ rescue StandardError
261
+ nil # unparseable body: activity + status survive, content lost
262
+ end
263
+ end
264
+ attrs
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module LLM
6
+ # Gap-filler for the `ruby-openai` gem (the long-standing community
7
+ # OpenAI client: `OpenAI::Client#chat`, NOT the official `openai`
8
+ # gem's resource layer OpenAIShim covers). Plenty of production Rails
9
+ # apps still ride it — presence-checked and version-windowed like its
10
+ # siblings so it is inert anywhere the gem is absent, and dark outside
11
+ # the supported window.
12
+ module RubyOpenAIShim
13
+ SUPPORTED_BELOW = Gem::Version.new("9.0.0")
14
+
15
+ class << self
16
+ # The official `openai` gem ALSO defines OpenAI::Client#chat — but
17
+ # as a zero-argument RESOURCE ACCESSOR (client.chat.completions…).
18
+ # ruby-openai's is the request method chat(parameters:). The shape
19
+ # check (the parameters: keyword) is the ONLY reliable discriminator
20
+ # between the two gems sharing this namespace — never the bare
21
+ # method name.
22
+ def present?
23
+ return false unless defined?(::OpenAI::Client) && ::OpenAI::Client.method_defined?(:chat)
24
+
25
+ chat = ::OpenAI::Client.instance_method(:chat)
26
+ takes_parameters = chat.parameters.any? { |type, name| %i[key keyreq].include?(type) && name == :parameters }
27
+ takes_parameters && supported_version?
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ def install!
33
+ return true if @installed
34
+
35
+ ::OpenAI::Client.prepend(ChatPatch)
36
+ if ::OpenAI::Client.method_defined?(:embeddings)
37
+ ::OpenAI::Client.prepend(EmbeddingsPatch)
38
+ end
39
+ Diagnostics.info("LLM shim installed: ruby-openai (client.chat, client.embeddings)")
40
+ @installed = true
41
+ end
42
+
43
+ def installed? = @installed
44
+
45
+ def supported_version?
46
+ spec = Gem.loaded_specs["ruby-openai"]
47
+ return true if spec.nil? # vendored/unknown: the shape checks decide
48
+
49
+ supported = spec.version < SUPPORTED_BELOW
50
+ unless supported || @version_warned
51
+ @version_warned = true
52
+ Diagnostics.warn("ruby-openai #{spec.version} is outside foam's LLM-shim window (< #{SUPPORTED_BELOW}) " \
53
+ "— the ruby-openai shim stays dark; file this with foam support")
54
+ end
55
+ supported
56
+ rescue StandardError
57
+ true
58
+ end
59
+
60
+ def chat_request(params)
61
+ params = params.is_a?(Hash) ? params : {}
62
+ LLM.request_attributes(
63
+ provider: "openai", operation: "chat",
64
+ model: params[:model] || params["model"],
65
+ max_tokens: params[:max_tokens] || params["max_tokens"] ||
66
+ params[:max_completion_tokens] || params["max_completion_tokens"],
67
+ input_messages: params[:messages] || params["messages"]
68
+ )
69
+ end
70
+
71
+ def embeddings_request(params)
72
+ params = params.is_a?(Hash) ? params : {}
73
+ # Embeddings INPUT is deliberately NOT captured (fleet parity:
74
+ # the python core rides contrib openai-v2, which emits no
75
+ # embeddings input under any attribute — document chunks are a
76
+ # classic PII carrier, and gen_ai.input.messages is defined for
77
+ # chat messages, not document batches). Tokens + models carry
78
+ # the signal; widening this is a fleet ruling, not a shim choice.
79
+ LLM.request_attributes(
80
+ provider: "openai", operation: "embeddings",
81
+ model: params[:model] || params["model"]
82
+ )
83
+ end
84
+ end
85
+
86
+ # ruby-openai returns plain parsed-JSON Hashes (string keys):
87
+ # {"id"=>..., "model"=>..., "choices"=>[{"message"=>{...},
88
+ # "finish_reason"=>...}], "usage"=>{"prompt_tokens"=>N, ...}}.
89
+ CHAT_RESPONSE = lambda do |response|
90
+ h = response.is_a?(Hash) ? response : {}
91
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
92
+ choices = h["choices"].is_a?(Array) ? h["choices"] : []
93
+ LLM.response_attributes(
94
+ model: h["model"],
95
+ id: h["id"],
96
+ finish_reasons: choices.map { |c| c.is_a?(Hash) ? c["finish_reason"] : nil }.compact,
97
+ input_tokens: usage["prompt_tokens"],
98
+ output_tokens: usage["completion_tokens"],
99
+ output_messages: choices.map { |c| c.is_a?(Hash) ? c["message"] : nil }.compact
100
+ )
101
+ end
102
+
103
+ # Embeddings response: usage.prompt_tokens only; the vectors are
104
+ # NOT content (and would be enormous) — count them, never ship them.
105
+ EMBEDDINGS_RESPONSE = lambda do |response|
106
+ h = response.is_a?(Hash) ? response : {}
107
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
108
+ LLM.response_attributes(
109
+ model: h["model"],
110
+ input_tokens: usage["prompt_tokens"]
111
+ )
112
+ end
113
+
114
+ module ChatPatch
115
+ def chat(parameters: {})
116
+ Foam::Otel::LLM.observe(
117
+ provider: "openai",
118
+ request: Foam::Otel::LLM.safe_request(provider: "openai") { RubyOpenAIShim.chat_request(parameters) },
119
+ response_extractor: RubyOpenAIShim::CHAT_RESPONSE
120
+ ) { super }
121
+ end
122
+ end
123
+
124
+ module EmbeddingsPatch
125
+ def embeddings(parameters: {})
126
+ Foam::Otel::LLM.observe(
127
+ provider: "openai", operation: "embeddings",
128
+ request: Foam::Otel::LLM.safe_request(provider: "openai") { RubyOpenAIShim.embeddings_request(parameters) },
129
+ response_extractor: RubyOpenAIShim::EMBEDDINGS_RESPONSE
130
+ ) { super }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
data/lib/foam/otel/llm.rb CHANGED
@@ -13,13 +13,22 @@ module Foam
13
13
  # `_openai_responses.py` and js/otel's anthropic gap-filler — over the
14
14
  # SDKs' public call sites:
15
15
  #
16
- # * `openai` (official OpenAI Ruby SDK) — chat.completions.create +
17
- # responses.create
18
- # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
- # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
- # Ruby SDK) — generate_content / stream_generate_content
21
- # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
- # covering its OpenAI/Anthropic/Gemini/... providers
16
+ # * `openai` (official OpenAI Ruby SDK) — chat.completions.create +
17
+ # responses.create
18
+ # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
+ # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
+ # Ruby SDK) — generate_content / stream_generate_content
21
+ # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
+ # covering its OpenAI/Anthropic/Gemini/... providers
23
+ # * `ruby-openai` (the long-standing COMMUNITY OpenAI client —
24
+ # OpenAI::Client#chat(parameters:) / #embeddings; shares
25
+ # the OpenAI namespace with the official gem, so presence
26
+ # is checked by the parameters:-keyword SHAPE, never the
27
+ # bare method name) — chat + embeddings
28
+ # * `http` (HTTP.rb) — hand-rolled-provider-client pattern: POSTs
29
+ # to api.anthropic.com/v1/messages from SDK-less apps;
30
+ # shape-checked per call so every other HTTP.rb request
31
+ # in the process keeps its plain transport span
23
32
  #
24
33
  # Contract invariants (identical to the python/js gap-fillers):
25
34
  # * Content is CAPTURED, then redacted BEFORE serialization (coverage
@@ -33,12 +42,15 @@ module Foam
33
42
  # fail-to-dark; the wrapper NEVER throws into app code or fails the
34
43
  # customer's model call — the SDK call's own error re-raises
35
44
  # IDENTICALLY, telemetry failures degrade to an unspanned call.
36
- # * ONE logical LLM call = ONE foam client span: the SDK's own HTTP send
37
- # runs inside the OTel suppression context the official http
38
- # instrumentations honor (Common::Utilities.untraced — the same
39
- # mechanism the exporters use, GOTCHAS G4), so the transport is never
40
- # double-spanned. Feature-detected; absent the call runs in the
41
- # gen_ai span's context and the transport span parents under it.
45
+ # * ONE logical LLM call = ONE foam gen_ai span: the SDK's own HTTP
46
+ # send runs inside the OTel suppression context (Common::Utilities
47
+ # .untraced — the same mechanism the exporters use, GOTCHAS G4).
48
+ # Instrumentations that honor it (net_http, excon) emit no transport
49
+ # span at all; those that DON'T (contrib -http GOTCHAS G13, the
50
+ # UNGUARDABLE list) emit a transport span that NESTS beneath the
51
+ # gen_ai span — the contract's sanctioned rendering (SPEC §10: a
52
+ # different layer, like rails-inside-rack). Either way exactly one
53
+ # gen_ai span. Feature-detected; Utilities absent → nesting.
42
54
  # * Wire vocabulary (rule 26): current GenAI semconv exactly as the
43
55
  # fleet's other packages emit it — span name "{operation} {model}",
44
56
  # gen_ai.operation.name / gen_ai.provider.name / request+response
@@ -69,7 +81,7 @@ module Foam
69
81
 
70
82
  class << self
71
83
  def adapters
72
- [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim]
84
+ [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim, RubyOpenAIShim, HttpAnthropicShim]
73
85
  end
74
86
 
75
87
  # Presence-checked, fault-isolated activation — one broken adapter
@@ -118,7 +130,7 @@ module Foam
118
130
  # value passes through untouched, its error re-raises identically.
119
131
  # Every telemetry step is individually guarded — a shim failure can
120
132
  # only ever mean a missing span, never a broken model call.
121
- def observe(provider:, request:, operation: "chat", response_extractor: nil, &block)
133
+ def observe(provider:, request:, operation: "chat", response_extractor: nil, error_detector: nil, &block)
122
134
  return yield unless emit?
123
135
 
124
136
  span = start_span(provider, operation, request)
@@ -132,6 +144,13 @@ module Foam
132
144
  raise
133
145
  end
134
146
  begin
147
+ # Non-raising transports (HTTP.rb: a 4xx/5xx is a RETURN VALUE,
148
+ # not an exception) surface their error through the detector —
149
+ # the observed status becomes the span status (rule 25).
150
+ detected = error_detector&.call(result)
151
+ if detected
152
+ span.status = OpenTelemetry::Trace::Status.error(detected.to_s)
153
+ end
135
154
  response = response_extractor&.call(result)
136
155
  apply_attributes(span, response) if response
137
156
  rescue StandardError, SystemStackError
@@ -237,7 +256,10 @@ module Foam
237
256
 
238
257
  def start_span(provider, operation, request)
239
258
  model = request[GEN_AI_REQUEST_MODEL]
240
- name = model ? "#{operation} #{model}" : operation
259
+ # Contract SPEC §10 span-name shape "<operation> <model|provider>":
260
+ # when the model is unextractable the provider stands in — never a
261
+ # bare operation name.
262
+ name = "#{operation} #{model || provider}"
241
263
  OpenTelemetry.tracer_provider.tracer(TRACER_NAME).start_span(
242
264
  name, kind: :client, attributes: request
243
265
  )
@@ -276,8 +298,13 @@ module Foam
276
298
  span.status = OpenTelemetry::Trace::Status.error(error.class.to_s)
277
299
  apply_attributes(span, ERROR_TYPE => error.class.to_s)
278
300
  # The SDKs' API errors carry the REAL transport status — emitted
279
- # only when OBSERVED (rule 25: never fabricate).
280
- status = error.respond_to?(:status) ? error.status : nil
301
+ # only when OBSERVED (rule 25: never fabricate). Faraday-backed
302
+ # SDKs (ruby-openai) expose it as #response_status.
303
+ status = if error.respond_to?(:status)
304
+ error.status
305
+ elsif error.respond_to?(:response_status)
306
+ error.response_status
307
+ end
281
308
  apply_attributes(span, HTTP_RESPONSE_STATUS_CODE => status) if status.is_a?(Integer)
282
309
  span.record_exception(error) if error.is_a?(Exception)
283
310
  rescue StandardError, SystemStackError
@@ -298,3 +325,5 @@ require_relative "llm/openai_shim"
298
325
  require_relative "llm/anthropic_shim"
299
326
  require_relative "llm/gemini_shim"
300
327
  require_relative "llm/ruby_llm_shim"
328
+ require_relative "llm/ruby_openai_shim"
329
+ require_relative "llm/http_anthropic_shim"
@@ -194,6 +194,56 @@ module Foam
194
194
  # a combination never tested. MAJOR: narrowing the supported-runtime
195
195
  # label is breaking for installs on the dropped versions. No wire/API
196
196
  # change — every 1.x contract above still holds.
197
- VERSION = "2.0.0"
197
+ # 2.1.0: LLM GAP-FILLER SHIMS + HTTP::Client COEXISTENCE SHIELD (MINOR —
198
+ # additive instrumentations, zero default-path behavior change for apps
199
+ # without the gems). Two new presence-checked, version-windowed LLM
200
+ # adapters closing the SDK-less-call gap: RubyOpenAIShim for the
201
+ # ruby-openai community client (OpenAI::Client#chat(parameters:) /
202
+ # #embeddings — presence by the parameters:-keyword SHAPE, never the
203
+ # bare method name: the official openai gem shares the OpenAI
204
+ # namespace and defines Client#chat as a zero-arg resource accessor,
205
+ # so a name check misfires on official-gem apps; both cross-gem
206
+ # negatives are pinned. Embeddings emit tokens + models ONLY — input
207
+ # documents are never captured, matching the python core's contrib
208
+ # openai-v2 path), and
209
+ # HttpAnthropicShim for hand-rolled HTTP.rb Anthropic clients (POST
210
+ # api.anthropic.com/v1/messages — EXACT path match; near-miss
211
+ # endpoints such as /v1/messages/count_tokens and
212
+ # /v1/messages/batches are pinned negative, never fabricated —
213
+ # including the persistent-client idiom
214
+ # HTTP.persistent(origin).post("/v1/messages", ...)), shape-checked
215
+ # per call so every other HTTP.rb request keeps only its plain
216
+ # transport span.
217
+ # Both inherit the LLM contract: RAW content capture redacted before
218
+ # serialization, one gen_ai span per logical call (transport spans
219
+ # from untraced-blind gems nest beneath — GOTCHAS G13),
220
+ # fail-to-dark everywhere. The transport shim is never the FIRST
221
+ # reader of the app's response: bounded JSON bodies
222
+ # (Content-Length ≤ 32 KiB) are captured and handed back re-readable
223
+ # (to_s/each/readpartial all intact); SSE (text/event-stream),
224
+ # chunked, and oversized bodies are never materialized (rule 49 cap;
225
+ # content omitted, activity + observed status survive). LLM.observe
226
+ # gains an optional error_detector: hook — HTTP.rb never raises on
227
+ # 4xx/5xx, so the shim turns the OBSERVED status into an ERROR span
228
+ # (rule 25); the status code rides http.response.status_code on the
229
+ # ERROR path ONLY (success gen_ai spans stay http.*-free, the fleet
230
+ # vocabulary) and error.type is the bare status string ("400").
231
+ # Degraded gen_ai span names now fall back to
232
+ # "<operation> <provider>" (contract SPEC §10 shape) instead of a
233
+ # bare operation, and record_failure reads Faraday-style
234
+ # #response_status so ruby-openai provider errors carry their
235
+ # observed status. ALSO
236
+ # (coexistence mandate, GOTCHAS F18 twin): the prepend-collision shield
237
+ # now also scans HTTP::Client — dd-trace-rb's httprb patch and the
238
+ # contrib http patch define annotate_span_with_response! at
239
+ # non-identical signatures (3-arg vs 2-arg), the exact F18 Net::HTTP
240
+ # collision on a second class, live in the wild under
241
+ # datadog/auto_instrument (upstream tracks it as unsupported:
242
+ # dd-trace-rb#3575, opentelemetry-ruby#1625, httprb/http#838); the
243
+ # signature-dispatch shim lets both agents keep working WHEN both
244
+ # vendor patches precede foam's init-time scan (F18 residual:
245
+ # initialize foam after the vendor agents; a later-arriving patch is
246
+ # unshielded until a re-init/fork re-scan).
247
+ VERSION = "2.1.0"
198
248
  end
199
249
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -395,8 +395,10 @@ files:
395
395
  - lib/foam/otel/llm.rb
396
396
  - lib/foam/otel/llm/anthropic_shim.rb
397
397
  - lib/foam/otel/llm/gemini_shim.rb
398
+ - lib/foam/otel/llm/http_anthropic_shim.rb
398
399
  - lib/foam/otel/llm/openai_shim.rb
399
400
  - lib/foam/otel/llm/ruby_llm_shim.rb
401
+ - lib/foam/otel/llm/ruby_openai_shim.rb
400
402
  - lib/foam/otel/logger_bridge.rb
401
403
  - lib/foam/otel/metrics.rb
402
404
  - lib/foam/otel/payload_capture.rb