riffer 0.32.0 → 0.33.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +34 -0
  5. data/README.md +13 -11
  6. data/docs/01_OVERVIEW.md +2 -0
  7. data/docs/04_AGENT_LIFECYCLE.md +15 -13
  8. data/docs/08_MESSAGES.md +39 -5
  9. data/docs/09_STREAM_EVENTS.md +14 -0
  10. data/docs/10_CONFIGURATION.md +73 -4
  11. data/docs/13_SKILLS.md +66 -4
  12. data/docs/14_MCP.md +2 -1
  13. data/docs/16_TRACING.md +250 -0
  14. data/docs/17_METRICS.md +123 -0
  15. data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
  16. data/lib/riffer/agent/response.rb +11 -2
  17. data/lib/riffer/agent/run.rb +136 -35
  18. data/lib/riffer/agent.rb +5 -5
  19. data/lib/riffer/config.rb +231 -15
  20. data/lib/riffer/guardrail.rb +8 -0
  21. data/lib/riffer/guardrails/runner.rb +33 -0
  22. data/lib/riffer/helpers/boolean.rb +22 -0
  23. data/lib/riffer/mcp/authenticated_tool.rb +14 -20
  24. data/lib/riffer/mcp/registration.rb +4 -4
  25. data/lib/riffer/mcp/tool.rb +23 -0
  26. data/lib/riffer/mcp/tool_factory.rb +14 -22
  27. data/lib/riffer/messages/assistant.rb +15 -3
  28. data/lib/riffer/messages/base.rb +2 -1
  29. data/lib/riffer/metrics/instruments.rb +25 -0
  30. data/lib/riffer/metrics/null.rb +14 -0
  31. data/lib/riffer/metrics/otel.rb +79 -0
  32. data/lib/riffer/metrics.rb +93 -0
  33. data/lib/riffer/providers/amazon_bedrock.rb +57 -21
  34. data/lib/riffer/providers/anthropic.rb +59 -24
  35. data/lib/riffer/providers/azure_open_ai.rb +7 -0
  36. data/lib/riffer/providers/base.rb +247 -15
  37. data/lib/riffer/providers/finish_reason.rb +27 -0
  38. data/lib/riffer/providers/gemini.rb +59 -11
  39. data/lib/riffer/providers/mock.rb +30 -9
  40. data/lib/riffer/providers/open_ai.rb +78 -24
  41. data/lib/riffer/providers/open_router.rb +56 -16
  42. data/lib/riffer/providers/repository.rb +9 -0
  43. data/lib/riffer/providers/token_usage.rb +27 -11
  44. data/lib/riffer/skills/activate_tool.rb +12 -2
  45. data/lib/riffer/skills/adapter.rb +15 -0
  46. data/lib/riffer/skills/context.rb +78 -11
  47. data/lib/riffer/skills/frontmatter.rb +13 -5
  48. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  49. data/lib/riffer/skills/xml_adapter.rb +1 -1
  50. data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
  51. data/lib/riffer/tools/runtime.rb +99 -3
  52. data/lib/riffer/tracing/capture.rb +92 -0
  53. data/lib/riffer/tracing/null.rb +61 -0
  54. data/lib/riffer/tracing/otel.rb +131 -0
  55. data/lib/riffer/tracing/stream_recorder.rb +51 -0
  56. data/lib/riffer/tracing.rb +78 -0
  57. data/lib/riffer/version.rb +1 -1
  58. data/sig/_private/opentelemetry.rbs +22 -0
  59. data/sig/generated/riffer/agent/response.rbs +9 -2
  60. data/sig/generated/riffer/agent/run.rbs +28 -8
  61. data/sig/generated/riffer/config.rbs +162 -16
  62. data/sig/generated/riffer/guardrail.rbs +6 -0
  63. data/sig/generated/riffer/guardrails/runner.rbs +14 -0
  64. data/sig/generated/riffer/helpers/boolean.rbs +11 -0
  65. data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
  66. data/sig/generated/riffer/mcp/registration.rbs +4 -4
  67. data/sig/generated/riffer/mcp/tool.rbs +19 -0
  68. data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
  69. data/sig/generated/riffer/messages/assistant.rbs +10 -4
  70. data/sig/generated/riffer/metrics/instruments.rbs +13 -0
  71. data/sig/generated/riffer/metrics/null.rbs +10 -0
  72. data/sig/generated/riffer/metrics/otel.rbs +47 -0
  73. data/sig/generated/riffer/metrics.rbs +71 -0
  74. data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
  75. data/sig/generated/riffer/providers/anthropic.rbs +41 -20
  76. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
  77. data/sig/generated/riffer/providers/base.rbs +78 -2
  78. data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
  79. data/sig/generated/riffer/providers/gemini.rbs +25 -2
  80. data/sig/generated/riffer/providers/mock.rbs +16 -5
  81. data/sig/generated/riffer/providers/open_ai.rbs +44 -22
  82. data/sig/generated/riffer/providers/open_router.rbs +31 -12
  83. data/sig/generated/riffer/providers/repository.rbs +7 -0
  84. data/sig/generated/riffer/providers/token_usage.rbs +20 -10
  85. data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
  86. data/sig/generated/riffer/skills/adapter.rbs +10 -0
  87. data/sig/generated/riffer/skills/context.rbs +52 -4
  88. data/sig/generated/riffer/skills/frontmatter.rbs +10 -3
  89. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
  90. data/sig/generated/riffer/tools/runtime.rbs +35 -0
  91. data/sig/generated/riffer/tracing/capture.rbs +46 -0
  92. data/sig/generated/riffer/tracing/null.rbs +46 -0
  93. data/sig/generated/riffer/tracing/otel.rbs +83 -0
  94. data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
  95. data/sig/generated/riffer/tracing.rbs +52 -0
  96. data/sig/manual/riffer/helpers/boolean.rbs +5 -0
  97. data/sig/manual/riffer/metrics/null.rbs +5 -0
  98. data/sig/manual/riffer/metrics.rbs +5 -0
  99. data/sig/manual/riffer/providers.rbs +9 -0
  100. data/sig/manual/riffer/tracing/capture.rbs +5 -0
  101. data/sig/manual/riffer/tracing/null.rbs +5 -0
  102. data/sig/manual/riffer/tracing.rbs +5 -0
  103. metadata +40 -4
