crystil 0.5.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 +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/crystil/api/base.rb +104 -0
- data/lib/crystil/api/invocation.rb +59 -0
- data/lib/crystil/api/sentinel.rb +11 -0
- data/lib/crystil/api/workflow.rb +34 -0
- data/lib/crystil/api/workflows.rb +39 -0
- data/lib/crystil/attribution.rb +66 -0
- data/lib/crystil/client.rb +95 -0
- data/lib/crystil/collector.rb +88 -0
- data/lib/crystil/config.rb +79 -0
- data/lib/crystil/errors.rb +35 -0
- data/lib/crystil/sentinel.rb +100 -0
- data/lib/crystil/version.rb +5 -0
- data/lib/crystil/wrappers/anthropic.rb +112 -0
- data/lib/crystil/wrappers/base.rb +259 -0
- data/lib/crystil/wrappers/constants.rb +12 -0
- data/lib/crystil/wrappers/geminiai.rb +173 -0
- data/lib/crystil/wrappers/google.rb +133 -0
- data/lib/crystil/wrappers/groq.rb +295 -0
- data/lib/crystil/wrappers/openai.rb +249 -0
- data/lib/crystil/wrappers/ruby_llm.rb +170 -0
- data/lib/crystil.rb +52 -0
- data/sig/crystil/api/base.rbs +24 -0
- data/sig/crystil/api/invocation.rbs +16 -0
- data/sig/crystil/api/sentinel.rbs +9 -0
- data/sig/crystil/api/workflow.rbs +15 -0
- data/sig/crystil/api/workflows.rbs +16 -0
- data/sig/crystil/attribution.rbs +17 -0
- data/sig/crystil/client.rbs +29 -0
- data/sig/crystil/collector.rbs +20 -0
- data/sig/crystil/config.rbs +33 -0
- data/sig/crystil/errors.rbs +28 -0
- data/sig/crystil/sentinel.rbs +17 -0
- data/sig/crystil/version.rbs +5 -0
- data/sig/crystil/wrappers/anthropic.rbs +18 -0
- data/sig/crystil/wrappers/base.rbs +21 -0
- data/sig/crystil/wrappers/constants.rbs +10 -0
- data/sig/crystil/wrappers/geminiai.rbs +19 -0
- data/sig/crystil/wrappers/google.rbs +19 -0
- data/sig/crystil/wrappers/groq.rbs +22 -0
- data/sig/crystil/wrappers/openai.rbs +19 -0
- data/sig/crystil/wrappers/ruby_llm.rbs +21 -0
- metadata +100 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
|
|
5
|
+
module Crystil
|
|
6
|
+
module Wrappers
|
|
7
|
+
# Wrapper for OpenAI Ruby client (ruby-openai gem)
|
|
8
|
+
class OpenAI
|
|
9
|
+
def initialize(config, collector, sentinel = nil)
|
|
10
|
+
@config = config
|
|
11
|
+
@collector = collector
|
|
12
|
+
@sentinel = sentinel
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register(client)
|
|
16
|
+
validate_client!(client)
|
|
17
|
+
|
|
18
|
+
# Prevent double registration
|
|
19
|
+
return client if client.instance_variable_defined?(:@crystil_registered)
|
|
20
|
+
|
|
21
|
+
# Store references in client instance
|
|
22
|
+
client.instance_variable_set(:@crystil_config, @config)
|
|
23
|
+
client.instance_variable_set(:@crystil_collector, @collector)
|
|
24
|
+
client.instance_variable_set(:@crystil_sentinel, @sentinel)
|
|
25
|
+
client.instance_variable_set(:@crystil_registered, true)
|
|
26
|
+
|
|
27
|
+
# Wrap the chat method
|
|
28
|
+
wrap_chat_method(client)
|
|
29
|
+
|
|
30
|
+
# Wrap responses.create if the client supports the Responses API (ruby-openai >= 8.0)
|
|
31
|
+
wrap_responses_method(client) if client.respond_to?(:responses)
|
|
32
|
+
|
|
33
|
+
client
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def wrap_responses_method(client)
|
|
39
|
+
responses_obj = client.responses
|
|
40
|
+
|
|
41
|
+
# Copy Crystil references onto the responses sub-object so Base helpers can access them
|
|
42
|
+
responses_obj.instance_variable_set(:@crystil_config, client.instance_variable_get(:@crystil_config))
|
|
43
|
+
responses_obj.instance_variable_set(:@crystil_collector, client.instance_variable_get(:@crystil_collector))
|
|
44
|
+
responses_obj.instance_variable_set(:@crystil_sentinel, client.instance_variable_get(:@crystil_sentinel))
|
|
45
|
+
|
|
46
|
+
responses_obj.singleton_class.class_eval do
|
|
47
|
+
include Base
|
|
48
|
+
|
|
49
|
+
alias_method :original_create, :create
|
|
50
|
+
|
|
51
|
+
define_method(:create) do |parameters: {}|
|
|
52
|
+
start_time = Time.now
|
|
53
|
+
version = defined?(::OpenAI::VERSION) ? ::OpenAI::VERSION : nil
|
|
54
|
+
|
|
55
|
+
sentinel = instance_variable_get(:@crystil_sentinel)
|
|
56
|
+
sentinel&.raise_if_irrelevant!(
|
|
57
|
+
title: OPENAI_CLIENT_TITLE,
|
|
58
|
+
request: parameters,
|
|
59
|
+
version: version
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Dup before mutating so we don't replace the caller's :stream key with our
|
|
63
|
+
# internal wrapping proc — the caller's hash should be unchanged after the call.
|
|
64
|
+
parameters = parameters.dup
|
|
65
|
+
|
|
66
|
+
streaming = parameters[:stream].respond_to?(:call)
|
|
67
|
+
|
|
68
|
+
if streaming
|
|
69
|
+
accumulated_events = []
|
|
70
|
+
user_callback = parameters[:stream]
|
|
71
|
+
# Determine how many arguments to forward to the user's callback.
|
|
72
|
+
# .arity returns negative values for methods with optional/splat params
|
|
73
|
+
# (e.g. def call(*args) => -1, def call(a, b=nil) => -2), so .abs gives
|
|
74
|
+
# us the minimum required argument count. For fixed-arity procs/lambdas
|
|
75
|
+
# this is exact. Note: a variadic callable (def call(*args)) has arity -1,
|
|
76
|
+
# so .abs yields 1 — the event_type argument will be silently dropped for
|
|
77
|
+
# that signature. Use def call(event, event_type = nil) to receive both.
|
|
78
|
+
user_callback_arity =
|
|
79
|
+
case user_callback
|
|
80
|
+
when Proc
|
|
81
|
+
user_callback.arity.abs
|
|
82
|
+
else
|
|
83
|
+
user_callback.method(:call).arity.abs
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
parameters[:stream] = proc do |chunk, event_type|
|
|
87
|
+
accumulated_events << chunk if chunk.is_a?(Hash)
|
|
88
|
+
user_callback.call(*[chunk, event_type].first(user_callback_arity))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
response = original_create(parameters: parameters)
|
|
93
|
+
|
|
94
|
+
# Default to the returned response; for streaming, replace it with the
|
|
95
|
+
# full response from the terminal response.completed event.
|
|
96
|
+
final_response = response
|
|
97
|
+
|
|
98
|
+
if streaming
|
|
99
|
+
error_event = accumulated_events.find { |e| e["type"] == "error" }
|
|
100
|
+
if error_event
|
|
101
|
+
final_response = {
|
|
102
|
+
"status" => "failed",
|
|
103
|
+
"error" => error_event["error"]
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Extract the full response from the terminal response.completed event.
|
|
108
|
+
# A missing terminal event means the stream was interrupted.
|
|
109
|
+
completed = accumulated_events.find { |e| e["type"] == "response.completed" }
|
|
110
|
+
unless completed || error_event
|
|
111
|
+
raise StreamError,
|
|
112
|
+
"Responses API stream ended without response.completed event"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
final_response = completed["response"] if completed && !error_event
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Treat only an explicit failed response status as failed analytics.
|
|
119
|
+
# Other non-exceptional statuses are still recorded as succeeded.
|
|
120
|
+
response_status = final_response.is_a?(Hash) ? final_response["status"] : nil
|
|
121
|
+
analytics_status = response_status == "failed" ? "failed" : "succeeded"
|
|
122
|
+
analytics_exception =
|
|
123
|
+
if analytics_status == "failed"
|
|
124
|
+
final_response.dig("error", "message") ||
|
|
125
|
+
"OpenAI response status: #{response_status || "unknown"}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
crystil_submit_analytics(
|
|
129
|
+
method: :create,
|
|
130
|
+
args: [],
|
|
131
|
+
kwargs: parameters,
|
|
132
|
+
response: final_response,
|
|
133
|
+
start_time: start_time,
|
|
134
|
+
end_time: Time.now,
|
|
135
|
+
title: OPENAI_CLIENT_TITLE,
|
|
136
|
+
version: version,
|
|
137
|
+
status: analytics_status,
|
|
138
|
+
exception: analytics_exception
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
response
|
|
142
|
+
rescue CrystilRequestInterceptedError => e
|
|
143
|
+
raise e
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
crystil_submit_error_analytics(
|
|
146
|
+
method: :create,
|
|
147
|
+
args: [],
|
|
148
|
+
kwargs: parameters,
|
|
149
|
+
error: e,
|
|
150
|
+
start_time: start_time,
|
|
151
|
+
end_time: Time.now,
|
|
152
|
+
title: OPENAI_CLIENT_TITLE,
|
|
153
|
+
version: version
|
|
154
|
+
)
|
|
155
|
+
raise e
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_client!(client)
|
|
161
|
+
# When register an OpenAI client, it currently only checks if there
|
|
162
|
+
# is the classic "chat" interface. OpenAI now supports the newer
|
|
163
|
+
# "responses" interface. So, if an OpenAI client later only has
|
|
164
|
+
# the newer "responses" interface, this validate_client will fail.
|
|
165
|
+
return if client.respond_to?(:chat)
|
|
166
|
+
|
|
167
|
+
raise RegistrationError, "Client does not appear to be a valid OpenAI client (missing chat method)"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def wrap_chat_method(client)
|
|
171
|
+
client.singleton_class.class_eval do
|
|
172
|
+
include Base
|
|
173
|
+
|
|
174
|
+
alias_method :original_chat, :chat
|
|
175
|
+
|
|
176
|
+
define_method(:chat) do |parameters: {}|
|
|
177
|
+
start_time = Time.now
|
|
178
|
+
version = defined?(::OpenAI::VERSION) ? ::OpenAI::VERSION : nil
|
|
179
|
+
|
|
180
|
+
sentinel = instance_variable_get(:@crystil_sentinel)
|
|
181
|
+
sentinel&.raise_if_irrelevant!(
|
|
182
|
+
title: OPENAI_CLIENT_TITLE,
|
|
183
|
+
request: parameters,
|
|
184
|
+
version: version
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Detect streaming and wrap callback to accumulate chunks
|
|
188
|
+
streaming = parameters[:stream].is_a?(Proc)
|
|
189
|
+
accumulated_response = {} if streaming
|
|
190
|
+
|
|
191
|
+
if streaming
|
|
192
|
+
# Configure streaming to include usage (matches Python SDK behavior).
|
|
193
|
+
# A caller-set value (true or false) wins — we only force `true`
|
|
194
|
+
# when the key is absent.
|
|
195
|
+
parameters[:stream_options] ||= {}
|
|
196
|
+
parameters[:stream_options][:include_usage] = true unless parameters[:stream_options].key?(:include_usage)
|
|
197
|
+
|
|
198
|
+
user_callback = parameters[:stream]
|
|
199
|
+
parameters[:stream] = proc do |chunk, bytesize|
|
|
200
|
+
# Normalize chunk to match Python SDK format (add missing delta keys with nil)
|
|
201
|
+
normalized_chunk = crystil_normalize_openai_chunk(chunk)
|
|
202
|
+
# Accumulate chunk (merge into accumulated_response)
|
|
203
|
+
crystil_merge_streaming_chunk(accumulated_response, normalized_chunk)
|
|
204
|
+
# Call user's original callback with original chunk (don't modify user's data)
|
|
205
|
+
user_callback.call(chunk, bytesize)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Call the original method
|
|
210
|
+
response = original_chat(parameters: parameters)
|
|
211
|
+
|
|
212
|
+
# Use accumulated response for streaming, otherwise use returned response
|
|
213
|
+
final_response = streaming ? accumulated_response : response
|
|
214
|
+
|
|
215
|
+
# Submit analytics
|
|
216
|
+
crystil_submit_analytics(
|
|
217
|
+
method: :chat,
|
|
218
|
+
args: [],
|
|
219
|
+
kwargs: parameters,
|
|
220
|
+
response: final_response,
|
|
221
|
+
start_time: start_time,
|
|
222
|
+
end_time: Time.now,
|
|
223
|
+
title: OPENAI_CLIENT_TITLE,
|
|
224
|
+
version: version
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
response
|
|
228
|
+
rescue CrystilRequestInterceptedError => e
|
|
229
|
+
# We don't want to send intercepts to collector
|
|
230
|
+
raise e
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
crystil_submit_error_analytics(
|
|
233
|
+
method: :chat,
|
|
234
|
+
args: [],
|
|
235
|
+
kwargs: parameters,
|
|
236
|
+
error: e,
|
|
237
|
+
start_time: start_time,
|
|
238
|
+
end_time: Time.now,
|
|
239
|
+
title: OPENAI_CLIENT_TITLE,
|
|
240
|
+
version: version
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
raise e
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
|
|
5
|
+
module Crystil
|
|
6
|
+
module Wrappers
|
|
7
|
+
# Wrapper for RubyLLM (ruby_llm gem)
|
|
8
|
+
# Supports any provider available via RubyLLM (Anthropic, OpenAI, Google, etc.)
|
|
9
|
+
# Returns raw response and raw formatted query to backend so that the existing
|
|
10
|
+
# extractors can be used.
|
|
11
|
+
class RubyLLM
|
|
12
|
+
def initialize(config, collector, sentinel = nil)
|
|
13
|
+
@config = config
|
|
14
|
+
@collector = collector
|
|
15
|
+
@sentinel = sentinel
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(client)
|
|
19
|
+
validate_client!(client)
|
|
20
|
+
|
|
21
|
+
# Prevent double registration
|
|
22
|
+
return client if client.instance_variable_defined?(:@crystil_registered)
|
|
23
|
+
|
|
24
|
+
# Store references in the client instance
|
|
25
|
+
client.instance_variable_set(:@crystil_config, @config)
|
|
26
|
+
client.instance_variable_set(:@crystil_collector, @collector)
|
|
27
|
+
client.instance_variable_set(:@crystil_sentinel, @sentinel)
|
|
28
|
+
client.instance_variable_set(:@crystil_registered, true)
|
|
29
|
+
|
|
30
|
+
# Ask method handles both streaming and non-streaming cases unlike some others.
|
|
31
|
+
wrap_ask_method(client)
|
|
32
|
+
|
|
33
|
+
client
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_client!(client)
|
|
39
|
+
return if client.respond_to?(:ask)
|
|
40
|
+
|
|
41
|
+
raise RegistrationError,
|
|
42
|
+
"Client does not appear to be a valid RubyLLM client (missing ask method)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def wrap_ask_method(client)
|
|
46
|
+
# Capture the wrapper instance in a local variable so it's accessible as a closure
|
|
47
|
+
# inside the block, where self will no longer refer to it.
|
|
48
|
+
wrapper = self
|
|
49
|
+
|
|
50
|
+
client.singleton_class.class_eval do
|
|
51
|
+
include Base
|
|
52
|
+
|
|
53
|
+
alias_method :original_ask, :ask
|
|
54
|
+
|
|
55
|
+
define_method(:ask) do |message, with: nil, &block|
|
|
56
|
+
start_time = Time.now
|
|
57
|
+
query = {} # Fallback for error analytics if an exception occurs before build_query completes
|
|
58
|
+
# Version reported is RubyLLM's, not the underlying provider's — RubyLLM is the
|
|
59
|
+
# meta-wrapper Crystil sees; the native provider gem may not even be loaded.
|
|
60
|
+
version = defined?(::RubyLLM::VERSION) ? ::RubyLLM::VERSION : nil
|
|
61
|
+
|
|
62
|
+
sentinel = instance_variable_get(:@crystil_sentinel)
|
|
63
|
+
provider = model.provider
|
|
64
|
+
# Telemetry invariant: `conversation.client.title` is never nil.
|
|
65
|
+
# `model.provider` should always return a non-empty string for
|
|
66
|
+
# well-formed RubyLLM models, but fall back to the meta-wrapper
|
|
67
|
+
# name (matches JS's pattern of "fallback to the SDK family
|
|
68
|
+
# name") if it ever isn't.
|
|
69
|
+
title = case provider
|
|
70
|
+
when "gemini" then GOOGLE_CLIENT_TITLE
|
|
71
|
+
when nil, "" then RUBY_LLM_PROVIDER
|
|
72
|
+
else provider
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Capture existing messages (e.g. system instruction) before the call
|
|
76
|
+
# adds the new user message to history.
|
|
77
|
+
existing_msgs = messages.map { |m| { role: m.role.to_s, content: m.content.to_s } }
|
|
78
|
+
all_msgs = existing_msgs + [{ role: "user", content: message.to_s }]
|
|
79
|
+
|
|
80
|
+
# Build a query in the provider's native format so the backend extractor can parse it.
|
|
81
|
+
query = wrapper.send(:build_query, provider, model.id, all_msgs, tools)
|
|
82
|
+
|
|
83
|
+
# Pass the actual provider title (e.g. "anthropic", "openai", "google") so the
|
|
84
|
+
# sentinel backend can route to the correct classifier for this request format.
|
|
85
|
+
sentinel&.raise_if_irrelevant!(title: title, request: query, version: version)
|
|
86
|
+
|
|
87
|
+
response = original_ask(message, with: with, &block)
|
|
88
|
+
|
|
89
|
+
crystil_submit_analytics(
|
|
90
|
+
method: :ask,
|
|
91
|
+
args: [],
|
|
92
|
+
kwargs: query,
|
|
93
|
+
response: response.raw&.body,
|
|
94
|
+
start_time: start_time,
|
|
95
|
+
end_time: Time.now,
|
|
96
|
+
title: title,
|
|
97
|
+
provider: RUBY_LLM_PROVIDER,
|
|
98
|
+
version: version
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
response
|
|
102
|
+
rescue CrystilRequestInterceptedError => e
|
|
103
|
+
raise e
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
crystil_submit_error_analytics(
|
|
106
|
+
method: :ask,
|
|
107
|
+
args: [],
|
|
108
|
+
kwargs: query,
|
|
109
|
+
error: e,
|
|
110
|
+
start_time: start_time,
|
|
111
|
+
end_time: Time.now,
|
|
112
|
+
title: title,
|
|
113
|
+
provider: RUBY_LLM_PROVIDER,
|
|
114
|
+
version: version
|
|
115
|
+
)
|
|
116
|
+
raise e
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Dispatches to the appropriate provider-native query builder.
|
|
122
|
+
def build_query(provider, model_id, messages, tools)
|
|
123
|
+
if provider == "gemini"
|
|
124
|
+
build_google_query(model_id, messages, tools)
|
|
125
|
+
else
|
|
126
|
+
# Anthropic and OpenAI have the same query format
|
|
127
|
+
build_messages_query(model_id, messages, tools)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Builds an OpenAI-compatible query (used for Anthropic and OpenAI providers).
|
|
132
|
+
def build_messages_query(model_id, messages, tools)
|
|
133
|
+
query = { model: model_id, messages: messages }
|
|
134
|
+
unless tools.empty?
|
|
135
|
+
query[:tools] = tools.values.map do |t|
|
|
136
|
+
{ name: t.name, description: t.description, parameters: t.params_schema }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
query
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Builds a Google-native query with contents/systemInstruction structure.
|
|
143
|
+
def build_google_query(model_id, messages, tools)
|
|
144
|
+
system_msgs = messages.select { |m| m[:role] == "system" }
|
|
145
|
+
content_msgs = messages.reject { |m| m[:role] == "system" }
|
|
146
|
+
|
|
147
|
+
query = {
|
|
148
|
+
model: model_id,
|
|
149
|
+
contents: content_msgs.map do |m|
|
|
150
|
+
google_role = m[:role] == "assistant" ? "model" : m[:role]
|
|
151
|
+
{ role: google_role, parts: [{ text: m[:content] }] }
|
|
152
|
+
end
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if system_msgs.any?
|
|
156
|
+
system_text = system_msgs.map { |m| m[:content] }.join("\n")
|
|
157
|
+
query[:systemInstruction] = { parts: [{ text: system_text }] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
unless tools.empty?
|
|
161
|
+
query[:tools] = tools.values.map do |t|
|
|
162
|
+
{ name: t.name, description: t.description, parameters: t.params_schema }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
query
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/crystil.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "crystil/version"
|
|
4
|
+
require_relative "crystil/errors"
|
|
5
|
+
require_relative "crystil/config"
|
|
6
|
+
require_relative "crystil/sentinel"
|
|
7
|
+
require_relative "crystil/attribution"
|
|
8
|
+
require_relative "crystil/collector"
|
|
9
|
+
require_relative "crystil/wrappers/base"
|
|
10
|
+
require_relative "crystil/wrappers/openai"
|
|
11
|
+
require_relative "crystil/wrappers/anthropic"
|
|
12
|
+
require_relative "crystil/wrappers/google"
|
|
13
|
+
require_relative "crystil/wrappers/geminiai"
|
|
14
|
+
require_relative "crystil/wrappers/ruby_llm"
|
|
15
|
+
require_relative "crystil/wrappers/groq"
|
|
16
|
+
require_relative "crystil/api/base"
|
|
17
|
+
require_relative "crystil/api/sentinel"
|
|
18
|
+
require_relative "crystil/api/workflows"
|
|
19
|
+
require_relative "crystil/api/invocation"
|
|
20
|
+
require_relative "crystil/api/workflow"
|
|
21
|
+
require_relative "crystil/client"
|
|
22
|
+
|
|
23
|
+
# Crystil - Cost visibility for AI agents
|
|
24
|
+
#
|
|
25
|
+
# Crystil is a lightweight infrastructure layer that gives AI teams
|
|
26
|
+
# real-time visibility into the true costs of deploying agents.
|
|
27
|
+
#
|
|
28
|
+
# @example Basic usage with OpenAI
|
|
29
|
+
# require 'openai'
|
|
30
|
+
# require 'crystil'
|
|
31
|
+
#
|
|
32
|
+
# openai = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
|
33
|
+
# crystil = Crystil::Client.new(api_key: ENV['CRYSTIL_API_KEY'])
|
|
34
|
+
# crystil.openai.register(openai)
|
|
35
|
+
#
|
|
36
|
+
# # Use OpenAI normally - analytics tracked automatically
|
|
37
|
+
# response = openai.chat(
|
|
38
|
+
# parameters: {
|
|
39
|
+
# model: "gpt-4",
|
|
40
|
+
# messages: [{ role: "user", content: "Hello!" }]
|
|
41
|
+
# }
|
|
42
|
+
# )
|
|
43
|
+
#
|
|
44
|
+
# @example With attribution
|
|
45
|
+
# crystil.attribution(
|
|
46
|
+
# parent_id: "user-123",
|
|
47
|
+
# parent_name: "John Doe"
|
|
48
|
+
# )
|
|
49
|
+
#
|
|
50
|
+
# @see https://developers.crystil.com
|
|
51
|
+
module Crystil
|
|
52
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
module API
|
|
5
|
+
class Base
|
|
6
|
+
@original_api_url: String
|
|
7
|
+
@api_url: String
|
|
8
|
+
@api_key: String
|
|
9
|
+
@timeout: Numeric
|
|
10
|
+
|
|
11
|
+
def initialize: (String api_url, String api_key, Numeric timeout) -> void
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
def get: (String path) -> untyped
|
|
15
|
+
def post: (String path, ?Hash[Symbol, untyped]? body) -> untyped
|
|
16
|
+
def put: (String path, ?Hash[Symbol, untyped]? body) -> untyped
|
|
17
|
+
def delete: (String path) -> untyped
|
|
18
|
+
def request: (Symbol method, String path, ?Hash[Symbol, untyped]? body) -> untyped
|
|
19
|
+
def build_request: (Symbol method, URI::Generic uri, Hash[Symbol, untyped]? body) -> (Net::HTTP::Delete | Net::HTTP::Get | Net::HTTP::Post | Net::HTTP::Put)
|
|
20
|
+
def handle_response: (Net::HTTPResponse response) -> untyped
|
|
21
|
+
def parse_json_response: (String? body) -> untyped
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
module API
|
|
5
|
+
class Invocation < Base
|
|
6
|
+
@attribution: Hash[Symbol, untyped]?
|
|
7
|
+
|
|
8
|
+
def initialize: (String api_url, String api_key, Numeric timeout) -> void
|
|
9
|
+
def attribution: (parent_id: String, ?parent_name: String?, ?subsidiary_id: String?, ?subsidiary_name: String?) -> self
|
|
10
|
+
def summary: (String workflow_uuid, date_start: (Date | Time | String), ?date_end: (Date | Time | String)?) -> untyped
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
def format_date: ((Date | Time | String) date) -> String
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
module API
|
|
5
|
+
class Sentinel < Base
|
|
6
|
+
def relevance_intercept: (Hash[Symbol, untyped] payload) -> untyped
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
module API
|
|
5
|
+
class Workflow < Base
|
|
6
|
+
@uuid: String
|
|
7
|
+
|
|
8
|
+
def initialize: (String uuid, String api_url, String api_key, Numeric timeout) -> void
|
|
9
|
+
def details: -> untyped
|
|
10
|
+
def update: (label: String) -> untyped
|
|
11
|
+
def destroy: -> untyped
|
|
12
|
+
def invocation: -> Crystil::API::Invocation
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
module API
|
|
5
|
+
class Workflows < Base
|
|
6
|
+
@invocation: Crystil::API::Invocation
|
|
7
|
+
|
|
8
|
+
def list: -> untyped
|
|
9
|
+
def create: (name: String, ?description: String?) -> untyped
|
|
10
|
+
def details: (String uuid) -> untyped
|
|
11
|
+
def update: (String uuid, label: String) -> untyped
|
|
12
|
+
def destroy: (String uuid) -> untyped
|
|
13
|
+
def invocation: -> Crystil::API::Invocation
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
class Attribution
|
|
5
|
+
attr_reader parent_id: String
|
|
6
|
+
attr_reader parent_name: String?
|
|
7
|
+
attr_reader subsidiary_id: String?
|
|
8
|
+
attr_reader subsidiary_name: String?
|
|
9
|
+
def initialize: (parent_id: String, ?parent_name: String?, ?subsidiary_id: String?, ?subsidiary_name: String?) -> void
|
|
10
|
+
def to_h: -> Hash[Symbol, untyped]
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
def validate_parent_id!: (untyped value) -> String
|
|
14
|
+
def validate_string_length!: (untyped value, String field_name) -> String?
|
|
15
|
+
def validate_subsidiary_requirements!: -> void
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
class Client
|
|
5
|
+
@openai: Crystil::Wrappers::OpenAI
|
|
6
|
+
@anthropic: Crystil::Wrappers::Anthropic
|
|
7
|
+
@google: Crystil::Wrappers::Google
|
|
8
|
+
@geminiai: Crystil::Wrappers::GeminiAI
|
|
9
|
+
@ruby_llm: Crystil::Wrappers::RubyLLM
|
|
10
|
+
@groq: Crystil::Wrappers::Groq
|
|
11
|
+
@workflows: Crystil::API::Workflows
|
|
12
|
+
|
|
13
|
+
attr_reader config: Crystil::Config
|
|
14
|
+
attr_reader collector: Crystil::Collector
|
|
15
|
+
attr_reader sentinel: Crystil::Sentinel
|
|
16
|
+
|
|
17
|
+
def initialize: (?api_key: String?, ?collector_url: String?, ?api_url: String?, ?timeout: Numeric?) -> void
|
|
18
|
+
def openai: -> Crystil::Wrappers::OpenAI
|
|
19
|
+
def anthropic: -> Crystil::Wrappers::Anthropic
|
|
20
|
+
def google: -> Crystil::Wrappers::Google
|
|
21
|
+
def geminiai: -> Crystil::Wrappers::GeminiAI
|
|
22
|
+
def ruby_llm: -> Crystil::Wrappers::RubyLLM
|
|
23
|
+
def groq: -> Crystil::Wrappers::Groq
|
|
24
|
+
def attribution: (parent_id: String, ?parent_name: String?, ?subsidiary_id: String?, ?subsidiary_name: String?) -> self
|
|
25
|
+
def new_transaction: -> self
|
|
26
|
+
def workflows: -> Crystil::API::Workflows
|
|
27
|
+
def shutdown: -> void
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
class Collector
|
|
5
|
+
DEFAULT_MAX_RETRIES: Integer
|
|
6
|
+
RETRY_DELAY: Integer
|
|
7
|
+
@config: Crystil::Config
|
|
8
|
+
# Concurrent::ThreadPoolExecutor — no upstream signatures available.
|
|
9
|
+
@executor: untyped
|
|
10
|
+
|
|
11
|
+
def initialize: (Crystil::Config config) -> void
|
|
12
|
+
def submit_async: (Hash[Symbol, untyped] payload) -> void
|
|
13
|
+
def submit: (Hash[Symbol, untyped] payload) -> untyped
|
|
14
|
+
def shutdown: -> void
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def submit_with_retry: (Hash[Symbol, untyped] payload, ?Integer attempt) -> untyped
|
|
18
|
+
def post_to_collector: (Hash[Symbol, untyped] payload) -> untyped
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
|
|
2
|
+
# "Code style: type signatures (RBS)" for the refinement convention.
|
|
3
|
+
module Crystil
|
|
4
|
+
class Config
|
|
5
|
+
# Concurrent::AtomicReference[T] — no `concurrent-ruby` signatures for
|
|
6
|
+
# that class are shipped in the public rbs_collection, so falls back to
|
|
7
|
+
# `untyped`. Refine once we add a hand-rolled stub or upstream sig.
|
|
8
|
+
@attribution: untyped
|
|
9
|
+
@tx_uuid: untyped
|
|
10
|
+
@raise_if_irrelevant: untyped
|
|
11
|
+
@secs_irrelevant_request_timeout: untyped
|
|
12
|
+
|
|
13
|
+
attr_reader api_key: String
|
|
14
|
+
attr_reader collector_url: String
|
|
15
|
+
attr_reader api_url: String
|
|
16
|
+
attr_reader timeout: Numeric
|
|
17
|
+
attr_reader version: String
|
|
18
|
+
|
|
19
|
+
def initialize: (?api_key: String?, ?collector_url: String?, ?api_url: String?, ?timeout: Numeric?) -> void
|
|
20
|
+
def attribution: -> Crystil::Attribution?
|
|
21
|
+
def attribution=: (Crystil::Attribution? value) -> void
|
|
22
|
+
def tx_uuid: -> String
|
|
23
|
+
def tx_uuid=: (String value) -> void
|
|
24
|
+
def raise_if_irrelevant: -> bool
|
|
25
|
+
def raise_if_irrelevant=: (bool value) -> void
|
|
26
|
+
def secs_irrelevant_request_timeout: -> Numeric
|
|
27
|
+
def secs_irrelevant_request_timeout=: (Numeric value) -> void
|
|
28
|
+
def new_transaction: -> String
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def nonempty_env: (String name) -> String?
|
|
32
|
+
end
|
|
33
|
+
end
|