little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,745 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "sse_parser"
5
+ require_relative "http_transport"
6
+
7
+ module LittleGhost
8
+ module Providers
9
+ # OpenAICompatible brings OpenAI-style Responses or Chat Completions endpoints
10
+ # into LittleGhost. Agents receive the same streaming events whether the
11
+ # endpoint is OpenAI, a hosted model service, or an application gateway.
12
+ #
13
+ # provider = LittleGhost::Providers::OpenAICompatible.new(
14
+ # api_key: ENV.fetch("MODEL_API_KEY"),
15
+ # model: "example-model",
16
+ # base_url: "https://models.example.test/v1/"
17
+ # )
18
+ #
19
+ # The client translates ModelRequest values to the selected wire API and
20
+ # translates responses back to StreamEvent objects.
21
+ #
22
+ # === Retries and streaming output
23
+ #
24
+ # Transient HTTP and stream failures retry with limited exponential backoff.
25
+ # A +:model_retry+ event reports each retry and whether text had already been
26
+ # emitted. Partial text may repeat after a retry, so consumers that assemble
27
+ # streams must use that event to discard or replace superseded output.
28
+ class OpenAICompatible
29
+ # The OpenAI API endpoint used when +base_url+ is omitted.
30
+ DEFAULT_BASE_URL = "https://api.openai.com/v1/"
31
+ INITIAL_RETRY_DELAY = 1 # :nodoc:
32
+ MAX_RETRY_DELAY = 16 # :nodoc:
33
+ TRANSIENT_STREAM_ERROR_TYPES = %w[
34
+ provider_overloaded provider_unavailable rate_limit_exceeded server timeout
35
+ ].freeze # :nodoc:
36
+ CONTEXT_OVERFLOW_MARKERS = [
37
+ "context_length_exceeded", "context window", "maximum context length",
38
+ "max context length", "input is too long", "too many input tokens"
39
+ ].freeze # :nodoc:
40
+
41
+ # Represents a structured error received inside an otherwise successful
42
+ # provider stream.
43
+ class StreamError < ProviderError
44
+ # Normalized provider error type and optional provider code.
45
+ attr_reader :error_type, :code
46
+
47
+ # Creates a structured stream error.
48
+ def initialize(message, error_type: nil, code: nil)
49
+ @error_type = error_type.to_s.strip.downcase
50
+ @code = code
51
+ super(message)
52
+ end
53
+
54
+ # Indicates whether LittleGhost may retry this provider error.
55
+ def retryable?
56
+ return true if TRANSIENT_STREAM_ERROR_TYPES.include?(error_type)
57
+ return true if code.to_s.strip.downcase == "server_error"
58
+
59
+ status = Integer(code, exception: false)
60
+ status == 408 || status == 429 || (status && status >= 500)
61
+ end
62
+
63
+ def self.from(error, prefix:) # :nodoc:
64
+ value = error.is_a?(Hash) ? error : {}
65
+ metadata = value["metadata"].is_a?(Hash) ? value["metadata"] : {}
66
+ error_type = metadata["error_type"] || value["type"]
67
+ code = value["code"]
68
+ message = value["message"].to_s
69
+ message = "unknown error" if message.empty?
70
+ new("#{prefix}: #{message}", error_type:, code:)
71
+ end
72
+ end
73
+
74
+ # Provider model identifier and selected OpenAI-compatible wire API.
75
+ attr_reader :model, :api
76
+
77
+ # Configures an OpenAI-compatible client.
78
+ #
79
+ # +api+ is +:responses+ or +:chat_completions+. +headers+ adds trusted
80
+ # endpoint-specific headers. +max_retries+ controls retries before the
81
+ # original error is raised, and +on_retry+ receives the attempt, error, and
82
+ # delay. Pass a custom +transport+ for alternate HTTP execution.
83
+ def initialize(
84
+ api_key:,
85
+ model:,
86
+ base_url: DEFAULT_BASE_URL,
87
+ api: :responses,
88
+ headers: {},
89
+ open_timeout: 10,
90
+ read_timeout: 120,
91
+ allow_insecure_http: false,
92
+ max_response_bytes: HTTPTransport::DEFAULT_MAX_RESPONSE_BYTES,
93
+ max_retries: 2,
94
+ max_retry_delay: MAX_RETRY_DELAY,
95
+ transport: nil,
96
+ sleeper: nil,
97
+ on_retry: ->(*) {}
98
+ )
99
+ @api_key = api_key
100
+ @model = model
101
+ @api = api.to_sym
102
+ raise ConfigurationError, "api must be :responses or :chat_completions" unless %i[responses chat_completions].include?(@api)
103
+
104
+ @headers = headers.transform_keys(&:to_s).freeze
105
+ @max_retries = Integer(max_retries)
106
+ @max_retry_delay = Integer(max_retry_delay)
107
+ @transport = transport || HTTPTransport.new(
108
+ base_url:,
109
+ open_timeout:,
110
+ read_timeout:,
111
+ allow_insecure_http:,
112
+ max_response_bytes:
113
+ )
114
+ @sleeper = sleeper
115
+ @on_retry = on_retry
116
+ end
117
+
118
+ # Streams LittleGhost StreamEvent objects for +request+.
119
+ #
120
+ # Without a block, returns an Enumerator. Context-window errors normalize
121
+ # to ContextWindowOverflowError, and malformed tool calls normalize to
122
+ # MalformedToolCallError.
123
+ def stream(request)
124
+ return enum_for(__method__, request) unless block_given?
125
+
126
+ attempts = 0
127
+
128
+ begin
129
+ partial_text = false
130
+ request.cancellation_token.raise_if_cancelled!
131
+ stream_once(request) do |event|
132
+ partial_text ||= event.type == :text_delta && !event.data[:text].to_s.empty?
133
+ yield event
134
+ end
135
+ rescue HTTPError, StreamError => error
136
+ if context_window_overflow?(error)
137
+ raise ContextWindowOverflowError, "The model context window was exceeded"
138
+ end
139
+ raise if !error.retryable? || attempts >= @max_retries
140
+
141
+ attempts += 1
142
+ request.cancellation_token.raise_if_cancelled!
143
+ delay = capped_retry_delay(request, retry_delay(attempts))
144
+ @on_retry.call(attempts, error, delay)
145
+ yield StreamEvent.build(
146
+ :model_retry,
147
+ attempt: attempts,
148
+ delay:,
149
+ error_class: error.class.name,
150
+ partial_text:,
151
+ **retry_error_metadata(error)
152
+ )
153
+ wait_before_retry(request, delay)
154
+ retry
155
+ end
156
+ end
157
+
158
+ # Returns the legacy capability contract expected from compatible APIs.
159
+ # Subclasses can override this when the endpoint advertises precise support.
160
+ def capabilities(metadata: {})
161
+ ModelCapabilities.legacy
162
+ end
163
+
164
+ private
165
+
166
+ def retry_error_metadata(error)
167
+ case error
168
+ when HTTPError
169
+ {http_status: error.status}.compact
170
+ when StreamError
171
+ {error_code: safe_retry_error_code(error)}.compact
172
+ else
173
+ {}
174
+ end
175
+ end
176
+
177
+ def safe_retry_error_code(error)
178
+ code = error.code.to_s.strip.downcase
179
+ return code if code == "server_error"
180
+
181
+ status = Integer(code, exception: false)
182
+ return status.to_s if status == 408 || status == 429 || (status && status >= 500)
183
+
184
+ error.error_type if TRANSIENT_STREAM_ERROR_TYPES.include?(error.error_type)
185
+ end
186
+
187
+ def context_window_overflow?(error)
188
+ values = [error.message]
189
+ values << error.body if error.respond_to?(:body)
190
+ values << error.error_type << error.code if error.is_a?(StreamError)
191
+ text = values.compact.join(" ").downcase
192
+ CONTEXT_OVERFLOW_MARKERS.any? { |marker| text.include?(marker) }
193
+ end
194
+
195
+ def stream_once(request)
196
+ parser = SSEParser.new
197
+ normalizer = normalizer_for(request)
198
+
199
+ @transport.stream(
200
+ path: endpoint,
201
+ headers: request_headers(request),
202
+ body: JSON.generate(request_body(request)),
203
+ cancellation_token: request.cancellation_token,
204
+ deadline: request.deadline
205
+ ) do |chunk|
206
+ parser.<<(chunk).each { |data| emit_data(data, normalizer) { |event| yield event } }
207
+ end
208
+ parser.finish.each { |data| emit_data(data, normalizer) { |event| yield event } }
209
+ normalizer.finish.each { |event| yield event }
210
+ rescue JSON::ParserError => error
211
+ raise ProtocolError, "Provider returned invalid JSON: #{error.message}"
212
+ end
213
+
214
+ def emit_data(data, normalizer)
215
+ if data == "[DONE]"
216
+ normalizer.stream_done
217
+ return
218
+ end
219
+
220
+ normalizer.consume(JSON.parse(data)).each { |event| yield event }
221
+ end
222
+
223
+ def endpoint
224
+ (api == :responses) ? "responses" : "chat/completions"
225
+ end
226
+
227
+ def request_headers(request)
228
+ {
229
+ "Authorization" => "Bearer #{@api_key}",
230
+ "Content-Type" => "application/json",
231
+ "Accept" => "text/event-stream"
232
+ }.merge(@headers).merge(dynamic_headers(request))
233
+ end
234
+
235
+ def dynamic_headers(_request)
236
+ {}
237
+ end
238
+
239
+ def request_body(request)
240
+ common = compact_hash(model:, stream: true).merge(provider_settings(request.settings))
241
+ if api == :responses
242
+ common[:max_output_tokens] ||= common[:max_tokens] || common[:max_completion_tokens]
243
+ common.delete(:max_tokens)
244
+ common.delete(:max_completion_tokens)
245
+ end
246
+ body = if api == :responses
247
+ common.merge(input: responses_input(request.messages), tools: responses_tools(request.tools))
248
+ else
249
+ common.merge(messages: chat_messages(request.messages), tools: chat_tools(request.tools), stream_options: {include_usage: true})
250
+ end
251
+ body.merge!(structured_output_parameters(request.output_schema)) if request.output_schema
252
+ body.merge!(tool_choice_parameters(request.tool_choice)) if request.tool_choice
253
+ body.delete(:tools) if request.tools.empty?
254
+ body
255
+ end
256
+
257
+ def tool_choice_parameters(choice)
258
+ return {tool_choice: "required"} if choice == :required
259
+
260
+ name = choice.fetch(:name).to_s
261
+ if api == :responses
262
+ {tool_choice: {type: "function", name:}}
263
+ else
264
+ {tool_choice: {type: "function", function: {name:}}}
265
+ end
266
+ end
267
+
268
+ def structured_output_parameters(output_schema)
269
+ schema = {
270
+ name: output_schema.fetch(:name),
271
+ schema: output_schema.fetch(:schema),
272
+ strict: true
273
+ }
274
+ schema[:description] = output_schema[:description] if output_schema[:description]
275
+ return {text: {format: {type: "json_schema", **schema}}} if api == :responses
276
+
277
+ {
278
+ response_format: {
279
+ type: "json_schema",
280
+ json_schema: schema
281
+ }
282
+ }
283
+ end
284
+
285
+ def provider_settings(settings)
286
+ allowed = %i[
287
+ temperature top_p max_tokens max_completion_tokens max_output_tokens
288
+ stop seed service_tier user metadata
289
+ ]
290
+ settings.each_with_object({}) do |(key, value), result|
291
+ symbol = key.to_sym
292
+ result[symbol] = value if allowed.include?(symbol)
293
+ end
294
+ end
295
+
296
+ def responses_input(messages)
297
+ messages.flat_map do |message|
298
+ tool_items = message.content.filter_map { |block| responses_tool_item(block) }
299
+ regular = message.content.reject do |block|
300
+ block.is_a?(Content::ToolUse) || block.is_a?(Content::ToolResult) || block.is_a?(Content::Reasoning)
301
+ end
302
+ item = {role: message.role.to_s, content: regular.map { |block| responses_content(block, message.role) }} unless regular.empty?
303
+ [item, *tool_items].compact
304
+ end
305
+ end
306
+
307
+ def responses_tool_item(block)
308
+ case block
309
+ when Content::ToolUse
310
+ {type: "function_call", call_id: block.id, name: block.name, arguments: JSON.generate(block.input)}
311
+ when Content::ToolResult
312
+ {type: "function_call_output", call_id: block.tool_use_id, output: tool_result_text(block)}
313
+ end
314
+ end
315
+
316
+ def responses_content(block, role)
317
+ case block
318
+ when Content::Text
319
+ {type: "input_text", text: block.text}
320
+ when Content::Image
321
+ {type: "input_image", image_url: data_url(block.data, block.media_type)}
322
+ when Content::Document
323
+ {type: "input_file", filename: block.name, file_data: data_url(block.data, block.media_type)}
324
+ else
325
+ raise ConfigurationError, "Unsupported Responses content block: #{block.class}"
326
+ end
327
+ end
328
+
329
+ def chat_messages(messages)
330
+ messages.flat_map do |message|
331
+ tool_results = message.content.grep(Content::ToolResult).map do |result|
332
+ {role: "tool", tool_call_id: result.tool_use_id, content: tool_result_text(result)}
333
+ end
334
+ regular = message.content.reject { |block| block.is_a?(Content::ToolResult) || block.is_a?(Content::Reasoning) }
335
+ reasoning_fields = chat_reasoning_fields(message)
336
+ entry = unless regular.empty? && reasoning_fields.empty?
337
+ compact_hash(
338
+ role: message.role.to_s,
339
+ content: chat_content(regular),
340
+ tool_calls: chat_tool_calls(regular)
341
+ ).merge(reasoning_fields)
342
+ end
343
+ [entry, *tool_results].compact
344
+ end
345
+ end
346
+
347
+ def chat_content(blocks)
348
+ content = blocks.filter_map do |block|
349
+ case block
350
+ when Content::Text then {type: "text", text: block.text}
351
+ when Content::Image then {type: "image_url", image_url: {url: data_url(block.data, block.media_type)}}
352
+ when Content::Document then {type: "file", file: {filename: block.name, file_data: data_url(block.data, block.media_type)}}
353
+ end
354
+ end
355
+ content unless content.empty?
356
+ end
357
+
358
+ def chat_tool_calls(blocks)
359
+ calls = blocks.grep(Content::ToolUse).map do |tool_use|
360
+ {id: tool_use.id, type: "function", function: {name: tool_use.name, arguments: JSON.generate(tool_use.input)}}
361
+ end
362
+ calls unless calls.empty?
363
+ end
364
+
365
+ def chat_reasoning_fields(_message) = {}
366
+
367
+ def responses_tools(tools)
368
+ tools.map do |tool|
369
+ definition = tool_definition(tool)
370
+ {
371
+ type: "function",
372
+ name: definition[:name],
373
+ description: definition[:description],
374
+ parameters: definition[:input_schema],
375
+ strict: definition[:strict]
376
+ }.compact
377
+ end
378
+ end
379
+
380
+ def chat_tools(tools)
381
+ responses_tools(tools).map { |definition| {type: "function", function: definition.except(:type)} }
382
+ end
383
+
384
+ def tool_definition(tool)
385
+ if tool.is_a?(Hash)
386
+ definition = tool.transform_keys(&:to_sym)
387
+ return {
388
+ name: definition.fetch(:name),
389
+ description: definition[:description],
390
+ input_schema: definition[:input_schema] || {},
391
+ strict: definition[:strict]
392
+ }
393
+ end
394
+
395
+ {
396
+ name: tool.public_send(:name),
397
+ description: tool.public_send(:description),
398
+ input_schema: tool.public_send(:input_schema),
399
+ strict: tool.respond_to?(:strict) ? tool.public_send(:strict) : nil
400
+ }
401
+ end
402
+
403
+ def tool_result_text(result)
404
+ Array(result.content).map { |block| block.is_a?(Content::Text) ? block.text : block.to_s }.join
405
+ end
406
+
407
+ def data_url(data, media_type)
408
+ return data if data.to_s.start_with?("data:", "http://", "https://")
409
+
410
+ "data:#{media_type};base64,#{[data].pack("m0")}"
411
+ end
412
+
413
+ def normalizer_for(request)
414
+ (api == :responses) ? ResponsesNormalizer.new(model:) : ChatNormalizer.new(model:)
415
+ end
416
+
417
+ def retry_delay(attempt)
418
+ [INITIAL_RETRY_DELAY * (2**(attempt - 1)), @max_retry_delay].min
419
+ end
420
+
421
+ def capped_retry_delay(request, delay)
422
+ return delay unless request.deadline
423
+
424
+ remaining = request.deadline - Time.now
425
+ raise DeadlineExceededError, "The run deadline was reached" unless remaining.positive?
426
+
427
+ [delay, remaining].min
428
+ end
429
+
430
+ def wait_before_retry(request, delay)
431
+ request.cancellation_token.raise_if_cancelled!
432
+ delay = capped_retry_delay(request, delay)
433
+ @sleeper ? @sleeper.call(delay) : request.cancellation_token.wait(delay)
434
+ request.cancellation_token.raise_if_cancelled!
435
+ raise DeadlineExceededError, "The run deadline was reached" if request.deadline && Time.now >= request.deadline
436
+ end
437
+
438
+ def compact_hash(hash)
439
+ hash.reject { |_key, value| value.nil? }
440
+ end
441
+
442
+ class Normalizer # :nodoc:
443
+ def initialize(model:)
444
+ @model = model
445
+ @message_id = nil
446
+ @text = +""
447
+ @reasoning = +""
448
+ @tool_calls = {}
449
+ @usage = Usage.new
450
+ @stop_reason = nil
451
+ @finished = false
452
+ end
453
+
454
+ def finish
455
+ return [] if @finished
456
+ raise ProtocolError, "Provider stream ended before its terminal event" unless @terminal
457
+
458
+ [final_event]
459
+ end
460
+
461
+ def stream_done
462
+ @terminal = true
463
+ end
464
+
465
+ private
466
+
467
+ def start_event(id, model = nil)
468
+ @message_id ||= id
469
+ StreamEvent.build(:message_start, id: @message_id, model: model || @model)
470
+ end
471
+
472
+ def text_delta(text)
473
+ @text << text
474
+ StreamEvent.build(:text_delta, text:)
475
+ end
476
+
477
+ def reasoning_delta(text)
478
+ @reasoning << text
479
+ StreamEvent.build(:reasoning_delta, text:)
480
+ end
481
+
482
+ def tool_start(index, id, name)
483
+ state = (@tool_calls[index] ||= {id: id, name: name, arguments: +"", started: false})
484
+ state[:id] ||= id
485
+ state[:name] ||= name
486
+ return if state[:started]
487
+
488
+ state[:started] = true
489
+ StreamEvent.build(:tool_call_start, index:, id: state[:id], name: state[:name])
490
+ end
491
+
492
+ def tool_delta(index, arguments)
493
+ state = (@tool_calls[index] ||= {id: nil, name: nil, arguments: +"", started: false})
494
+ state[:arguments] << arguments
495
+ StreamEvent.build(:tool_call_delta, index:, arguments:)
496
+ end
497
+
498
+ def tool_stop(index, complete_arguments: nil)
499
+ state = @tool_calls.fetch(index)
500
+ state[:arguments] = complete_arguments unless complete_arguments.nil? || complete_arguments.empty?
501
+ input = state[:arguments].empty? ? {} : JSON.parse(state[:arguments])
502
+ tool_use = Content::ToolUse.new(id: state[:id], name: state[:name], input:)
503
+ StreamEvent.build(:tool_call_stop, index:, tool_use:)
504
+ rescue JSON::ParserError, ArgumentError => error
505
+ raise MalformedToolCallError, "Provider returned an invalid tool call: #{error.message}"
506
+ end
507
+
508
+ def usage_event(usage)
509
+ @usage = usage
510
+ StreamEvent.build(:usage, usage:)
511
+ end
512
+
513
+ def final_event
514
+ @finished = true
515
+ blocks = []
516
+ reasoning = reasoning_content
517
+ blocks << reasoning if reasoning
518
+ blocks << Content::Text.new(text: @text) unless @text.empty?
519
+ @tool_calls.sort.map do |_index, state|
520
+ input = state[:arguments].empty? ? {} : JSON.parse(state[:arguments])
521
+ blocks << Content::ToolUse.new(id: state[:id], name: state[:name], input:)
522
+ end
523
+ response = ModelResponse.new(
524
+ message: Message.new(role: :assistant, content: blocks),
525
+ stop_reason: final_stop_reason,
526
+ usage: @usage,
527
+ metadata: {id: @message_id, model: @model}
528
+ )
529
+ StreamEvent.build(:message_stop, response:)
530
+ rescue JSON::ParserError, ArgumentError => error
531
+ raise MalformedToolCallError, "Provider returned an invalid tool call: #{error.message}"
532
+ end
533
+
534
+ def normalize_usage(input:, output:, cache_read: 0, cache_write: 0, reasoning: 0)
535
+ cache_read = Integer(cache_read || 0)
536
+ cache_write = Integer(cache_write || 0)
537
+ reasoning = Integer(reasoning || 0)
538
+ uncached_input = [Integer(input || 0) - cache_read - cache_write, 0].max
539
+ visible_output = [Integer(output || 0) - reasoning, 0].max
540
+ Usage.new(
541
+ input_tokens: uncached_input,
542
+ output_tokens: visible_output,
543
+ cache_read_tokens: cache_read,
544
+ cache_write_tokens: cache_write,
545
+ reasoning_tokens: reasoning
546
+ )
547
+ end
548
+
549
+ def reasoning_content
550
+ Content::Reasoning.new(text: @reasoning) unless @reasoning.empty?
551
+ end
552
+
553
+ def final_stop_reason
554
+ return :tool_use if !@tool_calls.empty? && (@stop_reason.nil? || @stop_reason == :end_turn)
555
+
556
+ @stop_reason || :end_turn
557
+ end
558
+
559
+ def stop_reason(value)
560
+ case value
561
+ when "tool_calls", "function_call" then :tool_use
562
+ when "length", "max_tokens", "incomplete" then :max_tokens
563
+ when "content_filter" then :content_filter
564
+ when nil then nil
565
+ else :end_turn
566
+ end
567
+ end
568
+ end
569
+
570
+ class ResponsesNormalizer < Normalizer # :nodoc:
571
+ def stream_done
572
+ nil
573
+ end
574
+
575
+ def consume(event)
576
+ type = event["type"]
577
+ case type
578
+ when "response.created"
579
+ response = event.fetch("response")
580
+ [start_event(response["id"], response["model"])]
581
+ when "response.output_text.delta"
582
+ [text_delta(event.fetch("delta"))]
583
+ when "response.reasoning_text.delta", "response.reasoning_summary_text.delta"
584
+ [reasoning_delta(event.fetch("delta"))]
585
+ when "response.output_item.added"
586
+ item = event.fetch("item")
587
+ return [] unless item["type"] == "function_call"
588
+
589
+ [tool_start(event.fetch("output_index"), item["call_id"] || item["id"], item["name"])]
590
+ when "response.function_call_arguments.delta"
591
+ [tool_delta(event.fetch("output_index"), event.fetch("delta"))]
592
+ when "response.output_item.done"
593
+ item = event.fetch("item")
594
+ return [] unless item["type"] == "function_call"
595
+
596
+ [tool_stop(event.fetch("output_index"), complete_arguments: item["arguments"])]
597
+ when "response.completed", "response.incomplete"
598
+ response = event.fetch("response")
599
+ @message_id ||= response["id"]
600
+ @model = response["model"] || @model
601
+ @stop_reason = (type == "response.incomplete") ? :max_tokens : stop_reason(response["status"])
602
+ events = []
603
+ events << usage_event(responses_usage(response["usage"])) if response["usage"]
604
+ events << final_event
605
+ when "response.failed"
606
+ error = event.dig("response", "error") || {}
607
+ raise StreamError.from(error, prefix: "Provider stream failed")
608
+ when "error"
609
+ error = event["error"] || event
610
+ raise StreamError.from(error, prefix: "Provider stream error")
611
+ else
612
+ []
613
+ end
614
+ end
615
+
616
+ private
617
+
618
+ def responses_usage(value)
619
+ input_details = value["input_tokens_details"] || {}
620
+ output_details = value["output_tokens_details"] || {}
621
+ normalize_usage(
622
+ input: value["input_tokens"],
623
+ output: value["output_tokens"],
624
+ cache_read: input_details["cached_tokens"],
625
+ cache_write: input_details["cache_write_tokens"] || output_details["cache_write_tokens"],
626
+ reasoning: output_details["reasoning_tokens"] || value["reasoning_tokens"]
627
+ )
628
+ end
629
+ end
630
+
631
+ class ChatNormalizer < Normalizer # :nodoc:
632
+ def initialize(...)
633
+ super
634
+ @reasoning_details = []
635
+ end
636
+
637
+ def consume(event)
638
+ if event["error"]
639
+ raise StreamError.from(event["error"], prefix: "Provider stream error")
640
+ end
641
+
642
+ events = []
643
+ events << start_event(event["id"], event["model"]) unless @message_id
644
+ choice = event.fetch("choices", []).first
645
+ if choice
646
+ delta = choice["delta"] || {}
647
+ events << text_delta(delta["content"]) if delta["content"]
648
+ reasoning = delta["reasoning_content"] || delta["reasoning"]
649
+ events << reasoning_delta(reasoning) if reasoning
650
+ capture_reasoning_details(delta["reasoning_details"]) if delta.key?("reasoning_details")
651
+ delta.fetch("tool_calls", []).each do |call|
652
+ index = call.fetch("index")
653
+ function = call["function"] || {}
654
+ if call["id"] || function["name"]
655
+ started = tool_start(index, call["id"], function["name"])
656
+ events << started if started
657
+ end
658
+ events << tool_delta(index, function["arguments"]) if function["arguments"]
659
+ end
660
+ if choice["finish_reason"]
661
+ @stop_reason = stop_reason(choice["finish_reason"])
662
+ @terminal = true
663
+ end
664
+ end
665
+ events << usage_event(chat_usage(event["usage"])) if event["usage"]
666
+ events
667
+ end
668
+
669
+ def finish
670
+ events = @tool_calls.keys.sort.map { |index| tool_stop(index) }
671
+ events.concat(super)
672
+ end
673
+
674
+ private
675
+
676
+ def capture_reasoning_details(details)
677
+ unless details.is_a?(Array) && details.all? { |detail| detail.is_a?(Hash) }
678
+ raise ProtocolError, "Provider returned invalid reasoning details"
679
+ end
680
+
681
+ @reasoning_details.concat(details.map(&:dup))
682
+ end
683
+
684
+ def reasoning_content
685
+ details = merged_reasoning_details
686
+ return if @reasoning.empty? && details.empty?
687
+
688
+ Content::Reasoning.new(
689
+ text: @reasoning,
690
+ details: details.empty? ? nil : details
691
+ )
692
+ end
693
+
694
+ def merged_reasoning_details
695
+ @reasoning_details.each_with_object([]) do |detail, merged|
696
+ previous = merged.last
697
+ if mergeable_reasoning_details?(previous, detail)
698
+ combined = previous.merge(detail) { |_key, old_value, new_value| new_value.nil? ? old_value : new_value }
699
+ if reasoning_detail_text?(previous) || reasoning_detail_text?(detail)
700
+ text_key = "text"
701
+ text_key = :text if detail.key?(:text)
702
+ combined.delete("text")
703
+ combined.delete(:text)
704
+ combined[text_key] = reasoning_detail_text(previous) + reasoning_detail_text(detail)
705
+ end
706
+ merged[-1] = combined
707
+ else
708
+ merged << detail.dup
709
+ end
710
+ end
711
+ end
712
+
713
+ def mergeable_reasoning_details?(left, right)
714
+ return false unless left
715
+
716
+ left_type = left["type"] || left[:type]
717
+ right_type = right["type"] || right[:type]
718
+ left_index = left.key?("index") ? left["index"] : left[:index]
719
+ right_index = right.key?("index") ? right["index"] : right[:index]
720
+ !left_type.nil? && !left_index.nil? && left_type == right_type && left_index == right_index
721
+ end
722
+
723
+ def reasoning_detail_text?(detail)
724
+ detail.key?("text") || detail.key?(:text)
725
+ end
726
+
727
+ def reasoning_detail_text(detail)
728
+ (detail["text"] || detail[:text]).to_s
729
+ end
730
+
731
+ def chat_usage(value)
732
+ prompt_details = value["prompt_tokens_details"] || {}
733
+ completion_details = value["completion_tokens_details"] || {}
734
+ normalize_usage(
735
+ input: value["prompt_tokens"],
736
+ output: value["completion_tokens"],
737
+ cache_read: prompt_details["cached_tokens"] || value["cache_read_input_tokens"],
738
+ cache_write: prompt_details["cache_write_tokens"] || value["cache_creation_input_tokens"],
739
+ reasoning: completion_details["reasoning_tokens"] || value["reasoning_tokens"]
740
+ )
741
+ end
742
+ end
743
+ end
744
+ end
745
+ end