@@ -2,38 +2,35 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  # Wraps MCP-generated tool classes so +tools/call+ resolves
5
- # +Riffer.config.mcp.credentials+ per invocation, delegating metadata to the
6
- # inner class.
5
+ # +Riffer.config.mcp.credentials+ per invocation, copying metadata from the
6
+ # inner class at wrap time.
7
7
  module Riffer::Mcp::AuthenticatedTool
8
8
  extend self
9
9
 
10
10
  # Returns one wrapper class per inner tool, sharing +manifest+ and +matched_tags+.
11
11
  #
12
12
  #--
13
- #: (Array[singleton(Riffer::Tool)], Riffer::Mcp::Manifest, Array[Symbol]) -> Array[singleton(Riffer::Tool)]
13
+ #: (Array[singleton(Riffer::Mcp::Tool)], Riffer::Mcp::Manifest, Array[Symbol]) -> Array[singleton(Riffer::Mcp::Tool)]
14
14
  def wrap_all(tool_classes, manifest, matched_tags)
15
15
  tool_classes.map { |tc| wrap_one(tc, manifest, matched_tags) }
16
16
  end
17
17
 
18
18
  #--
19
- #: (singleton(Riffer::Tool), Riffer::Mcp::Manifest, Array[Symbol]) -> singleton(Riffer::Tool)
20
- # Class.new(Riffer::Tool) is typed as ::Class by steep — it cannot verify the subtype
21
- # relationship for dynamically created anonymous classes, so the ignore is required.
22
- def wrap_one(inner_class, manifest, matched_tags) # steep:ignore MethodBodyTypeMismatch
19
+ #: (singleton(Riffer::Mcp::Tool), Riffer::Mcp::Manifest, Array[Symbol]) -> singleton(Riffer::Mcp::Tool)
20
+ def wrap_one(inner_class, manifest, matched_tags)
23
21
  inner = inner_class
24
22
  man = manifest
25
23
  tags = matched_tags
26
24
 
27
- Class.new(Riffer::Tool) do
28
- # steep cannot type the body of a dynamically created anonymous class:
29
- # its ivars and `self` inside define_method are unresolvable.
25
+ # steep does not model Class.new's class_eval semantics — the block body
26
+ # typechecks against the enclosing module, so the ivar assignments and the
27
+ # define_method bodies are unresolvable.
28
+ Class.new(Riffer::Mcp::Tool) do
30
29
  # steep:ignore:start
