lex-llm 0.1.1
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/.github/CODEOWNERS +7 -0
- data/.github/dependabot.yml +18 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +19 -0
- data/.rubocop.yml +42 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +50 -0
- data/LICENSE +21 -0
- data/README.md +279 -0
- data/lex-llm.gemspec +43 -0
- data/lib/generators/lex_llm/agent/agent_generator.rb +36 -0
- data/lib/generators/lex_llm/agent/templates/agent.rb.tt +6 -0
- data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +256 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +38 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
- data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +28 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +25 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +7 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +15 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +38 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +17 -0
- data/lib/generators/lex_llm/generator_helpers.rb +214 -0
- data/lib/generators/lex_llm/install/install_generator.rb +109 -0
- data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
- data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +7 -0
- data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +19 -0
- data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +39 -0
- data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +21 -0
- data/lib/generators/lex_llm/install/templates/initializer.rb.tt +20 -0
- data/lib/generators/lex_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/lex_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/lex_llm/schema/schema_generator.rb +26 -0
- data/lib/generators/lex_llm/schema/templates/schema.rb.tt +2 -0
- data/lib/generators/lex_llm/tool/templates/tool.rb.tt +9 -0
- data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +13 -0
- data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +13 -0
- data/lib/generators/lex_llm/tool/tool_generator.rb +96 -0
- data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
- data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
- data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
- data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
- data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
- data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +122 -0
- data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
- data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
- data/lib/legion/extensions/llm/provider_settings.rb +49 -0
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +70 -0
- data/lib/legion/extensions/llm.rb +50 -0
- data/lib/lex_llm/active_record/acts_as.rb +180 -0
- data/lib/lex_llm/active_record/acts_as_legacy.rb +503 -0
- data/lib/lex_llm/active_record/chat_methods.rb +468 -0
- data/lib/lex_llm/active_record/message_methods.rb +131 -0
- data/lib/lex_llm/active_record/model_methods.rb +76 -0
- data/lib/lex_llm/active_record/payload_helpers.rb +26 -0
- data/lib/lex_llm/active_record/tool_call_methods.rb +15 -0
- data/lib/lex_llm/agent.rb +365 -0
- data/lib/lex_llm/aliases.json +436 -0
- data/lib/lex_llm/aliases.rb +38 -0
- data/lib/lex_llm/attachment.rb +223 -0
- data/lib/lex_llm/chat.rb +351 -0
- data/lib/lex_llm/chunk.rb +6 -0
- data/lib/lex_llm/configuration.rb +81 -0
- data/lib/lex_llm/connection.rb +130 -0
- data/lib/lex_llm/content.rb +77 -0
- data/lib/lex_llm/context.rb +29 -0
- data/lib/lex_llm/embedding.rb +29 -0
- data/lib/lex_llm/error.rb +112 -0
- data/lib/lex_llm/image.rb +105 -0
- data/lib/lex_llm/message.rb +107 -0
- data/lib/lex_llm/mime_type.rb +71 -0
- data/lib/lex_llm/model/info.rb +113 -0
- data/lib/lex_llm/model/modalities.rb +22 -0
- data/lib/lex_llm/model/pricing.rb +48 -0
- data/lib/lex_llm/model/pricing_category.rb +46 -0
- data/lib/lex_llm/model/pricing_tier.rb +33 -0
- data/lib/lex_llm/model.rb +7 -0
- data/lib/lex_llm/models.json +57241 -0
- data/lib/lex_llm/models.rb +506 -0
- data/lib/lex_llm/models_schema.json +168 -0
- data/lib/lex_llm/moderation.rb +56 -0
- data/lib/lex_llm/provider.rb +278 -0
- data/lib/lex_llm/railtie.rb +35 -0
- data/lib/lex_llm/routing/lane_key.rb +51 -0
- data/lib/lex_llm/routing/model_offering.rb +169 -0
- data/lib/lex_llm/routing.rb +7 -0
- data/lib/lex_llm/stream_accumulator.rb +203 -0
- data/lib/lex_llm/streaming.rb +175 -0
- data/lib/lex_llm/thinking.rb +49 -0
- data/lib/lex_llm/tokens.rb +47 -0
- data/lib/lex_llm/tool.rb +254 -0
- data/lib/lex_llm/tool_call.rb +25 -0
- data/lib/lex_llm/transcription.rb +35 -0
- data/lib/lex_llm/utils.rb +91 -0
- data/lib/lex_llm/version.rb +5 -0
- data/lib/lex_llm.rb +95 -0
- data/lib/tasks/lex_llm.rake +23 -0
- metadata +349 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LexLLM
|
|
4
|
+
# Assembles streaming responses from LLMs into complete messages.
|
|
5
|
+
class StreamAccumulator
|
|
6
|
+
attr_reader :content, :model_id, :tool_calls
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@content = +''
|
|
10
|
+
@thinking_text = +''
|
|
11
|
+
@thinking_signature = nil
|
|
12
|
+
@tool_calls = {}
|
|
13
|
+
@input_tokens = nil
|
|
14
|
+
@output_tokens = nil
|
|
15
|
+
@cached_tokens = nil
|
|
16
|
+
@cache_creation_tokens = nil
|
|
17
|
+
@thinking_tokens = nil
|
|
18
|
+
@inside_think_tag = false
|
|
19
|
+
@pending_think_tag = +''
|
|
20
|
+
@latest_tool_call_id = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add(chunk)
|
|
24
|
+
LexLLM.logger.debug { chunk.inspect } if LexLLM.config.log_stream_debug
|
|
25
|
+
@model_id ||= chunk.model_id
|
|
26
|
+
|
|
27
|
+
handle_chunk_content(chunk)
|
|
28
|
+
append_thinking_from_chunk(chunk)
|
|
29
|
+
count_tokens chunk
|
|
30
|
+
LexLLM.logger.debug { inspect } if LexLLM.config.log_stream_debug
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_message(response)
|
|
34
|
+
Message.new(
|
|
35
|
+
role: :assistant,
|
|
36
|
+
content: content.empty? ? nil : content,
|
|
37
|
+
thinking: Thinking.build(
|
|
38
|
+
text: @thinking_text.empty? ? nil : @thinking_text,
|
|
39
|
+
signature: @thinking_signature
|
|
40
|
+
),
|
|
41
|
+
tokens: Tokens.build(
|
|
42
|
+
input: @input_tokens,
|
|
43
|
+
output: @output_tokens,
|
|
44
|
+
cached: @cached_tokens,
|
|
45
|
+
cache_creation: @cache_creation_tokens,
|
|
46
|
+
thinking: @thinking_tokens
|
|
47
|
+
),
|
|
48
|
+
model_id: model_id,
|
|
49
|
+
tool_calls: tool_calls_from_stream,
|
|
50
|
+
raw: response
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def tool_calls_from_stream
|
|
57
|
+
tool_calls.transform_values do |tc|
|
|
58
|
+
arguments = if tc.arguments.is_a?(String) && !tc.arguments.empty?
|
|
59
|
+
Legion::JSON.parse(tc.arguments, symbolize_names: false)
|
|
60
|
+
elsif tc.arguments.is_a?(String)
|
|
61
|
+
{}
|
|
62
|
+
else
|
|
63
|
+
tc.arguments
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ToolCall.new(
|
|
67
|
+
id: tc.id,
|
|
68
|
+
name: tc.name,
|
|
69
|
+
arguments: arguments,
|
|
70
|
+
thought_signature: tc.thought_signature
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
|
|
76
|
+
LexLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if LexLLM.config.log_stream_debug
|
|
77
|
+
new_tool_calls.each_value do |tool_call|
|
|
78
|
+
if tool_call.id
|
|
79
|
+
tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
|
|
80
|
+
tool_call_arguments = tool_call.arguments
|
|
81
|
+
if tool_call_arguments.nil? || (tool_call_arguments.respond_to?(:empty?) && tool_call_arguments.empty?)
|
|
82
|
+
tool_call_arguments = +''
|
|
83
|
+
end
|
|
84
|
+
@tool_calls[tool_call.id] = ToolCall.new(
|
|
85
|
+
id: tool_call_id,
|
|
86
|
+
name: tool_call.name,
|
|
87
|
+
arguments: tool_call_arguments,
|
|
88
|
+
thought_signature: tool_call.thought_signature
|
|
89
|
+
)
|
|
90
|
+
@latest_tool_call_id = tool_call.id
|
|
91
|
+
else
|
|
92
|
+
existing = @tool_calls[@latest_tool_call_id]
|
|
93
|
+
if existing
|
|
94
|
+
fragment = tool_call.arguments
|
|
95
|
+
fragment = '' if fragment.nil?
|
|
96
|
+
existing.arguments << fragment
|
|
97
|
+
if tool_call.thought_signature && existing.thought_signature.nil?
|
|
98
|
+
existing.thought_signature = tool_call.thought_signature
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def find_tool_call(tool_call_id)
|
|
106
|
+
if tool_call_id.nil?
|
|
107
|
+
@tool_calls[@latest_tool_call]
|
|
108
|
+
else
|
|
109
|
+
@latest_tool_call_id = tool_call_id
|
|
110
|
+
@tool_calls[tool_call_id]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def count_tokens(chunk)
|
|
115
|
+
@input_tokens = chunk.input_tokens if chunk.input_tokens
|
|
116
|
+
@output_tokens = chunk.output_tokens if chunk.output_tokens
|
|
117
|
+
@cached_tokens = chunk.cached_tokens if chunk.cached_tokens
|
|
118
|
+
@cache_creation_tokens = chunk.cache_creation_tokens if chunk.cache_creation_tokens
|
|
119
|
+
@thinking_tokens = chunk.thinking_tokens if chunk.thinking_tokens
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def handle_chunk_content(chunk)
|
|
123
|
+
return accumulate_tool_calls(chunk.tool_calls) if chunk.tool_call?
|
|
124
|
+
|
|
125
|
+
content_text = chunk.content || ''
|
|
126
|
+
if content_text.is_a?(String)
|
|
127
|
+
append_text_with_thinking(content_text)
|
|
128
|
+
else
|
|
129
|
+
@content << content_text.to_s
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def append_text_with_thinking(text)
|
|
134
|
+
content_chunk, thinking_chunk = extract_think_tags(text)
|
|
135
|
+
@content << content_chunk
|
|
136
|
+
@thinking_text << thinking_chunk if thinking_chunk
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def append_thinking_from_chunk(chunk)
|
|
140
|
+
thinking = chunk.thinking
|
|
141
|
+
return unless thinking
|
|
142
|
+
|
|
143
|
+
@thinking_text << thinking.text.to_s if thinking.text
|
|
144
|
+
@thinking_signature ||= thinking.signature # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def extract_think_tags(text)
|
|
148
|
+
start_tag = '<think>'
|
|
149
|
+
end_tag = '</think>'
|
|
150
|
+
remaining = @pending_think_tag + text
|
|
151
|
+
@pending_think_tag = +''
|
|
152
|
+
|
|
153
|
+
output = +''
|
|
154
|
+
thinking = +''
|
|
155
|
+
|
|
156
|
+
until remaining.empty?
|
|
157
|
+
remaining = if @inside_think_tag
|
|
158
|
+
consume_think_content(remaining, end_tag, thinking)
|
|
159
|
+
else
|
|
160
|
+
consume_non_think_content(remaining, start_tag, output)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
[output, thinking.empty? ? nil : thinking]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def consume_think_content(remaining, end_tag, thinking)
|
|
168
|
+
end_index = remaining.index(end_tag)
|
|
169
|
+
if end_index
|
|
170
|
+
thinking << remaining.slice(0, end_index)
|
|
171
|
+
@inside_think_tag = false
|
|
172
|
+
remaining.slice((end_index + end_tag.length)..) || +''
|
|
173
|
+
else
|
|
174
|
+
suffix_len = longest_suffix_prefix(remaining, end_tag)
|
|
175
|
+
thinking << remaining.slice(0, remaining.length - suffix_len)
|
|
176
|
+
@pending_think_tag = remaining.slice(-suffix_len, suffix_len)
|
|
177
|
+
+''
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def consume_non_think_content(remaining, start_tag, output)
|
|
182
|
+
start_index = remaining.index(start_tag)
|
|
183
|
+
if start_index
|
|
184
|
+
output << remaining.slice(0, start_index)
|
|
185
|
+
@inside_think_tag = true
|
|
186
|
+
remaining.slice((start_index + start_tag.length)..) || +''
|
|
187
|
+
else
|
|
188
|
+
suffix_len = longest_suffix_prefix(remaining, start_tag)
|
|
189
|
+
output << remaining.slice(0, remaining.length - suffix_len)
|
|
190
|
+
@pending_think_tag = remaining.slice(-suffix_len, suffix_len)
|
|
191
|
+
+''
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def longest_suffix_prefix(text, tag)
|
|
196
|
+
max = [text.length, tag.length - 1].min
|
|
197
|
+
max.downto(1) do |len|
|
|
198
|
+
return len if text.end_with?(tag[0, len])
|
|
199
|
+
end
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LexLLM
|
|
4
|
+
# Handles streaming responses from AI providers.
|
|
5
|
+
module Streaming
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def stream_response(connection, payload, additional_headers = {}, &block)
|
|
9
|
+
accumulator = StreamAccumulator.new
|
|
10
|
+
|
|
11
|
+
response = connection.post stream_url, payload do |req|
|
|
12
|
+
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
|
|
13
|
+
if faraday_1?
|
|
14
|
+
req.options[:on_data] = handle_stream do |chunk|
|
|
15
|
+
accumulator.add chunk
|
|
16
|
+
block.call chunk
|
|
17
|
+
end
|
|
18
|
+
else
|
|
19
|
+
req.options.on_data = handle_stream do |chunk|
|
|
20
|
+
accumulator.add chunk
|
|
21
|
+
block.call chunk
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
message = accumulator.to_message(response)
|
|
27
|
+
LexLLM.logger.debug { "Stream completed: #{message.content}" }
|
|
28
|
+
message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_stream(&block)
|
|
32
|
+
build_on_data_handler do |data|
|
|
33
|
+
block.call(build_chunk(data)) if data.is_a?(Hash)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def faraday_1?
|
|
40
|
+
Faraday::VERSION.start_with?('1')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_on_data_handler(&)
|
|
44
|
+
buffer = +''
|
|
45
|
+
parser = EventStreamParser::Parser.new
|
|
46
|
+
|
|
47
|
+
FaradayHandlers.build(
|
|
48
|
+
faraday_v1: faraday_1?,
|
|
49
|
+
on_chunk: ->(chunk, env) { process_stream_chunk(chunk, parser, env, &) },
|
|
50
|
+
on_failed_response: ->(chunk, env) { handle_failed_response(chunk, buffer, env) }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_stream_chunk(chunk, parser, env, &)
|
|
55
|
+
LexLLM.logger.debug { "Received chunk: #{chunk}" } if LexLLM.config.log_stream_debug
|
|
56
|
+
|
|
57
|
+
if error_chunk?(chunk)
|
|
58
|
+
handle_error_chunk(chunk, env)
|
|
59
|
+
elsif json_error_payload?(chunk)
|
|
60
|
+
handle_json_error_chunk(chunk, env)
|
|
61
|
+
else
|
|
62
|
+
yield handle_sse(chunk, parser, env, &)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def error_chunk?(chunk)
|
|
67
|
+
chunk.start_with?('event: error')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def json_error_payload?(chunk)
|
|
71
|
+
chunk.lstrip.start_with?('{') && chunk.include?('"error"')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_json_error_chunk(chunk, env)
|
|
75
|
+
parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_error_chunk(chunk, env)
|
|
79
|
+
error_data = chunk.split("\n")[1].delete_prefix('data: ')
|
|
80
|
+
parse_error_from_json(error_data, env, 'Failed to parse error chunk')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_failed_response(chunk, buffer, env)
|
|
84
|
+
buffer << chunk
|
|
85
|
+
error_data = Legion::JSON.parse(buffer, symbolize_names: false)
|
|
86
|
+
handle_parsed_error(error_data, env)
|
|
87
|
+
rescue Legion::JSON::ParseError
|
|
88
|
+
LexLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_sse(chunk, parser, env, &)
|
|
92
|
+
parser.feed(chunk) do |type, data|
|
|
93
|
+
case type.to_sym
|
|
94
|
+
when :error
|
|
95
|
+
handle_error_event(data, env)
|
|
96
|
+
else
|
|
97
|
+
yield handle_data(data, env, &) unless data == '[DONE]'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_data(data, env)
|
|
103
|
+
parsed = Legion::JSON.parse(data, symbolize_names: false)
|
|
104
|
+
return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
|
|
105
|
+
|
|
106
|
+
handle_parsed_error(parsed, env)
|
|
107
|
+
rescue Legion::JSON::ParseError => e
|
|
108
|
+
LexLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def handle_error_event(data, env)
|
|
112
|
+
parse_error_from_json(data, env, 'Failed to parse error event')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_streaming_error(data)
|
|
116
|
+
error_data = Legion::JSON.parse(data, symbolize_names: false)
|
|
117
|
+
[500, error_data['message'] || 'Unknown streaming error']
|
|
118
|
+
rescue Legion::JSON::ParseError => e
|
|
119
|
+
LexLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
|
|
120
|
+
[500, "Failed to parse error: #{data}"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def handle_parsed_error(parsed_data, env)
|
|
124
|
+
status, _message = parse_streaming_error(parsed_data.to_json)
|
|
125
|
+
error_response = build_stream_error_response(parsed_data, env, status)
|
|
126
|
+
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_error_from_json(data, env, error_message)
|
|
130
|
+
parsed_data = Legion::JSON.parse(data, symbolize_names: false)
|
|
131
|
+
handle_parsed_error(parsed_data, env)
|
|
132
|
+
rescue Legion::JSON::ParseError => e
|
|
133
|
+
LexLLM.logger.debug { "#{error_message}: #{e.message}" }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_stream_error_response(parsed_data, env, status)
|
|
137
|
+
error_status = status || env&.status || 500
|
|
138
|
+
|
|
139
|
+
if faraday_1?
|
|
140
|
+
Struct.new(:body, :status).new(parsed_data, error_status)
|
|
141
|
+
else
|
|
142
|
+
env.merge(body: parsed_data, status: error_status)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Builds Faraday on_data handlers for different major versions.
|
|
147
|
+
module FaradayHandlers
|
|
148
|
+
module_function
|
|
149
|
+
|
|
150
|
+
def build(faraday_v1:, on_chunk:, on_failed_response:)
|
|
151
|
+
if faraday_v1
|
|
152
|
+
v1_on_data(on_chunk)
|
|
153
|
+
else
|
|
154
|
+
v2_on_data(on_chunk, on_failed_response)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def v1_on_data(on_chunk)
|
|
159
|
+
proc do |chunk, _size|
|
|
160
|
+
on_chunk.call(chunk, nil)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def v2_on_data(on_chunk, on_failed_response)
|
|
165
|
+
proc do |chunk, _bytes, env|
|
|
166
|
+
if env&.status == 200
|
|
167
|
+
on_chunk.call(chunk, env)
|
|
168
|
+
else
|
|
169
|
+
on_failed_response.call(chunk, env)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LexLLM
|
|
4
|
+
# Represents provider thinking output.
|
|
5
|
+
class Thinking
|
|
6
|
+
attr_reader :text, :signature
|
|
7
|
+
|
|
8
|
+
def initialize(text: nil, signature: nil)
|
|
9
|
+
@text = text
|
|
10
|
+
@signature = signature
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.build(text: nil, signature: nil)
|
|
14
|
+
text = nil if text.is_a?(String) && text.empty?
|
|
15
|
+
signature = nil if signature.is_a?(String) && signature.empty?
|
|
16
|
+
|
|
17
|
+
return nil if text.nil? && signature.nil?
|
|
18
|
+
|
|
19
|
+
new(text: text, signature: signature)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def pretty_print(printer)
|
|
23
|
+
printer.object_group(self) do
|
|
24
|
+
printer.breakable
|
|
25
|
+
printer.text 'text='
|
|
26
|
+
printer.pp text
|
|
27
|
+
printer.comma_breakable
|
|
28
|
+
printer.text 'signature='
|
|
29
|
+
printer.pp(signature ? '[REDACTED]' : nil)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Thinking
|
|
35
|
+
# Normalized config for thinking across providers.
|
|
36
|
+
class Config
|
|
37
|
+
attr_reader :effort, :budget
|
|
38
|
+
|
|
39
|
+
def initialize(effort: nil, budget: nil)
|
|
40
|
+
@effort = effort.is_a?(Symbol) ? effort.to_s : effort
|
|
41
|
+
@budget = budget
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enabled?
|
|
45
|
+
!effort.nil? || !budget.nil?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LexLLM
|
|
4
|
+
# Represents token usage for a response.
|
|
5
|
+
class Tokens
|
|
6
|
+
attr_reader :input, :output, :cached, :cache_creation, :thinking
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/ParameterLists
|
|
9
|
+
def initialize(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
|
|
10
|
+
@input = input
|
|
11
|
+
@output = output
|
|
12
|
+
@cached = cached
|
|
13
|
+
@cache_creation = cache_creation
|
|
14
|
+
@thinking = thinking || reasoning
|
|
15
|
+
end
|
|
16
|
+
# rubocop:enable Metrics/ParameterLists
|
|
17
|
+
|
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
|
19
|
+
def self.build(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
|
|
20
|
+
return nil if [input, output, cached, cache_creation, thinking, reasoning].all?(&:nil?)
|
|
21
|
+
|
|
22
|
+
new(
|
|
23
|
+
input: input,
|
|
24
|
+
output: output,
|
|
25
|
+
cached: cached,
|
|
26
|
+
cache_creation: cache_creation,
|
|
27
|
+
thinking: thinking,
|
|
28
|
+
reasoning: reasoning
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
# rubocop:enable Metrics/ParameterLists
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
input_tokens: input,
|
|
36
|
+
output_tokens: output,
|
|
37
|
+
cached_tokens: cached,
|
|
38
|
+
cache_creation_tokens: cache_creation,
|
|
39
|
+
thinking_tokens: thinking
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reasoning
|
|
44
|
+
thinking
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|