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 +4 -4
- data/README.md +3 -3
- data/exe/bitfab-seed +13 -0
- data/lib/bitfab/assertion_categories.rb +42 -0
- data/lib/bitfab/assertions.rb +35 -0
- data/lib/bitfab/baml.rb +208 -0
- data/lib/bitfab/client.rb +95 -31
- data/lib/bitfab/db_snapshot.rb +18 -5
- data/lib/bitfab/detached_trace.rb +46 -0
- data/lib/bitfab/graders.rb +23 -0
- data/lib/bitfab/http_client.rb +106 -35
- data/lib/bitfab/labels.rb +80 -0
- data/lib/bitfab/replay.rb +92 -23
- data/lib/bitfab/replay_cli.rb +7 -1
- data/lib/bitfab/replay_concurrency.rb +44 -0
- data/lib/bitfab/replay_memory.rb +111 -0
- data/lib/bitfab/replay_processes.rb +218 -0
- data/lib/bitfab/replay_registry.rb +100 -15
- data/lib/bitfab/seed.rb +128 -0
- data/lib/bitfab/seed_cli.rb +67 -0
- data/lib/bitfab/seed_context.rb +21 -0
- data/lib/bitfab/simulation_plan.rb +248 -0
- data/lib/bitfab/span_context.rb +9 -4
- data/lib/bitfab/span_origin.rb +13 -0
- data/lib/bitfab/subtree.rb +144 -5
- data/lib/bitfab/thread_propagation.rb +111 -0
- data/lib/bitfab/traceable.rb +70 -155
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +9 -2
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c37ea054aa21aac4e6e10ff309ea4b7287ccf04b2b80692b4b260577d9f59ea9
|
|
4
|
+
data.tar.gz: 75f5892300254c708c9dac68cdb2a1cf125adf162a89c275e504546dca930cd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
data/lib/bitfab/baml.rb
ADDED
|
@@ -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
|