31
30
  @identifier = inner.identifier
32
-
33
- define_singleton_method(:name) { inner.name }
34
- define_singleton_method(:mcp_server_tool_name) { inner.mcp_server_tool_name }
35
- define_singleton_method(:description) { inner.description }
36
- define_singleton_method(:parameters_schema) { |strict: false| inner.parameters_schema(strict: strict) }
31
+ @description = inner.description
32
+ @input_schema = inner.parameters_schema
33
+ @mcp_server_tool_name = inner.mcp_server_tool_name
37
34
 
38
35
  # Creates a fresh client per +tools/call+ so headers from the credentials
39
36
  # proc stay current.
@@ -48,9 +45,6 @@ module Riffer::Mcp::AuthenticatedTool
48
45
  define_method(:call) do |context:, **kwargs|
49
46
  cred = Riffer.config.mcp.credentials
50
47
  unless cred
51
- # `next` rather than `return`: inside define_method the block IS the method
52
- # body, so both exit :call identically at runtime. `next` avoids a false
53
- # steep ReturnTypeMismatch that would otherwise need a steep:ignore.
54
48
  next inner.new.call(context: context, **kwargs)
55
49
  end
56
50
 
@@ -61,9 +55,9 @@ module Riffer::Mcp::AuthenticatedTool
61
55
  end
62
56
 
63
57
  client = build_call_client(man.endpoint, headers)
64
- text(client.tools_call(inner.mcp_server_tool_name, kwargs))
58
+ text(client.tools_call(self.class.mcp_server_tool_name, kwargs))
65
59
  end
66
60
  # steep:ignore:end
67
- end
61
+ end #: singleton(Riffer::Mcp::Tool)
68
62
  end
69
63
  end
@@ -5,16 +5,16 @@
5
5
  # +tools/list+ and generates tool classes when a server is registered.
6
6
  class Riffer::Mcp::Registration
7
7
  # @rbs @cancelled: bool
8
- # @rbs @tools: Array[singleton(Riffer::Tool)]
8
+ # @rbs @tools: Array[singleton(Riffer::Mcp::Tool)]
9
9
  # @rbs @mutex: Thread::Mutex
10
10
 
11
11
  # The manifest that describes this server.
12
12
  attr_reader :manifest #: Riffer::Mcp::Manifest
13
13
 
14
- # Generated Riffer::Tool subclasses.
14
+ # Generated Riffer::Mcp::Tool subclasses.
15
15
  #
16
16
  #--
17
- #: () -> Array[singleton(Riffer::Tool)]
17
+ #: () -> Array[singleton(Riffer::Mcp::Tool)]
18
18
  def tools
19
19
  @mutex.synchronize { @tools }
20
20
  end
@@ -24,7 +24,7 @@ class Riffer::Mcp::Registration
24
24
  def initialize(manifest)
25
25
  @manifest = manifest
26
26
  @cancelled = false
27
- @tools = [] #: Array[singleton(Riffer::Tool)]
27
+ @tools = [] #: Array[singleton(Riffer::Mcp::Tool)]
28
28
  @mutex = Mutex.new
29
29
  run_discovery
30
30
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Base class for MCP-generated tools.
5
+ class Riffer::Mcp::Tool < Riffer::Tool
6
+ # @rbs self.@mcp_server_tool_name: String?
7
+ # @rbs self.@input_schema: Hash[Symbol, untyped]?
8
+
9
+ # Returns the unprefixed tool name used for +tools/call+ on the MCP server.
10
+ #--
11
+ #: () -> String
12
+ def self.mcp_server_tool_name
13
+ @mcp_server_tool_name || raise(NotImplementedError, "#{self} must set @mcp_server_tool_name")
14
+ end
15
+
16
+ # Returns the server-published input schema, falling back to the params DSL.
17
+ # MCP schemas are server-defined, so +strict+ is not applied to them.
18
+ #--
19
+ #: (?strict: bool) -> Hash[Symbol, untyped]
20
+ def self.parameters_schema(strict: false)
21
+ @input_schema || super
22
+ end
23
+ end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Generates anonymous Riffer::Tool subclasses from MCP tool definitions.
4
+ # Generates anonymous Riffer::Mcp::Tool subclasses from MCP tool definitions.
5
5
  # Generated tools delegate +#call+ to the MCP client and skip Riffer's param
