bitfab 0.51.9 → 0.51.10

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: f468d991f207bab17dbf93e1d8336e293aa2d759a83a31a8eee7631f1e93daa8
4
- data.tar.gz: 4ab69ab42011952d9611a3708ed9f4308760aeb839eaa109ba413a798d5c59d4
3
+ metadata.gz: c37ea054aa21aac4e6e10ff309ea4b7287ccf04b2b80692b4b260577d9f59ea9
4
+ data.tar.gz: 75f5892300254c708c9dac68cdb2a1cf125adf162a89c275e504546dca930cd4
5
5
  SHA512:
6
- metadata.gz: fcd27044d500aa10fb278940b3665cd54e27c2732e011e828bdd778dc598807960ae84686968957d48fd279f26dc93e2ff72cc1c8ccfa8a91824235548f0b394
7
- data.tar.gz: 5c3c831e7c6e30e5ea9f8aaa2a5d63f55f561c831e57caa87504a86b7d73cfc62a98260c943f4024b664c9d6a419bb0b2727e83952dc10f0f62ba4bdb3b496ad
6
+ metadata.gz: 7436cd4d8ff50ee5dc6625b7f7f645fc8c31a60d38b80ee69dc93306b59a3d2ef781b7d5282ce6dfc857c3e4166a992c07f73faa1960bbfa84e87e675a17e1ce
7
+ data.tar.gz: 2a5503c884dafc47cab0d23373265ebac0810bb2672868677b5335093eb7d47ed86e4a77dcd7e828e7222db52efbbf7196d312ffb5864aceadf3833f769f0cf9
data/README.md CHANGED
@@ -235,13 +235,13 @@ class OrderPipeline
235
235
  end
