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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- 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
|