6
6
  # validation — the MCP server validates inputs.
7
7
  module Riffer::Mcp::ToolFactory
8
8
  extend self
9
9
 
10
- # Builds one Riffer::Tool subclass per tool definition, prefixing names with
11
- # the manifest name to avoid cross-server collisions (e.g. +jira__search+);
12
- # the server-side name stays on +.mcp_server_tool_name+.
10
+ # Builds one Riffer::Mcp::Tool subclass per tool definition, prefixing names
11
+ # with the manifest name to avoid cross-server collisions (e.g.
12
+ # +jira__search+); the server-side name stays on +.mcp_server_tool_name+.
13
13
  #--
14
- #: (String, Riffer::Mcp::Client, Array[Hash[Symbol, untyped]]) -> Array[singleton(Riffer::Tool)]
14
+ #: (String, Riffer::Mcp::Client, Array[Hash[Symbol, untyped]]) -> Array[singleton(Riffer::Mcp::Tool)]
15
15
  def build(manifest_name, client, tool_defs)
16
16
  tool_defs.map { |td| build_tool_class(manifest_name, client, td) }
17
17
  end
@@ -23,32 +23,24 @@ module Riffer::Mcp::ToolFactory
23
23
  str.gsub(/[^a-zA-Z0-9_-]/, "_")
24
24
  end
25
25
 
26
+ #: (String, Riffer::Mcp::Client, Hash[Symbol, untyped]) -> singleton(Riffer::Mcp::Tool)
26
27
  def build_tool_class(manifest_name, client, td)
27
28
  prefixed = "#{sanitize_name_component(manifest_name)}__#{sanitize_name_component(td[:name])}"
28
29
 
29
- # steep cannot type the body of a dynamically created anonymous class:
30
- # its ivars and `self` inside define_method are unresolvable, so the
31
- # block is ignored wholesale (cf. AuthenticatedTool.wrap_one).
32
- Class.new(Riffer::Tool) do
30
+ # steep does not model Class.new's class_eval semantics the block body
31
+ # typechecks against the enclosing module, so the ivar assignments and the
32
+ # define_method body are unresolvable.
33
+ Class.new(Riffer::Mcp::Tool) do
33
34
  # steep:ignore:start
34
- @mcp_client = client
35
35
  @mcp_server_tool_name = td[:name]
36
- # Set @identifier directly so .identifier does not fall back to
37
- # Riffer::Helpers::ClassNameConverter.convert(nil) on this anonymous class.
38
36
  @identifier = prefixed
39
-
40
- define_singleton_method(:name) { prefixed }
41
- define_singleton_method(:mcp_server_tool_name) { td[:name] }
42
- define_singleton_method(:description) { td[:description] }
43
- define_singleton_method(:parameters_schema) { |strict: false| td[:input_schema] || Riffer::Tool.send(:empty_schema) }
37
+ @description = td[:description]
38
+ @input_schema = td[:input_schema]
44
39
 
45
40
  define_method(:call) do |context:, **kwargs|
46
- result = self.class.instance_variable_get(:@mcp_client).tools_call(
47
- self.class.instance_variable_get(:@mcp_server_tool_name), kwargs
48
- )
49
- text(result)
41
+ text(client.tools_call(self.class.mcp_server_tool_name, kwargs))
50
42
  end
51
43
  # steep:ignore:end
52
- end
44
+ end #: singleton(Riffer::Mcp::Tool)
53
45
  end
54
46
  end
@@ -4,7 +4,7 @@
4
4
  # Represents an assistant (LLM) message in a conversation; may include tool
5
5
  # calls when the LLM requests tool execution.
6
6
  class Riffer::Messages::Assistant < Riffer::Messages::Base
7
- ToolCall = Struct.new(:call_id, :name, :arguments, keyword_init: true)
7
+ ToolCall = Struct.new(:call_id, :name, :arguments)
8
8
 
9
9
  # Array of tool calls requested by the assistant.
10
10
  attr_reader :tool_calls #: Array[Riffer::Messages::Assistant::ToolCall]
@@ -15,13 +15,24 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
15
15
  # Parsed structured output hash, or nil when not applicable.