236
236
  ```
237
237
 
238
- Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting `max_spans` stops new capture but allows already-open spans in every Fiber to finish. `max_depth:`, `max_spans:`, `exclude:`, and `include_wrappers:` control the subtree. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_span` when replay must substitute it: its explicit span records once, and automatic descendants continue beneath it.
238
+ Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting `max_spans` stops new capture but allows already-open spans in every Fiber to finish. `max_depth:`, `max_spans:`, `exclude:`, and `include_wrappers:` control the subtree. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_node` when replay must substitute it. The configured node records once in each enclosing trace. Mixing explicit spans with subtree tracing raises `Bitfab::MixedTracingError`.
239
239
 
240
240
  When capture ends, already-open automatic calls in suspended Fibers are emitted once with a nil output and `Subtree capture ended before the call returned` as the error, unless a real error was already observed. Their inputs and parenting are retained. The SDK does not resume Fibers, and later resumption cannot add spans to the ended capture. If hot reload moves the root to an unresolved source, the last valid first-party boundary and its exclusions remain in use.
241
241
 
242
242
  Returning an `Enumerator` keeps capture pending until iteration finishes or raises, including calls already paused in child Fibers. Capture is inactive between the initial return and consumption. Automatic spans record lazy return values as placeholders without iterating them; the traced root records the yielded values.
243
243
 
244
- A nested `bitfab_trace` with a different key starts an independent trace while the outer trace continues recording the overlapping subtree. Both traces have distinct trace and span IDs, including during replay, and the complete structure each root would record alone, so two active roots double span volume in their shared region. A recursive call or nested annotation using the same key stays in the active trace instead of creating another root.
244
+ Each nested `bitfab_trace` starts an independent trace during ordinary capture, including recursion and calls using the same key. Every enclosing trace retains its own recording of the shared subtree. The enclosing span and nested root carry reciprocal trace and span links. Each trace applies its own limits and exclusions. During replay and seed execution, nested annotations remain in the managed trace.
245
245
 
246
246
  Configure a discovered method with `bitfab_node`. A node never creates a span or trace by itself; the active `bitfab_trace` owns its key, limits, parent, and lifecycle:
247
247
 
@@ -463,7 +463,7 @@ Bitfab.reset!
463
463
  ## Thread Safety
464
464
 
465
465
  - Each thread has its own span context stack (using `Thread.current`)
466
- - Nested spans only work within the same thread
466
+ - Set `trace_across_threads: true` to propagate explicit spans and replay mocks to submitted threads and optional `concurrent-ruby` thread-pool jobs
467
467
  - Background span sending is thread-safe
468
468
 
469
469
  ## Examples
data/exe/bitfab-seed ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bitfab"
5
+
6
+ begin
7
+ path, args = Bitfab::ReplayCommand.parse(ARGV)
8
+ registry = Bitfab::ReplayCommand.load_registry(path)
9
+ Bitfab::SeedCli.run(registry, argv: args)
10
+ rescue => error
11
+ warn error.message
12
+ exit 1
13
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Bitfab
6
+ class AssertionCategories
7
+ PATH = "/api/sdk/assertionCategories"
8
+ JUSTIFICATION_UNSET = Object.new.freeze
9
+ private_constant :PATH
10
+ private_constant :JUSTIFICATION_UNSET
11
+
12
+ def initialize(http_client)
13
+ @http_client = http_client
14
+ end
15
+
16
+ def save(title, id: nil, description: nil, justification: JUSTIFICATION_UNSET)
17
+ payload = {"title" => title}
18
+ payload["id"] = id unless id.nil?
19
+ payload["description"] = description unless description.nil?
20
+ payload["justification"] = justification unless justification.equal?(JUSTIFICATION_UNSET)
21
+ @http_client.request(PATH, payload)["category"]
22
+ end
23
+
24
+ def get(id)
25
+ @http_client.get("#{PATH}/#{encode(id)}")["category"]
26
+ end
27
+
28
+ def list
29
+ @http_client.get(PATH)["categories"]
30
+ end
31
+
32
+ def delete(id)
33
+ @http_client.request("#{PATH}/#{encode(id)}", {}, method: "DELETE")["category"]
34
+ end
35
+
36
+ private
37
+
38
+ def encode(value)
39
+ URI.encode_www_form_component(value).gsub("+", "%20")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Bitfab
6
+ class Traces
7
+ def initialize(http_client)
8
+ @http_client = http_client
9
+ end
10
+
11
+ def get_assertions(trace_id)
12
+ @http_client.get(assertions_path(trace_id))
13
+ end
14
+
15
+ def save_assertions(trace_id, assertions, source: "agent")
16
+ save_assertions_all([{"traceId" => trace_id, "assertions" => assertions}], source:)
17
+ end
18
+
19
+ def save_assertions_all(updates, source: "agent")
20
+ return [] if updates.empty?
21
+
22
+ @http_client.request("/api/sdk/traces/assertions", {"updates" => updates, "source" => source})["assertions"]
23
+ end
24
+
25
+ def archive_assertions(trace_id, assertion_ids)
26
+ @http_client.request(assertions_path(trace_id, "/archive"), {"assertionIds" => assertion_ids})["archived"]
27
+ end
28
+
29
+ private
30
+
31
+ def assertions_path(trace_id, suffix = "")
32
+ "/api/sdk/traces/#{URI.encode_www_form_component(trace_id).gsub("+", "%20")}/assertions#{suffix}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bitfab
6
+ module BAML
7
+ ALLOWED_ENV_KEYS = %w[OPENAI_API_KEY].freeze
8
+ COLLECTOR_FIELDS = %i[logs last usage function_name log_type timing calls selected_call tags
9
+ selected http_request http_response provider client_name input_tokens output_tokens
10
+ cached_input_tokens start_time_utc_ms duration_ms url method headers body status].freeze
11
+
12
+ module_function
13
+
14
+ def load_runtime
15
+ require "baml" unless defined?(::Baml::DynamicStruct)
16
+ ::Baml
17
+ rescue LoadError
18
+ raise LoadError, "BAML is required for local execution. Install the baml gem matching your generated client."
19
+ end
20
+
21
+ def parameters(source)
22
+ signature = source.match(/function\s+\w+\s*\(([^)]*)\)\s*->/)&.[](1)
23
+ return {} unless signature
24
+
25
+ depth = 0
26
+ parts = [+""]
27
+ signature.each_char do |char|
28
+ depth += 1 if char == "<"
29
+ depth -= 1 if char == ">"
30
+ if char == "," && depth.zero?
31
+ parts << +""
32
+ else
33
+ parts.last << char
34
+ end
35
+ end
36
+ parts.filter_map do |part|
37
+ match = part.strip.match(/\A(\w+)\s*:\s*(.+)\z/)
38
+ [match[1], match[2].strip.delete_suffix("?")] if match
39
+ end.to_h
40
+ end
41
+
42
+ def coerce_inputs(inputs, types)
43
+ inputs.to_h do |key, value|
44
+ type = types[key.to_s]
45
+ [key, (value.is_a?(String) && type) ? coerce(value, type) : value]
46
+ end
47
+ end
48
+
49
+ def coerce(value, type)
50
+ case type
51
+ when "string" then value
52
+ when "int" then Integer(value, 10)
53
+ when "float" then Float(value)
54
+ when "bool"
55
+ return true if value.downcase == "true"
56
+ return false if value.downcase == "false"
57
+ value
58
+ else
59
+ parsed = JSON.parse(value)
60
+ (type.end_with?("[]") && !parsed.is_a?(Array)) ? value : parsed
61
+ end
62
+ rescue ArgumentError, JSON::ParserError
63
+ value
64
+ end
65
+
66
+ def client_name(provider, model)
67
+ prefix = {"openai" => "OpenAI", "anthropic" => "Anthropic", "google" => "Google"}[provider] || provider.capitalize
68
+ "#{prefix}_#{model.sub(/^gpt-/, "GPT").tr(".-", "__")}"
69
+ end
70
+
71
+ def prepare_source(source, providers)
72
+ return source if source.include?("client<llm> OpenAI_")
73
+
74
+ clients = providers.flat_map do |provider|
75
+ provider.fetch("models").map do |model|
76
+ name = model.fetch("model")
77
+ temperature = (provider["provider"] == "openai" && name.start_with?("gpt-4.1", "gpt-4o")) ? "\n temperature 0" : ""
78
+ "client<llm> #{client_name(provider.fetch("provider"), name)} {\n provider #{provider.fetch("provider")}\n options {\n model #{name.to_json}\n api_key env.#{provider.fetch("apiKeyEnv")}#{temperature}\n }\n}"
79
+ end
80
+ end
81
+ "#{clients.join("\n\n")}\n\n#{source}"
82
+ end
83
+
84
+ def run(source, inputs, providers, env_vars)
85
+ name = source.match(/function\s+(\w+)\s*\(/)&.[](1)
86
+ raise ArgumentError, "No function found in BAML source" unless name
87
+
88
+ baml = load_runtime
89
+ env = env_vars.select { |key, value| ALLOWED_ENV_KEYS.include?(key) && value.is_a?(String) }
90
+ runtime = baml::Ffi::BamlRuntime.from_files("/tmp/bitfab_baml_runtime", {"source.baml" => prepare_source(source, providers)}, env)
91
+ collector = baml::Collector.new(name: "bitfab-collector")
92
+ response = runtime.call_function(name, coerce_inputs(inputs, parameters(source)), runtime.create_context_manager, nil, nil, [collector], env)
93
+ types = Module.new
94
+ result = response.parsed_using_types(types, types, false)
95
+ [result, serialize_collector(collector)]
96
+ end
97
+
98
+ def serialize_collector(value, depth = 0)
99
+ return nil if depth > 8
100
+ return value if value.nil? || value == true || value == false || value.is_a?(Numeric) || value.is_a?(String)
101
+ return value.map { |item| serialize_collector(item, depth + 1) } if value.is_a?(Array)
102
+ return value.transform_values { |item| serialize_collector(item, depth + 1) } if value.is_a?(Hash)
103
+ return serialize_collector(value.json, depth + 1) if value.respond_to?(:json)
104
+
105
+ COLLECTOR_FIELDS.each_with_object({}) do |field, result|
106
+ result[field.to_s] = serialize_collector(value.public_send(field), depth + 1) if value.respond_to?(field)
107
+ rescue
108
+ next
109
+ end
110
+ rescue
111
+ nil
112
+ end
113
+
114
+ def enrich_span(collector)
115
+ log = collector.last
116
+ calls = log&.calls || []
117
+ call = calls.find(&:selected) || calls.first
118
+ body = call&.http_request&.body&.json
119
+ body = {} unless body.is_a?(Hash)
120
+ messages = body["messages"]
121
+ if messages.is_a?(Array)
122
+ rendered = messages.filter_map do |message|
123
+ next unless message.is_a?(Hash) && message["role"].is_a?(String)
124
+ {role: message["role"], content: message["content"].is_a?(String) ? message["content"] : message["content"].to_json}
125
+ end
126
+ Bitfab.current_span.set_prompt(rendered.to_json) unless rendered.empty?
127
+ end
128
+ usage = collector.usage
129
+ metadata = {
130
+ provider: call&.provider,
131
+ model: body["model"] || call&.http_request&.url&.match(%r{/models/([^/:]+)})&.[](1),
132
+ inputTokens: usage&.input_tokens || call&.usage&.input_tokens,
133
+ outputTokens: usage&.output_tokens || call&.usage&.output_tokens,
134
+ durationMs: log&.timing&.duration_ms
135
+ }.compact
136
+ Bitfab.current_span.add_context(metadata) unless metadata.empty?
137
+ rescue
138
+ nil
139
+ end
140
+
141
+ class Wrapper
142
+ attr_reader :collector
143
+
144
+ def initialize(client, method_name, on_collector)
145
+ @client = client
146
+ @method_name = method_name
147
+ @on_collector = on_collector
148
+ end
149
+
150
+ def call(*args, **kwargs, &block)
151
+ @collector = nil
152
+ begin
153
+ baml = BAML.load_runtime
154
+ rescue LoadError
155
+ return @client.public_send(@method_name, *args, **kwargs, &block)
156
+ end
157
+ collector = baml::Collector.new(name: "bitfab-baml-tracing")
158
+ tracked = @client.with_options(collector:)
159
+ result = tracked.public_send(@method_name, *args, **kwargs, &block)
160
+ @collector = collector
161
+ BAML.enrich_span(collector)
162
+ begin
163
+ @on_collector&.call(collector)
164
+ rescue
165
+ nil
166
+ end
167
+ result
168
+ end
169
+ end
170
+ end
171
+
172
+ class Client
173
+ # Execute a stored BAML prompt locally and return its parsed output.
174
+ def call(method_name, **inputs)
175
+ version = @http_client.request("/api/sdk/functions/lookup", {"name" => method_name})
176
+ raise ArgumentError, "Function #{method_name.inspect} not found. Create it at #{@service_url}/functions" unless version["id"]
177
+ raise ArgumentError, "Function #{method_name.inspect} has no prompt configured." if version["prompt"].to_s.empty?
178
+
179
+ result, collector = BAML.run(version["prompt"], inputs, version["providers"] || [], @env_vars)
180
+ output = begin
181
+ result.is_a?(String) ? result : result.to_json
182
+ rescue
183
+ result.to_s
184
+ end
185
+ @http_client.send_internal_trace(version["id"], {"result" => output, "inputs" => inputs, "rawCollector" => collector, "source" => "ruby-sdk"})
186
+ result
187
+ end
188
+
189
+ # Add rendered prompt and token metadata to the active span around a BAML method.
190
+ def wrap_baml(method_or_client, method = nil, on_collector: nil)
191
+ target = method ? method_or_client : @baml_client
192
+ actual = method || method_or_client
193
+ target ||= actual.receiver if actual.is_a?(Method)
194
+ raise ArgumentError, "baml_client is required for wrap_baml." unless target
195
+ name = actual.is_a?(Method) ? actual.name : actual
196
+ raise ArgumentError, "wrap_baml requires a named BAML method." unless name.is_a?(String) || name.is_a?(Symbol)
197
+
198
+ BAML::Wrapper.new(target, name, on_collector)
199
+ end
200
+ end
201
+
202
+ class BitfabFunction
203
+ # BAML collector wrappers enrich the currently active span under any key.
204
+ def wrap_baml(*args, **kwargs)
205
+ @client.wrap_baml(*args, **kwargs)
206
+ end
207
+ end
208
+ end