graphmind 0.6.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.
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ids"
4
+ require_relative "../wrap"
5
+ require_relative "support"
6
+
7
+ module Graphmind
8
+ module Integrations
9
+ # Instrumentation for the `ruby-openai` gem (`OpenAI::Client`).
10
+ #
11
+ # client = Graphmind.instrument_openai(OpenAI::Client.new(access_token: ...))
12
+ #
13
+ # It prepends a module to *that client's singleton class*, so nothing
14
+ # global is monkey-patched: an uninstrumented client in the same process is
15
+ # untouched, and the gem is never loaded unless you ask for it.
16
+ #
17
+ # What you get:
18
+ # * a `llm:step` node for every `chat` / `responses.create` call, with
19
+ # the request as sent (messages / input in full, the sampling
20
+ # parameters, tools by schema hash), the response text, the requested
21
+ # tool calls, the normalized finish reason and inclusive token usage
22
+ # (contract C1: prompt tokens INCLUDE cached ones);
23
+ # * a `before` gate — the debugger can pause and `inject` a response
24
+ # without the request ever leaving the process;
25
+ # * an `error` gate — a 429 or a timeout pauses instead of raising, and
26
+ # `retry` re-sends the request;
27
+ # * streamed deltas forwarded to the canvas when you pass `stream:`.
28
+ #
29
+ # Tool *execution* is your code, not the gem's, so gate it with
30
+ # `Graphmind.tool` / `wrap_tools` (see the README).
31
+ module RubyOpenAI
32
+ MARKER = :@__graphmind_openai
33
+
34
+ module_function
35
+
36
+ def instrument(client, session, node_id: Graphmind::Ids::LLM_NODE_ID,
37
+ name: Graphmind::Ids::LLM_NODE_NAME)
38
+ return client if client.nil?
39
+ return client if client.instance_variable_get(MARKER)
40
+
41
+ config = { session: session, node_id: node_id, name: name }
42
+ client.instance_variable_set(MARKER, config)
43
+ client.singleton_class.prepend(ClientPatch)
44
+ client
45
+ end
46
+
47
+ def instrumented?(client)
48
+ !client.instance_variable_get(MARKER).nil?
49
+ end
50
+
51
+ # What a `stream:` proc saw: the gem hands the caller chunks, and what
52
+ # `chat` returns for a streamed call is not the completion.
53
+ class StreamState
54
+ attr_reader :text, :calls, :usage, :finish_reason, :final
55
+
56
+ def initialize
57
+ @text = +""
58
+ @calls = {}
59
+ @usage = nil
60
+ @finish_reason = nil
61
+ @final = nil
62
+ @seen = false
63
+ end
64
+
65
+ def seen? = @seen
66
+
67
+ # The stream carried refusal deltas (a stop that is a refusal).
68
+ def refused? = @refused == true
69
+
70
+ def observe(chunk)
71
+ return unless chunk.is_a?(Hash)
72
+
73
+ @seen = true
74
+ @usage = chunk["usage"] if chunk["usage"].is_a?(Hash)
75
+ choice = chunk.dig("choices", 0)
76
+ observe_choice(choice) if choice.is_a?(Hash)
77
+ type = chunk["type"].to_s
78
+ @final = chunk["response"] if type.start_with?("response.") && chunk["response"].is_a?(Hash) &&
79
+ %w[response.completed response.incomplete response.failed].include?(type)
80
+ end
81
+
82
+ def observe_choice(choice)
83
+ @finish_reason = choice["finish_reason"] if choice["finish_reason"].is_a?(String)
84
+ delta = choice["delta"]
85
+ return unless delta.is_a?(Hash)
86
+
87
+ content = delta["content"]
88
+ @text << content if content.is_a?(String)
89
+ @refused = true if delta["refusal"].is_a?(String) && !delta["refusal"].empty?
90
+ Array(delta["tool_calls"]).each do |call|
91
+ next unless call.is_a?(Hash)
92
+
93
+ entry = (@calls[call["index"].is_a?(Integer) ? call["index"] : 0] ||= [nil, nil, +"", false])
94
+ entry[0] = call["id"] if call["id"].is_a?(String) && !call["id"].empty?
95
+ # A custom (freeform) tool streams `custom: {name, input}`.
96
+ entry[3] = true if call["type"] == "custom" || call["custom"].is_a?(Hash)
97
+ source_key, text_key = entry[3] ? %w[custom input] : %w[function arguments]
98
+ source = call[source_key].is_a?(Hash) ? call[source_key] : {}
99
+ entry[1] = source["name"] if source["name"].is_a?(String) && !source["name"].empty?
100
+ entry[2] << source[text_key] if source[text_key].is_a?(String)
101
+ end
102
+ end
103
+
104
+ # A custom tool's input is text by design: recorded as the string, as
105
+ # chat_tool_calls does for a non-streamed one.
106
+ def tool_calls
107
+ @calls.keys.sort.filter_map do |index|
108
+ id, name, text, custom = @calls[index]
109
+ next Support.tool_call(id, name, text) unless custom
110
+ next nil unless name.is_a?(String) && !name.empty?
111
+
112
+ id.nil? ? { "name" => name, "input" => text.dup } : { "id" => id, "name" => name, "input" => text.dup }
113
+ end
114
+ end
115
+ end
116
+
117
+ # One gated LLM call.
118
+ def call(config, parameters, operation)
119
+ session = config[:session]
120
+ node_id = config[:node_id]
121
+ return yield(parameters) if session.nil? || !session.enabled? || session.disposed?
122
+
123
+ stream = StreamState.new
124
+ Graphmind::Wrap.invoke(
125
+ session,
126
+ node_id: node_id,
127
+ kind: "llm",
128
+ name: config[:name],
129
+ input: describe(parameters, operation, session),
130
+ full_input: true,
131
+ output_for: ->(response) { summarize_response(response, stream) },
132
+ finish_extra: ->(response) { extra_for(response, stream) },
133
+ inject_as: ->(value) { coerce_response(value, operation) }
134
+ ) do
135
+ yield(instrument_stream(parameters, session, node_id, stream))
136
+ end
137
+ end
138
+
139
+ # An injected reply arrives as JSON. A Hash is already response-shaped
140
+ # and passes straight through; a bare string — what a human actually
141
+ # types in the viewer — is wrapped so the caller's `dig("choices", ...)`
142
+ # keeps working.
143
+ def coerce_response(value, operation)
144
+ return value unless value.is_a?(String)
145
+
146
+ if operation == "responses.create"
147
+ {
148
+ "id" => "graphmind-injected",
149
+ "object" => "response",
150
+ "output" => [{ "type" => "message", "role" => "assistant",
151
+ "content" => [{ "type" => "output_text", "text" => value }] }],
152
+ "graphmindInjected" => true
153
+ }
154
+ else
155
+ {
156
+ "id" => "graphmind-injected",
157
+ "object" => "chat.completion",
158
+ "choices" => [{ "index" => 0, "finish_reason" => "stop",
159
+ "message" => { "role" => "assistant", "content" => value } }],
160
+ "graphmindInjected" => true
161
+ }
162
+ end
163
+ end
164
+
165
+ # node.started.input: the request as sent (contract C1) — model,
166
+ # instructions and messages / input in FULL (no message-count or length
167
+ # trim; the 512 KB shrink bounds the event), the sampling parameters under
168
+ # the API's own names (an allow-list: `user`, `metadata` and the stream
169
+ # proc are never read), and tools as {name, schemaHash} with each
170
+ # definition sent once per run as toolSchemas.
171
+ def describe(parameters, operation, session = nil)
172
+ out = { "operation" => operation }
173
+ %i[model instructions previous_response_id].each do |key|
174
+ value = Support.param(parameters, key)
175
+ out[key.to_s] = Support.record(value) unless value.nil?
176
+ end
177
+ messages = Support.param(parameters, :messages)
178
+ out["messages"] = Support.record(messages) unless messages.nil?
179
+ input = Support.param(parameters, :input)
180
+ out["input"] = Support.record(input) unless input.nil?
181
+ out.merge!(Support.pick_params(parameters))
182
+ tools = capture_tools(session, Support.param(parameters, :tools))
183
+ out.merge!(tools) unless tools.nil?
184
+ out["stream"] = true if Support.param(parameters, :stream)
185
+ out
186
+ rescue StandardError
187
+ { "operation" => operation }
188
+ end
189
+
190
+ def capture_tools(session, tools)
191
+ return nil unless tools.is_a?(Array)
192
+
193
+ run = session&.current_run
194
+ Support.capture_tools(session || self, run.nil? ? "implicit" : run.run_id, tools)
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ # Wrap a caller-supplied `stream:` proc so deltas also reach the canvas
200
+ # (and the step's tool calls, finish reason and last-chunk usage reach
201
+ # `stream`). The caller's proc still runs, with the same arity it declared.
202
+ def instrument_stream(parameters, session, node_id, stream = nil)
203
+ return parameters unless parameters.is_a?(Hash)
204
+
205
+ key = Support.param_key(parameters, :stream)
206
+ original = parameters[key]
207
+ return parameters unless original.respond_to?(:call)
208
+
209
+ copy = parameters.dup
210
+ copy[key] = proc do |chunk, bytesize|
211
+ begin
212
+ text = delta_text(chunk)
213
+ session.push_token(node_id, "text", text) if text
214
+ stream&.observe(chunk)
215
+ rescue StandardError
216
+ nil
217
+ end
218
+ original.arity == 1 ? original.call(chunk) : original.call(chunk, bytesize)
219
+ end
220
+ copy
221
+ rescue StandardError
222
+ parameters
223
+ end
224
+
225
+ def delta_text(chunk)
226
+ return nil unless chunk.is_a?(Hash)
227
+
228
+ # Chat Completions streaming.
229
+ delta = chunk.dig("choices", 0, "delta")
230
+ text = delta.is_a?(Hash) ? (delta["content"] || delta["reasoning_content"]) : nil
231
+ return text if text.is_a?(String) && !text.empty?
232
+
233
+ # Responses API streaming.
234
+ text = chunk["delta"] if chunk["type"].to_s.include?("output_text")
235
+ text.is_a?(String) && !text.empty? ? text : nil
236
+ rescue StandardError
237
+ nil
238
+ end
239
+
240
+ # node.finished.output (contract C1): text, the requested tool calls as
241
+ # {id, name, input, inputText?}, the normalized finishReason and the API's
242
+ # own value as rawFinishReason (chat: finish_reason; Responses: the
243
+ # incomplete reason, else the status). A streamed call reports what the
244
+ # stream proc saw.
245
+ def summarize_response(response, stream = nil)
246
+ response = stream.final if stream&.final && !completion?(response)
247
+ return streamed_output(stream) if stream&.seen? && !completion?(response)
248
+ return Support.truncate(response.to_s) unless response.is_a?(Hash)
249
+
250
+ out = response.key?("choices") ? chat_output(response) : responses_output(response)
251
+ out["id"] = response["id"] if response["id"]
252
+ out["model"] = response["model"] if response["model"]
253
+ out.empty? ? Support.truncate(response.to_s) : out
254
+ rescue StandardError
255
+ nil
256
+ end
257
+
258
+ def completion?(response)
259
+ response.is_a?(Hash) && (response.key?("choices") || response.key?("output"))
260
+ end
261
+
262
+ def chat_output(response)
263
+ choice = response.dig("choices", 0) || {}
264
+ message = choice.is_a?(Hash) ? choice["message"] : nil
265
+ text = message.is_a?(Hash) ? message["content"] : nil
266
+ text = choice["text"] if text.nil? && choice.is_a?(Hash)
267
+ calls = chat_tool_calls(message.is_a?(Hash) ? message["tool_calls"] : nil)
268
+ out = {}
269
+ out["text"] = text if text.is_a?(String)
270
+ out["toolCalls"] = calls unless calls.empty?
271
+ refusal = message.is_a?(Hash) ? message["refusal"] : nil
272
+ out.merge!(Support.finish_fields(choice.is_a?(Hash) ? choice["finish_reason"] : nil, !calls.empty?,
273
+ refusal.is_a?(String) && !refusal.empty?))
274
+ end
275
+
276
+ def chat_tool_calls(calls)
277
+ Array(calls).filter_map do |call|
278
+ next nil unless call.is_a?(Hash)
279
+
280
+ function = call["function"]
281
+ if function.is_a?(Hash)
282
+ Support.tool_call(call["id"], function["name"], function["arguments"])
283
+ elsif call["custom"].is_a?(Hash) && call["custom"]["name"].is_a?(String)
284
+ entry = { "name" => call["custom"]["name"], "input" => call["custom"]["input"] || "" }
285
+ call["id"].is_a?(String) ? { "id" => call["id"] }.merge(entry) : entry
286
+ end
287
+ end
288
+ end
289
+
290
+ def responses_output(response)
291
+ calls = Array(response["output"]).filter_map do |item|
292
+ next nil unless item.is_a?(Hash)
293
+
294
+ if item["type"] == "function_call"
295
+ Support.tool_call(item["call_id"], item["name"], item["arguments"])
296
+ elsif item["type"] == "custom_tool_call" && item["name"].is_a?(String) && !item["name"].empty?
297
+ # A custom (freeform) tool's input is text by design.
298
+ entry = { "name" => item["name"], "input" => item["input"] || "" }
299
+ item["call_id"].is_a?(String) ? { "id" => item["call_id"] }.merge(entry) : entry
300
+ end
301
+ end
302
+ text = output_text(response)
303
+ reason = response.dig("incomplete_details", "reason")
304
+ raw = reason.is_a?(String) && !reason.empty? ? reason : response["status"]
305
+ out = {}
306
+ out["text"] = text if text.is_a?(String)
307
+ out["toolCalls"] = calls unless calls.empty?
308
+ refused = Array(response["output"]).any? do |item|
309
+ item.is_a?(Hash) && Array(item["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "refusal" }
310
+ end
311
+ out.merge!(Support.finish_fields(raw, !calls.empty?, refused))
312
+ out["status"] = response["status"] if response["status"].is_a?(String)
313
+ out
314
+ end
315
+
316
+ def streamed_output(stream)
317
+ calls = stream.tool_calls
318
+ out = { "text" => stream.text.dup }
319
+ out["toolCalls"] = calls unless calls.empty?
320
+ out.merge!(Support.finish_fields(stream.finish_reason, !calls.empty?, stream.refused?))
321
+ out["streamed"] = true
322
+ out
323
+ end
324
+
325
+ def output_text(response)
326
+ output = response["output"]
327
+ return nil unless output.is_a?(Array)
328
+
329
+ output.filter_map do |item|
330
+ content = item.is_a?(Hash) ? item["content"] : nil
331
+ next nil unless content.is_a?(Array)
332
+
333
+ content.filter_map { |part| part["text"] if part.is_a?(Hash) }.join
334
+ end.join
335
+ rescue StandardError
336
+ nil
337
+ end
338
+
339
+ # {"usage" => ...}: inclusive, cache and reasoning counts only when
340
+ # reported. A streamed chat call's usage rides its last chunk (with
341
+ # `stream_options: {include_usage: true}`).
342
+ def extra_for(response, stream = nil)
343
+ usage = response["usage"] if response.is_a?(Hash)
344
+ usage = stream.usage if usage.nil? && stream
345
+ usage = stream.final["usage"] if usage.nil? && stream&.final
346
+ tokens = Support.openai_usage(usage)
347
+ tokens.nil? ? nil : { "usage" => tokens }
348
+ rescue StandardError
349
+ nil
350
+ end
351
+
352
+ # Prepended onto one client instance.
353
+ module ClientPatch
354
+ def chat(parameters: {})
355
+ config = instance_variable_get(MARKER)
356
+ return super(parameters: parameters) if config.nil?
357
+
358
+ RubyOpenAI.call(config, parameters, "chat.completions") do |params|
359
+ super(parameters: params)
360
+ end
361
+ end
362
+
363
+ def responses
364
+ object = super
365
+ config = instance_variable_get(MARKER)
366
+ return object if config.nil? || object.nil?
367
+ return object if object.instance_variable_get(RubyOpenAI::MARKER)
368
+
369
+ object.instance_variable_set(RubyOpenAI::MARKER, config)
370
+ object.singleton_class.prepend(RubyOpenAI::ResponsesPatch)
371
+ object
372
+ end
373
+ end
374
+
375
+ # Prepended onto the client's memoized `responses` helper.
376
+ module ResponsesPatch
377
+ def create(parameters: {})
378
+ config = instance_variable_get(RubyOpenAI::MARKER)
379
+ return super(parameters: parameters) if config.nil?
380
+
381
+ RubyOpenAI.call(config, parameters, "responses.create") do |params|
382
+ super(parameters: params)
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
388
+ end