16
16
  attr_reader :structured_output #: Hash[Symbol, untyped]?
17
17
 
18
+ # Normalized reason the provider finished this response, when reported (see
19
+ # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
20
+ attr_reader :finish_reason #: Symbol?
21
+
22
+ # Raises Riffer::ArgumentError when +finish_reason+ is outside the
23
+ # normalized vocabulary.
18
24
  #--
19
- #: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?) -> void
20
- def initialize(content, id: nil, tool_calls: [], token_usage: nil, structured_output: nil)
25
+ #: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
26
+ def initialize(content, id: nil, tool_calls: [], token_usage: nil, structured_output: nil, finish_reason: nil)
27
+ if finish_reason && !Riffer::Providers::FinishReason::VALUES.include?(finish_reason)
28
+ raise Riffer::ArgumentError, "finish_reason must be one of #{Riffer::Providers::FinishReason::VALUES.inspect}, got #{finish_reason.inspect}"
29
+ end
30
+
21
31
  super(content, id: id)
22
32
  @tool_calls = tool_calls
23
33
  @token_usage = token_usage
24
34
  @structured_output = structured_output
35
+ @finish_reason = finish_reason
25
36
  end
26
37
 
27
38
  #--
@@ -58,6 +69,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
58
69
  hash[:tool_calls] = tool_calls.map(&:to_h) unless tool_calls.empty?
59
70
  hash[:token_usage] = token_usage.to_h if token_usage
60
71
  hash[:structured_output] = structured_output if structured_output?
72
+ hash[:finish_reason] = finish_reason if finish_reason
61
73
  hash
62
74
  end
63
75
  end
@@ -34,7 +34,8 @@ class Riffer::Messages::Base
34
34
  tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
35
35
  }
36
36
  structured_output = msg[:structured_output]
37
- Riffer::Messages::Assistant.new(content, id: id, tool_calls: tool_calls, structured_output: structured_output)
37
+ finish_reason = msg[:finish_reason]&.to_sym
38
+ Riffer::Messages::Assistant.new(content, id: id, tool_calls: tool_calls, structured_output: structured_output, finish_reason: finish_reason)
38
39
  when :system
39
40
  Riffer::Messages::System.new(content, id: id)
40
41
  when :tool
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # The catalog of metric instruments riffer records. Each handle is a constant
5
+ # that resolves its backend at record time, so it survives a meter-provider swap
6
+ # or a runtime +enabled+ flip.
7
+ module Riffer::Metrics::Instruments # :nodoc: all
8
+ OPERATION_DURATION = Riffer::Metrics.create_histogram(
9
+ "gen_ai.client.operation.duration",
10
+ unit: "s",
11
+ description: "Duration of GenAI client operations"
12
+ ) #: Riffer::Metrics::Histogram
13
+
14
+ TOKEN_USAGE = Riffer::Metrics.create_histogram(
15
+ "gen_ai.client.token.usage",
16
+ unit: "{token}",
17
+ description: "Number of input and output tokens used in GenAI operations"
18
+ ) #: Riffer::Metrics::Histogram
19
+
20
+ COST = Riffer::Metrics.create_histogram(
21
+ "riffer.gen_ai.cost",
22
+ unit: "USD",
23
+ description: "Cost of GenAI client operations in USD"
24
+ ) #: Riffer::Metrics::Histogram
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # No-op metrics backend, used when the OpenTelemetry metrics API is unavailable
5
+ # or metrics are disabled.
6
+ module Riffer::Metrics::Null # :nodoc: all
7
+ extend self
8
+
9
+ # Ignores the measurement; there is no meter without the OTEL metrics API.
10
+ #--
11
+ #: (String, Numeric, unit: String?, description: String?, attributes: Hash[String, untyped]?) -> void
12
+ def record_histogram(name, value, unit:, description:, attributes:)
13
+ end
14
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # OTEL-backed metrics backend. <tt>::OpenTelemetry</tt> constants appear only
5
+ # inside method bodies here, so the gem loads and eager-loads cleanly when the
6
+ # OpenTelemetry metrics API is absent.
7
+ class Riffer::Metrics::Otel # :nodoc: all
8
+ SUPPORTED_API_VERSIONS = Gem::Requirement.new(">= 0.2", "< 1.0") #: Gem::Requirement
9
+
10
+ class << self
11
+ # Builds a backend when the OpenTelemetry metrics API is loadable at a
12
+ # supported version; returns +nil+ so resolution falls back to Null.
13
+ #--
14
+ #: (provider: untyped) -> Riffer::Metrics::Otel?
15
+ def build(provider:)
16
+ version = api_version
17
+ return nil unless version
18
+
19
+ unless supported?(version)
20
+ Kernel.warn "riffer: opentelemetry-metrics-api #{version} is outside the supported range (#{SUPPORTED_API_VERSIONS}); metrics are disabled"
21
+ return nil
22
+ end
23
+
24
+ new(provider: provider || ::OpenTelemetry.meter_provider)
25
+ end
26
+
27
+ # Whether the OpenTelemetry metrics API gem is loadable at a supported
28
+ # version.
29
+ #--
30
+ #: () -> bool
31
+ def available?
32
+ version = api_version
33
+ !version.nil? && supported?(version)
34
+ end
35
+
36
+ # Whether the given opentelemetry-metrics-api version is one riffer codes
37
+ # against. The gem is undeclared, so this guard is the only protection
38
+ # against an incompatible, still-pre-1.0 API.
39
+ #--
40
+ #: (Gem::Version) -> bool
41
+ def supported?(version)
42
+ SUPPORTED_API_VERSIONS.satisfied_by?(version)
43
+ end
44
+
45
+ private
46
+
47
+ #--
48
+ #: () -> Gem::Version?
49
+ def api_version
50
+ require "opentelemetry-metrics-api"
51
+ spec = Gem.loaded_specs["opentelemetry-metrics-api"] #: untyped
52
+ spec&.version
53
+ rescue ::LoadError
54
+ nil
55
+ end
56
+ end
57
+
58
+ # @rbs @meter: untyped
59
+ # @rbs @instruments: Hash[String, untyped]
60
+ # @rbs @mutex: Mutex
61
+
62
+ #--
63
+ #: (provider: untyped) -> void
64
+ def initialize(provider:)
65
+ @meter = provider.meter("riffer", version: Riffer::VERSION)
66
+ @instruments = {}
67
+ @mutex = Mutex.new
68
+ end
69
+
70
+ # Records a value onto the named histogram.
71
+ #--
72
+ #: (String, Numeric, unit: String?, description: String?, attributes: Hash[String, untyped]?) -> void
73
+ def record_histogram(name, value, unit:, description:, attributes:)
74
+ histogram = @mutex.synchronize do
75
+ @instruments[name] ||= @meter.create_histogram(name, unit: unit, description: description)
76
+ end
77
+ histogram.record(value, attributes: attributes)
78
+ end
79
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Internal metrics port — records OTEL metric instruments when the host bundles
5
+ # the OpenTelemetry metrics API and no-ops otherwise, so riffer never declares
6
+ # an OTEL dependency.
7
+ module Riffer::Metrics # :nodoc: all
8
+ extend self
9
+
10
+ # @rbs @backend: (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))?
11
+
12
+ MUTEX = Mutex.new #: Mutex
13
+
14
+ # The Ruby API cannot attach a schema URL to a meter, so the semconv pin
15
+ # lives here as the documented contract version.
16
+ SCHEMA_URL = "https://opentelemetry.io/schemas/1.37.0" #: String
17
+
18
+ # A handle to a named histogram, safe to hold as a constant: it defers backend
19
+ # resolution to record time, so it survives a meter-provider swap or a runtime
20
+ # +enabled+ flip.
21
+ class Histogram
22
+ # @rbs @name: String
23
+ # @rbs @unit: String?
24
+ # @rbs @description: String?
25
+
26
+ #--
27
+ #: (String, ?unit: String?, ?description: String?) -> void
28
+ def initialize(name, unit: nil, description: nil)
29
+ @name = name
30
+ @unit = unit
31
+ @description = description
32
+ end
33
+
34
+ #--
35
+ #: (Numeric, ?attributes: Hash[String, untyped]?) -> void
36
+ def record(value, attributes: nil)
37
+ Riffer::Metrics.record_histogram(@name, value, unit: @unit, description: @description, attributes: attributes)
38
+ end
39
+ end
40
+
41
+ # Returns a handle to the named histogram.
42
+ #--
43
+ #: (String, ?unit: String?, ?description: String?) -> Riffer::Metrics::Histogram
44
+ def create_histogram(name, unit: nil, description: nil)
45
+ Histogram.new(name, unit: unit, description: description)
46
+ end
47
+
48
+ # Records a value onto the named histogram.
49
+ #--
50
+ #: (String, Numeric, ?unit: String?, ?description: String?, ?attributes: Hash[String, untyped]?) -> void
51
+ def record_histogram(name, value, unit: nil, description: nil, attributes: nil)
52
+ return unless Riffer.config.metrics.enabled
53
+ backend.record_histogram(name, value, unit: unit, description: description, attributes: attributes)
54
+ end
55
+
56
+ # Mirrors a span's +recording?+ so a caller can skip work that exists only to
57
+ # feed a metric.
58
+ #--
59
+ #: () -> bool
60
+ def recording?
61
+ Riffer.config.metrics.enabled && backend.is_a?(Otel)
62
+ end
63
+
64
+ # Reads the monotonic clock in seconds — the time source for duration metrics,
65
+ # immune to wall-clock adjustments.
66
+ #--
67
+ #: () -> Float
68
+ def monotonic_now
69
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ end
71
+
72
+ # Discards the resolved backend so the next record re-resolves it; cached
73
+ # instruments live on that backend, so this clears them too.
74
+ #--
75
+ #: () -> void
76
+ def reset!
77
+ MUTEX.synchronize { @backend = nil }
78
+ end
79
+
80
+ private
81
+
82
+ #--
83
+ #: () -> (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))
84
+ def backend
85
+ @backend || MUTEX.synchronize { @backend ||= resolve_backend }
86
+ end
87
+
88
+ #--
89
+ #: () -> (Riffer::Metrics::Otel | singleton(Riffer::Metrics::Null))
90
+ def resolve_backend
91
+ Otel.build(provider: Riffer.config.metrics.meter_provider) || Null
92
+ end
93
+ end
@@ -10,6 +10,15 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
10
10
  # cross-region (+us.anthropic.claude-...+) ids.
11
11
  ANTHROPIC_MODEL_PATTERN = /(?:^|\.)anthropic\./ #: Regexp
12
12
 
13
+ FINISH_REASONS = {
14
+ "end_turn" => :stop,
15
+ "stop_sequence" => :stop,
16
+ "max_tokens" => :length,
17
+ "tool_use" => :tool_calls,
18
+ "guardrail_intervened" => :content_filter,
19
+ "content_filtered" => :content_filter
20
+ }.freeze #: Hash[String, Symbol]
21
+
13
22
  # Returns the skill adapter for the Bedrock model — XML for Anthropic models
14
23
  # (which Bedrock hosts alongside other vendors'), else Markdown.
15
24
  #--
@@ -19,6 +28,13 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
19
28
  Riffer::Skills::MarkdownAdapter
20
29
  end
21
30
 
31
+ # The GenAI semconv well-known provider name.
32
+ #--
33
+ #: () -> String
34
+ def self.semconv_provider_name
35
+ "aws.bedrock"
36
+ end
37
+
22
38
  #--
23
39
  #: (?api_token: String?, ?region: String?, **untyped) -> void
24
40
  def initialize(api_token: nil, region: nil, **options)
@@ -120,14 +136,39 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
120
136
  #: (untyped) -> Riffer::Providers::TokenUsage?
121
137
  def extract_token_usage(response)
122
138
  typed_response = response #: Aws::BedrockRuntime::Client::_ConverseResponseSuccess
123
- usage = typed_response.usage
139
+ build_token_usage(typed_response.usage)
140
+ end
124
141
 
125
- Riffer::Providers::TokenUsage.new(
126
- input_tokens: usage.input_tokens,
142
+ # Converse's +input_tokens+ excludes the cache buckets; TokenUsage's
143
+ # input includes them.
144
+ #--
145
+ #: (untyped) -> Riffer::Providers::TokenUsage
146
+ def build_token_usage(usage)
147
+ cache_write = usage.cache_write_input_tokens
148
+ cache_read = usage.cache_read_input_tokens
149
+
150
+ apply_pricing(Riffer::Providers::TokenUsage.new(
151
+ input_tokens: usage.input_tokens + (cache_write || 0) + (cache_read || 0),
127
152
  output_tokens: usage.output_tokens,
128
- cache_write_tokens: usage.cache_write_input_tokens,
129
- cache_read_tokens: usage.cache_read_input_tokens
130
- )
153
+ cache_write_tokens: cache_write,
154
+ cache_read_tokens: cache_read
155
+ ))
156
+ end
157
+
158
+ #--
159
+ #: (untyped) -> Riffer::Providers::FinishReason?
160
+ def extract_finish_reason(response)
161
+ typed_response = response #: Aws::BedrockRuntime::Client::_ConverseResponseSuccess
162
+ build_finish_reason(typed_response.stop_reason)
163
+ end
164
+
165
+ #--
166
+ #: (untyped) -> Riffer::Providers::FinishReason?
167
+ def build_finish_reason(stop_reason)
168
+ return nil unless stop_reason
169
+
170
+ raw = stop_reason.to_s
171
+ Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(raw, :other), raw: raw)
131
172
  end
132
173
 
133
174
  #--
@@ -169,7 +210,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
169
210
  end
170
211
 
171
212
  #--
172
- #: (Hash[Symbol, untyped], Enumerator::Yielder) -> void
213
+ #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
173
214
  def execute_stream(params, yielder)
174
215
  current_state = {
175
216
  text: nil,
@@ -187,6 +228,8 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
187
228
  when Aws::BedrockRuntime::Types::ContentBlockStopEvent
188
229
  handle_content_block_stop_text_delta(event, state: current_state, yielder: yielder) if current_state[:text]
189
230
  handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder) if current_state[:tool_call]
231
+ when Aws::BedrockRuntime::Types::MessageStopEvent
232
+ yield_finish_reason(yielder, build_finish_reason(event.stop_reason))
190
233
  when Aws::BedrockRuntime::Types::ConverseStreamMetadataEvent
191
234
  handle_metadata_usage(event, state: current_state, yielder: yielder) if event.usage
192
235
  else
@@ -212,7 +255,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
212
255
  end
213
256
 
214
257
  #--
215
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
258
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
216
259
  def handle_content_block_start_tool_use(event, state:, yielder:)
217
260
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockStartEvent
218
261
  state[:tool_call] = {
@@ -223,7 +266,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
223
266
  end
224
267
 
225
268
  #--
226
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
269
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
227
270
  def handle_content_block_delta_text_delta(event, state:, yielder:)
228
271
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockDeltaEvent
229
272
  delta_text = typed_event.delta.text
@@ -233,7 +276,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
233
276
  end
234
277
 
235
278
  #--
236
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
279
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
237
280
  def handle_content_block_delta_tool_use(event, state:, yielder:)
238
281
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockDeltaEvent
239
282
  input_delta = typed_event.delta.tool_use.input
@@ -248,14 +291,14 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
248
291
  end
249
292
 
250
293
  #--
251
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
294
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
252
295
  def handle_content_block_stop_text_delta(_event, state:, yielder:)
253
296
  yielder << Riffer::StreamEvents::TextDone.new(state[:text])
254
297
  state[:text] = nil
255
298
  end
256
299
 
257
300
  #--
258
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
301
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
259
302
  def handle_content_block_stop_tool_use(_event, state:, yielder:)
260
303
  tool_call = state[:tool_call]
261
304
  yielder << Riffer::StreamEvents::ToolCallDone.new(
@@ -268,17 +311,10 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
268
311
  end
269
312
 
270
313
  #--
271
- #: (untyped, state: Hash[Symbol, untyped], yielder: Enumerator::Yielder) -> void
314
+ #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
272
315
  def handle_metadata_usage(event, state:, yielder:)
273
316
  typed_event = event #: Aws::BedrockRuntime::Types::ConverseStreamMetadataEvent
274
- yielder << Riffer::StreamEvents::TokenUsageDone.new(
275
- token_usage: Riffer::Providers::TokenUsage.new(
276
- input_tokens: typed_event.usage.input_tokens,
277
- output_tokens: typed_event.usage.output_tokens,
278
- cache_write_tokens: typed_event.usage.cache_write_input_tokens,
279
- cache_read_tokens: typed_event.usage.cache_read_input_tokens
280
- )
281
- )
317
+ yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(typed_event.usage))
282
318
  end
283
319
 
284
320
  #--